Compare commits

...

7 Commits

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

View File

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

View File

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

View File

@ -82,10 +82,6 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
WSGI_APPLICATION = "cmsmanage.wsgi.application"
DATABASE_ROUTERS = [
"membershipworks.routers.MembershipWorksRouter",
]
# Default URL to redirect to after authentication
LOGIN_REDIRECT_URL = "/"
LOGIN_URL = "/auth/login/"
@ -106,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

View File

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

View File

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

View File

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

View File

@ -2,13 +2,32 @@ from django.contrib import admin
from django_object_actions import DjangoObjectActions, action
from .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)

View File

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

41
doorcontrol/forms.py Normal file
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.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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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