doorcontrol: Migrate door updater over from memberPlumbing
All checks were successful
Ruff / ruff (push) Successful in 2m1s
Test / test (push) Successful in 7m20s

This commit is contained in:
Adam Goldsmith 2024-02-23 15:55:16 -05:00
parent dc9a06b415
commit b6280d701f
13 changed files with 905 additions and 3 deletions

View File

@ -102,6 +102,22 @@ USE_TZ = True
STATIC_URL = "/static/" STATIC_URL = "/static/"
STATICFILES_DIRS = [BASE_DIR / "static"] STATICFILES_DIRS = [BASE_DIR / "static"]
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"my_console": {
"class": "logging.StreamHandler",
},
},
"loggers": {
"": {
"handlers": ["my_console"],
"level": "WARNING",
},
},
}
WIKI_URL = "https://wiki.claremontmakerspace.org" WIKI_URL = "https://wiki.claremontmakerspace.org"
# Django Rest Framework # Django Rest Framework

View File

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

View File

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

41
doorcontrol/forms.py Normal file
View File

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

View File

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

View File

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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