Fix various type issues

This commit is contained in:
Adam Goldsmith 2024-05-04 16:38:51 -04:00
parent 9658366d72
commit 0944dd7992
15 changed files with 264 additions and 92 deletions

View File

@ -220,7 +220,7 @@ class HIDEvent(models.Model):
)
@classmethod
def from_xml_attributes(cls, door: Door, attrib: dict[str:str]):
def from_xml_attributes(cls, door: Door, attrib: dict[str, str]):
field_lookup = {
field.column: field.attname for field in HIDEvent._meta.get_fields()
}
@ -287,7 +287,7 @@ class HIDEvent(models.Model):
def __str__(self):
return f"{self.door.name} {self.timestamp} - {self.description}"
def decoded_card_number(self) -> str:
def decoded_card_number(self) -> str | None:
"""Requires annotations from `with_decoded_card_number`"""
if self.raw_card_number is None:
return None

View File

@ -1,5 +1,6 @@
import dataclasses
import logging
from typing import TypedDict
from django_q.tasks import async_task
@ -11,10 +12,20 @@ from membershipworks.models import Member
logger = logging.getLogger(__name__)
class CardholderAttribs(TypedDict):
forename: str
middleName: str
surname: str
email: str
phone: str
custom1: str
custom2: str
@dataclasses.dataclass
class DoorMember:
door: Door
attribs: dict[str, str]
attribs: CardholderAttribs
credentials: set[Credential]
schedules: set[str]
cardholderID: str | None = None
@ -33,7 +44,7 @@ class DoorMember:
else:
credentials = set()
reasons_and_schedules = {}
reasons_and_schedules: dict[str, str] = {}
if (
member.is_active
or member.flags.filter(name="Misc. Access", type="folder").exists()
@ -112,6 +123,9 @@ class DoorMember:
all_members: list["DoorMember"],
old_credentials: set[Credential] = set(),
):
# cardholderID should be set on a member before this is called
assert self.cardholderID is not None
other_assigned_cards = {
card for m in all_members if m != self for card in m.credentials
}
@ -187,7 +201,7 @@ def update_door(door: Door, dry_run: bool = False):
cardholders = {
member.membershipworks_id: member
for member in [
DoorMember.from_cardholder(ch, door.controller)
DoorMember.from_cardholder(ch, door)
for ch in door.controller.get_cardholders()
]
}

View File

@ -12,7 +12,7 @@ from django.views.generic.list import ListView
import django_filters
import django_tables2 as tables
from django_filters.views import BaseFilterView
from django_mysql.models import GroupConcat
from django_mysql.models.aggregates import GroupConcat
from django_mysql.models.functions import ConcatWS
from django_tables2 import SingleTableMixin
from django_tables2.export.views import ExportMixin
@ -30,7 +30,7 @@ from .tables import (
REPORTS = []
def register_report(cls: "BaseAccessReport"):
def register_report(cls: "type[BaseAccessReport]"):
REPORTS.append(cls)
return cls
@ -55,7 +55,7 @@ class BaseAccessReport(
filterset_class = AccessReportFilterSet
_report_name = None
_report_name: str
@classmethod
def _report_types(cls):
@ -76,9 +76,9 @@ class BaseAccessReport(
def _selected_report(self):
return self._report_name
def get_paginate_by(self, queryset) -> int:
def get_paginate_by(self, queryset) -> int | None:
if "items_per_page" in self.request.GET:
return int(self.request.GET.get("items_per_page"))
return int(self.request.GET["items_per_page"])
return super().get_paginate_by(queryset)
def get_queryset(self):

View File

@ -28,6 +28,8 @@ def make_multipart_email(
def make_instructor_email(
invoice: EventInvoice, pdf: bytes, event_url: str
) -> EmailMessage:
if invoice.event.instructor is None or invoice.event.instructor.member is None:
raise ValueError("Event Instructor not defined or is not member")
template = loader.get_template(
"membershipworks/email/event_invoice_instructor.dj.html"
)

View File

@ -2,6 +2,7 @@ import csv
import datetime
from enum import Enum
from io import StringIO
from typing import Any
import requests
@ -67,6 +68,13 @@ staticFlags = {
}
class NotAuthenticatedError(Exception):
def __init__(self) -> None:
super().__init__(
"Not authenticated to membershipworks, please call .login() first"
)
class MembershipWorksRemoteError(Exception):
def __init__(self, reason, r):
super().__init__(
@ -104,7 +112,7 @@ class MembershipWorks:
def _inject_auth(self, kwargs):
# TODO: should probably be a decorator or something
if self.auth_token is None:
raise RuntimeError("Not Logged in to MembershipWorks")
raise NotAuthenticatedError()
# add auth token to params
if "params" not in kwargs:
kwargs["params"] = {}
@ -126,6 +134,8 @@ class MembershipWorks:
Is this terrible? Yes. Also, not dissimilar to how MW does it
in all.js.
"""
if not self.org_info:
raise NotAuthenticatedError()
fields = staticFlags.copy()
# TODO: this will take the later option, if the same field
@ -148,7 +158,9 @@ class MembershipWorks:
This is terrible, and there might be a better way to do this.
"""
ret = {"folders": {}, "levels": {}, "addons": {}, "labels": {}}
if not self.org_info:
raise NotAuthenticatedError()
ret: dict[str, Any] = {"folders": {}, "levels": {}, "addons": {}, "labels": {}}
for dek in self.org_info["dek"]:
# TODO: there must be a better way. this is stupid
@ -242,8 +254,8 @@ class MembershipWorks:
def get_events_list(
self,
start_date: datetime.datetime = None,
end_date: datetime.datetime = None,
start_date: datetime.datetime | None = None,
end_date: datetime.datetime | None = None,
categories=False,
):
"""Retrive a list of events between `start_date` and `end_date`, optionally including category information"""

View File

@ -1,6 +1,7 @@
import uuid
from datetime import datetime
from typing import Optional
from datetime import datetime, timedelta
from decimal import Decimal
from typing import TYPE_CHECKING, TypedDict
import django.core.mail.message
from django.conf import settings
@ -15,6 +16,7 @@ from django.db.models import (
Func,
OuterRef,
Q,
QuerySet,
Subquery,
Sum,
Value,
@ -26,12 +28,13 @@ from django.utils import timezone
import nh3
from django_db_views.db_view import DBView
from django_stubs_ext import WithAnnotations
class BaseModel(models.Model):
_api_names_override = {}
_date_fields = {}
_allowed_missing_fields = []
_api_names_override: dict[str, str] = {}
_date_fields: dict[str, str | None] = {}
_allowed_missing_fields: list[str] = []
class Meta:
abstract = True
@ -274,9 +277,10 @@ class Member(BaseModel):
]
@classmethod
def from_user(cls, user) -> Optional["Member"]:
def from_user(cls, user) -> "Member | None":
if hasattr(user, "ldap_user"):
return cls.objects.get(uid=user.ldap_user.attrs["employeeNumber"][0])
return None
def sanitized_mailbox(self, use_volunteer=False) -> str:
if use_volunteer and self.volunteer_email:
@ -447,7 +451,7 @@ class EventInstructor(models.Model):
return str(self.member) if self.member else self.name
class EventExtQuerySet(models.QuerySet["EventExt"]):
class EventExtQuerySet(models.QuerySet["EventExtAnnotated"]):
def summarize(self, aggregate: bool = False):
method = self.aggregate if aggregate else self.annotate
return method(
@ -465,7 +469,7 @@ class EventExtQuerySet(models.QuerySet["EventExt"]):
net_revenue__sum=Sum("net_revenue", filter=F("occurred")),
)
def with_financials(self):
def with_financials(self) -> "QuerySet[EventExtAnnotatedWithFinancials]":
return self.annotate(
**{
field: Subquery(
@ -495,12 +499,9 @@ class EventExtQuerySet(models.QuerySet["EventExt"]):
)
class EventExtManager(models.Manager["EventExt"]):
def get_queryset(self) -> models.QuerySet["EventExt"]:
return (
super()
.get_queryset()
.annotate(
class EventExtManager(models.Manager):
def get_queryset(self) -> EventExtQuerySet:
return EventExtQuerySet(self.model, using=self._db).annotate(
meetings=Subquery(
EventMeetingTime.objects.filter(event=OuterRef("pk"))
.values("event__pk")
@ -516,8 +517,7 @@ class EventExtManager(models.Manager["EventExt"]):
output_field=models.DurationField(),
),
person_hours=ExpressionWrapper(
ExpressionWrapper(F("duration"), models.IntegerField())
* F("count"),
ExpressionWrapper(F("duration"), models.IntegerField()) * F("count"),
models.DurationField(),
),
# TODO: this could be a GeneratedField, but that
@ -529,7 +529,6 @@ class EventExtManager(models.Manager["EventExt"]):
output_field=models.DateTimeField(),
),
)
)
class EventExt(Event):
@ -574,7 +573,7 @@ class EventExt(Event):
self.materials_fee_included_in_price is not None
or self.materials_fee == 0
)
and self.total_due_to_instructor is not None
and getattr(self, "total_due_to_instructor") is not None
)
class Meta:
@ -582,6 +581,32 @@ class EventExt(Event):
ordering = ["-start"]
if TYPE_CHECKING:
class EventExtAnnotations(TypedDict):
meetings: int
duration: timedelta
person_hours: timedelta
details_timestamp: datetime
class EventExtFinancialAnnotations(TypedDict):
quantity: Decimal
amount: Decimal
materials: Decimal
amount_without_materials: Decimal
instructor_revenue: Decimal
instructor_amount: Decimal
total_due_to_instructor: Decimal
gross_revenue: Decimal
net_revenue: Decimal
EventExtAnnotated = WithAnnotations[EventExt, EventExtAnnotations]
EventExtAnnotatedWithFinancials = WithAnnotations[EventExt, EventExtAnnotations]
else:
EventExtAnnotated = WithAnnotations[EventExt]
EventExtAnnotatedWithFinancials = WithAnnotations[EventExt]
class EventMeetingTime(models.Model):
event = models.ForeignKey(
EventExt, on_delete=models.CASCADE, related_name="meeting_times"

View File

@ -1,4 +1,5 @@
import logging
from collections.abc import Iterable
from datetime import datetime, timedelta
from django.conf import settings
@ -30,7 +31,7 @@ def flags_for_member(csv_member, all_flags, folders):
yield flag
def update_flags(mw_flags) -> list[Flag]:
def update_flags(mw_flags) -> Iterable[Flag]:
for typ, flags_of_type in mw_flags.items():
for name, id in flags_of_type.items():
flag = Flag(id=id, name=name, type=typ[:-1])

View File

@ -32,7 +32,7 @@ import django_tables2 as tables
import weasyprint
from dal import autocomplete
from django_filters.views import BaseFilterView
from django_mysql.models import GroupConcat
from django_mysql.models.aggregates import GroupConcat
from django_sendfile import sendfile
from django_tables2 import A, SingleTableMixin
from django_tables2.export.views import ExportMixin
@ -241,7 +241,7 @@ class EventMonthReport(
class UserEventView(SingleTableMixin, ListView):
model = EventExt
model: type[EventExt] = EventExt
table_class = UserEventTable
export_formats = ("csv", "xlsx", "ods")
template_name = "membershipworks/user_event_list.dj.html"

View File

@ -1,4 +1,6 @@
from django.db.models import Q
from typing import Required, TypedDict
from django.db.models import Q, QuerySet
from rest_framework import routers, serializers, viewsets
from rest_framework.decorators import action
@ -20,8 +22,20 @@ class DepartmentSerializer(serializers.HyperlinkedModelSerializer):
fields = ["name", "parent", "shop_lead_flag", "list_reply_to_address"]
class ListConfig(TypedDict, total=False):
real_name: str
subject_prefix: str
reply_to_address: str
class MailingList(TypedDict, total=False):
config: ListConfig
moderators: set[str]
members: Required[set[str]]
class DepartmentViewSet(viewsets.ModelViewSet):
queryset = Department.objects.all()
queryset: QuerySet[Department] = Department.objects.all()
serializer_class = DepartmentSerializer
@action(detail=False, methods=["get"])
@ -34,15 +48,20 @@ class DepartmentViewSet(viewsets.ModelViewSet):
"children",
"shop_lead_flag__members",
)
lists = {}
lists: dict[str, MailingList] = {}
shopleads: dict[Member, list[Department]] = {}
for department in departments.filter(has_mailing_list=True):
if department.shop_lead_flag is not None:
moderator_emails = {
member.volunteer_email if member.volunteer_email else member.email
for member in department.shop_lead_flag.members.all()
}
for member in department.shop_lead_flag.members.all():
if member not in shopleads:
shopleads[member] = []
shopleads[member].append(department)
else:
moderator_emails = []
moderator_emails = set()
active_certified_members = {
member.sanitized_mailbox()
@ -52,6 +71,9 @@ class DepartmentViewSet(viewsets.ModelViewSet):
)
}
# list_name can only be None if has_mailing_list is False
assert department.list_name is not None
lists[department.list_name] = {
"config": {
"real_name": department.list_name,
@ -76,13 +98,6 @@ class DepartmentViewSet(viewsets.ModelViewSet):
if department.parent_id is None:
recurse_children(department)
shopleads = {}
for department in departments.filter(shop_lead_flag__isnull=False):
for member in department.shop_lead_flag.members.all():
if member not in shopleads:
shopleads[member] = []
shopleads[member].append(department)
# Add members to the Shop Leads mailing list, but don't configure it
lists["ShopLeads"] = {
"members": {

View File

@ -1,7 +1,7 @@
from django import forms
from django.core.exceptions import ValidationError
from dal import autocomplete
from dal.autocomplete import ModelSelect2
from .models import Certification, CertificationDefinition
@ -49,7 +49,7 @@ class CertificationForm(forms.ModelForm):
"notes",
]
widgets = {
"certification_version": autocomplete.ModelSelect2(
"certification_version": ModelSelect2(
url="paperwork:certification_version_autocomplete",
forward=["certification_definition"],
)

View File

@ -1,8 +1,11 @@
from collections.abc import Callable
from itertools import chain
from typing import TypedDict
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from django.contrib.auth.models import AbstractBaseUser, Permission
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.test import Client
from hypothesis import given
@ -19,9 +22,20 @@ from paperwork.models import (
)
class PermissionLookup(TypedDict):
codename: str
model: type[models.Model]
class PermissionRequiredViewTestCaseMixin:
permissions = []
path = None
permissions: list[PermissionLookup] = []
path: str
client: Client
user_with_permission: AbstractBaseUser
user_without_permission: AbstractBaseUser
assertEqual: Callable
@classmethod
def setUpTestData(cls):

View File

@ -1,7 +1,9 @@
from collections.abc import Iterable
from django.conf import settings
from django.contrib import staticfiles
from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.contrib.staticfiles import finders as staticfiles_finders
from django.db import models
from django.db.models import (
Case,
@ -21,7 +23,7 @@ from django.views.generic import ListView
import requests
import weasyprint
from django_mysql.models import GroupConcat
from django_mysql.models.aggregates import GroupConcat
from django_tables2 import SingleTableMixin
from django_tables2.export.views import ExportMixin
@ -63,6 +65,7 @@ class MemberCertificationListView(ListView):
@login_required
def department_certifications(request):
departments: Iterable[Department]
if (member := Member.from_user(request.user)) is not None:
departments = Department.objects.filter_by_shop_lead(member)
else:
@ -115,7 +118,7 @@ def certification_pdf(request, cert_name):
html = weasyprint.HTML(f"{WIKI_URL}/index.php?title={wiki_page}")
stylesheet = staticfiles.finders.find("paperwork/certification-print.css")
stylesheet = staticfiles_finders.find("paperwork/certification-print.css")
pdf = html.write_pdf(stylesheets=[stylesheet])
return HttpResponse(
pdf,

103
pdm.lock
View File

@ -5,7 +5,7 @@
groups = ["default", "debug", "lint", "server", "typing", "dev"]
strategy = ["cross_platform"]
lock_version = "4.4.1"
content_hash = "sha256:4bada05219eeff5a98ea768509968f336ba2a4e86914ba0992cd90bfa91cbb31"
content_hash = "sha256:8e68a7f1608469e70bc3e7502f747bbe5f38ca4bc15f504811377509599bb7a1"
[[package]]
name = "aiohttp"
@ -415,6 +415,16 @@ files = [
{file = "cssbeautifier-1.14.7.tar.gz", hash = "sha256:be7f1ea7a7b009f0172c2c0d0bebb2d136346e786f7182185ea944affb52135a"},
]
[[package]]
name = "cssselect"
version = "1.2.0"
requires_python = ">=3.7"
summary = "cssselect parses CSS3 Selectors and translates them to XPath 1.0"
files = [
{file = "cssselect-1.2.0-py2.py3-none-any.whl", hash = "sha256:da1885f0c10b60c03ed5eccbb6b68d6eff248d91976fcde348f395d54c9fd35e"},
{file = "cssselect-1.2.0.tar.gz", hash = "sha256:666b19839cfaddb9ce9d36bfe4c969132c647b92fc9088c4e23f786b30f1b3dc"},
]
[[package]]
name = "cssselect2"
version = "0.7.0"
@ -1237,15 +1247,6 @@ files = [
{file = "lxml-5.2.1.tar.gz", hash = "sha256:3f7765e69bbce0906a7c74d5fe46d2c7a7596147318dbc08e4a2431f3060e306"},
]
[[package]]
name = "lxml-stubs"
version = "0.5.1"
summary = "Type annotations for the lxml package"
files = [
{file = "lxml-stubs-0.5.1.tar.gz", hash = "sha256:e0ec2aa1ce92d91278b719091ce4515c12adc1d564359dfaf81efa7d4feab79d"},
{file = "lxml_stubs-0.5.1-py3-none-any.whl", hash = "sha256:1f689e5dbc4b9247cb09ae820c7d34daeb1fdbd1db06123814b856dae7787272"},
]
[[package]]
name = "markdown"
version = "3.4.1"
@ -2010,6 +2011,19 @@ files = [
{file = "traitlets-5.13.0.tar.gz", hash = "sha256:9b232b9430c8f57288c1024b34a8f0251ddcc47268927367a0dd3eeaca40deb5"},
]
[[package]]
name = "types-beautifulsoup4"
version = "4.12.0.20240229"
requires_python = ">=3.8"
summary = "Typing stubs for beautifulsoup4"
dependencies = [
"types-html5lib",
]
files = [
{file = "types-beautifulsoup4-4.12.0.20240229.tar.gz", hash = "sha256:e37e4cfa11b03b01775732e56d2c010cb24ee107786277bae6bc0fa3e305b686"},
{file = "types_beautifulsoup4-4.12.0.20240229-py3-none-any.whl", hash = "sha256:000cdddb8aee4effb45a04be95654de8629fb8594a4f2f1231cff81108977324"},
]
[[package]]
name = "types-bleach"
version = "6.1.0.20240331"
@ -2023,6 +2037,16 @@ files = [
{file = "types_bleach-6.1.0.20240331-py3-none-any.whl", hash = "sha256:399bc59bfd20a36a56595f13f805e56c8a08e5a5c07903e5cf6fafb5a5107dd4"},
]
[[package]]
name = "types-docutils"
version = "0.21.0.20240423"
requires_python = ">=3.8"
summary = "Typing stubs for docutils"
files = [
{file = "types-docutils-0.21.0.20240423.tar.gz", hash = "sha256:7716ec6c68b5179b7ba1738cace2f1326e64df9f44b7ab08d9904d32c23fc15f"},
{file = "types_docutils-0.21.0.20240423-py3-none-any.whl", hash = "sha256:7f6e84ba8fcd2454c5b8bb8d77384d091a901929cc2b31079316e10eb346580a"},
]
[[package]]
name = "types-html5lib"
version = "1.1.11.20240228"
@ -2033,6 +2057,55 @@ files = [
{file = "types_html5lib-1.1.11.20240228-py3-none-any.whl", hash = "sha256:af5de0125cb0fe5667543b158db83849b22e25c0e36c9149836b095548bf1020"},
]
[[package]]
name = "types-lxml"
version = "2024.4.14"
requires_python = ">=3.8"
summary = "Complete lxml external type annotation"
dependencies = [
"cssselect~=1.2",
"types-beautifulsoup4~=4.12",
"typing-extensions~=4.5",
]
files = [
{file = "types_lxml-2024.4.14-py3-none-any.whl", hash = "sha256:7e5f836067cde4fddce3cdbf2bac7192c764bf5ee6d3eb86c732ad1b84f265c5"},
{file = "types_lxml-2024.4.14.tar.gz", hash = "sha256:dd8105b579925af1b6ae77469f4fc835be3872b15e86cb46ad4fcc33b20c781d"},
]
[[package]]
name = "types-markdown"
version = "3.6.0.20240316"
requires_python = ">=3.8"
summary = "Typing stubs for Markdown"
files = [
{file = "types-Markdown-3.6.0.20240316.tar.gz", hash = "sha256:de9fb84860b55b647b170ca576895fcca61b934a6ecdc65c31932c6795b440b8"},
{file = "types_Markdown-3.6.0.20240316-py3-none-any.whl", hash = "sha256:d3ecd26a940781787c7b57a0e3c9d77c150db64e12989ef687059edc83dfd78a"},
]
[[package]]
name = "types-psycopg2"
version = "2.9.21.20240417"
requires_python = ">=3.8"
summary = "Typing stubs for psycopg2"
files = [
{file = "types-psycopg2-2.9.21.20240417.tar.gz", hash = "sha256:05db256f4a459fb21a426b8e7fca0656c3539105ff0208eaf6bdaf406a387087"},
{file = "types_psycopg2-2.9.21.20240417-py3-none-any.whl", hash = "sha256:644d6644d64ebbe37203229b00771012fb3b3bddd507a129a2e136485990e4f8"},
]
[[package]]
name = "types-pygments"
version = "2.17.0.20240310"
requires_python = ">=3.8"
summary = "Typing stubs for Pygments"
dependencies = [
"types-docutils",
"types-setuptools",
]
files = [
{file = "types-Pygments-2.17.0.20240310.tar.gz", hash = "sha256:b1d97e905ce36343c7283b0319182ae6d4f967188f361f45502a18ae43e03e1f"},
{file = "types_Pygments-2.17.0.20240310-py3-none-any.whl", hash = "sha256:b101ca9448aaff52af6966506f1fdd73b1e60a79b8a79a8bace3366cbf1f7ed9"},
]
[[package]]
name = "types-pyyaml"
version = "6.0.12.2"
@ -2055,6 +2128,16 @@ files = [
{file = "types_requests-2.31.0.20240406-py3-none-any.whl", hash = "sha256:6216cdac377c6b9a040ac1c0404f7284bd13199c0e1bb235f4324627e8898cf5"},
]
[[package]]
name = "types-setuptools"
version = "69.5.0.20240423"
requires_python = ">=3.8"
summary = "Typing stubs for setuptools"
files = [
{file = "types-setuptools-69.5.0.20240423.tar.gz", hash = "sha256:a7ba908f1746c4337d13f027fa0f4a5bcad6d1d92048219ba792b3295c58586d"},
{file = "types_setuptools-69.5.0.20240423-py3-none-any.whl", hash = "sha256:a4381e041510755a6c9210e26ad55b1629bc10237aeb9cb8b6bd24996b73db48"},
]
[[package]]
name = "types-urllib3"
version = "1.26.25.14"

View File

@ -123,7 +123,10 @@ typing = [
"types-requests~=2.31",
"types-urllib3~=1.26",
"djangorestframework-stubs[compatible-mypy]~=3.15",
"lxml-stubs~=0.5",
"types-Markdown~=3.6",
"types-Pygments~=2.17",
"types-psycopg2~=2.9",
"types-lxml~=2024.4",
]
debug = [
"django-debug-toolbar~=4.3",

View File

@ -1,6 +1,6 @@
from django import forms
from dal import autocomplete
from dal.autocomplete import ModelSelect2
from rentals.models import LockerInfo
@ -16,7 +16,7 @@ class LockerInfoForm(forms.ModelForm):
"notes",
]
widgets = {
"renter": autocomplete.ModelSelect2(
"renter": ModelSelect2(
url="membershipworks:member-autocomplete",
attrs={
"data-placeholder": "-- No Renter --",