diff --git a/src/meshapi/migrations/0005_alter_install_options.py b/src/meshapi/migrations/0005_alter_install_options.py new file mode 100644 index 00000000..9ea54207 --- /dev/null +++ b/src/meshapi/migrations/0005_alter_install_options.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.17 on 2025-01-08 01:59 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("meshapi", "0004_alter_historicalinstall_status_and_more"), + ] + + operations = [ + migrations.AlterModelOptions( + name="install", + options={ + "ordering": ["-install_number"], + "permissions": [ + ("assign_nn", "Can assign an NN to install"), + ("disambiguate_number", "Can disambiguate an install number from an NN"), + ("update_panoramas", "Can update panoramas"), + ], + }, + ), + ] diff --git a/src/meshapi/models/install.py b/src/meshapi/models/install.py index e50d6b62..97e5aacb 100644 --- a/src/meshapi/models/install.py +++ b/src/meshapi/models/install.py @@ -18,6 +18,7 @@ class Install(models.Model): class Meta: permissions = [ ("assign_nn", "Can assign an NN to install"), + ("disambiguate_number", "Can disambiguate an install number from an NN"), ("update_panoramas", "Can update panoramas"), ] ordering = ["-install_number"] diff --git a/src/meshapi/permissions.py b/src/meshapi/permissions.py index 014a25e5..39228c40 100644 --- a/src/meshapi/permissions.py +++ b/src/meshapi/permissions.py @@ -42,6 +42,10 @@ class HasNNAssignPermission(HasDjangoPermission): django_permission = "meshapi.assign_nn" +class HasDisambiguateNumberPermission(HasDjangoPermission): + django_permission = "meshapi.disambiguate_number" + + class HasMaintenanceModePermission(HasDjangoPermission): django_permission = "meshapi.maintenance_mode" diff --git a/src/meshapi/serializers/nested_object_references.py b/src/meshapi/serializers/nested_object_references.py new file mode 100644 index 00000000..ef70ecf5 --- /dev/null +++ b/src/meshapi/serializers/nested_object_references.py @@ -0,0 +1,16 @@ +from rest_framework import serializers + +from meshapi.models import Install +from meshapi.serializers import NestedKeyObjectRelatedField, NestedKeyRelatedMixIn + + +class InstallNestedRefSerializer(NestedKeyRelatedMixIn, serializers.ModelSerializer): + serializer_related_field = NestedKeyObjectRelatedField + + class Meta: + model = Install + fields = ["install_number", "id", "node"] + extra_kwargs = { + "node": {"additional_keys": ("network_number",)}, + "install_number": {"read_only": True}, + } diff --git a/src/meshapi/tests/test_helpers.py b/src/meshapi/tests/test_helpers.py new file mode 100644 index 00000000..070b496a --- /dev/null +++ b/src/meshapi/tests/test_helpers.py @@ -0,0 +1,295 @@ +from django.contrib.auth.models import User +from django.test import TestCase + +from meshapi.models import Building, Install, Member, Node +from meshapi.tests.sample_data import sample_building, sample_install, sample_member, sample_node + + +class TestDisambiguate(TestCase): + def setUp(self): + self.sample_install_copy = sample_install.copy() + self.building_1 = Building(**sample_building) + self.building_1.save() + self.sample_install_copy["building"] = self.building_1 + + self.member = Member(**sample_member) + self.member.save() + self.sample_install_copy["member"] = self.member + + self.active_install_node = Node( + **sample_node, + network_number=201, + id="6db25b1e-2b43-47f3-8acd-4c540d77b89e", + ) + self.active_install_node.save() + + self.active_install = Install( + **self.sample_install_copy, + id="ca5d22ee-ae54-44b9-9aff-2f6e07a2c4b7", + install_number=11000, + node=self.active_install_node, + ) + self.active_install.save() + + self.active_install_low_number_node = Node( + **sample_node, + network_number=100, + id="472676bb-f07d-443a-8eda-753a2803ef89", + ) + self.active_install_low_number_node.save() + + self.active_install_low_number_king_of_node = Install( + **self.sample_install_copy, + id="2a38c763-e2d0-4edc-93b1-5a5bce23bc49", + install_number=100, + node=self.active_install_low_number_node, + ) + self.active_install_low_number_king_of_node.save() + + self.active_install_low_number_non_king = Install( + **self.sample_install_copy, + id="f1a53017-0249-4ccd-aa9e-3497ebb0abbe", + install_number=150, + node=self.active_install_low_number_node, + ) + self.active_install_low_number_non_king.save() + + self.install_no_node = Install( + **self.sample_install_copy, + id="5e05afc1-6767-44ae-addd-ce7d1c37bf05", + install_number=12000, + ) + self.install_no_node.status = Install.InstallStatus.REQUEST_RECEIVED + self.install_no_node.save() + + self.recycled_install = Install( + **self.sample_install_copy, + id="f9265de3-3d6c-4a36-bd38-f757768e7833", + install_number=123, + ) + self.recycled_install.status = Install.InstallStatus.REQUEST_RECEIVED + self.recycled_install.save() + + self.node_with_recycled_number = Node( + **sample_node, + network_number=123, + id="34f9961b-d0b3-4920-bda9-a08e9a8a6fc1", + ) + self.node_with_recycled_number.save() + + self.admin_user = User.objects.create_superuser( + username="admin", password="admin_password", email="admin@example.com" + ) + self.client.login(username="admin", password="admin_password") + + def test_disambiguate_unauth(self): + self.client.logout() + response = self.client.get("/api/v1/disambiguate-number/?number=201") + self.assertEqual( + 403, + response.status_code, + f"status code incorrect, should be 403, but got {response.status_code}", + ) + + def test_disambiguate_no_number(self): + response = self.client.get("/api/v1/disambiguate-number/") + self.assertEqual( + 400, + response.status_code, + f"status code incorrect, should be 400, but got {response.status_code}", + ) + self.assertEqual( + response.json(), + {"detail": "Invalid number: ''. Must be an integer greater than zero"}, + ) + + def test_disambiguate_negative_number(self): + response = self.client.get("/api/v1/disambiguate-number/?number=-213") + self.assertEqual( + 400, + response.status_code, + f"status code incorrect, should be 400, but got {response.status_code}", + ) + self.assertEqual( + response.json(), + {"detail": "Invalid number: '-213'. Must be an integer greater than zero"}, + ) + + def test_disambiguate_nonexistent_number(self): + response = self.client.get("/api/v1/disambiguate-number/?number=2137213") + self.assertEqual( + 404, + response.status_code, + f"status code incorrect, should be 404, but got {response.status_code}", + ) + self.assertEqual( + response.json(), + {"detail": "Provided number: 2137213 did not correspond to any install or node objects"}, + ) + + def test_disambiguate_active_install(self): + response = self.client.get("/api/v1/disambiguate-number/?number=11000") + self.assertEqual( + 200, + response.status_code, + f"status code incorrect, should be 200, but got {response.status_code}", + ) + self.assertEqual( + response.json(), + { + "resolved_node": { + "id": self.active_install_node.id, + "network_number": 201, + }, + "supporting_data": { + "exact_match_recycled_install": None, + "exact_match_node": None, + "exact_match_nonrecycled_install": { + "id": self.active_install.id, + "install_number": 11000, + "node": { + "id": self.active_install_node.id, + "network_number": 201, + }, + }, + }, + }, + ) + + def test_disambiguate_install_no_node(self): + response = self.client.get("/api/v1/disambiguate-number/?number=12000") + self.assertEqual( + 200, + response.status_code, + f"status code incorrect, should be 200, but got {response.status_code}", + ) + self.assertEqual( + response.json(), + { + "resolved_node": None, + "supporting_data": { + "exact_match_recycled_install": None, + "exact_match_node": None, + "exact_match_nonrecycled_install": { + "id": self.install_no_node.id, + "install_number": 12000, + "node": None, + }, + }, + }, + ) + + def test_disambiguate_recyled_install(self): + response = self.client.get("/api/v1/disambiguate-number/?number=123") + self.assertEqual( + 200, + response.status_code, + f"status code incorrect, should be 200, but got {response.status_code}", + ) + self.assertEqual( + response.json(), + { + "resolved_node": { + "id": self.node_with_recycled_number.id, + "network_number": 123, + }, + "supporting_data": { + "exact_match_recycled_install": { + "id": self.recycled_install.id, + "install_number": 123, + "node": None, + }, + "exact_match_node": { + "id": self.node_with_recycled_number.id, + "network_number": 123, + }, + "exact_match_nonrecycled_install": None, + }, + }, + ) + + def test_disambiguate_non_recycled_node(self): + response = self.client.get("/api/v1/disambiguate-number/?number=201") + self.assertEqual( + 200, + response.status_code, + f"status code incorrect, should be 200, but got {response.status_code}", + ) + self.assertEqual( + response.json(), + { + "resolved_node": { + "id": self.active_install_node.id, + "network_number": 201, + }, + "supporting_data": { + "exact_match_recycled_install": None, + "exact_match_node": { + "id": self.active_install_node.id, + "network_number": 201, + }, + "exact_match_nonrecycled_install": None, + }, + }, + ) + + def test_disambiguate_old_active_install_node_combo(self): + response = self.client.get("/api/v1/disambiguate-number/?number=100") + self.assertEqual( + 200, + response.status_code, + f"status code incorrect, should be 200, but got {response.status_code}", + ) + self.assertEqual( + response.json(), + { + "resolved_node": { + "id": self.active_install_low_number_node.id, + "network_number": 100, + }, + "supporting_data": { + "exact_match_recycled_install": None, + "exact_match_node": { + "id": self.active_install_low_number_node.id, + "network_number": 100, + }, + "exact_match_nonrecycled_install": { + "id": self.active_install_low_number_king_of_node.id, + "install_number": 100, + "node": { + "id": self.active_install_low_number_node.id, + "network_number": 100, + }, + }, + }, + }, + ) + + def test_disambiguate_old_active_install_without_node_combo(self): + response = self.client.get("/api/v1/disambiguate-number/?number=150") + self.assertEqual( + 200, + response.status_code, + f"status code incorrect, should be 200, but got {response.status_code}", + ) + self.assertEqual( + response.json(), + { + "resolved_node": { + "id": self.active_install_low_number_node.id, + "network_number": 100, + }, + "supporting_data": { + "exact_match_recycled_install": None, + "exact_match_node": None, + "exact_match_nonrecycled_install": { + "id": self.active_install_low_number_non_king.id, + "install_number": 150, + "node": { + "id": self.active_install_low_number_node.id, + "network_number": 100, + }, + }, + }, + }, + ) diff --git a/src/meshapi/urls.py b/src/meshapi/urls.py index dc9a2642..14a90bb4 100644 --- a/src/meshapi/urls.py +++ b/src/meshapi/urls.py @@ -34,6 +34,11 @@ path("devices//", views.DeviceDetail.as_view(), name="meshapi-v1-device-detail"), path("join/", views.join_form, name="meshapi-v1-join"), path("nn-assign/", views.network_number_assignment, name="meshapi-v1-nn-assign"), + path( + "disambiguate-number/", + views.DisambiguateInstallOrNetworkNumber.as_view(), + name="meshapi-v1-disambiguate-number", + ), path("buildings/lookup/", views.LookupBuilding.as_view(), name="meshapi-v1-lookup-building"), path("members/lookup/", views.LookupMember.as_view(), name="meshapi-v1-lookup-member"), path("installs/lookup/", views.LookupInstall.as_view(), name="meshapi-v1-lookup-install"), diff --git a/src/meshapi/views/__init__.py b/src/meshapi/views/__init__.py index e98e888f..081a763a 100644 --- a/src/meshapi/views/__init__.py +++ b/src/meshapi/views/__init__.py @@ -1,5 +1,6 @@ from .forms import * from .geography import * +from .helpers import * from .lookups import * from .map import * from .model_api import * diff --git a/src/meshapi/views/helpers.py b/src/meshapi/views/helpers.py new file mode 100644 index 00000000..449487f6 --- /dev/null +++ b/src/meshapi/views/helpers.py @@ -0,0 +1,128 @@ +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema, inline_serializer +from rest_framework import serializers, status +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView + +from meshapi.models import Install, Node +from meshapi.permissions import HasDisambiguateNumberPermission +from meshapi.serializers import NestedKeyObjectRelatedField +from meshapi.serializers.nested_object_references import InstallNestedRefSerializer + +helper_err_response_schema = inline_serializer("ErrorResponse", fields={"detail": serializers.CharField()}) + +install_serializer_field = InstallNestedRefSerializer() +node_serializer_field = NestedKeyObjectRelatedField( + queryset=Node.objects.all(), + additional_keys=("network_number",), +) + + +# TODO: Fix docs, maybe with custom install serializer? +class DisambiguateInstallOrNetworkNumber(APIView): + permission_classes = [HasDisambiguateNumberPermission] + + @extend_schema( + tags=["Helpers"], + summary="Identify a number as either an NN or an install number (or both) " + "based on MeshDB data about Installs and/or Nodes with that number", + parameters=[ + OpenApiParameter( + "number", + OpenApiTypes.INT, + OpenApiParameter.QUERY, + description="The number to use to look up Installs and Nodes", + required=True, + ), + ], + responses={ + "200": OpenApiResponse( + inline_serializer( + "DisambiguateNumberSuccessResponse", + fields={ + "resolved_node": NestedKeyObjectRelatedField( + queryset=Node.objects.all(), + additional_keys=("network_number",), + help_text="The node that we guess this number represents. This is an exact NN match " + "if that node exists, otherwise we treat the input number as an install number " + "and return the related node", + ), + "supporting_data": inline_serializer( + "DisambiguateNumberSupportingData", + fields={ + "exact_match_recycled_install": InstallNestedRefSerializer( + help_text="An install with the install number exactly matching the requested " + "number, if that install HAS had its install number recycled (or null " + "if none exists). When this field is non-null, exact_match_node will " + "also be populated with that node" + ), + "exact_match_node": NestedKeyObjectRelatedField( + queryset=Node.objects.all(), + additional_keys=("network_number",), + help_text="A Node with the network number exactly matching the requested number, " + "if it exists", + ), + "exact_match_nonrecycled_install": InstallNestedRefSerializer( + help_text="An install with the install number exactly matching the requested " + "number, if that install has NOT had its install number recycled (or null if " + "none exists)" + ), + }, + ), + }, + ), + description="At least one Node or Install exists corresponding to this", + ), + "400": OpenApiResponse( + helper_err_response_schema, + description="Invalid request", + ), + "404": OpenApiResponse( + helper_err_response_schema, + description="Requested number could not be found as either an Network or Install number", + ), + "500": OpenApiResponse(helper_err_response_schema, description="Unexpected internal error"), + }, + ) + def get(self, request: Request) -> Response: + ambiguous_number_str = request.query_params.get("number", "") + try: + ambiguous_number = int(ambiguous_number_str) + if ambiguous_number <= 0: + raise ValueError() + except ValueError: + return Response( + {"detail": f"Invalid number: '{ambiguous_number_str}'. Must be an integer greater than zero"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + install_object = Install.objects.filter(install_number=ambiguous_number).first() + node_object = Node.objects.filter(network_number=ambiguous_number).first() + + if not install_object and not node_object: + return Response( + {"detail": f"Provided number: {ambiguous_number} did not correspond to any install or node objects"}, + status=status.HTTP_404_NOT_FOUND, + ) + + resolved_node = node_object if node_object else (install_object.node if install_object else None) + + output = { + "resolved_node": node_serializer_field.to_representation(resolved_node) if resolved_node else None, + "supporting_data": { + "exact_match_recycled_install": ( + install_serializer_field.to_representation(install_object) + if install_object and install_object.status == Install.InstallStatus.NN_REASSIGNED + else None + ), + "exact_match_nonrecycled_install": ( + install_serializer_field.to_representation(install_object) + if install_object and install_object.status != Install.InstallStatus.NN_REASSIGNED + else None + ), + "exact_match_node": node_serializer_field.to_representation(node_object) if node_object else None, + }, + } + + return Response(data=output, status=status.HTTP_200_OK) diff --git a/src/meshdb/settings.py b/src/meshdb/settings.py index f20f40c4..779c25e8 100644 --- a/src/meshdb/settings.py +++ b/src/meshdb/settings.py @@ -487,6 +487,10 @@ "Uses a legacy data format, not recommended for new applications", }, {"name": "User Forms", "description": "Forms exposed directly to humans"}, + { + "name": "Helpers", + "description": "Utilities to assist with misc tasks related to NYC Mesh data", + }, { "name": "Panoramas", "description": "Used to bulk ingest panoramas. Internal use only (use Building.panoramas instead)",