From 7277ea931e11e08ec1ad43dc825f1e95a403d7d7 Mon Sep 17 00:00:00 2001 From: Andrew Dickinson Date: Sat, 27 Jan 2024 22:34:04 -0500 Subject: [PATCH] Add map data endpoints --- src/meshapi/permissions.py | 11 +- src/meshapi/serializers.py | 193 ++++++++- src/meshapi/tests/test_map_endpoints.py | 458 ++++++++++++++++++++ src/meshapi/urls.py | 6 +- src/meshapi/views/__init__.py | 3 +- src/meshapi/views/map.py | 49 +++ src/meshdb/utils/spreadsheet_import/main.py | 2 + 7 files changed, 718 insertions(+), 4 deletions(-) create mode 100644 src/meshapi/tests/test_map_endpoints.py create mode 100644 src/meshapi/views/map.py diff --git a/src/meshapi/permissions.py b/src/meshapi/permissions.py index 622338aa8..2cfacf502 100644 --- a/src/meshapi/permissions.py +++ b/src/meshapi/permissions.py @@ -87,7 +87,7 @@ def has_permission(self, request, view): # Anyone can list installs, but only installers or admins can create them class InstallListCreatePermissions(permissions.BasePermission): def has_permission(self, request, view): - if request.method == "GET": + if request.method in ["GET", "OPTIONS"]: return True else: if not (request.user.is_superuser or is_admin(request.user) or is_installer(request.user)): @@ -95,6 +95,15 @@ def has_permission(self, request, view): return True +# Anyone can list links and sectors +class LinkSectorListPermissions(permissions.BasePermission): + def has_permission(self, request, view): + if request.method in ["GET", "OPTIONS"]: + return True + else: + raise PermissionDenied(perm_denied_generic_msg) + + # Anyone can retrieve installs, installers can update them, only # admins can delete them class InstallRetrieveUpdateDestroyPermissions(permissions.BasePermission): diff --git a/src/meshapi/serializers.py b/src/meshapi/serializers.py index 2f216eee9..cc14be585 100644 --- a/src/meshapi/serializers.py +++ b/src/meshapi/serializers.py @@ -1,6 +1,10 @@ +import datetime +from collections import OrderedDict + from django.contrib.auth.models import User from rest_framework import serializers -from meshapi.models import Building, Member, Install + +from meshapi.models import Building, Install, Link, Member, Sector class UserSerializer(serializers.ModelSerializer): @@ -25,3 +29,190 @@ class InstallSerializer(serializers.ModelSerializer): class Meta: model = Install fields = "__all__" + + +class JavascriptDateField(serializers.IntegerField): + def to_internal_value(self, date_int_val: int): + if date_int_val is None: + return None + + return datetime.datetime.fromtimestamp(date_int_val / 1000).date() + + def to_representation(self, date_val: datetime.date): + if date_val is None: + return None + + return int( + datetime.datetime.combine( + date_val, + datetime.datetime.min.time(), + ).timestamp() + * 1000 + ) + + +def get_install_number_from_building(building): + installs = building.install_set.all() + if len(installs) == 0: + if building.primary_nn: + return building.primary_nn + else: + raise ValueError( + f"Building with ID {building} is invalid for install " + f"number conversion, no attached installs or NN assigned" + ) + else: + return min(install.install_number for install in installs) + + +class MapDataInstallSerializer(serializers.ModelSerializer): + class Meta: + model = Install + fields = ( + "id", + "name", + "status", + "coordinates", + "requestDate", + "installDate", + "roofAccess", + "notes", + "panoramas", + ) + + id = serializers.IntegerField(source="install_number") + name = serializers.CharField(source="building.node_name") + status = serializers.SerializerMethodField("convert_status_to_spreadsheet_status") + coordinates = serializers.SerializerMethodField("get_building_coordinates") + requestDate = JavascriptDateField(source="request_date") + installDate = JavascriptDateField(source="install_date") + roofAccess = serializers.BooleanField(source="roof_access") + notes = serializers.SerializerMethodField("get_start_of_notes") + panoramas = serializers.ReadOnlyField(default=[]) # FIXME: THIS WILL REMOVE ALL PANORAMAS FROM THE MAP UI + + def get_building_coordinates(self, install): + building = install.building + return [building.longitude, building.latitude, building.altitude] + + def get_start_of_notes(self, install): + if install.notes: + note_parts = install.notes.split("\n") + return note_parts[1] if len(note_parts) > 1 and note_parts[1] != "None" else None + return None + + def convert_status_to_spreadsheet_status(self, install): + if install.install_status == Install.InstallStatus.OPEN: + return None + elif install.install_status == Install.InstallStatus.NN_ASSIGNED: + return "NN assigned" + elif install.install_status == Install.InstallStatus.BLOCKED: + return "No Los" + elif install.install_status == Install.InstallStatus.ACTIVE: + return "Installed" + elif install.install_status == Install.InstallStatus.INACTIVE: + return "Powered Off" + elif install.install_status == Install.InstallStatus.CLOSED: + return "Abandoned" + + return install.install_status + + def to_representation(self, install): + result = super().to_representation(install) + + # Remove null fields when applicable to match the existing interface + for key in ["name", "status", "notes", "installDate"]: + if result[key] is None: + del result[key] + + return result + + +class MapDataLinkSerializer(serializers.ModelSerializer): + class Meta: + model = Link + fields = ( + "from_", + "to", + "status", + "installDate", + ) + + from_ = serializers.SerializerMethodField("get_from_install_number") + to = serializers.SerializerMethodField("get_to_install_number") + status = serializers.SerializerMethodField("convert_status_to_spreadsheet_status") + installDate = JavascriptDateField(source="install_date") + + def convert_status_to_spreadsheet_status(self, link): + if link.status != Link.LinkStatus.ACTIVE: + return str(link.status).lower() + + if link.type == Link.LinkType.FIBER: + return "fiber" + elif link.type == Link.LinkType.VPN: + return "vpn" + elif link.type == Link.LinkType.MMWAVE: + return "60GHz" + + return "active" + + def get_to_install_number(self, link): + return get_install_number_from_building(link.to_building) + + def get_from_install_number(self, link): + return get_install_number_from_building(link.from_building) + + def get_fields(self): + result = super().get_fields() + # Rename `from_` to `from` + from_ = result.pop("from_") + + new_fields = OrderedDict({"from": from_}) + for key, value in result.items(): + new_fields[key] = value + + return new_fields + + def to_representation(self, link): + result = super().to_representation(link) + + # Remove null fields when applicable to match the existing interface + for key in ["installDate"]: + if result[key] is None: + del result[key] + + return result + + +class MapDataSectorSerializer(serializers.ModelSerializer): + class Meta: + model = Sector + fields = ( + "nodeId", + "radius", + "azimuth", + "width", + "status", + "device", + "installDate", + ) + + nodeId = serializers.SerializerMethodField("get_node_id") + status = serializers.SerializerMethodField("convert_status_to_spreadsheet_status") + device = serializers.CharField(source="device_name") + installDate = JavascriptDateField(source="install_date") + + def get_node_id(self, sector): + return get_install_number_from_building(sector.building) + + def convert_status_to_spreadsheet_status(self, sector): + return str(sector.status).lower() + + def to_representation(self, sector): + result = super().to_representation(sector) + + # Remove null fields when applicable to match the existing interface + for key in ["installDate"]: + if result[key] is None: + del result[key] + + return result diff --git a/src/meshapi/tests/test_map_endpoints.py b/src/meshapi/tests/test_map_endpoints.py new file mode 100644 index 000000000..e89fa526a --- /dev/null +++ b/src/meshapi/tests/test_map_endpoints.py @@ -0,0 +1,458 @@ +import datetime +import json + +from django.contrib.auth.models import Group, User +from django.test import Client, TestCase +from rest_framework.authtoken.models import Token + +from meshapi.models import Building, Install, Link, Member, Sector + + +class TestViewsGetUnauthenticated(TestCase): + c = Client() + + # def setUp(self) -> None: + # + + def test_views_get_unauthenticated(self): + routes = [ + ("/api/v1/mapdata/installs/", 200), + ("/api/v1/mapdata/links/", 200), + ("/api/v1/mapdata/sectors/", 200), + ] + + for route, code in routes: + response = self.c.get(route) + self.assertEqual( + code, + response.status_code, + f"status code incorrect for GET {route}. Should be {code}, but got {response.status_code}", + ) + + response = self.c.options(route) + self.assertEqual( + code, + response.status_code, + f"status code incorrect for OPTIONS {route}. Should be {code}, but got {response.status_code}", + ) + + def test_install_data(self): + installs = [] + buildings = [] + + # Use the same member for everything since it doesn't matter + member = Member(name="Fake Name") + member.save() + + buildings.append( + Building( + building_status=Building.BuildingStatus.INACTIVE, + address_truth_sources="", + latitude=40.7686554, + longitude=-73.9291817, + altitude=37, + ) + ) + installs.append( + Install( + install_number=2, + install_status=Install.InstallStatus.INACTIVE, + request_date=datetime.date(2015, 3, 15), + install_date=datetime.date(2021, 7, 25), + roof_access=False, + building=buildings[-1], + member=member, + notes="Spreadsheet notes:\nPeter", + ) + ) + + buildings.append( + Building( + building_status=Building.BuildingStatus.ACTIVE, + address_truth_sources="", + latitude=40.724868, + longitude=-73.987881, + altitude=27, + node_name="Brian", + ) + ) + installs.append( + Install( + install_number=3, + install_status=Install.InstallStatus.ACTIVE, + request_date=datetime.date(2015, 3, 15), + install_date=datetime.date(2014, 10, 14), + roof_access=False, + building=buildings[-1], + member=member, + notes="Spreadsheet notes:\nHub: LiteBeamLR to SN1 plus kiosk failover", + ) + ) + + buildings.append( + Building( + building_status=Building.BuildingStatus.ACTIVE, + address_truth_sources="", + latitude=40.660073, + longitude=-73.921184, + altitude=16, + ) + ) + installs.append( + Install( + install_number=190, + install_status=Install.InstallStatus.NN_ASSIGNED, + request_date=datetime.date(2015, 9, 30), + roof_access=False, + building=buildings[-1], + member=member, + ) + ) + + buildings.append( + Building( + building_status=Building.BuildingStatus.ACTIVE, + address_truth_sources="", + latitude=40.6962265, + longitude=-73.9917741, + altitude=66, + ) + ) + installs.append( + Install( + install_number=14956, + install_status=Install.InstallStatus.OPEN, + request_date=datetime.date(2024, 1, 27), + roof_access=True, + building=buildings[-1], + member=member, + ) + ) + + installs.append( + Install( + install_number=2134, + install_status=Install.InstallStatus.CLOSED, + request_date=datetime.date(2024, 1, 27), + roof_access=True, + building=buildings[-1], + member=member, + ) + ) + + for building in buildings: + building.save() + + for install in installs: + install.save() + + self.maxDiff = None + response = self.c.get("/api/v1/mapdata/installs/") + + self.assertEqual( + json.loads(response.content.decode("UTF8")), + [ + { + "id": 2, + "status": "Powered Off", + "coordinates": [-73.9291817, 40.7686554, 37.0], + "requestDate": 1426377600000, + "installDate": 1627171200000, + "roofAccess": False, + "notes": "Peter", + "panoramas": [], + }, + { + "id": 3, + "name": "Brian", + "status": "Installed", + "coordinates": [-73.987881, 40.724868, 27.0], + "requestDate": 1426377600000, + "installDate": 1413244800000, + "roofAccess": False, + "notes": "Hub: LiteBeamLR to SN1 plus kiosk failover", + "panoramas": [], + }, + { + "id": 190, + "status": "NN assigned", + "coordinates": [-73.921184, 40.660073, 16], + "requestDate": 1443571200000, + "roofAccess": False, + "panoramas": [], + }, + { + "id": 14956, + "coordinates": [-73.9917741, 40.6962265, 66], + "requestDate": 1706313600000, + "roofAccess": True, + "panoramas": [], + }, + ], + ) + + def test_sector_data(self): + sectors = [] + buildings = [] + + # Use the same member for everything since it doesn't matter + member = Member(name="Fake Name") + member.save() + + buildings.append( + Building( + building_status=Building.BuildingStatus.ACTIVE, + address_truth_sources="", + latitude=0, + longitude=0, + altitude=0, + primary_nn=155, + ) + ) + sectors.append( + Sector( + building=buildings[-1], + radius=0.3, + azimuth=0, + width=360, + status=Sector.SectorStatus.ACTIVE, + device_name="Omni", + install_date=datetime.date(2021, 3, 21), + ) + ) + buildings.append( + Building( + building_status=Building.BuildingStatus.ACTIVE, + address_truth_sources="", + latitude=0, + longitude=0, + altitude=0, + primary_nn=227, + ) + ) + sectors.append( + Sector( + building=buildings[-1], + radius=0.75, + azimuth=300, + width=90, + status=Sector.SectorStatus.ABANDONED, + device_name="SN1Sector2", + ) + ) + buildings.append( + Building( + building_status=Building.BuildingStatus.ACTIVE, + address_truth_sources="", + latitude=0, + longitude=0, + altitude=0, + ) + ) + sectors.append( + Sector( + building=buildings[-1], + radius=0.3, + azimuth=0, + width=360, + status=Sector.SectorStatus.POTENTIAL, + device_name="Omni", + ) + ) + + for building in buildings: + building.save() + + for sector in sectors: + sector.save() + + install = Install( + install_number=1126, + install_status=Install.InstallStatus.ACTIVE, + request_date=datetime.date(2015, 9, 30), + roof_access=False, + building=buildings[-1], + member=member, + ) + install.save() + + self.maxDiff = None + response = self.c.get("/api/v1/mapdata/sectors/") + + self.assertEqual( + json.loads(response.content.decode("UTF8")), + [ + { + "nodeId": 155, + "radius": 0.3, + "azimuth": 0, + "width": 360, + "status": "active", + "device": "Omni", + "installDate": 1616284800000, + }, + { + "nodeId": 227, + "radius": 0.75, + "azimuth": 300, + "width": 90, + "status": "abandoned", + "device": "SN1Sector2", + }, + { + "nodeId": 1126, + "radius": 0.3, + "azimuth": 0, + "width": 360, + "status": "potential", + "device": "Omni", + }, + ], + ) + + def test_link_data(self): + links = [] + + grand = Building( + building_status=Building.BuildingStatus.ACTIVE, + address_truth_sources="", + latitude=0, + longitude=0, + altitude=0, + primary_nn=1934, + ) + grand.save() + + sn1 = Building( + building_status=Building.BuildingStatus.ACTIVE, + address_truth_sources="", + latitude=0, + longitude=0, + altitude=0, + primary_nn=227, + ) + sn1.save() + + sn10 = Building( + building_status=Building.BuildingStatus.ACTIVE, + address_truth_sources="", + latitude=0, + longitude=0, + altitude=0, + primary_nn=10, + ) + sn10.save() + + sn3 = Building( + building_status=Building.BuildingStatus.ACTIVE, + address_truth_sources="", + latitude=0, + longitude=0, + altitude=0, + primary_nn=713, + ) + sn3.save() + + brian = Building( + building_status=Building.BuildingStatus.ACTIVE, + address_truth_sources="", + latitude=0, + longitude=0, + altitude=0, + primary_nn=3, + ) + brian.save() + + random = Building( + building_status=Building.BuildingStatus.ACTIVE, + address_truth_sources="", + latitude=0, + longitude=0, + altitude=0, + primary_nn=123, + ) + random.save() + + links.append( + Link( + from_building=sn1, + to_building=sn3, + status=Link.LinkStatus.ACTIVE, + type=Link.LinkType.VPN, + install_date=datetime.date(2022, 1, 26), + ) + ) + + links.append( + Link( + from_building=sn1, + to_building=grand, + status=Link.LinkStatus.ACTIVE, + type=Link.LinkType.MMWAVE, + ) + ) + + links.append( + Link( + from_building=sn1, + to_building=brian, + status=Link.LinkStatus.ACTIVE, + type=Link.LinkType.STANDARD, + ) + ) + + links.append( + Link( + from_building=grand, + to_building=sn10, + status=Link.LinkStatus.ACTIVE, + type=Link.LinkType.FIBER, + ) + ) + + links.append( + Link( + from_building=grand, + to_building=random, + status=Link.LinkStatus.PLANNED, + type=Link.LinkType.STANDARD, + ) + ) + + for link in links: + link.save() + + self.maxDiff = None + response = self.c.get("/api/v1/mapdata/links/") + + self.assertEqual( + json.loads(response.content.decode("UTF8")), + [ + { + "from": 227, + "to": 713, + "status": "vpn", + "installDate": 1643155200000, + }, + { + "from": 227, + "to": 1934, + "status": "60GHz", + }, + { + "from": 227, + "to": 3, + "status": "active", + }, + { + "from": 1934, + "to": 10, + "status": "fiber", + }, + { + "from": 1934, + "to": 123, + "status": "planned", + }, + ], + ) diff --git a/src/meshapi/urls.py b/src/meshapi/urls.py index 2ec523d93..b69af4eb6 100644 --- a/src/meshapi/urls.py +++ b/src/meshapi/urls.py @@ -1,5 +1,6 @@ -from django.urls import path, include +from django.urls import include, path from rest_framework.urlpatterns import format_suffix_patterns + from meshapi import views urlpatterns = [ @@ -17,6 +18,9 @@ path("nn-assign/", views.network_number_assignment, name="meshapi-v1-nn-assign"), path("member/lookup/", views.LookupMember.as_view(), name="meshapi-v1-lookup-member"), path("install/lookup/", views.LookupInstall.as_view(), name="meshapi-v1-lookup-install"), + path("mapdata/installs/", views.MapDataInstallList.as_view(), name="meshapi-v1-map-data-installs"), + path("mapdata/links/", views.MapDataLinkList.as_view(), name="meshapi-v1-map-data-links"), + path("mapdata/sectors/", views.MapDataSectorlList.as_view(), name="meshapi-v1-map-data-sectors"), ] urlpatterns = format_suffix_patterns(urlpatterns) diff --git a/src/meshapi/views/__init__.py b/src/meshapi/views/__init__.py index 5b5d65ea3..743e2a49d 100644 --- a/src/meshapi/views/__init__.py +++ b/src/meshapi/views/__init__.py @@ -1,3 +1,4 @@ -from .model_api import * from .forms import * from .lookups import * +from .map import * +from .model_api import * diff --git a/src/meshapi/views/map.py b/src/meshapi/views/map.py new file mode 100644 index 000000000..c82edd2fc --- /dev/null +++ b/src/meshapi/views/map.py @@ -0,0 +1,49 @@ +from django.db.models import Q +from rest_framework import generics + +from meshapi.models import Building, Install, Link, Sector +from meshapi.permissions import InstallListCreatePermissions, LinkSectorListPermissions +from meshapi.serializers import MapDataInstallSerializer, MapDataLinkSerializer, MapDataSectorSerializer + + +class MapDataInstallList(generics.ListAPIView): + permission_classes = [InstallListCreatePermissions] + serializer_class = MapDataInstallSerializer + pagination_class = None + + def get_queryset(self): + all_installs = [] + + queryset = Install.objects.filter(~Q(install_status__in=[Install.InstallStatus.CLOSED])) + + for install in queryset: + all_installs.append(install) + + for building in Building.objects.filter(primary_nn__isnull=False): + representative_install = building.install_set.all()[0] + all_installs.append( + Install( + install_number=building.primary_nn, + install_status=Install.InstallStatus.NN_ASSIGNED, + building=building, + request_date=representative_install.request_date, + roof_access=representative_install.roof_access, + ), + ) + + all_installs.sort(key=lambda i: i.install_number) + return all_installs + + +class MapDataLinkList(generics.ListAPIView): + permission_classes = [LinkSectorListPermissions] + serializer_class = MapDataLinkSerializer + pagination_class = None + queryset = Link.objects.filter(~Q(status__in=[Link.LinkStatus.DEAD])) + + +class MapDataSectorlList(generics.ListAPIView): + permission_classes = [LinkSectorListPermissions] + serializer_class = MapDataSectorSerializer + pagination_class = None + queryset = Sector.objects.all() diff --git a/src/meshdb/utils/spreadsheet_import/main.py b/src/meshdb/utils/spreadsheet_import/main.py index 1c39329ab..d5d74abe5 100644 --- a/src/meshdb/utils/spreadsheet_import/main.py +++ b/src/meshdb/utils/spreadsheet_import/main.py @@ -160,6 +160,8 @@ def main(): link_type = models.Link.LinkType.VPN elif spreadsheet_link.status == SpreadsheetLinkStatus.sixty_ghz: link_type = models.Link.LinkType.MMWAVE + elif spreadsheet_link.status == SpreadsheetLinkStatus.fiber: + link_type = models.Link.LinkType.FIBER link_notes = "\n".join([spreadsheet_link.notes, spreadsheet_link.comments]).strip() link = models.Link(