doorcontrol: Migrate door updater over from memberPlumbing
This commit is contained in:
parent
dc9a06b415
commit
b6280d701f
@ -102,6 +102,22 @@ USE_TZ = True
|
||||
STATIC_URL = "/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"
|
||||
|
||||
# Django Rest Framework
|
||||
|
@ -2,13 +2,32 @@ from django.contrib import admin
|
||||
|
||||
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
|
||||
|
||||
|
||||
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)
|
||||
class DoorAdmin(admin.ModelAdmin):
|
||||
pass
|
||||
form = DoorAdminForm
|
||||
list_display = ["name", "access_field"]
|
||||
|
||||
|
||||
@admin.register(HIDEvent)
|
||||
|
@ -8,6 +8,7 @@ def post_migrate_callback(sender, **kwargs):
|
||||
from cmsmanage.django_q2_helper import ensure_scheduled
|
||||
|
||||
from .tasks.scrapehidevents import q_getMessagesAllDoors
|
||||
from .tasks.update_doors import q_update_all_doors
|
||||
|
||||
ensure_scheduled(
|
||||
q_getMessagesAllDoors.q_task_group,
|
||||
@ -16,6 +17,13 @@ def post_migrate_callback(sender, **kwargs):
|
||||
minutes=15,
|
||||
)
|
||||
|
||||
ensure_scheduled(
|
||||
"Update Door Controller Members and Cards",
|
||||
q_update_all_doors,
|
||||
schedule_type=Schedule.MINUTES,
|
||||
minutes=15,
|
||||
)
|
||||
|
||||
|
||||
class DoorControlConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
|
41
doorcontrol/forms.py
Normal file
41
doorcontrol/forms.py
Normal 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__"
|
292
doorcontrol/hid/tests/test_DoorController.py
Normal file
292
doorcontrol/hid/tests/test_DoorController.py
Normal 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):
|
0
doorcontrol/management/__init__.py
Normal file
0
doorcontrol/management/__init__.py
Normal file
0
doorcontrol/management/commands/__init__.py
Normal file
0
doorcontrol/management/commands/__init__.py
Normal file
32
doorcontrol/management/commands/update_doors.py
Normal file
32
doorcontrol/management/commands/update_doors.py
Normal 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)
|
@ -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,
|
||||
},
|
||||
),
|
||||
]
|
@ -5,7 +5,9 @@ from django.db import models
|
||||
from django.db.models import F, Func, OuterRef, Q, Subquery
|
||||
from django.db.models.functions import Mod
|
||||
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 .hid.DoorController import DoorController
|
||||
@ -14,6 +16,10 @@ from .hid.DoorController import DoorController
|
||||
class Door(models.Model):
|
||||
name = models.CharField(max_length=64, unique=True)
|
||||
ip = models.GenericIPAddressField(protocol="IPv4")
|
||||
access_field = models.TextField(
|
||||
max_length=128,
|
||||
help_text="Membershipworks field that grants members access to this door",
|
||||
)
|
||||
|
||||
@property
|
||||
def controller(self) -> DoorController:
|
||||
@ -26,6 +32,14 @@ class Door(models.Model):
|
||||
def __str__(self):
|
||||
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):
|
||||
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}"
|
||||
|
||||
|
||||
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):
|
||||
def with_decoded_card_number(self):
|
||||
# TODO: CONV and BIT_COUNT are MySQL/MariaDB specific
|
||||
|
262
doorcontrol/tasks/update_doors.py
Normal file
262
doorcontrol/tasks/update_doors.py
Normal 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}",
|
||||
)
|
73
pdm.lock
generated
73
pdm.lock
generated
@ -5,7 +5,7 @@
|
||||
groups = ["default", "debug", "lint", "server", "typing", "dev"]
|
||||
strategy = ["cross_platform"]
|
||||
lock_version = "4.4.1"
|
||||
content_hash = "sha256:379d72c4be2ef3f09d6a3f9cf0517f784fa18df7367386dfc09cd40fa521a25f"
|
||||
content_hash = "sha256:d596669dbbecb4da5d8b0091ff6db601670ed9a1251b7e76d7f8329ccddbf43a"
|
||||
|
||||
[[package]]
|
||||
name = "aiohttp"
|
||||
@ -164,6 +164,77 @@ files = [
|
||||
{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]]
|
||||
name = "brotli"
|
||||
version = "1.0.9"
|
||||
|
@ -25,6 +25,7 @@ dependencies = [
|
||||
"django-q2~=1.6",
|
||||
"lxml~=5.1",
|
||||
"django-object-actions~=4.2",
|
||||
"bitstring~=4.1",
|
||||
"udm-rest-client~=1.2",
|
||||
"openapi-client-udm~=1.0",
|
||||
"django-nh3~=0.1",
|
||||
|
Loading…
Reference in New Issue
Block a user