diff --git a/.github/workflows/main.yaml b/.github/workflows/lint.yaml similarity index 97% rename from .github/workflows/main.yaml rename to .github/workflows/lint.yaml index a0dd2cf2..3174731e 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/lint.yaml @@ -5,7 +5,7 @@ on: [pull_request] permissions: read-all jobs: - black: + invoke_lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 diff --git a/pyproject.toml b/pyproject.toml index bdda432d..cef763a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,12 +46,15 @@ dev = [ "black == 23.7.*", "isort == 5.12.*", "coverage == 7.3.*", - "mypy == 1.5.*", + "mypy == 1.10.0", "flask == 3.0.*", "django-cprofile-middleware==1.0.5", "django-silk==5.1.0", "types-requests==2.32.*", "types-six==1.16.0.*", + "django-stubs[compatible-mypy]==5.0.*", + "djangorestframework-stubs[compatible-mypy]==3.15.*", + "django-filter-stubs==0.1.3", ] [project.scripts] @@ -66,6 +69,7 @@ where = ["src"] [tool.mypy] +plugins = ["mypy_django_plugin.main", "mypy_drf_plugin.main"] check_untyped_defs = true show_error_codes = true pretty = true @@ -74,13 +78,30 @@ disallow_untyped_calls = true disallow_untyped_defs = true disallow_incomplete_defs = true warn_unused_configs = true +files = [ "src/" ] exclude = [ - "^src/meshdb/utils/spreadsheet_import/.*", - "^src/meshapi/tests/.*", - "^src/meshapi_hooks/tests/.*", - "^src/meshapi/migrations/.*", + "src/meshapi/tests/.*", + "src/meshapi_hooks/tests/.*", + "src/meshapi/migrations/.*", + "src/meshdb/utils/spreadsheet_import/.*", + "src/meshapi/util/django_pglocks.py", + "src/meshapi/models/util/custom_many_to_many.py", + "src/meshapi/docs.py" ] +[[tool.mypy.overrides]] +module = [ + "meshdb.utils.spreadsheet_import.*", + "meshapi.util.django_pglocks", + "meshapi.models.util.custom_many_to_many", + "meshapi.docs", +] +follow_imports = "skip" + + +[tool.django-stubs] +django_settings_module = "meshdb.settings" + [tool.black] line-length = 120 diff --git a/src/meshapi/admin/building.py b/src/meshapi/admin/building.py index 02585f93..d21306f3 100644 --- a/src/meshapi/admin/building.py +++ b/src/meshapi/admin/building.py @@ -1,6 +1,10 @@ +from typing import Iterable + +from django import forms from django.contrib import admin -from django.contrib.admin.options import forms -from django.utils.safestring import mark_safe +from django.contrib.admin import ModelAdmin +from django.db.models import QuerySet +from django.http import HttpRequest from meshapi.admin.inlines import InstallInline from meshapi.models import Building @@ -11,16 +15,16 @@ class BoroughFilter(admin.SimpleListFilter): title = "Borough" parameter_name = "borough" - def lookups(self, request, model_admin): - return ( - ("bronx", ("The Bronx")), - ("manhattan", ("Manhattan")), - ("brooklyn", ("Brooklyn")), - ("queens", ("Queens")), - ("staten_island", ("Staten Island")), - ) + def lookups(self, request: HttpRequest, model_admin: ModelAdmin) -> Iterable[tuple[str, str]]: + return [ + ("bronx", "The Bronx"), + ("manhattan", "Manhattan"), + ("brooklyn", "Brooklyn"), + ("queens", "Queens"), + ("staten_island", "Staten Island"), + ] - def queryset(self, request, queryset): + def queryset(self, request: HttpRequest, queryset: QuerySet[Building]) -> QuerySet[Building]: if self.value() == "bronx": return queryset.filter(city="Bronx") elif self.value() == "manhattan": @@ -116,12 +120,3 @@ class BuildingAdmin(admin.ModelAdmin): ] inlines = [InstallInline] filter_horizontal = ("nodes",) - - # This is probably a bad idea because you'll have to load a million panos - # and OOM your computer - # Need to find a way to "thumbnail-ize" them on the server side, probably. - @mark_safe - def thumb(self, obj): - return f"" - - thumb.__name__ = "Thumbnail" diff --git a/src/meshapi/admin/device.py b/src/meshapi/admin/device.py index 98087363..0e40782f 100644 --- a/src/meshapi/admin/device.py +++ b/src/meshapi/admin/device.py @@ -1,5 +1,7 @@ +from django import forms from django.contrib import admin -from django.contrib.admin.options import forms +from django.db.models import QuerySet +from django.http import HttpRequest from meshapi.admin.admin import device_fieldsets from meshapi.admin.inlines import DeviceLinkInline @@ -31,10 +33,10 @@ class DeviceAdmin(admin.ModelAdmin): "install_date", "model", ] - fieldsets = device_fieldsets + fieldsets = device_fieldsets # type: ignore[assignment] inlines = [DeviceLinkInline] - def get_queryset(self, request): + def get_queryset(self, request: HttpRequest) -> QuerySet[Device]: # Get the base queryset queryset = super().get_queryset(request) # Filter out sectors diff --git a/src/meshapi/admin/inlines.py b/src/meshapi/admin/inlines.py index 587fc98d..d051410c 100644 --- a/src/meshapi/admin/inlines.py +++ b/src/meshapi/admin/inlines.py @@ -1,10 +1,11 @@ -from typing import Any +from typing import Any, Optional from django.contrib import admin -from django.db.models import Q +from django.db.models import Model, Q, QuerySet +from django.http import HttpRequest from nonrelated_inlines.admin import NonrelatedTabularInline -from meshapi.models import Building, Device, Install, Link, Sector +from meshapi.models import Building, Device, Install, Link, Node, Sector # Inline with the typical rules we want + Formatting @@ -13,7 +14,7 @@ class BetterInline(admin.TabularInline): can_delete = False template = "admin/install_tabular.html" - def has_add_permission(self, request, obj) -> bool: + def has_add_permission(self, request: HttpRequest, obj: Optional[Any]) -> bool: # type: ignore[override] return False class Media: @@ -27,10 +28,10 @@ class BetterNonrelatedInline(NonrelatedTabularInline): can_delete = False template = "admin/install_tabular.html" - def has_add_permission(self, request, obj) -> bool: + def has_add_permission(self, request: HttpRequest, obj: Model) -> bool: return False - def save_new_instance(self, parent, instance): + def save_new_instance(self, parent: Any, instance: Any) -> None: pass class Media: @@ -48,7 +49,7 @@ class PanoramaInline(BetterNonrelatedInline): all_panoramas: dict[str, list[Any]] = {} - def get_form_queryset(self, obj): + def get_form_queryset(self, obj: Node) -> QuerySet[Building]: buildings = self.model.objects.filter(nodes=obj) panoramas = [] for b in buildings: @@ -73,7 +74,7 @@ class NonrelatedBuildingInline(BetterNonrelatedInline): # Hack to get the NN network_number = None - def get_form_queryset(self, obj): + def get_form_queryset(self, obj: Node) -> QuerySet[Building]: self.network_number = obj.pk return self.model.objects.filter(nodes=obj) @@ -90,9 +91,9 @@ class BuildingMembershipInline(admin.TabularInline): class DeviceInline(BetterInline): model = Device fields = ["status", "type", "model"] - readonly_fields = fields + readonly_fields = fields # type: ignore[assignment] - def get_queryset(self, request): + def get_queryset(self, request: HttpRequest) -> QuerySet[Device]: # Get the base queryset queryset = super().get_queryset(request) # Filter out sectors @@ -105,7 +106,7 @@ class NodeLinkInline(BetterNonrelatedInline): fields = ["status", "type", "from_device", "to_device"] readonly_fields = fields - def get_form_queryset(self, obj): + def get_form_queryset(self, obj: Node) -> QuerySet[Link]: from_device_q = Q(from_device__in=obj.devices.all()) to_device_q = Q(to_device__in=obj.devices.all()) all_links = from_device_q | to_device_q @@ -117,7 +118,7 @@ class DeviceLinkInline(BetterNonrelatedInline): fields = ["status", "type", "from_device", "to_device"] readonly_fields = fields - def get_form_queryset(self, obj): + def get_form_queryset(self, obj: Link) -> QuerySet[Link]: from_device_q = Q(from_device=obj) to_device_q = Q(to_device=obj) all_links = from_device_q | to_device_q @@ -127,11 +128,11 @@ def get_form_queryset(self, obj): class SectorInline(BetterInline): model = Sector fields = ["status", "type", "model"] - readonly_fields = fields + readonly_fields = fields # type: ignore[assignment] # This controls the list of installs reverse FK'd to Buildings and Members class InstallInline(BetterInline): model = Install fields = ["status", "node", "member", "building", "unit"] - readonly_fields = fields + readonly_fields = fields # type: ignore[assignment] diff --git a/src/meshapi/admin/install.py b/src/meshapi/admin/install.py index 5dbef951..0973ccb2 100644 --- a/src/meshapi/admin/install.py +++ b/src/meshapi/admin/install.py @@ -1,5 +1,9 @@ +from typing import Tuple + +from django import forms from django.contrib import admin -from django.contrib.admin.options import forms +from django.db.models import QuerySet +from django.http import HttpRequest from meshapi.models import Install @@ -94,7 +98,9 @@ class InstallAdmin(admin.ModelAdmin): ), ] - def get_search_results(self, request, queryset, search_term): + def get_search_results( + self, request: HttpRequest, queryset: QuerySet[Install], search_term: str + ) -> Tuple[QuerySet[Install], bool]: queryset, may_have_duplicates = super().get_search_results( request, queryset, diff --git a/src/meshapi/admin/link.py b/src/meshapi/admin/link.py index 5ec522bd..365bcdfb 100644 --- a/src/meshapi/admin/link.py +++ b/src/meshapi/admin/link.py @@ -1,5 +1,5 @@ +from django import forms from django.contrib import admin -from django.contrib.admin.options import forms from meshapi.models import Link diff --git a/src/meshapi/admin/member.py b/src/meshapi/admin/member.py index 2b0641fb..bce50246 100644 --- a/src/meshapi/admin/member.py +++ b/src/meshapi/admin/member.py @@ -1,5 +1,5 @@ +from django import forms from django.contrib import admin -from django.contrib.admin.options import forms from meshapi.admin.inlines import InstallInline from meshapi.models import Member diff --git a/src/meshapi/admin/node.py b/src/meshapi/admin/node.py index d1c3138c..cea291d9 100644 --- a/src/meshapi/admin/node.py +++ b/src/meshapi/admin/node.py @@ -1,5 +1,7 @@ +from typing import Optional + +from django import forms from django.contrib import admin -from django.contrib.admin.options import forms from meshapi.admin.inlines import ( BuildingMembershipInline, @@ -10,7 +12,7 @@ PanoramaInline, SectorInline, ) -from meshapi.models import Node +from meshapi.models import Building, Node admin.site.site_header = "MeshDB Admin" admin.site.site_title = "MeshDB Admin Portal" @@ -83,5 +85,5 @@ class NodeAdmin(admin.ModelAdmin): NodeLinkInline, ] - def address(self, obj): + def address(self, obj: Node) -> Optional[Building]: return obj.buildings.first() diff --git a/src/meshapi/admin/sector.py b/src/meshapi/admin/sector.py index 0c925f00..6b01f431 100644 --- a/src/meshapi/admin/sector.py +++ b/src/meshapi/admin/sector.py @@ -1,5 +1,5 @@ +from django import forms from django.contrib import admin -from django.contrib.admin.options import forms from meshapi.admin.admin import device_fieldsets from meshapi.admin.inlines import DeviceLinkInline @@ -39,4 +39,4 @@ class SectorAdmin(admin.ModelAdmin): ] }, ), - ] + ] # type: ignore[assignment] diff --git a/src/meshapi/docs.py b/src/meshapi/docs.py index d9aefac5..b54d12c2 100644 --- a/src/meshapi/docs.py +++ b/src/meshapi/docs.py @@ -1,7 +1,8 @@ import json from textwrap import dedent -from typing import Dict, List, Optional +from typing import Any, Dict, List, Optional +from django.http import HttpRequest, HttpResponse from django.urls import reverse from drf_spectacular.authentication import SessionScheme from drf_spectacular.settings import spectacular_settings @@ -12,7 +13,7 @@ class SpectacularSwaggerInjectVarsView(SpectacularSwaggerView): @extend_schema(exclude=True) - def get(self, request, *args, **kwargs): + def get(self, request: HttpRequest, *args: List[Any], **kwargs: Dict[str, Any]) -> HttpResponse: spectacular_settings.DESCRIPTION = ( "Programmatic access to mesh core data, detailing our installs, members, etc. " '\n\nTo use an authorization token, use the "Authorize" button, and under "tokenAuth" enter ' diff --git a/src/meshapi/management/commands/scramble_members.py b/src/meshapi/management/commands/scramble_members.py index 72df07cb..2e382be2 100644 --- a/src/meshapi/management/commands/scramble_members.py +++ b/src/meshapi/management/commands/scramble_members.py @@ -1,7 +1,7 @@ from argparse import ArgumentParser from datetime import date, timedelta from random import randint, randrange -from typing import Any, Tuple +from typing import Any, Optional, Tuple from django.core.management.base import BaseCommand from django.db import transaction @@ -84,7 +84,7 @@ def handle(self, *args: Any, **options: Any) -> None: for device in devices: device.notes = fake.text() _, device.install_date, device.abandon_date = self.fuzz_dates( - None, device.install_date, device.abandon_date + date.today(), device.install_date, device.abandon_date ) device.save() @@ -92,20 +92,28 @@ def handle(self, *args: Any, **options: Any) -> None: links = Link.objects.all() for link in links: link.notes = fake.text() - _, link.install_date, link.abandon_date = self.fuzz_dates(None, link.install_date, link.abandon_date) + _, link.install_date, link.abandon_date = self.fuzz_dates( + date.today(), link.install_date, link.abandon_date + ) link.save() print("Scrambling nodes...") nodes = Node.objects.all() for node in nodes: node.notes = fake.text() - _, node.install_date, node.abandon_date = self.fuzz_dates(None, node.install_date, node.abandon_date) + _, node.install_date, node.abandon_date = self.fuzz_dates( + date.today(), node.install_date, node.abandon_date + ) node.save() print("Done") @staticmethod - def fuzz_dates(request_date: date | None, install_date: date, abandon_date: date) -> Tuple[date | None, date, date]: + def fuzz_dates( + request_date: date, + install_date: Optional[date], + abandon_date: Optional[date], + ) -> Tuple[date, Optional[date], Optional[date]]: if request_date: # Make it happen sooner so that there's no way the request date is # now beyond the install/abandon date. diff --git a/src/meshapi/models/node.py b/src/meshapi/models/node.py index 7da7cf69..cf699506 100644 --- a/src/meshapi/models/node.py +++ b/src/meshapi/models/node.py @@ -1,12 +1,19 @@ -from typing import Any +from typing import TYPE_CHECKING, Any from django.core.validators import MaxValueValidator from django.db import models from meshapi.util.network_number import NETWORK_NUMBER_MAX, get_next_available_network_number +if TYPE_CHECKING: + # Gate the import to avoid cycles + from meshapi.models.building import Building + class Node(models.Model): + # This should be added automatically by django-stubs, but for some reason it's not :( + buildings: models.QuerySet["Building"] + class NodeStatus(models.TextChoices): INACTIVE = "Inactive" ACTIVE = "Active" diff --git a/src/meshapi/permissions.py b/src/meshapi/permissions.py index a50b0631..8e6cb534 100644 --- a/src/meshapi/permissions.py +++ b/src/meshapi/permissions.py @@ -1,11 +1,11 @@ import json +import os from typing import Any, Optional -from django.conf import os -from django.contrib.auth import PermissionDenied from django.contrib.auth.models import User from django.db.models import Model from rest_framework import permissions +from rest_framework.exceptions import PermissionDenied from rest_framework.permissions import BasePermission from rest_framework.request import Request @@ -29,7 +29,7 @@ def has_permission(self, request: Request, view: Any) -> bool: raise NotImplementedError( "You must subclass HasDjangoPermission and specify the django_permission attribute" ) - return request.user and request.user.has_perm(self.django_permission) + return bool(request.user) and request.user.has_perm(self.django_permission) class HasNNAssignPermission(HasDjangoPermission): diff --git a/src/meshapi/serializers/map.py b/src/meshapi/serializers/map.py index 4d8c3fe9..c638cd58 100644 --- a/src/meshapi/serializers/map.py +++ b/src/meshapi/serializers/map.py @@ -1,7 +1,7 @@ import datetime import os from collections import OrderedDict -from typing import List, Optional +from typing import List, Optional, Tuple from urllib.parse import urlparse from rest_framework import serializers @@ -15,8 +15,8 @@ ALLOWED_INSTALL_STATUSES = set(Install.InstallStatus.values) - EXCLUDED_INSTALL_STATUSES -class JavascriptDateField(serializers.IntegerField): - def to_internal_value(self, date_int_val: int) -> Optional[datetime.date]: +class JavascriptDateField(serializers.Field): + def to_internal_value(self, date_int_val: Optional[int]) -> Optional[datetime.date]: if date_int_val is None: return None @@ -60,9 +60,9 @@ class Meta: notes = serializers.SerializerMethodField("get_synthetic_notes") panoramas = serializers.SerializerMethodField("get_panorama_filename") - def get_building_coordinates(self, install: Install) -> List[float]: + def get_building_coordinates(self, install: Install) -> Tuple[float, float, Optional[float]]: building = install.building - return [building.longitude, building.latitude, building.altitude] + return (building.longitude, building.latitude, building.altitude) def get_node_name(self, install: Install) -> Optional[str]: # Only include the node name if this is an old-school "node as install" situation @@ -70,7 +70,7 @@ def get_node_name(self, install: Install) -> Optional[str]: # case we add extra fake install objects with install_number = NN so that we can still # see the node name node = install.node - return node.name if node and install.node.network_number == install.install_number else None + return node.name if node and node.network_number == install.install_number else None def get_synthetic_notes(self, install: Install) -> Optional[str]: if not install.node: diff --git a/src/meshapi/serializers/model_api.py b/src/meshapi/serializers/model_api.py index 0e06113e..f1cd93cf 100644 --- a/src/meshapi/serializers/model_api.py +++ b/src/meshapi/serializers/model_api.py @@ -9,9 +9,11 @@ class Meta: model = Building exclude = ("primary_node", "nodes") - installs = serializers.PrimaryKeyRelatedField(many=True, read_only=True) - network_numbers = serializers.PrimaryKeyRelatedField(source="nodes", many=True, read_only=True) - primary_network_number = serializers.PrimaryKeyRelatedField( + installs: serializers.PrimaryKeyRelatedField = serializers.PrimaryKeyRelatedField(many=True, read_only=True) + network_numbers: serializers.PrimaryKeyRelatedField = serializers.PrimaryKeyRelatedField( + source="nodes", many=True, read_only=True + ) + primary_network_number: serializers.PrimaryKeyRelatedField = serializers.PrimaryKeyRelatedField( source="primary_node", queryset=Node.objects.all(), required=False, allow_null=True ) @@ -21,8 +23,8 @@ class Meta: model = Member fields = "__all__" - all_email_addresses = serializers.ReadOnlyField() - installs = serializers.PrimaryKeyRelatedField(many=True, read_only=True) + all_email_addresses: serializers.ReadOnlyField = serializers.ReadOnlyField() + installs: serializers.PrimaryKeyRelatedField = serializers.PrimaryKeyRelatedField(many=True, read_only=True) class InstallSerializer(serializers.ModelSerializer): @@ -46,8 +48,8 @@ class Meta: validators=[UniqueValidator(queryset=Node.objects.all())], read_only=True, ) - buildings = serializers.PrimaryKeyRelatedField(many=True, read_only=True) - devices = serializers.PrimaryKeyRelatedField(many=True, read_only=True) + buildings: serializers.PrimaryKeyRelatedField = serializers.PrimaryKeyRelatedField(many=True, read_only=True) + devices: serializers.PrimaryKeyRelatedField = serializers.PrimaryKeyRelatedField(many=True, read_only=True) class LinkSerializer(serializers.ModelSerializer): @@ -65,8 +67,8 @@ class Meta: source="node", queryset=Node.objects.all(), required=True, allow_null=False ) - links_from = serializers.PrimaryKeyRelatedField(many=True, read_only=True) - links_to = serializers.PrimaryKeyRelatedField(many=True, read_only=True) + links_from: serializers.PrimaryKeyRelatedField = serializers.PrimaryKeyRelatedField(many=True, read_only=True) + links_to: serializers.PrimaryKeyRelatedField = serializers.PrimaryKeyRelatedField(many=True, read_only=True) class SectorSerializer(serializers.ModelSerializer): @@ -74,9 +76,9 @@ class Meta: model = Sector exclude = ("node",) - network_number = serializers.PrimaryKeyRelatedField( + network_number: serializers.PrimaryKeyRelatedField = serializers.PrimaryKeyRelatedField( source="node", queryset=Node.objects.all(), required=True, allow_null=False ) - links_from = serializers.PrimaryKeyRelatedField(many=True, read_only=True) - links_to = serializers.PrimaryKeyRelatedField(many=True, read_only=True) + links_from: serializers.PrimaryKeyRelatedField = serializers.PrimaryKeyRelatedField(many=True, read_only=True) + links_to: serializers.PrimaryKeyRelatedField = serializers.PrimaryKeyRelatedField(many=True, read_only=True) diff --git a/src/meshapi/tests/sample_join_form_data.py b/src/meshapi/tests/sample_join_form_data.py index 89b45e1d..4847cfd0 100644 --- a/src/meshapi/tests/sample_join_form_data.py +++ b/src/meshapi/tests/sample_join_form_data.py @@ -68,7 +68,7 @@ "email": "rsmith@gmail.com", "phone": "+1585-758-3425", # CSH's phone number :P "street_address": "250 Bedford Park Blvd W", - "parsed_street_address": "250 Bedford Park Bl West", + "parsed_street_address": "250 Bedford Park Blvd West", "city": "Bronx", "state": "NY", "zip": 10468, diff --git a/src/meshapi/util/network_number.py b/src/meshapi/util/network_number.py index afb24972..41fa4d88 100644 --- a/src/meshapi/util/network_number.py +++ b/src/meshapi/util/network_number.py @@ -1,5 +1,3 @@ -from typing import Optional - from django.apps import apps NETWORK_NUMBER_MIN = 101 @@ -49,7 +47,7 @@ def get_next_available_network_number() -> int: # If we are re-assigning a number from another install, mark it with NN Assigned to indicate # that this has happened - nn_donor_install: Optional[Install] = Install.objects.select_for_update().filter(install_number=free_nn).first() + nn_donor_install = Install.objects.select_for_update().filter(install_number=free_nn).first() if nn_donor_install: # Double check that if we are re-assigning something that has been used before that it is # definitely unused. The logic above should do that, but this is so important that for diff --git a/src/meshapi/views/geography.py b/src/meshapi/views/geography.py index 91804fee..271b94eb 100644 --- a/src/meshapi/views/geography.py +++ b/src/meshapi/views/geography.py @@ -1,4 +1,4 @@ -from typing import Dict +from typing import Dict, List, Optional, Tuple from django.db.models import F, Q from django.db.models.functions import Greatest @@ -10,6 +10,8 @@ from pygeoif import LineString, Point from rest_framework import permissions, status from rest_framework.negotiation import BaseContentNegotiation +from rest_framework.parsers import BaseParser +from rest_framework.renderers import BaseRenderer from rest_framework.views import APIView from meshapi.models import Install, Link @@ -34,22 +36,27 @@ class IgnoreClientContentNegotiation(BaseContentNegotiation): - def select_parser(self, request, parsers): + def select_parser(self, request: HttpRequest, parsers: List[BaseParser]) -> BaseParser: # type: ignore[override] """ Select the first parser in the `.parser_classes` list. """ return parsers[0] - def select_renderer(self, request, renderers, format_suffix=None): + def select_renderer( + self, + request: HttpRequest, + renderers: List[BaseRenderer], # type: ignore[override] + format_suffix: Optional[str] = None, + ) -> Tuple[BaseRenderer, str]: """ Select the first renderer in the `.renderer_classes` list. """ - return (renderers[0], renderers[0].media_type) + return renderers[0], renderers[0].media_type def create_placemark(identifier: str, point: Point, active: bool, status: str, roof_access: bool) -> kml.Placemark: placemark = kml.Placemark( - name=str(identifier), + name=identifier, style_url=styles.StyleUrl(url="#red_dot" if active else "#grey_dot"), kml_geometry=geometry.Point( geometry=point, @@ -58,10 +65,10 @@ def create_placemark(identifier: str, point: Point, active: bool, status: str, r ) extended_data = { - "name": str(identifier), + "name": identifier, "roofAccess": str(roof_access), "marker-color": ACTIVE_COLOR if active else INACTIVE_COLOR, - "id": str(identifier), + "id": identifier, "status": status, # Leave disabled, notes can leak a lot of information & this endpoint is public # "notes": install.notes, @@ -146,8 +153,8 @@ def get(self, request: HttpRequest) -> HttpResponse: inactive_links_folder = kml.Folder(name="Inactive") links_folder.append(inactive_links_folder) - active_folder_map: Dict[str, kml.Folder] = {} - inactive_folder_map: Dict[str, kml.Folder] = {} + active_folder_map: Dict[Optional[str], kml.Folder] = {} + inactive_folder_map: Dict[Optional[str], kml.Folder] = {} for city_name, folder_name in CITY_FOLDER_MAP.items(): active_folder_map[city_name] = kml.Folder(name=folder_name) @@ -174,7 +181,7 @@ def get(self, request: HttpRequest) -> HttpResponse: folder = folder_map[install.building.city if install.building.city in folder_map.keys() else None] install_placemark = create_placemark( - install.install_number, + str(install.install_number), Point( install.building.longitude, install.building.latitude, @@ -190,7 +197,7 @@ def get(self, request: HttpRequest) -> HttpResponse: # this makes searching much easier if install.node and install.node.network_number not in mapped_nns: node_placemark = create_placemark( - install.node.network_number, + str(install.node.network_number), Point( install.node.longitude, install.node.latitude, diff --git a/src/meshapi/views/lookups.py b/src/meshapi/views/lookups.py index 48cfb90a..a4995a5a 100644 --- a/src/meshapi/views/lookups.py +++ b/src/meshapi/views/lookups.py @@ -1,6 +1,6 @@ -from typing import Any, List +from typing import Any, List, Type -from django.db.models import Q +from django.db.models import Q, QuerySet from django_filters import rest_framework as filters from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view @@ -21,8 +21,10 @@ class FilterRequiredListAPIView(generics.ListAPIView): + filterset_class: Type[filters.FilterSet] + def get(self, request: Request, *args: Any, **kwargs: Any) -> Response: - possible_filters = set(self.filterset_class.base_filters.keys()) + possible_filters = set(self.filterset_class.get_filters().keys()) # type: ignore[no-untyped-call] provided_filters = set(self.request.query_params.keys()) invalid_filters = provided_filters - possible_filters @@ -41,7 +43,7 @@ class MemberFilter(filters.FilterSet): email_address = filters.CharFilter(method="filter_on_all_emails") phone_number = filters.CharFilter(field_name="phone_number", lookup_expr="icontains") - def filter_on_all_emails(self, queryset, name, value): + def filter_on_all_emails(self, queryset: QuerySet[Member], field_name: str, value: str) -> QuerySet[Member]: return queryset.filter( Q(primary_email_address__icontains=value) | Q(stripe_email_address__icontains=value) @@ -274,10 +276,10 @@ class LinkFilter(filters.FilterSet): network_number = filters.NumberFilter(method="filter_from_to_network_number") device = filters.NumberFilter(method="filter_from_to_device_id") - def filter_from_to_network_number(self, queryset, name, value): + def filter_from_to_network_number(self, queryset: QuerySet[Link], field_name: str, value: str) -> QuerySet[Link]: return queryset.filter(Q(from_device__node__network_number=value) | Q(to_device__node__network_number=value)) - def filter_from_to_device_id(self, queryset, name, value): + def filter_from_to_device_id(self, queryset: QuerySet[Link], field_name: str, value: str) -> QuerySet[Link]: return queryset.filter(Q(from_device__id=value) | Q(to_device__id=value)) class Meta: diff --git a/src/meshapi/views/map.py b/src/meshapi/views/map.py index 670b7218..83f955e6 100644 --- a/src/meshapi/views/map.py +++ b/src/meshapi/views/map.py @@ -1,9 +1,11 @@ from datetime import datetime -from typing import List +from typing import Any, Dict, List from django.db.models import Count, F, OuterRef, Prefetch, Q, Subquery from drf_spectacular.utils import extend_schema, extend_schema_view from rest_framework import generics, permissions +from rest_framework.request import Request +from rest_framework.response import Response from meshapi.models import Device, Install, Link, Node, Sector from meshapi.serializers import ( @@ -29,7 +31,7 @@ class MapDataNodeList(generics.ListAPIView): serializer_class = MapDataInstallSerializer pagination_class = None - def get_queryset(self) -> List[Install]: + def get_queryset(self) -> List[Install]: # type: ignore[override] all_installs = [] queryset = ( @@ -66,7 +68,10 @@ def get_queryset(self) -> List[Install]: if node.network_number not in covered_nns: # Arbitrarily pick a representative install for the details of the "Fake" node, # preferring active installs if possible - representative_install = (node.active_installs or node.prefetched_installs)[0] + representative_install = ( + node.active_installs # type: ignore[attr-defined] + or node.prefetched_installs # type: ignore[attr-defined] + )[0] all_installs.append( Install( @@ -85,7 +90,7 @@ def get_queryset(self) -> List[Install]: all_installs.sort(key=lambda i: i.install_number) return all_installs - def list(self, request, *args, **kwargs): + def list(self, request: Request, *args: List[Any], **kwargs: Dict[str, Any]) -> Response: response = super().list(request, args, kwargs) access_points = [] @@ -169,8 +174,8 @@ class MapDataLinkList(generics.ListAPIView): # .exclude(to_device__status=Device.DeviceStatus.INACTIVE) ) - def list(self, request, *args, **kwargs): - response = super().list(request, args, kwargs) + def list(self, request: Request, *args: List[Any], **kwargs: Dict[str, Any]) -> Response: + response = super().list(request, *args, **kwargs) covered_links = {(link["from"], link["to"]) for link in response.data} @@ -179,6 +184,7 @@ def list(self, request, *args, **kwargs): # and add fake link objects to connect the installs on those other buildings to # the node dot cable_runs = [] + for node in ( Node.objects.annotate(num_buildings=Count("buildings")) .filter(num_buildings__gt=1) @@ -192,8 +198,9 @@ def list(self, request, *args, **kwargs): ) ): for building in node.buildings.all(): - if building.active_installs: - from_install = building.active_installs[0].install_number + active_installs = building.active_installs # type: ignore[attr-defined] + if active_installs: + from_install = active_installs[0].install_number if from_install != node.network_number: if (from_install, node.network_number) not in covered_links: cable_runs.append( diff --git a/src/meshapi/views/panoramas.py b/src/meshapi/views/panoramas.py index 9643d141..1472f842 100644 --- a/src/meshapi/views/panoramas.py +++ b/src/meshapi/views/panoramas.py @@ -4,10 +4,10 @@ from typing import Union import requests +from rest_framework import status from rest_framework.decorators import api_view, permission_classes from rest_framework.request import Request from rest_framework.response import Response -from rest_framework.views import status from meshapi.models import Install from meshapi.models.building import Building @@ -115,7 +115,7 @@ def build_panorama_list(building: Building, filenames: list[str]) -> None: continue # Get the first install from the node and use its building. Can't # really do any better than that. - install: Install = node_installs[0] + install = node_installs[0] build_panorama_list(install.building, filenames) panoramas_saved += len(filenames) diff --git a/src/meshapi/views/query_api.py b/src/meshapi/views/query_api.py index 35e4e51e..f2b490fc 100644 --- a/src/meshapi/views/query_api.py +++ b/src/meshapi/views/query_api.py @@ -1,11 +1,11 @@ -from typing import Any, List +from typing import List -from django.db.models import Q +from django.db.models import Q, QuerySet from django_filters import rest_framework as filters from drf_spectacular.utils import extend_schema, extend_schema_view from meshapi.docs import query_form_password_param -from meshapi.models import Install +from meshapi.models import Install, Member from meshapi.permissions import LegacyMeshQueryPassword from meshapi.serializers.query_api import QueryFormSerializer from meshapi.views.lookups import FilterRequiredListAPIView @@ -24,7 +24,7 @@ class QueryMemberFilter(filters.FilterSet): email_address = filters.CharFilter(method="filter_on_all_emails") phone_number = filters.CharFilter(field_name="member.phone_number", lookup_expr="icontains") - def filter_on_all_emails(self, queryset, name, value): + def filter_on_all_emails(self, queryset: QuerySet[Member], field_name: str, value: str) -> QuerySet[Member]: return queryset.filter( Q(member__primary_email_address__icontains=value) | Q(member__stripe_email_address__icontains=value) @@ -33,7 +33,7 @@ def filter_on_all_emails(self, queryset, name, value): class Meta: model = Install - fields: List[Any] = [] + fields: List[str] = [] @extend_schema_view( diff --git a/src/meshapi/widgets.py b/src/meshapi/widgets.py index 90015563..0304ee17 100644 --- a/src/meshapi/widgets.py +++ b/src/meshapi/widgets.py @@ -1,5 +1,6 @@ import json import os +from typing import Any, Dict, Optional from django.forms import widgets from django.template import loader @@ -13,7 +14,7 @@ class PanoramaViewer(JSONFormWidget): def __init__(self, schema: dict): super().__init__(schema) - def pano_get_context(self, name, value, attrs=None) -> dict: + def pano_get_context(self, name: str, value: str) -> dict: value_as_array = json.loads(value) return { "widget": { @@ -22,7 +23,9 @@ def pano_get_context(self, name, value, attrs=None) -> dict: } } - def render(self, name, value, attrs=None, renderer=None): + def render( + self, name: str, value: str, attrs: Optional[Dict[str, Any]] = None, renderer: Optional[Any] = None + ) -> str: if "DISALLOW_PANO_EDITS" in os.environ: super_template = "" else: @@ -30,7 +33,7 @@ def render(self, name, value, attrs=None, renderer=None): super_template = super().render(name, value, attrs, renderer) # Then, render the panoramas for viewing - context = self.pano_get_context(name, value, attrs) + context = self.pano_get_context(name, value) pano_template = loader.get_template(self.pano_template_name).render(context) template = super_template + pano_template diff --git a/src/meshapi_hooks/hooks.py b/src/meshapi_hooks/hooks.py index ffe6b95e..cfdbc48e 100644 --- a/src/meshapi_hooks/hooks.py +++ b/src/meshapi_hooks/hooks.py @@ -1,3 +1,6 @@ +from typing import Any, Dict, Optional, Sequence + +from django.contrib.auth.models import User from django.db import models from drf_hooks.models import AbstractHook @@ -29,13 +32,13 @@ class Meta: verbose_name = "Webhook Target" verbose_name_plural = "Webhook Targets" - def deliver_hook(self, serialized_hook) -> None: + def deliver_hook(self, serialized_hook: Dict[str, Any]) -> None: # Inline import to prevent circular import loop from meshapi_hooks.tasks import deliver_webhook_task deliver_webhook_task.apply_async([self.id, serialized_hook]) @classmethod - def find_hooks(cls, event_name, user=None): + def find_hooks(cls, event_name: str, user: Optional[User] = None) -> Sequence[AbstractHook]: hooks = super().find_hooks(event_name, user=user) return hooks.filter(enabled=True) diff --git a/src/meshapi_hooks/tasks.py b/src/meshapi_hooks/tasks.py index be99ec7d..719228b3 100644 --- a/src/meshapi_hooks/tasks.py +++ b/src/meshapi_hooks/tasks.py @@ -1,5 +1,7 @@ +from typing import Any, Dict + import requests -from celery import shared_task +from celery import Task, shared_task from celery.exceptions import MaxRetriesExceededError from meshapi_hooks.hooks import CelerySerializerHook @@ -8,7 +10,7 @@ @shared_task(bind=True, max_retries=HTTP_ATTEMPT_COUNT_PER_DELIVERY_ATTEMPT - 1) -def deliver_webhook_task(self, hook_id, payload): +def deliver_webhook_task(self: Task, hook_id: int, payload: Dict[str, Any]) -> None: """Deliver the payload to the hook target""" hook = CelerySerializerHook.objects.get(id=hook_id) try: diff --git a/src/meshdb/admin.py b/src/meshdb/admin.py index fbbfaaec..c65161d6 100644 --- a/src/meshdb/admin.py +++ b/src/meshdb/admin.py @@ -1,8 +1,11 @@ +from typing import Any, List, Optional + from django.contrib import admin +from django.http import HttpRequest class MeshDBAdminSite(admin.AdminSite): - def get_app_list(self, request, app_label=None): + def get_app_list(self, request: HttpRequest, app_label: Optional[str] = None) -> List[Any]: """Reorder the apps in the admin site. By default, django admin apps are order alphabetically. diff --git a/src/meshweb/views.py b/src/meshweb/views.py index be4709ba..08177e1d 100644 --- a/src/meshweb/views.py +++ b/src/meshweb/views.py @@ -1,8 +1,8 @@ from django.http import HttpRequest, HttpResponse +from django.template import loader from drf_spectacular.utils import extend_schema from rest_framework import permissions from rest_framework.decorators import api_view, permission_classes -from rest_framework.filters import loader # Home view diff --git a/tasks.py b/tasks.py index 3566bedb..38bfa618 100644 --- a/tasks.py +++ b/tasks.py @@ -12,7 +12,7 @@ def lint(context): context.run("black . --check") context.run("isort . --check") context.run("flake8 src") - # context.run("mypy src") + context.run("mypy") @task