Compare commits

..

7 Commits

Author SHA1 Message Date
b6280d701f doorcontrol: Migrate door updater over from memberPlumbing
All checks were successful
Ruff / ruff (push) Successful in 2m1s
Test / test (push) Successful in 7m20s
2024-02-23 18:39:41 -05:00
dc9a06b415 Remove separate membershipworks database, merging it into default 2024-02-23 13:01:37 -05:00
61c81e05b6 Set up hypothesis profiles in dev/ci environments
All checks were successful
Ruff / ruff (push) Successful in 25s
Test / test (push) Successful in 5m20s
2024-02-19 16:22:39 -05:00
8d0730bf70 paperwork: Improve performance of random_certifications testing strategy
Some checks failed
Ruff / ruff (push) Successful in 22s
Test / test (push) Failing after 3m56s
2024-02-17 15:56:32 -05:00
6cf520fdf9 Add pdm lock check to pre-commit checks 2024-02-17 15:44:12 -05:00
aec64ea5f3 gitea-actions: Use pdm sync instead of install
Some checks failed
Ruff / ruff (push) Successful in 22s
Test / test (push) Failing after 3m50s
2024-02-17 13:58:07 -05:00
4a0ccdb8bc gitea-actions: Use empty string for github token 2024-02-17 13:58:04 -05:00
23 changed files with 1009 additions and 104 deletions

View File

@ -24,11 +24,12 @@ jobs:
with: with:
cache: true cache: true
python-version: ~3.11 python-version: ~3.11
token: ""
- name: Install apt dependencies - name: Install apt dependencies
run: >- run: >-
sudo apt-get update && sudo apt-get update &&
sudo apt-get -y install build-essential python3-dev libldap2-dev libsasl2-dev mariadb-client sudo apt-get -y install build-essential python3-dev libldap2-dev libsasl2-dev mariadb-client
- name: Install python dependencies - name: Install python dependencies
run: pdm install run: pdm sync -d
- name: Run tests - name: Run tests
run: pdm run ./manage.py test --parallel auto run: pdm run -v ./manage.py test --parallel auto

View File

@ -19,6 +19,11 @@ repos:
- id: ruff - id: ruff
- id: ruff-format - id: ruff-format
- repo: https://github.com/pdm-project/pdm
rev: 2.12.3
hooks:
- id: pdm-lock-check
# TODO: waiting on django-recurrence 1.12 to be released on PyPi # TODO: waiting on django-recurrence 1.12 to be released on PyPi
# - repo: local # - repo: local
# hooks: # hooks:

View File

@ -82,10 +82,6 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
WSGI_APPLICATION = "cmsmanage.wsgi.application" WSGI_APPLICATION = "cmsmanage.wsgi.application"
DATABASE_ROUTERS = [
"membershipworks.routers.MembershipWorksRouter",
]
# Default URL to redirect to after authentication # Default URL to redirect to after authentication
LOGIN_REDIRECT_URL = "/" LOGIN_REDIRECT_URL = "/"
LOGIN_URL = "/auth/login/" LOGIN_URL = "/auth/login/"
@ -106,6 +102,22 @@ USE_TZ = True
STATIC_URL = "/static/" STATIC_URL = "/static/"
STATICFILES_DIRS = [BASE_DIR / "static"] STATICFILES_DIRS = [BASE_DIR / "static"]
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"my_console": {
"class": "logging.StreamHandler",
},
},
"loggers": {
"": {
"handlers": ["my_console"],
"level": "WARNING",
},
},
}
WIKI_URL = "https://wiki.claremontmakerspace.org" WIKI_URL = "https://wiki.claremontmakerspace.org"
# Django Rest Framework # Django Rest Framework

View File

@ -1,4 +1,7 @@
from hypothesis import settings
from .base import * # noqa: F403 from .base import * # noqa: F403
from .hypothesis import configure_hypothesis_profiles
# Quick-start development settings - unsuitable for production # Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/ # See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/
@ -31,3 +34,5 @@ DATABASES = {
}, },
}, },
} }
configure_hypothesis_profiles()
settings.load_profile("ci")

View File

@ -1,4 +1,9 @@
import os
from hypothesis import settings
from .base import * # noqa: F403 from .base import * # noqa: F403
from .hypothesis import configure_hypothesis_profiles
# Quick-start development settings - unsuitable for production # Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/ # See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/
@ -13,3 +18,6 @@ INSTALLED_APPS.append("debug_toolbar") # noqa: F405
INSTALLED_APPS.append("django_extensions") # noqa: F405 INSTALLED_APPS.append("django_extensions") # noqa: F405
MIDDLEWARE.insert(0, "debug_toolbar.middleware.DebugToolbarMiddleware") # noqa: F405 MIDDLEWARE.insert(0, "debug_toolbar.middleware.DebugToolbarMiddleware") # noqa: F405
configure_hypothesis_profiles()
settings.load_profile(os.getenv("HYPOTHESIS_PROFILE", "dev"))

View File

@ -0,0 +1,7 @@
from hypothesis import HealthCheck, Verbosity, settings
def configure_hypothesis_profiles():
settings.register_profile("ci", suppress_health_check=(HealthCheck.too_slow,))
settings.register_profile("dev", max_examples=20)
settings.register_profile("debug", max_examples=10, verbosity=Verbosity.verbose)

View File

@ -2,13 +2,32 @@ from django.contrib import admin
from django_object_actions import DjangoObjectActions, action from django_object_actions import DjangoObjectActions, action
from .models import Door, HIDEvent from .forms import AttributeScheduleRuleForm, DoorAdminForm
from .models import AttributeScheduleRule, Door, FlagScheduleRule, HIDEvent, Schedule
from .tasks.scrapehidevents import q_getMessagesAllDoors from .tasks.scrapehidevents import q_getMessagesAllDoors
class FlagScheduleRuleInline(admin.TabularInline):
model = FlagScheduleRule
autocomplete_fields = ["flag"]
extra = 0
class AttributeScheduleRuleInline(admin.TabularInline):
model = AttributeScheduleRule
form = AttributeScheduleRuleForm
extra = 0
@admin.register(Schedule)
class ScheduleAdmin(admin.ModelAdmin):
inlines = [FlagScheduleRuleInline, AttributeScheduleRuleInline]
@admin.register(Door) @admin.register(Door)
class DoorAdmin(admin.ModelAdmin): class DoorAdmin(admin.ModelAdmin):
pass form = DoorAdminForm
list_display = ["name", "access_field"]
@admin.register(HIDEvent) @admin.register(HIDEvent)

View File

@ -8,6 +8,7 @@ def post_migrate_callback(sender, **kwargs):
from cmsmanage.django_q2_helper import ensure_scheduled from cmsmanage.django_q2_helper import ensure_scheduled
from .tasks.scrapehidevents import q_getMessagesAllDoors from .tasks.scrapehidevents import q_getMessagesAllDoors
from .tasks.update_doors import q_update_all_doors
ensure_scheduled( ensure_scheduled(
q_getMessagesAllDoors.q_task_group, q_getMessagesAllDoors.q_task_group,
@ -16,6 +17,13 @@ def post_migrate_callback(sender, **kwargs):
minutes=15, minutes=15,
) )
ensure_scheduled(
"Update Door Controller Members and Cards",
q_update_all_doors,
schedule_type=Schedule.MINUTES,
minutes=15,
)
class DoorControlConfig(AppConfig): class DoorControlConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField" default_auto_field = "django.db.models.BigAutoField"

41
doorcontrol/forms.py Normal file
View File

@ -0,0 +1,41 @@
from django import forms
from django.db import models
from membershipworks.models import Member
from .models import AttributeScheduleRule, Door
class DoorAdminForm(forms.ModelForm):
access_field = forms.ChoiceField(
choices=[(None, "---------")]
+ [
(field.name, field.verbose_name)
for field in Member._meta.get_fields()
if (
isinstance(field, models.BooleanField)
and field.name.startswith("access_")
)
],
help_text=Door._meta.get_field("access_field").help_text,
)
class Meta:
model = Door
fields = "__all__"
class AttributeScheduleRuleForm(forms.ModelForm):
access_field = forms.ChoiceField(
choices=[(None, "---------")]
+ [
(field.name, field.verbose_name)
for field in Member._meta.get_fields()
if isinstance(field, models.BooleanField)
],
help_text=AttributeScheduleRule._meta.get_field("access_field").help_text,
)
class Meta:
model = AttributeScheduleRule
fields = "__all__"

View File

@ -0,0 +1,292 @@
from unittest.mock import patch
import pytest
import responses
from lxml.etree import Element
from ..DoorController import ROOT, DoorController, E, E_corp, RemoteError
# https://stackoverflow.com/questions/7905380/testing-equivalence-of-xml-etree-elementtree
def assert_elements_equal(e1: Element, e2: Element) -> None:
assert e1.tag == e2.tag
assert e1.text == e2.text
assert e1.tail == e2.tail
assert e1.attrib == e2.attrib
assert len(e1) == len(e2)
for c1, c2 in zip(e1, e2):
assert_elements_equal(c1, c2)
@pytest.fixture
def door_controller():
return DoorController("127.0.0.1", "test", "test", name="Test", access="Test")
@responses.activate
def test_doXMLRequest_bytes(door_controller: DoorController) -> None:
responses.add(
responses.GET,
"https://127.0.0.1/cgi-bin/vertx_xml.cgi",
body='<?xml version="1.0" encoding="UTF-8"?><VertXMessage xmlns:hid="http://www.hidcorp.com/VertX"><hid:Banana></hid:Banana></VertXMessage>',
content_type="text/xml",
)
ret = door_controller.doXMLRequest(
b"<VertXMessage><hid:TEST></hid:TEST></VertXMessage>"
)
assert (
responses.calls[0].request.params["/cgi-bin/vertx_xml.cgi?XML"]
== '<?xml version="1.0" encoding="UTF-8"?><VertXMessage><hid:TEST></hid:TEST></VertXMessage>'
)
assert_elements_equal(ret, ROOT(E_corp.Banana()))
@responses.activate
def test_doXMLRequest_xml(door_controller: DoorController) -> None:
responses.add(
responses.GET,
"https://127.0.0.1/cgi-bin/vertx_xml.cgi",
body='<?xml version="1.0" encoding="UTF-8"?><VertXMessage xmlns:hid="http://www.hidcorp.com/VertX"><hid:Banana></hid:Banana></VertXMessage>',
content_type="text/xml",
)
ret = door_controller.doXMLRequest(ROOT(E.TEST()))
assert (
responses.calls[0].request.params["/cgi-bin/vertx_xml.cgi?XML"]
== '<?xml version="1.0" encoding="UTF-8"?><VertXMessage xmlns:hid="http://www.hidglobal.com/VertX"><hid:TEST/></VertXMessage>'
)
assert_elements_equal(ret, ROOT(E_corp.Banana()))
@responses.activate
def test_doXMLRequest_HTTPError(door_controller: DoorController) -> None:
responses.add(
responses.GET,
"https://127.0.0.1/cgi-bin/vertx_xml.cgi",
body="whatever",
status=403,
)
with pytest.raises(RemoteError) as excinfo:
door_controller.doXMLRequest(ROOT(E.TEST()))
assert excinfo.value.args[0] == "Door Updating Error: 403 Forbidden\nwhatever"
@responses.activate
def test_doXMLRequest_XMLerror(door_controller: DoorController) -> None:
body = '<?xml version="1.0" encoding="UTF-8"?><VertXMessage xmlns:hid="http://www.hidcorp.com/VertX"><hid:Error action="RS" elementType="hid:TEST" errorCode="72" errorReporter="vertx" errorMessage="Unrecognized element"/></VertXMessage>'
responses.add(
responses.GET,
"https://127.0.0.1/cgi-bin/vertx_xml.cgi",
body=body,
status=200,
)
with pytest.raises(RemoteError) as excinfo:
door_controller.doXMLRequest(ROOT(E.TEST()))
assert excinfo.value.args[0] == "Door Updating Error: 200 OK\n" + body
# def doImport(self, params=None, files=None):
# def doCSVImport(self, csv):
def test_get_scheduleMap(door_controller: DoorController) -> None:
with patch.object(door_controller, "doXMLRequest") as mockXMLRequest:
mockXMLRequest.return_value = E_corp.VertXMessage(
E_corp.Schedules(
{"action": "RL"},
E_corp.Schedule({"scheduleID": "1", "scheduleName": "Test1"}),
E_corp.Schedule({"scheduleID": "2", "scheduleName": "Test2"}),
E_corp.Schedule({"scheduleID": "3", "scheduleName": "Test3"}),
)
)
ret = door_controller.get_scheduleMap()
assert ret == {"Test1": "1", "Test2": "2", "Test3": "3"}
# TODO: these two methods might want to be reworked: they are a bit clunky
# def get_schedules(self):
# def set_schedules(self, schedules):
def test_set_cardholder_schedules(door_controller: DoorController) -> None:
with patch.object(door_controller, "doXMLRequest") as mockXMLRequest:
door_controller._scheduleMap = {"Test1": "1", "Test2": "2", "Test3": "3"}
# TODO: should replace with a captured output
mockXMLRequest.return_value = ROOT()
ret = door_controller.set_cardholder_schedules("123", ["Test1", "Test3"])
assert_elements_equal(
door_controller.doXMLRequest.call_args[0][0],
ROOT(
E.RoleSet(
{"action": "UD", "roleSetID": "123"},
E.Roles(
E.Role({"roleID": "123", "scheduleID": "1", "resourceID": "0"}),
E.Role({"roleID": "123", "scheduleID": "3", "resourceID": "0"}),
),
)
),
)
assert_elements_equal(ret, ROOT())
def test_get_cardFormats(door_controller):
with patch.object(door_controller, "doXMLRequest") as mockXMLRequest:
mockXMLRequest.return_value = E_corp.VertXMessage(
E_corp.CardFormats(
{"action": "RL"},
E_corp.CardFormat(
{
"formatID": "1",
"formatName": "H10301 26-Bit",
"isTemplate": "true",
"templateID": "1",
}
),
# irrelevant templates omitted
E_corp.CardFormat(
{
"formatID": "6",
"formatName": "A901146A-123",
"isTemplate": "false",
"templateID": "1",
},
E_corp.FixedField({"value": "123"}),
),
E_corp.CardFormat(
{
"formatID": "7",
"formatName": "A901146A-456",
"isTemplate": "false",
"templateID": "1",
},
E_corp.FixedField({"value": "456"}),
),
)
)
ret = door_controller.get_cardFormats()
assert ret == {"123": "6", "456": "7"}
def test_set_cardFormat(door_controller):
with patch.object(door_controller, "doXMLRequest") as mockXMLRequest:
# TODO: should replace with a captured output
mockXMLRequest.return_value = ROOT()
ret = door_controller.set_cardFormat("testname", 3, 123)
assert_elements_equal(
door_controller.doXMLRequest.call_args[0][0],
ROOT(
E.CardFormats(
{"action": "AD"},
E.CardFormat(
{"formatName": "testname", "templateID": "3"},
E.FixedField({"value": "123"}),
),
)
),
)
assert_elements_equal(ret, ROOT())
def test_get_records_no_morerecords(door_controller):
"""Test for when all the records fit in one 'page'"""
with patch.object(door_controller, "doXMLRequest") as mockXMLRequest:
mockXMLRequest.return_value = E_corp.VertXMessage(
E_corp.TestElements(
{
"action": "RL",
"recordOffset": "0",
"recordCount": "2",
"moreRecords": "false",
},
E_corp.TestElement({"asdf": "a"}),
E_corp.TestElement({"qwer": "b"}),
)
)
ret = door_controller.get_records(E.TestElements, 12, {"blah": "test"})
assert_elements_equal(
door_controller.doXMLRequest.call_args[0][0],
ROOT(
E.TestElements(
{
"action": "LR",
# TODO: should really be 12, but isn't for bug workaround
"recordCount": "13",
"recordOffset": "0",
"blah": "test",
},
)
),
)
assert_elements_equal(ret[0], E_corp.TestElement({"asdf": "a"}))
assert_elements_equal(ret[1], E_corp.TestElement({"qwer": "b"}))
# def test_get_records_morerecords(door_controller):
# """Test for when all the records span multiple 'pages'"""
# pass
# def test_get_records_morerecords_bad_last_record(door_controller):
# """Test for bug in which last record of each 'page' is missing data"""
# pass
# def test_get_records_stopFunction(door_controller):
# pass
# def test_get_cardholders(door_controller):
# door_controller = DoorController(
# "172.18.51.11",
# "admin",
# "PVic6ydFS/",
# name="Test",
# access="Test",
# cert="../../hidglobal.com.pem",
# )
# with patch.object(door_controller, "doXMLRequest") as mockXMLRequest:
# mockXMLRequest.return_value = E_corp.VertXMessage(
# E_corp.Cardholders(
# {"action": "RL"},
# E_corp.Cardholder({"scheduleID": "1", "scheduleName": "Test1"}),
# E_corp.Cardholder({"scheduleID": "2", "scheduleName": "Test2"}),
# E_corp.Cardholder({"scheduleID": "3", "scheduleName": "Test3"}),
# )
# )
# for x in [0]:
# ret = door_controller.get_cardholders()
# assert ret == {"Test1": "1", "Test2": "2", "Test3": "3"}
# def add_cardholder(self, attribs):
# def update_cardholder(self, cardholderID, attribs):
# def get_credentials(self):
# def add_credentials(self, credentials, cardholderID=None):
# def assign_credential(self, credential, cardholderID=None):
# def get_events(self, threshold):
# def get_lock(self):
# def set_lock(self, lock=True):

View File

View File

@ -0,0 +1,32 @@
import logging
from django.core.management.base import BaseCommand, CommandError
from doorcontrol.models import Door
from doorcontrol.tasks.update_doors import logger, update_door
class Command(BaseCommand):
def add_arguments(self, parser):
parser.add_argument("door_names", nargs="*")
parser.add_argument("--dry-run", action="store_true")
def handle(
self, *args, door_names: list[str], dry_run: bool, verbosity: int, **options
):
verbosity_levels = {
0: logging.ERROR,
1: logging.WARNING,
2: logging.INFO,
3: logging.DEBUG,
}
logger.setLevel(verbosity_levels.get(verbosity, logging.WARNING))
doors = Door.objects.all()
if door_names:
doors = doors.filter(name__in=door_names)
if len(doors) != len(door_names):
raise CommandError("Not all door names matched doors in database")
for door in doors:
update_door(door, dry_run=dry_run)

View File

@ -0,0 +1,106 @@
# Generated by Django 5.0.2 on 2024-02-23 18:36
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("doorcontrol", "0005_doorcardholdermember_and_more"),
("membershipworks", "0015_eventmeetingtime_end_after_start"),
]
operations = [
migrations.CreateModel(
name="Schedule",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255, unique=True)),
],
),
migrations.AddField(
model_name="door",
name="access_field",
field=models.TextField(
default="CHANGE ME",
help_text="Membershipworks field that grants members access to this door",
max_length=128,
),
preserve_default=False,
),
migrations.CreateModel(
name="FlagScheduleRule",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("doors", models.ManyToManyField(to="doorcontrol.door")),
(
"flag",
models.ForeignKey(
blank=True,
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.PROTECT,
to="membershipworks.flag",
),
),
(
"schedule",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="doorcontrol.schedule",
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="AttributeScheduleRule",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"access_field",
models.CharField(
help_text="Membershipworks field that grants members access to this door using this schedule.",
max_length=128,
),
),
("doors", models.ManyToManyField(to="doorcontrol.door")),
(
"schedule",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="doorcontrol.schedule",
),
),
],
options={
"abstract": False,
},
),
]

View File

@ -5,7 +5,9 @@ from django.db import models
from django.db.models import F, Func, OuterRef, Q, Subquery from django.db.models import F, Func, OuterRef, Q, Subquery
from django.db.models.functions import Mod from django.db.models.functions import Mod
from django.utils import timezone from django.utils import timezone
from django.utils.functional import cached_property
from membershipworks.models import Flag as MembershipWorksFlag
from membershipworks.models import Member from membershipworks.models import Member
from .hid.DoorController import DoorController from .hid.DoorController import DoorController
@ -14,6 +16,10 @@ from .hid.DoorController import DoorController
class Door(models.Model): class Door(models.Model):
name = models.CharField(max_length=64, unique=True) name = models.CharField(max_length=64, unique=True)
ip = models.GenericIPAddressField(protocol="IPv4") ip = models.GenericIPAddressField(protocol="IPv4")
access_field = models.TextField(
max_length=128,
help_text="Membershipworks field that grants members access to this door",
)
@property @property
def controller(self) -> DoorController: def controller(self) -> DoorController:
@ -26,6 +32,14 @@ class Door(models.Model):
def __str__(self): def __str__(self):
return self.name return self.name
@cached_property
def card_formats(self):
return self.controller.get_card_formats()
@cached_property
def schedules_map(self):
return self.controller.get_scheduleMap()
class DoorCardholderMember(models.Model): class DoorCardholderMember(models.Model):
door = models.ForeignKey(Door, on_delete=models.CASCADE) door = models.ForeignKey(Door, on_delete=models.CASCADE)
@ -46,6 +60,46 @@ class DoorCardholderMember(models.Model):
return f"{self.door} [{self.cardholder_id}]: {self.member}" return f"{self.door} [{self.cardholder_id}]: {self.member}"
class Schedule(models.Model):
name = models.CharField(max_length=255, unique=True)
def __str__(self) -> str:
return f"{self.name}"
class AbstractScheduleRule(models.Model):
schedule = models.ForeignKey(Schedule, on_delete=models.CASCADE)
doors = models.ManyToManyField(Door)
class Meta:
abstract = True
class FlagScheduleRule(AbstractScheduleRule):
flag = models.ForeignKey(
MembershipWorksFlag,
null=True,
blank=True,
on_delete=models.PROTECT,
db_constraint=False,
)
def __str__(self) -> str:
return f"{self.schedule} [flag: {self.flag}]"
class AttributeScheduleRule(AbstractScheduleRule):
access_field = models.CharField(
max_length=128,
help_text=(
"Membershipworks field that grants members access to this door using this schedule."
),
)
def __str__(self) -> str:
return f"{self.schedule} [attribute: {self.access_field}]"
class HIDEventQuerySet(models.QuerySet): class HIDEventQuerySet(models.QuerySet):
def with_decoded_card_number(self): def with_decoded_card_number(self):
# TODO: CONV and BIT_COUNT are MySQL/MariaDB specific # TODO: CONV and BIT_COUNT are MySQL/MariaDB specific

View File

@ -0,0 +1,262 @@
import dataclasses
import logging
from django_q.tasks import async_task
from doorcontrol.hid.Credential import Credential
from doorcontrol.hid.DoorController import ROOT, E
from doorcontrol.models import AttributeScheduleRule, Door, FlagScheduleRule
from membershipworks.models import Member
logger = logging.getLogger(__name__)
@dataclasses.dataclass
class DoorMember:
door: Door
attribs: dict[str, str]
credentials: set[Credential]
schedules: set[str]
cardholderID: str | None = None
@classmethod
def from_membershipworks_member(cls, member: Member, door: Door):
if member.access_card_facility_code and member.access_card_number:
credentials = {
Credential(
code=(
member.access_card_facility_code,
member.access_card_number,
)
)
}
else:
credentials = set()
reasons_and_schedules = {}
if (
member.is_active
or member.flags.filter(name="Misc. Access", type="folder").exists()
) and getattr(member, door.access_field):
# TODO: could this be annotated?
reasons_and_schedules |= FlagScheduleRule.objects.filter(
doors=door, flag__members=member
).values_list("flag__name", "schedule__name")
# TODO: this seems like it could be cleaner
reasons_and_schedules |= {
attribute_rule.access_field: attribute_rule.schedule.name
for attribute_rule in AttributeScheduleRule.objects.filter(doors=door)
if getattr(member, attribute_rule.access_field)
}
reasons = sorted(reasons_and_schedules.keys())
return cls(
door=door,
attribs={
"forename": member.first_name,
"middleName": "",
"surname": member.last_name,
"email": member.email,
"phone": member.phone,
"custom1": "|".join(reasons).replace("&", "and"),
"custom2": member.uid,
},
credentials=credentials,
schedules=set(reasons_and_schedules.values()),
)
@classmethod
def from_cardholder(cls, data, door: Door):
return cls(
door=door,
attribs={
"forename": data.get("forename", ""),
"middleName": data.attrib.get("middleName", ""),
"surname": data.get("surname", ""),
"email": data.attrib.get("email", ""),
"phone": data.attrib.get("phone", ""),
"custom1": data.attrib.get("custom1", ""),
"custom2": data.attrib.get("custom2", ""),
},
cardholderID=data.attrib["cardholderID"],
credentials={
Credential(hex=(c.attrib["rawCardNumber"]))
for c in data.findall("{*}Credential")
},
schedules={r.attrib["scheduleName"] for r in data.findall("{*}Role")},
)
@property
def membershipworks_id(self):
return self.attribs["custom2"]
@property
def full_name(self):
return f"{self.attribs['forename']} {self.attribs['surname']}"
def update_attribs(self):
self.door.controller.doXMLRequest(
ROOT(
E.Cardholders(
{"action": "UD", "cardholderID": self.cardholderID},
E.CardHolder(self.attribs),
)
)
)
def update_credentials(
self,
existing_door_credentials: set[Credential],
all_members: list["DoorMember"],
old_credentials: set[Credential] = set(),
):
other_assigned_cards = {
card for m in all_members if m != self for card in m.credentials
}
# cards removed, and won't be reassigned to someone else
for card in (old_credentials - self.credentials) - other_assigned_cards:
self.door.controller.update_credential(card.hex, "")
if self.credentials - old_credentials: # cards added
for card in (
self.credentials & other_assigned_cards
): # new card exists in another member
logger.info(
[
m
for m in all_members
for card in m.credentials
if card in self.credentials
]
)
raise Exception(f"Duplicate Card in input data! {card}")
# card existed in door.controller, and needs to be reassigned
for card in self.credentials & existing_door_credentials:
self.door.controller.update_credential(card.hex, self.cardholderID)
# cards that never existed, and need to be created
if self.credentials - existing_door_credentials:
xml_credentials = [
E.Credential(
{
"formatName": str(credential.code[0]),
"cardNumber": str(credential.code[1]),
"formatID": self.door.card_formats[str(credential.code[0])],
"isCard": "true",
"cardholderID": self.cardholderID,
}
)
for credential in self.credentials - existing_door_credentials
]
self.door.controller.doXMLRequest(
ROOT(E.Credentials({"action": "AD"}, *xml_credentials))
)
def update_schedules(self):
roles = [
E.Role(
{
"roleID": self.cardholderID,
"scheduleID": self.door.schedules_map[schedule],
"resourceID": "0",
}
)
for schedule in self.schedules
]
self.door.controller.doXMLRequest(
ROOT(
E.RoleSet(
{"action": "UD", "roleSetID": self.cardholderID}, E.Roles(*roles)
)
)
)
def update_door(door: Door, dry_run: bool = False):
members = [
DoorMember.from_membershipworks_member(membershipworks_member, door)
for membershipworks_member in (Member.objects.with_is_active()).all()
]
cardholders = {
member.membershipworks_id: member
for member in [
DoorMember.from_cardholder(ch, door.controller)
for ch in door.controller.get_cardholders()
]
}
existing_door_credentials = {
Credential(hex=c.attrib["rawCardNumber"])
for c in door.controller.get_credentials()
}
# TODO: can I combine requests?
for member in members:
# cardholder did not exist, so add them
if member.membershipworks_id not in cardholders:
logger.info(f"Adding Member {member.full_name}: {member}")
if not dry_run:
resp = door.controller.doXMLRequest(
ROOT(E.Cardholders({"action": "AD"}, E.Cardholder(member.attribs)))
)
member.cardholderID = resp.find("{*}Cardholders/{*}Cardholder").attrib[
"cardholderID"
]
member.update_attribs()
member.update_credentials(existing_door_credentials, members)
member.update_schedules()
# cardholder exists, compare contents
else:
existing_cardholder = cardholders.pop(member.membershipworks_id)
member.cardholderID = existing_cardholder.cardholderID
if member.attribs != existing_cardholder.attribs:
changes = {
k: f'"{existing_cardholder.attribs[k]}" -> "{v}"'
for k, v in member.attribs.items()
if existing_cardholder.attribs[k] != v
}
logger.info(f"Updating profile for {member.full_name}: {changes}")
if not dry_run:
member.update_attribs()
if member.credentials != existing_cardholder.credentials:
logger.info(
f"Updating card for {member.full_name}:"
f" {existing_cardholder.credentials} -> {member.credentials}"
)
if not dry_run:
member.update_credentials(
existing_door_credentials,
members,
old_credentials=existing_cardholder.credentials,
)
if member.schedules != existing_cardholder.schedules:
logger.info(
f"Updating schedule for {member.full_name}:"
f" {existing_cardholder.schedules} -> {member.schedules}"
)
if not dry_run:
member.update_schedules()
# TODO: delete cardholders that are no longer members?
def q_update_all_doors():
for door in Door.objects.all():
async_task(
update_door,
door,
cluster="internal",
group=f"Update HID Door Controller - {door.name}",
)

View File

@ -1,23 +0,0 @@
class MembershipWorksRouter:
app_label = "membershipworks"
db = "membershipworks"
def db_for_read(self, model, **hints):
if model._meta.app_label == self.app_label:
return self.db
return None
def db_for_write(self, model, **hints):
if model._meta.app_label == self.app_label:
return self.db
return None
def allow_migrate(self, db, app_label, model_name=None, **hints):
if app_label == self.app_label:
return db == self.db
return None
def allow_relation(self, obj1, obj2, **hints):
if self.app_label in (obj1._meta.app_label, obj2._meta.app_label):
return True
return None

View File

@ -1,4 +1,4 @@
from django.db.models import Prefetch, Q from django.db.models import Q
from rest_framework import routers, serializers, viewsets from rest_framework import routers, serializers, viewsets
from rest_framework.decorators import action from rest_framework.decorators import action
@ -33,10 +33,6 @@ class DepartmentViewSet(viewsets.ModelViewSet):
departments = self.queryset.prefetch_related( departments = self.queryset.prefetch_related(
"children", "children",
"shop_lead_flag__members", "shop_lead_flag__members",
Prefetch(
"certificationdefinition_set__certificationversion_set__certification_set__member",
queryset=Member.objects.with_is_active(),
),
) )
lists = {} lists = {}
for department in departments.filter(has_mailing_list=True): for department in departments.filter(has_mailing_list=True):
@ -48,14 +44,12 @@ class DepartmentViewSet(viewsets.ModelViewSet):
else: else:
moderator_emails = [] moderator_emails = []
# TODO: this could be done in SQL instead if
# membershipworks was in the same database
active_certified_members = { active_certified_members = {
member_cert.member.sanitized_mailbox() member.sanitized_mailbox()
for certification in department.certificationdefinition_set.all() for member in Member.objects.with_is_active().filter(
for version in certification.certificationversion_set.all() is_active=True,
for member_cert in version.certification_set.all() certification__certification_version__definition__department=department,
if member_cert.member and member_cert.member.is_active )
} }
lists[department.list_name] = { lists[department.list_name] = {

View File

@ -71,11 +71,11 @@ class CmsRedRiverVeteransScholarship(models.Model):
class DepartmentQuerySet(models.QuerySet): class DepartmentQuerySet(models.QuerySet):
def filter_by_shop_lead(self, member: Member) -> models.QuerySet["Department"]: def filter_by_shop_lead(self, member: Member) -> models.QuerySet["Department"]:
"""Get departments for which `member` is a shop lead""" """Get departments for which `member` is a shop lead"""
# TODO: could be a lot simpler if membershipworks was in the same database # TODO: should select children recursively, instead of specific levels
# TODO: should also select children return self.filter(
member_flags = list(member.flags.all().values_list("pk", flat=True)) Q(shop_lead_flag__members=member)
return self.prefetch_related("shop_lead_flag__members").filter( | Q(parent__shop_lead_flag__members=member)
shop_lead_flag__in=member_flags | Q(parent__parent__shop_lead_flag__members=member)
) )

View File

@ -1,3 +1,5 @@
from itertools import chain
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
@ -78,29 +80,42 @@ class InstructorOrVendorReportTestCase(PermissionRequiredViewTestCaseMixin, Test
@st.composite @st.composite
def random_certifications(draw): def random_certifications(
departments = draw(st.lists(from_model(Department), min_size=1)) draw,
definitions = draw( ) -> list[Certification]:
st.lists( def certifications(version: CertificationVersion):
return st.lists(
from_model( from_model(
CertificationDefinition, department=st.sampled_from(departments) Certification,
number=st.none(),
certification_version=st.just(version),
), ),
min_size=1, max_size=10,
) )
def versions_with_certifications(definition: CertificationDefinition):
return st.lists(
from_model(CertificationVersion, definition=st.just(definition)).flatmap(
certifications
),
max_size=2,
) )
certification_versions = draw(
st.lists( def definitions_with_versions(department: Department):
from_model(CertificationVersion, definition=st.sampled_from(definitions)), return st.lists(
min_size=1, from_model(CertificationDefinition, department=st.just(department)).flatmap(
) versions_with_certifications
),
max_size=2,
) )
return draw( return draw(
st.lists( st.lists(
from_model( from_model(Department).flatmap(definitions_with_versions),
Certification, max_size=2,
number=st.none(), ).map(
certification_version=st.sampled_from(certification_versions), lambda x: list(
chain.from_iterable(chain.from_iterable(chain.from_iterable(x)))
) )
) )
) )
@ -111,7 +126,7 @@ class CertifiersReportTestCase(PermissionRequiredViewTestCaseMixin, TestCase):
path = "/paperwork/certifiers" path = "/paperwork/certifiers"
@given(certifications=random_certifications()) @given(certifications=random_certifications())
def test_certifers_report(self, certifications: list[Certification]) -> None: def test_certifiers_report(self, certifications: list[Certification]) -> None:
self.client.force_login(self.user_with_permission) self.client.force_login(self.user_with_permission)
response = self.client.get(self.path) response = self.client.get(self.path)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)

View File

@ -3,7 +3,17 @@ from django.contrib import staticfiles
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db import models from django.db import models
from django.db.models import Case, Count, Max, Q, Value, When from django.db.models import (
Case,
Count,
Exists,
Max,
OuterRef,
Q,
Subquery,
Value,
When,
)
from django.db.models.functions import Concat from django.db.models.functions import Concat
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseNotFound from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseNotFound
from django.shortcuts import get_object_or_404, render from django.shortcuts import get_object_or_404, render
@ -236,49 +246,24 @@ class AccessVerificationReport(
export_formats = ("csv", "xlsx", "ods") export_formats = ("csv", "xlsx", "ods")
def get_queryset(self): def get_queryset(self):
# TODO: could be done with subqueries if membershipworks was not a separate DB
def shop_error(access_field: str, shop_name: str): def shop_error(access_field: str, shop_name: str):
member_list = list( member_list = CertificationVersion.objects.filter(
CertificationVersion.objects.filter(
is_current=True, is_current=True,
definition__department__name=shop_name, definition__department__name=shop_name,
certification__member__pk__isnull=False, certification__member__pk__isnull=False,
) ).values("certification__member__pk")
.values_list(
"certification__member__pk",
flat=True,
)
.distinct()
)
return Case( return Case(
When( When(
Q(**{access_field: True}) & ~Q(uid__in=member_list), Q(**{access_field: True}) & ~Q(uid__in=Subquery(member_list)),
Value("Has access but no cert"), Value("Has access but no cert"),
), ),
When( When(
Q(**{access_field: False}) & Q(uid__in=member_list), Q(**{access_field: False}) & Q(uid__in=Subquery(member_list)),
Value("Has cert but no access"), Value("Has cert but no access"),
), ),
default=None, default=None,
) )
# TODO: could be a lot cleaner if membershipworks was not a separate DB
storage_closet_members = (
Member.objects.filter(
Member.objects.has_flag("label", "Volunteer: Desker")
| Q(billing_method__startswith="Desker")
)
.union(
*[
department.shop_lead_flag.members.all()
for department in Department.objects.filter(
shop_lead_flag__isnull=False
)
]
)
.values_list("pk", flat=True)
)
qs = ( qs = (
Member.objects.with_is_active() Member.objects.with_is_active()
.filter(is_active=True) .filter(is_active=True)
@ -307,7 +292,17 @@ class AccessVerificationReport(
storage_closet_error=Case( storage_closet_error=Case(
When( When(
Q(access_storage_closet=True) Q(access_storage_closet=True)
& ~Q(uid__in=storage_closet_members), & ~(
Member.objects.has_flag("label", "Volunteer: Desker")
| Q(billing_method__startswith="Desker")
| Q(
Exists(
Department.objects.filter(
shop_lead_flag__members=OuterRef("pk")
)
)
)
),
Value("Has access but not shop lead or desker"), Value("Has access but not shop lead or desker"),
), ),
default=None, default=None,

View File

@ -5,7 +5,7 @@
groups = ["default", "debug", "lint", "server", "typing", "dev"] groups = ["default", "debug", "lint", "server", "typing", "dev"]
strategy = ["cross_platform"] strategy = ["cross_platform"]
lock_version = "4.4.1" lock_version = "4.4.1"
content_hash = "sha256:379d72c4be2ef3f09d6a3f9cf0517f784fa18df7367386dfc09cd40fa521a25f" content_hash = "sha256:d596669dbbecb4da5d8b0091ff6db601670ed9a1251b7e76d7f8329ccddbf43a"
[[package]] [[package]]
name = "aiohttp" name = "aiohttp"
@ -164,6 +164,77 @@ files = [
{file = "beautifulsoup4-4.11.1.tar.gz", hash = "sha256:ad9aa55b65ef2808eb405f46cf74df7fcb7044d5cbc26487f96eb2ef2e436693"}, {file = "beautifulsoup4-4.11.1.tar.gz", hash = "sha256:ad9aa55b65ef2808eb405f46cf74df7fcb7044d5cbc26487f96eb2ef2e436693"},
] ]
[[package]]
name = "bitarray"
version = "2.9.2"
summary = "efficient arrays of booleans -- C extension"
files = [
{file = "bitarray-2.9.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:fe71fd4b76380c2772f96f1e53a524da7063645d647a4fcd3b651bdd80ca0f2e"},
{file = "bitarray-2.9.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d527172919cdea1e13994a66d9708a80c3d33dedcf2f0548e4925e600fef3a3a"},
{file = "bitarray-2.9.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:052c5073bdcaa9dd10628d99d37a2f33ec09364b86dd1f6281e2d9f8d3db3060"},
{file = "bitarray-2.9.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e064caa55a6ed493aca1eda06f8b3f689778bc780a75e6ad7724642ba5dc62f7"},
{file = "bitarray-2.9.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:508069a04f658210fdeee85a7a0ca84db4bcc110cbb1d21f692caa13210f24a7"},
{file = "bitarray-2.9.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4da73ebd537d75fa7bccfc2228fcaedea0803f21dd9d0bf0d3b67fef3c4af294"},
{file = "bitarray-2.9.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5cb378eaa65cd43098f11ff5d27e48ee3b956d2c00d2d6b5bfc2a09fe183be47"},
{file = "bitarray-2.9.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d14c790b91f6cbcd9b718f88ed737c78939980c69ac8c7f03dd7e60040c12951"},
{file = "bitarray-2.9.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:7eea9318293bc0ea6447e9ebfba600a62f3428bea7e9c6d42170ae4f481dbab3"},
{file = "bitarray-2.9.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:b76ffec27c7450b8a334f967366a9ebadaea66ee43f5b530c12861b1a991f503"},
{file = "bitarray-2.9.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:76b76a07d4ee611405045c6950a1e24c4362b6b44808d4ad6eea75e0dbc59af4"},
{file = "bitarray-2.9.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:c7d16beeaaab15b075990cd26963d6b5b22e8c5becd131781514a00b8bdd04bd"},
{file = "bitarray-2.9.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:60df43e868a615c7e15117a1e1c2e5e11f48f6457280eba6ddf8fbefbec7da99"},
{file = "bitarray-2.9.2-cp311-cp311-win32.whl", hash = "sha256:e788608ed7767b7b3bbde6d49058bccdf94df0de9ca75d13aa99020cc7e68095"},
{file = "bitarray-2.9.2-cp311-cp311-win_amd64.whl", hash = "sha256:a23397da092ef0a8cfe729571da64c2fc30ac18243caa82ac7c4f965087506ff"},
{file = "bitarray-2.9.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:90e3a281ffe3897991091b7c46fca38c2675bfd4399ffe79dfeded6c52715436"},
{file = "bitarray-2.9.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:bed637b674db5e6c8a97a4a321e3e4d73e72d50b5c6b29950008a93069cc64cd"},
{file = "bitarray-2.9.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e49066d251dbbe4e6e3a5c3937d85b589e40e2669ad0eef41a00f82ec17d844b"},
{file = "bitarray-2.9.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c4344e96642e2211fb3a50558feff682c31563a4c64529a931769d40832ca79"},
{file = "bitarray-2.9.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aeb60962ec4813c539a59fbd4f383509c7222b62c3fb1faa76b54943a613e33a"},
{file = "bitarray-2.9.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed0f7982f10581bb16553719e5e8f933e003f5b22f7d25a68bdb30fac630a6ff"},
{file = "bitarray-2.9.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c71d1cabdeee0cdda4669168618f0e46b7dace207b29da7b63aaa1adc2b54081"},
{file = "bitarray-2.9.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0ef2d0a6f1502d38d911d25609b44c6cc27bee0a4363dd295df78b075041b60"},
{file = "bitarray-2.9.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:6f71d92f533770fb027388b35b6e11988ab89242b883f48a6fe7202d238c61f8"},
{file = "bitarray-2.9.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:ba0734aa300757c924f3faf8148e1b8c247176a0ac8e16aefdf9c1eb19e868f7"},
{file = "bitarray-2.9.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:d91406f413ccbf4af6ab5ae7bc78f772a95609f9ddd14123db36ef8c37116d95"},
{file = "bitarray-2.9.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:87abb7f80c0a042f3fe8e5264da1a2756267450bb602110d5327b8eaff7682e7"},
{file = "bitarray-2.9.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4b558ce85579b51a2e38703877d1e93b7728a7af664dd45a34e833534f0b755d"},
{file = "bitarray-2.9.2-cp312-cp312-win32.whl", hash = "sha256:dac2399ee2889fbdd3472bfc2ede74c34cceb1ccf29a339964281a16eb1d3188"},
{file = "bitarray-2.9.2-cp312-cp312-win_amd64.whl", hash = "sha256:48a30d718d1a6dfc22a49547450107abe8f4afdf2abdcbe76eb9ed88edc49498"},
{file = "bitarray-2.9.2-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:43847799461d8ba71deb4d97b47250c2c2fb66d82cd3cb8b4caf52bb97c03034"},
{file = "bitarray-2.9.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4f44381b0a4bdf64416082f4f0e7140377ae962c0ced6f983c6d7bbfc034040"},
{file = "bitarray-2.9.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a484061616fb4b158b80789bd3cb511f399d2116525a8b29b6334c68abc2310f"},
{file = "bitarray-2.9.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1ff9e38356cc803e06134cf8ae9758e836ccd1b793135ef3db53c7c5d71e93bc"},
{file = "bitarray-2.9.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b44105792fbdcfbda3e26ee88786790fda409da4c71f6c2b73888108cf8f062f"},
{file = "bitarray-2.9.2-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7e913098de169c7fc890638ce5e171387363eb812579e637c44261460ac00aa2"},
{file = "bitarray-2.9.2-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6fe315355cdfe3ed22ef355b8bdc81a805ca4d0949d921576560e5b227a1112"},
{file = "bitarray-2.9.2-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f708e91fdbe443f3bec2df394ed42328fb9b0446dff5cb4199023ac6499e09fd"},
{file = "bitarray-2.9.2-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5b7b09489b71f9f1f64c0fa0977e250ec24500767dab7383ba9912495849cadf"},
{file = "bitarray-2.9.2-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:128cc3488176145b9b137fdcf54c1c201809bbb8dd30b260ee40afe915843b43"},
{file = "bitarray-2.9.2-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:21f21e7f56206be346bdbda2a6bdb2165a5e6a11821f88fd4911c5a6bbbdc7e2"},
{file = "bitarray-2.9.2-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f4dd3af86dd8a617eb6464622fb64ca86e61ce99b59b5c35d8cd33f9c30603d"},
{file = "bitarray-2.9.2-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6465de861aff7a2559f226b37982007417eab8c3557543879987f58b453519bd"},
{file = "bitarray-2.9.2-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dbaf2bb71d6027152d603f1d5f31e0dfd5e50173d06f877bec484e5396d4594b"},
{file = "bitarray-2.9.2-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:2f32948c86e0d230a296686db28191b67ed229756f84728847daa0c7ab7406e3"},
{file = "bitarray-2.9.2-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:be94e5a685e60f9d24532af8fe5c268002e9016fa80272a94727f435de3d1003"},
{file = "bitarray-2.9.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5cc9381fd54f3c23ae1039f977bfd6d041a5c3c1518104f616643c3a5a73b15"},
{file = "bitarray-2.9.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cd926e8ae4d1ed1ac4a8f37212a62886292f692bc1739fde98013bf210c2d175"},
{file = "bitarray-2.9.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:461a3dafb9d5fda0bb3385dc507d78b1984b49da3fe4c6d56c869a54373b7008"},
{file = "bitarray-2.9.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:393cb27fd859af5fd9c16eb26b1c59b17b390ff66b3ae5d0dd258270191baf13"},
{file = "bitarray-2.9.2.tar.gz", hash = "sha256:a8f286a51a32323715d77755ed959f94bef13972e9a2fe71b609e40e6d27957e"},
]
[[package]]
name = "bitstring"
version = "4.1.4"
requires_python = ">=3.7"
summary = "Simple construction, analysis and modification of binary data."
dependencies = [
"bitarray<3.0.0,>=2.8.0",
]
files = [
{file = "bitstring-4.1.4-py3-none-any.whl", hash = "sha256:da46c4d6f8f3fb75a85566fdd33d5083ba8b8f268ed76f34eefe5a00da426192"},
{file = "bitstring-4.1.4.tar.gz", hash = "sha256:94f3f1c45383ebe8fd4a359424ffeb75c2f290760ae8fcac421b44f89ac85213"},
]
[[package]] [[package]]
name = "brotli" name = "brotli"
version = "1.0.9" version = "1.0.9"

View File

@ -25,6 +25,7 @@ dependencies = [
"django-q2~=1.6", "django-q2~=1.6",
"lxml~=5.1", "lxml~=5.1",
"django-object-actions~=4.2", "django-object-actions~=4.2",
"bitstring~=4.1",
"udm-rest-client~=1.2", "udm-rest-client~=1.2",
"openapi-client-udm~=1.0", "openapi-client-udm~=1.0",
"django-nh3~=0.1", "django-nh3~=0.1",