diff --git a/src/meshapi/migrations/0001_squashed_0015_alter_building_bin.py b/src/meshapi/migrations/0001_initial.py similarity index 51% rename from src/meshapi/migrations/0001_squashed_0015_alter_building_bin.py rename to src/meshapi/migrations/0001_initial.py index 537ff098..ad52f5f1 100644 --- a/src/meshapi/migrations/0001_squashed_0015_alter_building_bin.py +++ b/src/meshapi/migrations/0001_initial.py @@ -1,6 +1,8 @@ -# Generated by Django 4.2.5 on 2023-10-26 01:46 +# Generated by Django 4.2.5 on 2023-12-24 23:23 import django.contrib.auth.models +import django.contrib.postgres.fields +import django.core.validators from django.db import migrations, models import django.db.models.deletion @@ -13,6 +15,27 @@ class Migration(migrations.Migration): ] operations = [ + migrations.CreateModel( + name="Admin", + fields=[ + ( + "group_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="auth.group", + ), + ), + ("description", models.TextField(blank=True, max_length=100)), + ], + bases=("auth.group",), + managers=[ + ("objects", django.contrib.auth.models.GroupManager()), + ], + ), migrations.CreateModel( name="Building", fields=[ @@ -26,9 +49,29 @@ class Migration(migrations.Migration): ("latitude", models.FloatField()), ("longitude", models.FloatField()), ("altitude", models.FloatField()), - ("network_number", models.IntegerField(blank=True, null=True)), - ("install_date", models.DateField(blank=True, default=None, null=True)), - ("abandon_date", models.DateField(blank=True, default=None, null=True)), + ("primary_nn", models.IntegerField(blank=True, null=True)), + ("node_name", models.TextField(blank=True, default=None, null=True)), + ], + ), + migrations.CreateModel( + name="Installer", + fields=[ + ( + "group_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="auth.group", + ), + ), + ("description", models.TextField(blank=True, max_length=100)), + ], + bases=("auth.group",), + managers=[ + ("objects", django.contrib.auth.models.GroupManager()), ], ), migrations.CreateModel( @@ -38,25 +81,18 @@ class Migration(migrations.Migration): ("first_name", models.TextField()), ("last_name", models.TextField()), ("email_address", models.EmailField(max_length=254)), + ( + "secondary_emails", + django.contrib.postgres.fields.ArrayField( + base_field=models.EmailField(max_length=254), null=True, size=None + ), + ), ("phone_number", models.TextField(blank=True, default=None, null=True)), ("slack_handle", models.TextField(blank=True, default=None, null=True)), ], ), migrations.CreateModel( - name="Install", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("install_number", models.IntegerField()), - ("install_status", models.IntegerField(choices=[(0, "Planned"), (1, "Inactive"), (2, "Active")])), - ("building_id", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to="meshapi.building")), - ("member_id", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to="meshapi.member")), - ("abandon_date", models.DateField(blank=True, default=None, null=True)), - ("install_date", models.DateField(blank=True, default=None, null=True)), - ("unit", models.TextField(blank=True, default=None, null=True)), - ], - ), - migrations.CreateModel( - name="Installer", + name="ReadOnly", fields=[ ( "group_ptr", @@ -77,22 +113,44 @@ class Migration(migrations.Migration): ], ), migrations.CreateModel( - name="Request", + name="Install", fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("request_status", models.IntegerField(choices=[(0, "Open"), (1, "Closed"), (2, "Installed")])), - ("ticket_id", models.IntegerField(blank=True, null=True)), - ("building_id", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to="meshapi.building")), + ("install_number", models.AutoField(db_column="install_number", primary_key=True, serialize=False)), ( - "install_id", - models.ForeignKey( - blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to="meshapi.install" + "network_number", + models.IntegerField( + blank=True, + null=True, + validators=[ + django.core.validators.MinValueValidator(101), + django.core.validators.MaxValueValidator(8192), + ], ), ), - ("member_id", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to="meshapi.member")), + ( + "install_status", + models.IntegerField( + choices=[ + (0, "Open"), + (1, "Scheduled"), + (2, "Nn Assigned"), + (3, "Blocked"), + (4, "Active"), + (5, "Inactive"), + (6, "Closed"), + ] + ), + ), + ("ticket_id", models.IntegerField(blank=True, null=True)), + ("request_date", models.DateField(blank=True, default=None, null=True)), + ("install_date", models.DateField(blank=True, default=None, null=True)), + ("abandon_date", models.DateField(blank=True, default=None, null=True)), + ("unit", models.TextField(blank=True, default=None, null=True)), ("roof_access", models.BooleanField(default=False)), ("referral", models.TextField(blank=True, default=None, null=True)), - ("unit", models.TextField(blank=True, default=None, null=True)), + ("notes", models.TextField(blank=True, default=None, null=True)), + ("building_id", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to="meshapi.building")), + ("member_id", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to="meshapi.member")), ], ), ] diff --git a/src/meshapi/migrations/0016_create_default_groups.py b/src/meshapi/migrations/0016_create_default_groups.py deleted file mode 100644 index 98680e10..00000000 --- a/src/meshapi/migrations/0016_create_default_groups.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 4.2.5 on 2023-12-22 01:20 - -from django.contrib.auth.models import Group -from django.db import migrations - - -# Make sure that the basic groups are created. -def create_default_groups(apps, schema_editor): - Group.objects.get_or_create(name="Installer") - Group.objects.get_or_create(name="Admin") - Group.objects.get_or_create(name="ReadOnly") - - -class Migration(migrations.Migration): - dependencies = [ - ("meshapi", "0001_squashed_0015_alter_building_bin"), - ] - - operations = [ - migrations.RunPython(create_default_groups), - ] diff --git a/src/meshapi/models.py b/src/meshapi/models.py index 7cf450b4..860bbf4c 100644 --- a/src/meshapi/models.py +++ b/src/meshapi/models.py @@ -1,6 +1,12 @@ +from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models +from django.contrib.postgres.fields import ArrayField from django.contrib.auth.models import Group +from django.db.models.fields import EmailField + +NETWORK_NUMBER_MIN = 101 +NETWORK_NUMBER_MAX = 8192 class Installer(Group): @@ -10,6 +16,20 @@ def __str__(self): return self.name +class Admin(Group): + description = models.TextField(max_length=100, blank=True) + + def __str__(self): + return self.name + + +class ReadOnly(Group): + description = models.TextField(max_length=100, blank=True) + + def __str__(self): + return self.name + + class Building(models.Model): class BuildingStatus(models.IntegerChoices): INACTIVE = 0 @@ -24,47 +44,60 @@ class BuildingStatus(models.IntegerChoices): latitude = models.FloatField() longitude = models.FloatField() altitude = models.FloatField() - network_number = models.IntegerField(blank=True, null=True) - install_date = models.DateField(default=None, blank=True, null=True) - abandon_date = models.DateField(default=None, blank=True, null=True) + primary_nn = models.IntegerField(blank=True, null=True) + node_name = models.TextField(default=None, blank=True, null=True) class Member(models.Model): first_name = models.TextField() last_name = models.TextField() email_address = models.EmailField() - phone_number = models.TextField( - default=None, blank=True, null=True - ) # TODO (willnilges): Can we get some validation on this? + secondary_emails = ArrayField(EmailField(), null=True) + phone_number = models.TextField(default=None, blank=True, null=True) slack_handle = models.TextField(default=None, blank=True, null=True) class Install(models.Model): class InstallStatus(models.IntegerChoices): - PLANNED = 0 - INACTIVE = 1 - ACTIVE = 2 + OPEN = 0 + SCHEDULED = 1 + NN_ASSIGNED = 2 + BLOCKED = 3 + ACTIVE = 4 + INACTIVE = 5 + CLOSED = 6 - install_number = models.IntegerField() - install_status = models.IntegerField(choices=InstallStatus.choices) - building_id = models.ForeignKey(Building, on_delete=models.PROTECT) - unit = models.TextField(default=None, blank=True, null=True) - member_id = models.ForeignKey(Member, on_delete=models.PROTECT) - install_date = models.DateField(default=None, blank=True, null=True) - abandon_date = models.DateField(default=None, blank=True, null=True) + # Install Number (generated when form is submitted) + install_number = models.AutoField( + primary_key=True, + db_column="install_number", + ) + # The NN this install is associated with. + # Through this, a building can have multiple NNs + network_number = models.IntegerField( + blank=True, + null=True, + validators=[MinValueValidator(NETWORK_NUMBER_MIN), MaxValueValidator(NETWORK_NUMBER_MAX)], + ) -class Request(models.Model): - class RequestStatus(models.IntegerChoices): - OPEN = 0 - CLOSED = 1 - INSTALLED = 2 + # Summary status of install + install_status = models.IntegerField(choices=InstallStatus.choices) - request_status = models.IntegerField(choices=RequestStatus.choices) - roof_access = models.BooleanField(default=False) - referral = models.TextField(default=None, blank=True, null=True) + # OSTicket ID ticket_id = models.IntegerField(blank=True, null=True) - member_id = models.ForeignKey(Member, on_delete=models.PROTECT) + + # Important dates + request_date = models.DateField(default=None, blank=True, null=True) + install_date = models.DateField(default=None, blank=True, null=True) + abandon_date = models.DateField(default=None, blank=True, null=True) + + # Relation to Building building_id = models.ForeignKey(Building, on_delete=models.PROTECT) unit = models.TextField(default=None, blank=True, null=True) - install_id = models.ForeignKey(Install, on_delete=models.PROTECT, blank=True, null=True) + roof_access = models.BooleanField(default=False) + + # Relation to Member + member_id = models.ForeignKey(Member, on_delete=models.PROTECT) + referral = models.TextField(default=None, blank=True, null=True) + notes = models.TextField(default=None, blank=True, null=True) diff --git a/src/meshapi/permissions.py b/src/meshapi/permissions.py index 6dd8622c..19ebe877 100644 --- a/src/meshapi/permissions.py +++ b/src/meshapi/permissions.py @@ -111,23 +111,8 @@ def has_permission(self, request, view): return True -# Anyone can view requests, but only installers and admins can create them -class RequestListCreatePermissions(permissions.BasePermission): +class NetworkNumberAssignmentPermissions(permissions.BasePermission): def has_permission(self, request, view): - if request.method == "GET": - return True - else: - if not request.user.is_superuser or is_admin(request.user): - raise PermissionDenied(perm_denied_generic_msg) - return True - - -# Anyone can retrieve requests, but only an admin can do anything else -class RequestRetrieveUpdateDestroyPermissions(permissions.BasePermission): - def has_permission(self, request, view): - if request.method == "GET": - return True - else: - if not request.user.is_superuser or is_admin(request.user): - raise PermissionDenied(perm_denied_generic_msg) - return True + if not (request.user.is_superuser or is_admin(request.user) or is_installer(request.user)): + raise PermissionDenied(perm_denied_generic_msg) + return True diff --git a/src/meshapi/serializers.py b/src/meshapi/serializers.py index d51a1bd7..2f216eee 100644 --- a/src/meshapi/serializers.py +++ b/src/meshapi/serializers.py @@ -1,6 +1,6 @@ from django.contrib.auth.models import User from rest_framework import serializers -from meshapi.models import Building, Member, Install, Request +from meshapi.models import Building, Member, Install class UserSerializer(serializers.ModelSerializer): @@ -25,9 +25,3 @@ class InstallSerializer(serializers.ModelSerializer): class Meta: model = Install fields = "__all__" - - -class RequestSerializer(serializers.ModelSerializer): - class Meta: - model = Request - fields = "__all__" diff --git a/src/meshapi/tests/sample_data.py b/src/meshapi/tests/sample_data.py index 83215aeb..788d2967 100644 --- a/src/meshapi/tests/sample_data.py +++ b/src/meshapi/tests/sample_data.py @@ -1,4 +1,4 @@ -from meshapi.models import Install, Request +from meshapi.models import Install sample_member = { "first_name": "John", @@ -18,24 +18,19 @@ "latitude": 0.0, "longitude": 0.0, "altitude": 0.0, - "network_number": 9001, - "install_date": "2222-02-02", - "abandon_date": "", + "primary_nn": 2000, } sample_install = { - "install_number": 420, + "network_number": 2000, "install_status": Install.InstallStatus.ACTIVE, + "ticket_id": 69, + "request_date": "2022-02-27", "install_date": "2022-03-01", "abandon_date": "", - "member_id": 1, "building_id": 1, -} - -sample_request = { - "request_status": Request.RequestStatus.OPEN, - "ticket_id": 1, + "unit": 3, + "roof_access": True, "member_id": 1, - "building_id": 1, - "install_id": "", + "notes": "Referral: Read about it on the internet", } diff --git a/src/meshapi/tests/test_join_form.py b/src/meshapi/tests/test_join_form.py index ed639da2..8bf99e4e 100644 --- a/src/meshapi/tests/test_join_form.py +++ b/src/meshapi/tests/test_join_form.py @@ -1,7 +1,7 @@ import json from django.contrib.auth.models import User from django.test import TestCase, Client -from meshapi.models import Building, Member, Install, Request +from meshapi.models import Building, Member, Install from meshapi.views import JoinFormRequest from .sample_join_form_data import * @@ -42,15 +42,15 @@ def validate_successful_join_form_submission(test_case, test_name, s, response): f"Didn't find created building for {test_name}. Should be {length}, but got {len(existing_buildings)}", ) - # Check that a request was created - request_id = json.loads(response.content.decode("utf-8"))["request_id"] - join_form_requests = Request.objects.filter(pk=request_id) + # Check that a install was created + install_number = json.loads(response.content.decode("utf-8"))["install_number"] + join_form_installs = Install.objects.filter(pk=install_number) length = 1 test_case.assertEqual( - len(join_form_requests), + len(join_form_installs), length, - f"Didn't find created request for {test_name}. Should be {length}, but got {len(join_form_requests)}", + f"Didn't find created install for {test_name}. Should be {length}, but got {len(join_form_installs)}", ) @@ -189,3 +189,50 @@ def test_bad_address_join_form(self): response.content.decode("utf-8"), f"Did not get correct response content for bad address join form: {response.content.decode('utf-8')}", ) + + def test_member_moved_join_form(self): + # Name, email, phone, location, apt, rooftop, referral + response = self.c.post("/api/v1/join/", valid_join_form_submission, content_type="application/json") + + code = 201 + self.assertEqual( + code, + response.status_code, + f"status code incorrect for Valid Join Form. Should be {code}, but got {response.status_code}.\n Response is: {response.content.decode('utf-8')}", + ) + + # Make sure that we get the right stuff out of the database afterwards + s = JoinFormRequest(**valid_join_form_submission) + + # Match the format from OSM. I did this to see how OSM would mutate the + # raw request we get. + s.street_address = "151 Broome Street" + s.city = "Manhattan" + s.state = "New York" + + validate_successful_join_form_submission(self, "Valid Join Form", s, response) + + # Now test that the member can "move" and still access the jon form + form = valid_join_form_submission.copy() + form["street_address"] = "152 Broome Street" + + # Name, email, phone, location, apt, rooftop, referral + response = self.c.post("/api/v1/join/", form, content_type="application/json") + + code = 201 + self.assertEqual( + code, + response.status_code, + f"status code incorrect for Valid Join Form. Should be {code}, but got {response.status_code}.\n Response is: {response.content.decode('utf-8')}", + ) + + # Make sure that we get the right stuff out of the database afterwards + s = JoinFormRequest(**valid_join_form_submission) + + # Match the format from OSM. I did this to see how OSM would mutate the + # raw request we get. + s.street_address = "152 Broome Street" + s.city = "Manhattan" + s.state = "New York" + + validate_successful_join_form_submission(self, "Valid Join Form", s, response) diff --git a/src/meshapi/tests/test_nn.py b/src/meshapi/tests/test_nn.py new file mode 100644 index 00000000..8be95429 --- /dev/null +++ b/src/meshapi/tests/test_nn.py @@ -0,0 +1,174 @@ +import json +from django.contrib.auth.models import User +from django.test import TestCase, Client +from meshapi.models import Building, Install, Member + +from .sample_data import sample_member, sample_building, sample_install + + +# Test basic NN form stuff (input validation, etc) +class TestNN(TestCase): + admin_c = Client() + + def setUp(self): + self.admin_user = User.objects.create_superuser( + username="admin", password="admin_password", email="admin@example.com" + ) + self.admin_c.login(username="admin", password="admin_password") + + # Create sample data + self.admin_c.post("/api/v1/members/", sample_member) + building = sample_building.copy() + building["primary_nn"] = "" + self.admin_c.post("/api/v1/buildings/", building) + inst = sample_install.copy() + inst["building_id"] = Building.objects.all()[0].id + inst["member_id"] = Member.objects.all()[0].id + inst["network_number"] = "" + self.admin_c.post("/api/v1/installs/", inst) + + self.install_number = Install.objects.all()[0].install_number + + def test_nn_valid_install_number(self): + response = self.admin_c.post( + "/api/v1/nn-assign/", {"install_number": self.install_number}, content_type="application/json" + ) + + code = 200 + self.assertEqual( + code, + response.status_code, + f"status code incorrect for test_nn_valid_install_number. Should be {code}, but got {response.status_code}", + ) + + resp_nn = json.loads(response.content.decode("utf-8"))["network_number"] + expected_nn = 101 + self.assertEqual( + expected_nn, + resp_nn, + f"status code incorrect for test_nn_valid_install_number. Should be {code}, but got {response.status_code}", + ) + + def test_nn_invalid_building_id(self): + response = self.admin_c.post("/api/v1/nn-assign/", {"install_number": 69420}, content_type="application/json") + + code = 404 + self.assertEqual( + code, + response.status_code, + f"status code incorrect for test_nn_invalid_building_id. Should be {code}, but got {response.status_code}", + ) + + def test_nn_bad_request(self): + response = self.admin_c.post("/api/v1/nn-assign/", {"install_number": "chom"}, content_type="application/json") + + code = 404 + self.assertEqual( + code, + response.status_code, + f"status code incorrect for test_nn_invalid_building_id. Should be {code}, but got {response.status_code}", + ) + + response = self.admin_c.post("/api/v1/nn-assign/", "Tell me your secrets >:)", content_type="application/json") + + code = 400 + self.assertEqual( + code, + response.status_code, + f"status code incorrect for test_nn_invalid_building_id. Should be {code}, but got {response.status_code}", + ) + + +# Test that the NN function can find gaps +class TestFindGaps(TestCase): + c = Client() + admin_c = Client() + + def add_data(self, b, m, i, index=101, nn=False): + b["zip_code"] += index + + if nn: + b["primary_nn"] = index + i["network_number"] = index + else: + b["primary_nn"] = "" + i["network_number"] = "" + + self.admin_c.post("/api/v1/buildings/", b) + + m["email_address"] = f"john{index}@gmail.com" + self.admin_c.post("/api/v1/members/", m) + + i["building_id"] = Building.objects.filter(zip_code=b["zip_code"])[0].id + i["member_id"] = Member.objects.filter(email_address=m["email_address"])[0].id + i["ticket_id"] = index + self.admin_c.post("/api/v1/installs/", i) + i["install_number"] = Install.objects.filter(ticket_id=i["ticket_id"])[0].install_number + + def setUp(self): + self.admin_user = User.objects.create_superuser( + username="admin", password="admin_password", email="admin@example.com" + ) + self.admin_c.login(username="admin", password="admin_password") + + # Create a whole bunch of sample data + build = sample_building.copy() + inst = sample_install.copy() + memb = sample_member.copy() + build["street_address"] = "123 Fake St" + for i in range(101, 111): + self.add_data(build, memb, inst, index=i, nn=True) + + # Skip a few numbers (111, 112) + for i in range(113, 130): + self.add_data(build, memb, inst, index=i, nn=True) + + # Then create another couple installs + # These will get numbers assigned next + b2 = sample_building.copy() + m2 = sample_member.copy() + self.inst2 = sample_install.copy() + self.add_data(b2, m2, self.inst2, index=5002, nn=False) + + b3 = sample_building.copy() + m3 = sample_member.copy() + self.inst3 = sample_install.copy() + self.add_data(b3, m3, self.inst3, index=5003, nn=False) + + b4 = sample_building.copy() + m4 = sample_member.copy() + self.inst4 = sample_install.copy() + self.add_data(b4, m4, self.inst4, index=5004, nn=False) + + def test_nn_search_for_new_number(self): + # Try to give NNs to all the installs. Should end up with two right + # next to each other and then one at the end. + + for inst, nn in [(self.inst2, 111), (self.inst3, 112), (self.inst4, 130)]: + response = self.admin_c.post( + "/api/v1/nn-assign/", {"install_number": inst["install_number"]}, content_type="application/json" + ) + response.content.decode("utf-8") + + code = 200 + self.assertEqual( + code, + response.status_code, + f"status code incorrect for test_nn_valid_building_id. Should be {code}, but got {response.status_code}", + ) + + resp_nn = json.loads(response.content.decode("utf-8"))["network_number"] + expected_nn = nn + + self.assertEqual( + resp_nn, + expected_nn, + f"Got wrong nn for install {inst['install_number']}. Got {resp_nn} but expected {expected_nn}", + ) + + # Sanity check that the other buildings actually exist + self.assertIsNotNone(Install.objects.filter(network_number=129)[0].install_number) + self.assertIsNotNone(Building.objects.filter(primary_nn=129)[0].id) + + self.assertIsNotNone(Install.objects.filter(network_number=130)[0].install_number) + self.assertIsNotNone(Building.objects.filter(primary_nn=130)[0].id) diff --git a/src/meshapi/tests/test_views_get.py b/src/meshapi/tests/test_views_get.py index b53cb5b9..cf0ef8b7 100644 --- a/src/meshapi/tests/test_views_get.py +++ b/src/meshapi/tests/test_views_get.py @@ -13,7 +13,6 @@ def test_views_get_unauthenticated(self): ("/api/v1/buildings/", 200), ("/api/v1/members/", 403), ("/api/v1/installs/", 200), - ("/api/v1/requests/", 200), ] for route, code in routes: @@ -44,7 +43,6 @@ def test_views_get_installer(self): ("/api/v1/buildings/", 200), ("/api/v1/members/", 200), ("/api/v1/installs/", 200), - ("/api/v1/requests/", 200), ] for route, code in routes: @@ -73,7 +71,6 @@ def test_views_get_admin(self): ("/api/v1/buildings/", 200), ("/api/v1/members/", 200), ("/api/v1/installs/", 200), - ("/api/v1/requests/", 200), ] for route, code in routes: @@ -94,7 +91,6 @@ def test_views_get_admin_token(self): ("/api/v1/buildings/", 200), ("/api/v1/members/", 200), ("/api/v1/installs/", 200), - ("/api/v1/requests/", 200), ] for route, code in routes: diff --git a/src/meshapi/tests/test_views_post_delete.py b/src/meshapi/tests/test_views_post_delete.py index 929cb06b..ac99c773 100644 --- a/src/meshapi/tests/test_views_post_delete.py +++ b/src/meshapi/tests/test_views_post_delete.py @@ -2,7 +2,7 @@ from django.test import TestCase, Client from django.contrib.auth.models import User, Group -from .sample_data import sample_member, sample_building, sample_install, sample_request +from .sample_data import sample_member, sample_building, sample_install def assert_correct_response(test, response, code): @@ -16,8 +16,8 @@ def assert_correct_response(test, response, code): # Wow so brittle -def get_first_id(client, route): - return json.loads(client.get(route).content.decode("utf-8")).get("results")[0].get("id") +def get_first_id(client, route, field="id"): + return json.loads(client.get(route).content.decode("utf-8")).get("results")[0].get(field) class TestViewsPostDeleteUnauthenticated(TestCase): @@ -33,13 +33,7 @@ def test_views_post_unauthenticated(self): response = self.c.post("/api/v1/installs/", sample_install) assert_correct_response(self, response, 403) - response = self.c.post("/api/v1/requests/", sample_request) - assert_correct_response(self, response, 403) # 400 because previous requests failed - def test_views_delete_unauthenticated(self): - response = self.c.delete(f"/api/v1/requests/1/") - assert_correct_response(self, response, 403) - response = self.c.delete(f"/api/v1/installs/1/") assert_correct_response(self, response, 403) @@ -86,13 +80,7 @@ def test_views_post_installer(self): response = self.c.post("/api/v1/installs/", sample_install) assert_correct_response(self, response, 201) - response = self.c.post("/api/v1/requests/", sample_request) - assert_correct_response(self, response, 403) - def test_views_delete_installer(self): - response = self.c.delete(f"/api/v1/requests/1/") - assert_correct_response(self, response, 403) - response = self.c.delete(f"/api/v1/installs/1/") assert_correct_response(self, response, 403) @@ -125,22 +113,10 @@ def test_views_post_admin(self): sample_install["building_id"] = building_id response = self.c.post("/api/v1/installs/", sample_install) assert_correct_response(self, response, 201) - install_id = get_first_id(self.c, "/api/v1/installs/") - - sample_request["member_id"] = member_id - sample_request["building_id"] = building_id - response = self.c.post("/api/v1/requests/", sample_request) - assert_correct_response(self, response, 201) - request_id = get_first_id(self.c, "/api/v1/requests/") - - # FIXME: For some reason this fails as a separate test - # I have literally no idea why. Could be an issue with - # the test DB. None of the other tests hit this, probably - # because nobody is allowed to do things that would warrant - # cross-function testing. Fair enough I suppose. - response = self.c.delete(f"/api/v1/requests/{request_id}/") - assert_correct_response(self, response, 204) + # XXX: This is how I know that getting the install number from the API is working + install_id = get_first_id(self.c, "/api/v1/installs/", "install_number") + # Now delete response = self.c.delete(f"/api/v1/installs/{install_id}/") assert_correct_response(self, response, 204) diff --git a/src/meshapi/urls.py b/src/meshapi/urls.py index ade7010f..1e628367 100644 --- a/src/meshapi/urls.py +++ b/src/meshapi/urls.py @@ -13,9 +13,8 @@ path("members//", views.MemberDetail.as_view(), name="meshapi-v1-member-detail"), path("installs/", views.InstallList.as_view(), name="meshapi-v1-install-list"), path("installs//", views.InstallDetail.as_view(), name="meshapi-v1-install-detail"), - path("requests/", views.RequestList.as_view(), name="meshapi-v1-request-list"), - path("requests//", views.RequestDetail.as_view(), name="meshapi-v1-request-detail"), path("join/", views.join_form, name="meshapi-v1-join"), + path("nn-assign/", views.network_number_assignment, name="meshapi-v1-nn-assign"), ] urlpatterns = format_suffix_patterns(urlpatterns) diff --git a/src/meshapi/views.py b/src/meshapi/views.py index b768ce9b..5ea7ce7c 100644 --- a/src/meshapi/views.py +++ b/src/meshapi/views.py @@ -1,18 +1,18 @@ from dataclasses import dataclass +from datetime import datetime import json +from json.decoder import JSONDecodeError import time from geopy.exc import GeocoderUnavailable -import requests from django.contrib.auth.models import User from django.db import IntegrityError from rest_framework import generics, permissions -from meshapi.models import Building, Member, Install, Request +from meshapi.models import NETWORK_NUMBER_MAX, NETWORK_NUMBER_MIN, Building, Member, Install from meshapi.serializers import ( UserSerializer, BuildingSerializer, MemberSerializer, InstallSerializer, - RequestSerializer, ) from meshapi.permissions import ( BuildingListCreatePermissions, @@ -21,8 +21,7 @@ MemberRetrieveUpdateDestroyPermissions, InstallListCreatePermissions, InstallRetrieveUpdateDestroyPermissions, - RequestListCreatePermissions, - RequestRetrieveUpdateDestroyPermissions, + NetworkNumberAssignmentPermissions, ) from meshapi.validation import ( OSMAddressInfo, @@ -31,11 +30,11 @@ NYCAddressInfo, ) from meshapi.exceptions import AddressError, AddressAPIError -from rest_framework.decorators import api_view +from rest_framework.decorators import api_view, permission_classes from rest_framework.response import Response from rest_framework import status -from django.http.response import JsonResponse -from rest_framework.parsers import JSONParser + +# TODO: Do we need more routes for just getting a NN and stuff? # Home view @@ -44,9 +43,6 @@ def api_root(request, format=None): return Response("We're meshin'.") -# === USERS === - - class UserList(generics.ListAPIView): permission_classes = [permissions.IsAdminUser] queryset = User.objects.all() @@ -59,9 +55,6 @@ class UserDetail(generics.RetrieveAPIView): serializer_class = UserSerializer -# === BUILDINGS === - - class BuildingList(generics.ListCreateAPIView): permission_classes = [BuildingListCreatePermissions] queryset = Building.objects.all() @@ -74,12 +67,6 @@ class BuildingDetail(generics.RetrieveUpdateDestroyAPIView): serializer_class = BuildingSerializer -# TODO: Do we need more routes for just getting a NN and stuff? - - -# === MEMBER === - - class MemberList(generics.ListCreateAPIView): permission_classes = [MemberListCreatePermissions] queryset = Member.objects.all() @@ -92,9 +79,6 @@ class MemberDetail(generics.RetrieveUpdateDestroyAPIView): serializer_class = MemberSerializer -# === INSTALL === - - class InstallList(generics.ListCreateAPIView): permission_classes = [InstallListCreatePermissions] queryset = Install.objects.all() @@ -107,34 +91,6 @@ class InstallDetail(generics.RetrieveUpdateDestroyAPIView): serializer_class = InstallSerializer -# === REQUEST === - - -# class RequestList(mixins.ListModelMixin, mixins.CreateModelMixin, generics.GenericAPIView): -# queryset = Request.objects.all() -# serializer_class = RequestSerializer -# -# def get(self, request, *args, **kwargs): -# return self.list(request, *args, **kwargs) -# -# def post(self, request, *args, **kwargs): -# if not request.user.is_superuser: -# raise PermissionDenied("You do not have permission to delete this resource") -# return self.create(request, *args, **kwargs) - - -class RequestList(generics.ListCreateAPIView): - permission_classes = [RequestListCreatePermissions] - queryset = Request.objects.all() - serializer_class = RequestSerializer - - -class RequestDetail(generics.RetrieveUpdateDestroyAPIView): - permission_classes = [RequestRetrieveUpdateDestroyPermissions] - queryset = Request.objects.all() - serializer_class = RequestSerializer - - # Join Form @dataclass class JoinFormRequest: @@ -225,22 +181,21 @@ def join_form(request): if nyc_addr_info == None: return Response("(NYC) Error validating address", status=status.HTTP_500_INTERNAL_SERVER_ERROR) - # Check if there's an existing member, and bail if there is + # Check if there's an existing member. Dedupe on email for now. + # A member can have multiple install requests existing_members = Member.objects.filter( - first_name=r.first_name, - last_name=r.last_name, email_address=r.email, - phone_number=r.phone, ) - if len(existing_members) > 0: - return Response("Member already exists", status=status.HTTP_400_BAD_REQUEST) - - join_form_member = Member( - first_name=r.first_name, - last_name=r.last_name, - email_address=r.email, - phone_number=r.phone, - slack_handle="", + join_form_member = ( + existing_members[0] + if len(existing_members) > 0 + else Member( + first_name=r.first_name, + last_name=r.last_name, + email_address=r.email, + phone_number=r.phone, + slack_handle=None, + ) ) # If the address is in NYC, then try to look up by BIN, otherwise fallback @@ -269,21 +224,23 @@ def join_form(request): latitude=nyc_addr_info.latitude if nyc_addr_info is not None else osm_addr_info.latitude, longitude=nyc_addr_info.longitude if nyc_addr_info is not None else osm_addr_info.longitude, altitude=nyc_addr_info.altitude if nyc_addr_info is not None else osm_addr_info.altitude, - network_number=None, - install_date=None, - abandon_date=None, + primary_nn=None, ) ) - join_form_request = Request( - request_status=Request.RequestStatus.OPEN, - roof_access=r.roof_access, - referral=r.referral, + join_form_install = Install( + network_number=None, + install_status=Install.InstallStatus.OPEN, ticket_id=None, - member_id=join_form_member, + request_date=datetime.today(), + install_date=None, + abandon_date=None, building_id=join_form_building, unit=r.apartment, - install_id=None, + roof_access=r.roof_access, + member_id=join_form_member, + referral=r.referral, + notes=None, ) try: @@ -301,7 +258,7 @@ def join_form(request): return Response("Could not save building", status=status.HTTP_400_BAD_REQUEST) try: - join_form_request.save() + join_form_install.save() except IntegrityError as e: print(e) # Delete the member, building (if we just created it), and bail @@ -311,6 +268,81 @@ def join_form(request): return Response("Could not save request", status=status.HTTP_400_BAD_REQUEST) return Response( - {"building_id": join_form_building.id, "member_id": join_form_member.id, "request_id": join_form_request.id}, + { + "building_id": join_form_building.id, + "member_id": join_form_member.id, + "install_number": join_form_install.install_number, + # If this is an existing member, then set a flag to let them know we have + # their information in case they need to update anything. + "member_exists": True if len(existing_members) > 0 else False, + }, status=status.HTTP_201_CREATED, ) + + +@dataclass +class NetworkNumberAssignmentRequest: + install_number: int + + +@api_view(["POST"]) +@permission_classes([NetworkNumberAssignmentPermissions]) +def network_number_assignment(request): + """ + Takes an install number, and assigns the install a network number, + deduping using the other buildings in our database. + """ + + try: + request_json = json.loads(request.body) + r = NetworkNumberAssignmentRequest(**request_json) + except (TypeError, JSONDecodeError) as e: + print(f"NN Request failed. Could not decode request: {e}") + return Response({"Got incomplete request"}, status=status.HTTP_400_BAD_REQUEST) + + try: + nn_install = Install.objects.get(install_number=r.install_number) + except Exception as e: + print(f'NN Request failed. Could not get Install w/ Install Number "{r.install_number}": {e}') + return Response({"Install Number not found"}, status=status.HTTP_404_NOT_FOUND) + + # Check if the install already has a network number + if nn_install.network_number != None: + message = f"NN Request failed. This Install Number already has a Network Number associated with it! ({nn_install.network_number})" + print(message) + return Response(message, status=status.HTTP_409_CONFLICT) + + nn_building = nn_install.building_id + + # If the building already has a primary NN, then use that. + if nn_building.primary_nn is not None: + nn_install.network_number = nn_building.primary_nn + else: + free_nn = None + + defined_nns = set(Install.objects.values_list("network_number", flat=True)) + + # Find the first valid NN that isn't in use + free_nn = next(i for i in range(NETWORK_NUMBER_MIN, NETWORK_NUMBER_MAX + 1) if i not in defined_nns) + + # Set the NN on both the install and the Building + nn_install.network_number = free_nn + nn_building.primary_nn = free_nn + + nn_install.install_status = Install.InstallStatus.NN_ASSIGNED + + try: + nn_building.save() + nn_install.save() + except IntegrityError as e: + print(e) + return Response("NN Request failed. Could not save node number.", status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + return Response( + { + "building_id": nn_building.id, + "install_number": nn_install.install_number, + "network_number": nn_install.network_number, + }, + status=status.HTTP_200_OK, + )