diff --git a/cmsmanage/settings/base.py b/cmsmanage/settings/base.py
index 8639a4f..820faf9 100644
--- a/cmsmanage/settings/base.py
+++ b/cmsmanage/settings/base.py
@@ -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
diff --git a/doorcontrol/admin.py b/doorcontrol/admin.py
index c0579e5..95ed482 100644
--- a/doorcontrol/admin.py
+++ b/doorcontrol/admin.py
@@ -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)
diff --git a/doorcontrol/apps.py b/doorcontrol/apps.py
index 4fb6d02..c528677 100644
--- a/doorcontrol/apps.py
+++ b/doorcontrol/apps.py
@@ -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"
diff --git a/doorcontrol/forms.py b/doorcontrol/forms.py
new file mode 100644
index 0000000..719255d
--- /dev/null
+++ b/doorcontrol/forms.py
@@ -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__"
diff --git a/doorcontrol/hid/tests/test_DoorController.py b/doorcontrol/hid/tests/test_DoorController.py
new file mode 100644
index 0000000..8059780
--- /dev/null
+++ b/doorcontrol/hid/tests/test_DoorController.py
@@ -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='',
+ content_type="text/xml",
+ )
+
+ ret = door_controller.doXMLRequest(
+ b""
+ )
+
+ assert (
+ responses.calls[0].request.params["/cgi-bin/vertx_xml.cgi?XML"]
+ == ''
+ )
+
+ 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='',
+ content_type="text/xml",
+ )
+
+ ret = door_controller.doXMLRequest(ROOT(E.TEST()))
+
+ assert (
+ responses.calls[0].request.params["/cgi-bin/vertx_xml.cgi?XML"]
+ == ''
+ )
+
+ 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 = ''
+ 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):
diff --git a/doorcontrol/management/__init__.py b/doorcontrol/management/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/doorcontrol/management/commands/__init__.py b/doorcontrol/management/commands/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/doorcontrol/management/commands/update_doors.py b/doorcontrol/management/commands/update_doors.py
new file mode 100644
index 0000000..195b948
--- /dev/null
+++ b/doorcontrol/management/commands/update_doors.py
@@ -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)
diff --git a/doorcontrol/migrations/0006_schedule_door_access_field_flagschedulerule_and_more.py b/doorcontrol/migrations/0006_schedule_door_access_field_flagschedulerule_and_more.py
new file mode 100644
index 0000000..ef874b2
--- /dev/null
+++ b/doorcontrol/migrations/0006_schedule_door_access_field_flagschedulerule_and_more.py
@@ -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,
+ },
+ ),
+ ]
diff --git a/doorcontrol/models.py b/doorcontrol/models.py
index 63f68e5..285e6c5 100644
--- a/doorcontrol/models.py
+++ b/doorcontrol/models.py
@@ -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
diff --git a/doorcontrol/tasks/update_doors.py b/doorcontrol/tasks/update_doors.py
new file mode 100644
index 0000000..c7400e3
--- /dev/null
+++ b/doorcontrol/tasks/update_doors.py
@@ -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}",
+ )
diff --git a/pdm.lock b/pdm.lock
index 77b12b2..820d0a5 100644
--- a/pdm.lock
+++ b/pdm.lock
@@ -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"
diff --git a/pyproject.toml b/pyproject.toml
index 923637a..acd8aa7 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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",