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",