Compare commits
7 Commits
7c26cf252d
...
b6280d701f
Author | SHA1 | Date | |
---|---|---|---|
b6280d701f | |||
dc9a06b415 | |||
61c81e05b6 | |||
8d0730bf70 | |||
6cf520fdf9 | |||
aec64ea5f3 | |||
4a0ccdb8bc |
@ -24,11 +24,12 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
cache: true
|
cache: true
|
||||||
python-version: ~3.11
|
python-version: ~3.11
|
||||||
|
token: ""
|
||||||
- name: Install apt dependencies
|
- name: Install apt dependencies
|
||||||
run: >-
|
run: >-
|
||||||
sudo apt-get update &&
|
sudo apt-get update &&
|
||||||
sudo apt-get -y install build-essential python3-dev libldap2-dev libsasl2-dev mariadb-client
|
sudo apt-get -y install build-essential python3-dev libldap2-dev libsasl2-dev mariadb-client
|
||||||
- name: Install python dependencies
|
- name: Install python dependencies
|
||||||
run: pdm install
|
run: pdm sync -d
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: pdm run ./manage.py test --parallel auto
|
run: pdm run -v ./manage.py test --parallel auto
|
||||||
|
@ -19,6 +19,11 @@ repos:
|
|||||||
- id: ruff
|
- id: ruff
|
||||||
- id: ruff-format
|
- 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
|
# TODO: waiting on django-recurrence 1.12 to be released on PyPi
|
||||||
# - repo: local
|
# - repo: local
|
||||||
# hooks:
|
# hooks:
|
||||||
|
@ -82,10 +82,6 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
|||||||
|
|
||||||
WSGI_APPLICATION = "cmsmanage.wsgi.application"
|
WSGI_APPLICATION = "cmsmanage.wsgi.application"
|
||||||
|
|
||||||
DATABASE_ROUTERS = [
|
|
||||||
"membershipworks.routers.MembershipWorksRouter",
|
|
||||||
]
|
|
||||||
|
|
||||||
# Default URL to redirect to after authentication
|
# Default URL to redirect to after authentication
|
||||||
LOGIN_REDIRECT_URL = "/"
|
LOGIN_REDIRECT_URL = "/"
|
||||||
LOGIN_URL = "/auth/login/"
|
LOGIN_URL = "/auth/login/"
|
||||||
@ -106,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
|
||||||
|
@ -1,4 +1,7 @@
|
|||||||
|
from hypothesis import settings
|
||||||
|
|
||||||
from .base import * # noqa: F403
|
from .base import * # noqa: F403
|
||||||
|
from .hypothesis import configure_hypothesis_profiles
|
||||||
|
|
||||||
# Quick-start development settings - unsuitable for production
|
# Quick-start development settings - unsuitable for production
|
||||||
# See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/
|
# See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/
|
||||||
@ -31,3 +34,5 @@ DATABASES = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
configure_hypothesis_profiles()
|
||||||
|
settings.load_profile("ci")
|
||||||
|
@ -1,4 +1,9 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
from hypothesis import settings
|
||||||
|
|
||||||
from .base import * # noqa: F403
|
from .base import * # noqa: F403
|
||||||
|
from .hypothesis import configure_hypothesis_profiles
|
||||||
|
|
||||||
# Quick-start development settings - unsuitable for production
|
# Quick-start development settings - unsuitable for production
|
||||||
# See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/
|
# 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
|
INSTALLED_APPS.append("django_extensions") # noqa: F405
|
||||||
|
|
||||||
MIDDLEWARE.insert(0, "debug_toolbar.middleware.DebugToolbarMiddleware") # noqa: F405
|
MIDDLEWARE.insert(0, "debug_toolbar.middleware.DebugToolbarMiddleware") # noqa: F405
|
||||||
|
|
||||||
|
configure_hypothesis_profiles()
|
||||||
|
settings.load_profile(os.getenv("HYPOTHESIS_PROFILE", "dev"))
|
||||||
|
7
cmsmanage/settings/hypothesis.py
Normal file
7
cmsmanage/settings/hypothesis.py
Normal 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)
|
@ -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)
|
||||||
|
@ -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
41
doorcontrol/forms.py
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
from django import forms
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
from membershipworks.models import Member
|
||||||
|
|
||||||
|
from .models import AttributeScheduleRule, Door
|
||||||
|
|
||||||
|
|
||||||
|
class DoorAdminForm(forms.ModelForm):
|
||||||
|
access_field = forms.ChoiceField(
|
||||||
|
choices=[(None, "---------")]
|
||||||
|
+ [
|
||||||
|
(field.name, field.verbose_name)
|
||||||
|
for field in Member._meta.get_fields()
|
||||||
|
if (
|
||||||
|
isinstance(field, models.BooleanField)
|
||||||
|
and field.name.startswith("access_")
|
||||||
|
)
|
||||||
|
],
|
||||||
|
help_text=Door._meta.get_field("access_field").help_text,
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Door
|
||||||
|
fields = "__all__"
|
||||||
|
|
||||||
|
|
||||||
|
class AttributeScheduleRuleForm(forms.ModelForm):
|
||||||
|
access_field = forms.ChoiceField(
|
||||||
|
choices=[(None, "---------")]
|
||||||
|
+ [
|
||||||
|
(field.name, field.verbose_name)
|
||||||
|
for field in Member._meta.get_fields()
|
||||||
|
if isinstance(field, models.BooleanField)
|
||||||
|
],
|
||||||
|
help_text=AttributeScheduleRule._meta.get_field("access_field").help_text,
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = AttributeScheduleRule
|
||||||
|
fields = "__all__"
|
292
doorcontrol/hid/tests/test_DoorController.py
Normal file
292
doorcontrol/hid/tests/test_DoorController.py
Normal file
@ -0,0 +1,292 @@
|
|||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import responses
|
||||||
|
from lxml.etree import Element
|
||||||
|
|
||||||
|
from ..DoorController import ROOT, DoorController, E, E_corp, RemoteError
|
||||||
|
|
||||||
|
|
||||||
|
# https://stackoverflow.com/questions/7905380/testing-equivalence-of-xml-etree-elementtree
|
||||||
|
def assert_elements_equal(e1: Element, e2: Element) -> None:
|
||||||
|
assert e1.tag == e2.tag
|
||||||
|
assert e1.text == e2.text
|
||||||
|
assert e1.tail == e2.tail
|
||||||
|
assert e1.attrib == e2.attrib
|
||||||
|
assert len(e1) == len(e2)
|
||||||
|
|
||||||
|
for c1, c2 in zip(e1, e2):
|
||||||
|
assert_elements_equal(c1, c2)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def door_controller():
|
||||||
|
return DoorController("127.0.0.1", "test", "test", name="Test", access="Test")
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_doXMLRequest_bytes(door_controller: DoorController) -> None:
|
||||||
|
responses.add(
|
||||||
|
responses.GET,
|
||||||
|
"https://127.0.0.1/cgi-bin/vertx_xml.cgi",
|
||||||
|
body='<?xml version="1.0" encoding="UTF-8"?><VertXMessage xmlns:hid="http://www.hidcorp.com/VertX"><hid:Banana></hid:Banana></VertXMessage>',
|
||||||
|
content_type="text/xml",
|
||||||
|
)
|
||||||
|
|
||||||
|
ret = door_controller.doXMLRequest(
|
||||||
|
b"<VertXMessage><hid:TEST></hid:TEST></VertXMessage>"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
responses.calls[0].request.params["/cgi-bin/vertx_xml.cgi?XML"]
|
||||||
|
== '<?xml version="1.0" encoding="UTF-8"?><VertXMessage><hid:TEST></hid:TEST></VertXMessage>'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_elements_equal(ret, ROOT(E_corp.Banana()))
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_doXMLRequest_xml(door_controller: DoorController) -> None:
|
||||||
|
responses.add(
|
||||||
|
responses.GET,
|
||||||
|
"https://127.0.0.1/cgi-bin/vertx_xml.cgi",
|
||||||
|
body='<?xml version="1.0" encoding="UTF-8"?><VertXMessage xmlns:hid="http://www.hidcorp.com/VertX"><hid:Banana></hid:Banana></VertXMessage>',
|
||||||
|
content_type="text/xml",
|
||||||
|
)
|
||||||
|
|
||||||
|
ret = door_controller.doXMLRequest(ROOT(E.TEST()))
|
||||||
|
|
||||||
|
assert (
|
||||||
|
responses.calls[0].request.params["/cgi-bin/vertx_xml.cgi?XML"]
|
||||||
|
== '<?xml version="1.0" encoding="UTF-8"?><VertXMessage xmlns:hid="http://www.hidglobal.com/VertX"><hid:TEST/></VertXMessage>'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_elements_equal(ret, ROOT(E_corp.Banana()))
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_doXMLRequest_HTTPError(door_controller: DoorController) -> None:
|
||||||
|
responses.add(
|
||||||
|
responses.GET,
|
||||||
|
"https://127.0.0.1/cgi-bin/vertx_xml.cgi",
|
||||||
|
body="whatever",
|
||||||
|
status=403,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(RemoteError) as excinfo:
|
||||||
|
door_controller.doXMLRequest(ROOT(E.TEST()))
|
||||||
|
|
||||||
|
assert excinfo.value.args[0] == "Door Updating Error: 403 Forbidden\nwhatever"
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_doXMLRequest_XMLerror(door_controller: DoorController) -> None:
|
||||||
|
body = '<?xml version="1.0" encoding="UTF-8"?><VertXMessage xmlns:hid="http://www.hidcorp.com/VertX"><hid:Error action="RS" elementType="hid:TEST" errorCode="72" errorReporter="vertx" errorMessage="Unrecognized element"/></VertXMessage>'
|
||||||
|
responses.add(
|
||||||
|
responses.GET,
|
||||||
|
"https://127.0.0.1/cgi-bin/vertx_xml.cgi",
|
||||||
|
body=body,
|
||||||
|
status=200,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(RemoteError) as excinfo:
|
||||||
|
door_controller.doXMLRequest(ROOT(E.TEST()))
|
||||||
|
|
||||||
|
assert excinfo.value.args[0] == "Door Updating Error: 200 OK\n" + body
|
||||||
|
|
||||||
|
|
||||||
|
# def doImport(self, params=None, files=None):
|
||||||
|
# def doCSVImport(self, csv):
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_scheduleMap(door_controller: DoorController) -> None:
|
||||||
|
with patch.object(door_controller, "doXMLRequest") as mockXMLRequest:
|
||||||
|
mockXMLRequest.return_value = E_corp.VertXMessage(
|
||||||
|
E_corp.Schedules(
|
||||||
|
{"action": "RL"},
|
||||||
|
E_corp.Schedule({"scheduleID": "1", "scheduleName": "Test1"}),
|
||||||
|
E_corp.Schedule({"scheduleID": "2", "scheduleName": "Test2"}),
|
||||||
|
E_corp.Schedule({"scheduleID": "3", "scheduleName": "Test3"}),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
ret = door_controller.get_scheduleMap()
|
||||||
|
assert ret == {"Test1": "1", "Test2": "2", "Test3": "3"}
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: these two methods might want to be reworked: they are a bit clunky
|
||||||
|
# def get_schedules(self):
|
||||||
|
# def set_schedules(self, schedules):
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_cardholder_schedules(door_controller: DoorController) -> None:
|
||||||
|
with patch.object(door_controller, "doXMLRequest") as mockXMLRequest:
|
||||||
|
door_controller._scheduleMap = {"Test1": "1", "Test2": "2", "Test3": "3"}
|
||||||
|
# TODO: should replace with a captured output
|
||||||
|
mockXMLRequest.return_value = ROOT()
|
||||||
|
|
||||||
|
ret = door_controller.set_cardholder_schedules("123", ["Test1", "Test3"])
|
||||||
|
|
||||||
|
assert_elements_equal(
|
||||||
|
door_controller.doXMLRequest.call_args[0][0],
|
||||||
|
ROOT(
|
||||||
|
E.RoleSet(
|
||||||
|
{"action": "UD", "roleSetID": "123"},
|
||||||
|
E.Roles(
|
||||||
|
E.Role({"roleID": "123", "scheduleID": "1", "resourceID": "0"}),
|
||||||
|
E.Role({"roleID": "123", "scheduleID": "3", "resourceID": "0"}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_elements_equal(ret, ROOT())
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_cardFormats(door_controller):
|
||||||
|
with patch.object(door_controller, "doXMLRequest") as mockXMLRequest:
|
||||||
|
mockXMLRequest.return_value = E_corp.VertXMessage(
|
||||||
|
E_corp.CardFormats(
|
||||||
|
{"action": "RL"},
|
||||||
|
E_corp.CardFormat(
|
||||||
|
{
|
||||||
|
"formatID": "1",
|
||||||
|
"formatName": "H10301 26-Bit",
|
||||||
|
"isTemplate": "true",
|
||||||
|
"templateID": "1",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
# irrelevant templates omitted
|
||||||
|
E_corp.CardFormat(
|
||||||
|
{
|
||||||
|
"formatID": "6",
|
||||||
|
"formatName": "A901146A-123",
|
||||||
|
"isTemplate": "false",
|
||||||
|
"templateID": "1",
|
||||||
|
},
|
||||||
|
E_corp.FixedField({"value": "123"}),
|
||||||
|
),
|
||||||
|
E_corp.CardFormat(
|
||||||
|
{
|
||||||
|
"formatID": "7",
|
||||||
|
"formatName": "A901146A-456",
|
||||||
|
"isTemplate": "false",
|
||||||
|
"templateID": "1",
|
||||||
|
},
|
||||||
|
E_corp.FixedField({"value": "456"}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
ret = door_controller.get_cardFormats()
|
||||||
|
|
||||||
|
assert ret == {"123": "6", "456": "7"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_cardFormat(door_controller):
|
||||||
|
with patch.object(door_controller, "doXMLRequest") as mockXMLRequest:
|
||||||
|
# TODO: should replace with a captured output
|
||||||
|
mockXMLRequest.return_value = ROOT()
|
||||||
|
|
||||||
|
ret = door_controller.set_cardFormat("testname", 3, 123)
|
||||||
|
|
||||||
|
assert_elements_equal(
|
||||||
|
door_controller.doXMLRequest.call_args[0][0],
|
||||||
|
ROOT(
|
||||||
|
E.CardFormats(
|
||||||
|
{"action": "AD"},
|
||||||
|
E.CardFormat(
|
||||||
|
{"formatName": "testname", "templateID": "3"},
|
||||||
|
E.FixedField({"value": "123"}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_elements_equal(ret, ROOT())
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_records_no_morerecords(door_controller):
|
||||||
|
"""Test for when all the records fit in one 'page'"""
|
||||||
|
with patch.object(door_controller, "doXMLRequest") as mockXMLRequest:
|
||||||
|
mockXMLRequest.return_value = E_corp.VertXMessage(
|
||||||
|
E_corp.TestElements(
|
||||||
|
{
|
||||||
|
"action": "RL",
|
||||||
|
"recordOffset": "0",
|
||||||
|
"recordCount": "2",
|
||||||
|
"moreRecords": "false",
|
||||||
|
},
|
||||||
|
E_corp.TestElement({"asdf": "a"}),
|
||||||
|
E_corp.TestElement({"qwer": "b"}),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
ret = door_controller.get_records(E.TestElements, 12, {"blah": "test"})
|
||||||
|
|
||||||
|
assert_elements_equal(
|
||||||
|
door_controller.doXMLRequest.call_args[0][0],
|
||||||
|
ROOT(
|
||||||
|
E.TestElements(
|
||||||
|
{
|
||||||
|
"action": "LR",
|
||||||
|
# TODO: should really be 12, but isn't for bug workaround
|
||||||
|
"recordCount": "13",
|
||||||
|
"recordOffset": "0",
|
||||||
|
"blah": "test",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_elements_equal(ret[0], E_corp.TestElement({"asdf": "a"}))
|
||||||
|
assert_elements_equal(ret[1], E_corp.TestElement({"qwer": "b"}))
|
||||||
|
|
||||||
|
|
||||||
|
# def test_get_records_morerecords(door_controller):
|
||||||
|
# """Test for when all the records span multiple 'pages'"""
|
||||||
|
# pass
|
||||||
|
|
||||||
|
# def test_get_records_morerecords_bad_last_record(door_controller):
|
||||||
|
# """Test for bug in which last record of each 'page' is missing data"""
|
||||||
|
# pass
|
||||||
|
|
||||||
|
# def test_get_records_stopFunction(door_controller):
|
||||||
|
# pass
|
||||||
|
|
||||||
|
|
||||||
|
# def test_get_cardholders(door_controller):
|
||||||
|
|
||||||
|
# door_controller = DoorController(
|
||||||
|
# "172.18.51.11",
|
||||||
|
# "admin",
|
||||||
|
# "PVic6ydFS/",
|
||||||
|
# name="Test",
|
||||||
|
# access="Test",
|
||||||
|
# cert="../../hidglobal.com.pem",
|
||||||
|
# )
|
||||||
|
|
||||||
|
# with patch.object(door_controller, "doXMLRequest") as mockXMLRequest:
|
||||||
|
# mockXMLRequest.return_value = E_corp.VertXMessage(
|
||||||
|
# E_corp.Cardholders(
|
||||||
|
# {"action": "RL"},
|
||||||
|
# E_corp.Cardholder({"scheduleID": "1", "scheduleName": "Test1"}),
|
||||||
|
# E_corp.Cardholder({"scheduleID": "2", "scheduleName": "Test2"}),
|
||||||
|
# E_corp.Cardholder({"scheduleID": "3", "scheduleName": "Test3"}),
|
||||||
|
# )
|
||||||
|
# )
|
||||||
|
|
||||||
|
# for x in [0]:
|
||||||
|
# ret = door_controller.get_cardholders()
|
||||||
|
# assert ret == {"Test1": "1", "Test2": "2", "Test3": "3"}
|
||||||
|
|
||||||
|
|
||||||
|
# def add_cardholder(self, attribs):
|
||||||
|
# def update_cardholder(self, cardholderID, attribs):
|
||||||
|
# def get_credentials(self):
|
||||||
|
# def add_credentials(self, credentials, cardholderID=None):
|
||||||
|
# def assign_credential(self, credential, cardholderID=None):
|
||||||
|
# def get_events(self, threshold):
|
||||||
|
|
||||||
|
# def get_lock(self):
|
||||||
|
# def set_lock(self, lock=True):
|
0
doorcontrol/management/__init__.py
Normal file
0
doorcontrol/management/__init__.py
Normal file
0
doorcontrol/management/commands/__init__.py
Normal file
0
doorcontrol/management/commands/__init__.py
Normal file
32
doorcontrol/management/commands/update_doors.py
Normal file
32
doorcontrol/management/commands/update_doors.py
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
|
||||||
|
from doorcontrol.models import Door
|
||||||
|
from doorcontrol.tasks.update_doors import logger, update_door
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument("door_names", nargs="*")
|
||||||
|
parser.add_argument("--dry-run", action="store_true")
|
||||||
|
|
||||||
|
def handle(
|
||||||
|
self, *args, door_names: list[str], dry_run: bool, verbosity: int, **options
|
||||||
|
):
|
||||||
|
verbosity_levels = {
|
||||||
|
0: logging.ERROR,
|
||||||
|
1: logging.WARNING,
|
||||||
|
2: logging.INFO,
|
||||||
|
3: logging.DEBUG,
|
||||||
|
}
|
||||||
|
logger.setLevel(verbosity_levels.get(verbosity, logging.WARNING))
|
||||||
|
|
||||||
|
doors = Door.objects.all()
|
||||||
|
if door_names:
|
||||||
|
doors = doors.filter(name__in=door_names)
|
||||||
|
if len(doors) != len(door_names):
|
||||||
|
raise CommandError("Not all door names matched doors in database")
|
||||||
|
|
||||||
|
for door in doors:
|
||||||
|
update_door(door, dry_run=dry_run)
|
@ -0,0 +1,106 @@
|
|||||||
|
# Generated by Django 5.0.2 on 2024-02-23 18:36
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("doorcontrol", "0005_doorcardholdermember_and_more"),
|
||||||
|
("membershipworks", "0015_eventmeetingtime_end_after_start"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Schedule",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("name", models.CharField(max_length=255, unique=True)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="door",
|
||||||
|
name="access_field",
|
||||||
|
field=models.TextField(
|
||||||
|
default="CHANGE ME",
|
||||||
|
help_text="Membershipworks field that grants members access to this door",
|
||||||
|
max_length=128,
|
||||||
|
),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="FlagScheduleRule",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("doors", models.ManyToManyField(to="doorcontrol.door")),
|
||||||
|
(
|
||||||
|
"flag",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
db_constraint=False,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
to="membershipworks.flag",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"schedule",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="doorcontrol.schedule",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"abstract": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="AttributeScheduleRule",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"access_field",
|
||||||
|
models.CharField(
|
||||||
|
help_text="Membershipworks field that grants members access to this door using this schedule.",
|
||||||
|
max_length=128,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("doors", models.ManyToManyField(to="doorcontrol.door")),
|
||||||
|
(
|
||||||
|
"schedule",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="doorcontrol.schedule",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"abstract": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
@ -5,7 +5,9 @@ from django.db import models
|
|||||||
from django.db.models import F, Func, OuterRef, Q, Subquery
|
from django.db.models 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
|
||||||
|
262
doorcontrol/tasks/update_doors.py
Normal file
262
doorcontrol/tasks/update_doors.py
Normal file
@ -0,0 +1,262 @@
|
|||||||
|
import dataclasses
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from django_q.tasks import async_task
|
||||||
|
|
||||||
|
from doorcontrol.hid.Credential import Credential
|
||||||
|
from doorcontrol.hid.DoorController import ROOT, E
|
||||||
|
from doorcontrol.models import AttributeScheduleRule, Door, FlagScheduleRule
|
||||||
|
from membershipworks.models import Member
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class DoorMember:
|
||||||
|
door: Door
|
||||||
|
attribs: dict[str, str]
|
||||||
|
credentials: set[Credential]
|
||||||
|
schedules: set[str]
|
||||||
|
cardholderID: str | None = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_membershipworks_member(cls, member: Member, door: Door):
|
||||||
|
if member.access_card_facility_code and member.access_card_number:
|
||||||
|
credentials = {
|
||||||
|
Credential(
|
||||||
|
code=(
|
||||||
|
member.access_card_facility_code,
|
||||||
|
member.access_card_number,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
credentials = set()
|
||||||
|
|
||||||
|
reasons_and_schedules = {}
|
||||||
|
if (
|
||||||
|
member.is_active
|
||||||
|
or member.flags.filter(name="Misc. Access", type="folder").exists()
|
||||||
|
) and getattr(member, door.access_field):
|
||||||
|
# TODO: could this be annotated?
|
||||||
|
reasons_and_schedules |= FlagScheduleRule.objects.filter(
|
||||||
|
doors=door, flag__members=member
|
||||||
|
).values_list("flag__name", "schedule__name")
|
||||||
|
|
||||||
|
# TODO: this seems like it could be cleaner
|
||||||
|
reasons_and_schedules |= {
|
||||||
|
attribute_rule.access_field: attribute_rule.schedule.name
|
||||||
|
for attribute_rule in AttributeScheduleRule.objects.filter(doors=door)
|
||||||
|
if getattr(member, attribute_rule.access_field)
|
||||||
|
}
|
||||||
|
|
||||||
|
reasons = sorted(reasons_and_schedules.keys())
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
door=door,
|
||||||
|
attribs={
|
||||||
|
"forename": member.first_name,
|
||||||
|
"middleName": "",
|
||||||
|
"surname": member.last_name,
|
||||||
|
"email": member.email,
|
||||||
|
"phone": member.phone,
|
||||||
|
"custom1": "|".join(reasons).replace("&", "and"),
|
||||||
|
"custom2": member.uid,
|
||||||
|
},
|
||||||
|
credentials=credentials,
|
||||||
|
schedules=set(reasons_and_schedules.values()),
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_cardholder(cls, data, door: Door):
|
||||||
|
return cls(
|
||||||
|
door=door,
|
||||||
|
attribs={
|
||||||
|
"forename": data.get("forename", ""),
|
||||||
|
"middleName": data.attrib.get("middleName", ""),
|
||||||
|
"surname": data.get("surname", ""),
|
||||||
|
"email": data.attrib.get("email", ""),
|
||||||
|
"phone": data.attrib.get("phone", ""),
|
||||||
|
"custom1": data.attrib.get("custom1", ""),
|
||||||
|
"custom2": data.attrib.get("custom2", ""),
|
||||||
|
},
|
||||||
|
cardholderID=data.attrib["cardholderID"],
|
||||||
|
credentials={
|
||||||
|
Credential(hex=(c.attrib["rawCardNumber"]))
|
||||||
|
for c in data.findall("{*}Credential")
|
||||||
|
},
|
||||||
|
schedules={r.attrib["scheduleName"] for r in data.findall("{*}Role")},
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def membershipworks_id(self):
|
||||||
|
return self.attribs["custom2"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def full_name(self):
|
||||||
|
return f"{self.attribs['forename']} {self.attribs['surname']}"
|
||||||
|
|
||||||
|
def update_attribs(self):
|
||||||
|
self.door.controller.doXMLRequest(
|
||||||
|
ROOT(
|
||||||
|
E.Cardholders(
|
||||||
|
{"action": "UD", "cardholderID": self.cardholderID},
|
||||||
|
E.CardHolder(self.attribs),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_credentials(
|
||||||
|
self,
|
||||||
|
existing_door_credentials: set[Credential],
|
||||||
|
all_members: list["DoorMember"],
|
||||||
|
old_credentials: set[Credential] = set(),
|
||||||
|
):
|
||||||
|
other_assigned_cards = {
|
||||||
|
card for m in all_members if m != self for card in m.credentials
|
||||||
|
}
|
||||||
|
|
||||||
|
# cards removed, and won't be reassigned to someone else
|
||||||
|
for card in (old_credentials - self.credentials) - other_assigned_cards:
|
||||||
|
self.door.controller.update_credential(card.hex, "")
|
||||||
|
|
||||||
|
if self.credentials - old_credentials: # cards added
|
||||||
|
for card in (
|
||||||
|
self.credentials & other_assigned_cards
|
||||||
|
): # new card exists in another member
|
||||||
|
logger.info(
|
||||||
|
[
|
||||||
|
m
|
||||||
|
for m in all_members
|
||||||
|
for card in m.credentials
|
||||||
|
if card in self.credentials
|
||||||
|
]
|
||||||
|
)
|
||||||
|
raise Exception(f"Duplicate Card in input data! {card}")
|
||||||
|
|
||||||
|
# card existed in door.controller, and needs to be reassigned
|
||||||
|
for card in self.credentials & existing_door_credentials:
|
||||||
|
self.door.controller.update_credential(card.hex, self.cardholderID)
|
||||||
|
|
||||||
|
# cards that never existed, and need to be created
|
||||||
|
if self.credentials - existing_door_credentials:
|
||||||
|
xml_credentials = [
|
||||||
|
E.Credential(
|
||||||
|
{
|
||||||
|
"formatName": str(credential.code[0]),
|
||||||
|
"cardNumber": str(credential.code[1]),
|
||||||
|
"formatID": self.door.card_formats[str(credential.code[0])],
|
||||||
|
"isCard": "true",
|
||||||
|
"cardholderID": self.cardholderID,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
for credential in self.credentials - existing_door_credentials
|
||||||
|
]
|
||||||
|
|
||||||
|
self.door.controller.doXMLRequest(
|
||||||
|
ROOT(E.Credentials({"action": "AD"}, *xml_credentials))
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_schedules(self):
|
||||||
|
roles = [
|
||||||
|
E.Role(
|
||||||
|
{
|
||||||
|
"roleID": self.cardholderID,
|
||||||
|
"scheduleID": self.door.schedules_map[schedule],
|
||||||
|
"resourceID": "0",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
for schedule in self.schedules
|
||||||
|
]
|
||||||
|
|
||||||
|
self.door.controller.doXMLRequest(
|
||||||
|
ROOT(
|
||||||
|
E.RoleSet(
|
||||||
|
{"action": "UD", "roleSetID": self.cardholderID}, E.Roles(*roles)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def update_door(door: Door, dry_run: bool = False):
|
||||||
|
members = [
|
||||||
|
DoorMember.from_membershipworks_member(membershipworks_member, door)
|
||||||
|
for membershipworks_member in (Member.objects.with_is_active()).all()
|
||||||
|
]
|
||||||
|
|
||||||
|
cardholders = {
|
||||||
|
member.membershipworks_id: member
|
||||||
|
for member in [
|
||||||
|
DoorMember.from_cardholder(ch, door.controller)
|
||||||
|
for ch in door.controller.get_cardholders()
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
existing_door_credentials = {
|
||||||
|
Credential(hex=c.attrib["rawCardNumber"])
|
||||||
|
for c in door.controller.get_credentials()
|
||||||
|
}
|
||||||
|
|
||||||
|
# TODO: can I combine requests?
|
||||||
|
for member in members:
|
||||||
|
# cardholder did not exist, so add them
|
||||||
|
if member.membershipworks_id not in cardholders:
|
||||||
|
logger.info(f"Adding Member {member.full_name}: {member}")
|
||||||
|
if not dry_run:
|
||||||
|
resp = door.controller.doXMLRequest(
|
||||||
|
ROOT(E.Cardholders({"action": "AD"}, E.Cardholder(member.attribs)))
|
||||||
|
)
|
||||||
|
member.cardholderID = resp.find("{*}Cardholders/{*}Cardholder").attrib[
|
||||||
|
"cardholderID"
|
||||||
|
]
|
||||||
|
|
||||||
|
member.update_attribs()
|
||||||
|
member.update_credentials(existing_door_credentials, members)
|
||||||
|
member.update_schedules()
|
||||||
|
|
||||||
|
# cardholder exists, compare contents
|
||||||
|
else:
|
||||||
|
existing_cardholder = cardholders.pop(member.membershipworks_id)
|
||||||
|
member.cardholderID = existing_cardholder.cardholderID
|
||||||
|
|
||||||
|
if member.attribs != existing_cardholder.attribs:
|
||||||
|
changes = {
|
||||||
|
k: f'"{existing_cardholder.attribs[k]}" -> "{v}"'
|
||||||
|
for k, v in member.attribs.items()
|
||||||
|
if existing_cardholder.attribs[k] != v
|
||||||
|
}
|
||||||
|
logger.info(f"Updating profile for {member.full_name}: {changes}")
|
||||||
|
if not dry_run:
|
||||||
|
member.update_attribs()
|
||||||
|
|
||||||
|
if member.credentials != existing_cardholder.credentials:
|
||||||
|
logger.info(
|
||||||
|
f"Updating card for {member.full_name}:"
|
||||||
|
f" {existing_cardholder.credentials} -> {member.credentials}"
|
||||||
|
)
|
||||||
|
if not dry_run:
|
||||||
|
member.update_credentials(
|
||||||
|
existing_door_credentials,
|
||||||
|
members,
|
||||||
|
old_credentials=existing_cardholder.credentials,
|
||||||
|
)
|
||||||
|
|
||||||
|
if member.schedules != existing_cardholder.schedules:
|
||||||
|
logger.info(
|
||||||
|
f"Updating schedule for {member.full_name}:"
|
||||||
|
f" {existing_cardholder.schedules} -> {member.schedules}"
|
||||||
|
)
|
||||||
|
if not dry_run:
|
||||||
|
member.update_schedules()
|
||||||
|
|
||||||
|
# TODO: delete cardholders that are no longer members?
|
||||||
|
|
||||||
|
|
||||||
|
def q_update_all_doors():
|
||||||
|
for door in Door.objects.all():
|
||||||
|
async_task(
|
||||||
|
update_door,
|
||||||
|
door,
|
||||||
|
cluster="internal",
|
||||||
|
group=f"Update HID Door Controller - {door.name}",
|
||||||
|
)
|
@ -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
|
|
@ -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 import routers, serializers, viewsets
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
@ -33,10 +33,6 @@ class DepartmentViewSet(viewsets.ModelViewSet):
|
|||||||
departments = self.queryset.prefetch_related(
|
departments = self.queryset.prefetch_related(
|
||||||
"children",
|
"children",
|
||||||
"shop_lead_flag__members",
|
"shop_lead_flag__members",
|
||||||
Prefetch(
|
|
||||||
"certificationdefinition_set__certificationversion_set__certification_set__member",
|
|
||||||
queryset=Member.objects.with_is_active(),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
lists = {}
|
lists = {}
|
||||||
for department in departments.filter(has_mailing_list=True):
|
for department in departments.filter(has_mailing_list=True):
|
||||||
@ -48,14 +44,12 @@ class DepartmentViewSet(viewsets.ModelViewSet):
|
|||||||
else:
|
else:
|
||||||
moderator_emails = []
|
moderator_emails = []
|
||||||
|
|
||||||
# TODO: this could be done in SQL instead if
|
|
||||||
# membershipworks was in the same database
|
|
||||||
active_certified_members = {
|
active_certified_members = {
|
||||||
member_cert.member.sanitized_mailbox()
|
member.sanitized_mailbox()
|
||||||
for certification in department.certificationdefinition_set.all()
|
for member in Member.objects.with_is_active().filter(
|
||||||
for version in certification.certificationversion_set.all()
|
is_active=True,
|
||||||
for member_cert in version.certification_set.all()
|
certification__certification_version__definition__department=department,
|
||||||
if member_cert.member and member_cert.member.is_active
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
lists[department.list_name] = {
|
lists[department.list_name] = {
|
||||||
|
@ -71,11 +71,11 @@ class CmsRedRiverVeteransScholarship(models.Model):
|
|||||||
class DepartmentQuerySet(models.QuerySet):
|
class DepartmentQuerySet(models.QuerySet):
|
||||||
def filter_by_shop_lead(self, member: Member) -> models.QuerySet["Department"]:
|
def filter_by_shop_lead(self, member: Member) -> models.QuerySet["Department"]:
|
||||||
"""Get departments for which `member` is a shop lead"""
|
"""Get departments for which `member` is a shop lead"""
|
||||||
# TODO: could be a lot simpler if membershipworks was in the same database
|
# TODO: should select children recursively, instead of specific levels
|
||||||
# TODO: should also select children
|
return self.filter(
|
||||||
member_flags = list(member.flags.all().values_list("pk", flat=True))
|
Q(shop_lead_flag__members=member)
|
||||||
return self.prefetch_related("shop_lead_flag__members").filter(
|
| Q(parent__shop_lead_flag__members=member)
|
||||||
shop_lead_flag__in=member_flags
|
| Q(parent__parent__shop_lead_flag__members=member)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
from itertools import chain
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
@ -78,29 +80,42 @@ class InstructorOrVendorReportTestCase(PermissionRequiredViewTestCaseMixin, Test
|
|||||||
|
|
||||||
|
|
||||||
@st.composite
|
@st.composite
|
||||||
def random_certifications(draw):
|
def random_certifications(
|
||||||
departments = draw(st.lists(from_model(Department), min_size=1))
|
draw,
|
||||||
definitions = draw(
|
) -> list[Certification]:
|
||||||
st.lists(
|
def certifications(version: CertificationVersion):
|
||||||
|
return st.lists(
|
||||||
from_model(
|
from_model(
|
||||||
CertificationDefinition, department=st.sampled_from(departments)
|
Certification,
|
||||||
|
number=st.none(),
|
||||||
|
certification_version=st.just(version),
|
||||||
),
|
),
|
||||||
min_size=1,
|
max_size=10,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def versions_with_certifications(definition: CertificationDefinition):
|
||||||
|
return st.lists(
|
||||||
|
from_model(CertificationVersion, definition=st.just(definition)).flatmap(
|
||||||
|
certifications
|
||||||
|
),
|
||||||
|
max_size=2,
|
||||||
)
|
)
|
||||||
certification_versions = draw(
|
|
||||||
st.lists(
|
def definitions_with_versions(department: Department):
|
||||||
from_model(CertificationVersion, definition=st.sampled_from(definitions)),
|
return st.lists(
|
||||||
min_size=1,
|
from_model(CertificationDefinition, department=st.just(department)).flatmap(
|
||||||
)
|
versions_with_certifications
|
||||||
|
),
|
||||||
|
max_size=2,
|
||||||
)
|
)
|
||||||
|
|
||||||
return draw(
|
return draw(
|
||||||
st.lists(
|
st.lists(
|
||||||
from_model(
|
from_model(Department).flatmap(definitions_with_versions),
|
||||||
Certification,
|
max_size=2,
|
||||||
number=st.none(),
|
).map(
|
||||||
certification_version=st.sampled_from(certification_versions),
|
lambda x: list(
|
||||||
|
chain.from_iterable(chain.from_iterable(chain.from_iterable(x)))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -111,7 +126,7 @@ class CertifiersReportTestCase(PermissionRequiredViewTestCaseMixin, TestCase):
|
|||||||
path = "/paperwork/certifiers"
|
path = "/paperwork/certifiers"
|
||||||
|
|
||||||
@given(certifications=random_certifications())
|
@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)
|
self.client.force_login(self.user_with_permission)
|
||||||
response = self.client.get(self.path)
|
response = self.client.get(self.path)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
@ -3,7 +3,17 @@ from django.contrib import staticfiles
|
|||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.contrib.auth.mixins import PermissionRequiredMixin
|
from django.contrib.auth.mixins import PermissionRequiredMixin
|
||||||
from django.db import models
|
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.db.models.functions import Concat
|
||||||
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseNotFound
|
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseNotFound
|
||||||
from django.shortcuts import get_object_or_404, render
|
from django.shortcuts import get_object_or_404, render
|
||||||
@ -236,49 +246,24 @@ class AccessVerificationReport(
|
|||||||
export_formats = ("csv", "xlsx", "ods")
|
export_formats = ("csv", "xlsx", "ods")
|
||||||
|
|
||||||
def get_queryset(self):
|
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):
|
def shop_error(access_field: str, shop_name: str):
|
||||||
member_list = list(
|
member_list = CertificationVersion.objects.filter(
|
||||||
CertificationVersion.objects.filter(
|
|
||||||
is_current=True,
|
is_current=True,
|
||||||
definition__department__name=shop_name,
|
definition__department__name=shop_name,
|
||||||
certification__member__pk__isnull=False,
|
certification__member__pk__isnull=False,
|
||||||
)
|
).values("certification__member__pk")
|
||||||
.values_list(
|
|
||||||
"certification__member__pk",
|
|
||||||
flat=True,
|
|
||||||
)
|
|
||||||
.distinct()
|
|
||||||
)
|
|
||||||
return Case(
|
return Case(
|
||||||
When(
|
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"),
|
Value("Has access but no cert"),
|
||||||
),
|
),
|
||||||
When(
|
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"),
|
Value("Has cert but no access"),
|
||||||
),
|
),
|
||||||
default=None,
|
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 = (
|
qs = (
|
||||||
Member.objects.with_is_active()
|
Member.objects.with_is_active()
|
||||||
.filter(is_active=True)
|
.filter(is_active=True)
|
||||||
@ -307,7 +292,17 @@ class AccessVerificationReport(
|
|||||||
storage_closet_error=Case(
|
storage_closet_error=Case(
|
||||||
When(
|
When(
|
||||||
Q(access_storage_closet=True)
|
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"),
|
Value("Has access but not shop lead or desker"),
|
||||||
),
|
),
|
||||||
default=None,
|
default=None,
|
||||||
|
73
pdm.lock
73
pdm.lock
@ -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"
|
||||||
|
@ -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",
|
||||||
|
Loading…
Reference in New Issue
Block a user