Compare commits
No commits in common. "b6280d701f68029ce1ee77f5c1ddb36f55ebc33f" and "7c26cf252db2bfe1c3510ae93a517c29e216f730" have entirely different histories.
b6280d701f
...
7c26cf252d
@ -24,12 +24,11 @@ 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 sync -d
|
run: pdm install
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: pdm run -v ./manage.py test --parallel auto
|
run: pdm run ./manage.py test --parallel auto
|
||||||
|
@ -19,11 +19,6 @@ 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,6 +82,10 @@ 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/"
|
||||||
@ -102,22 +106,6 @@ 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,7 +1,4 @@
|
|||||||
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/
|
||||||
@ -34,5 +31,3 @@ DATABASES = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
configure_hypothesis_profiles()
|
|
||||||
settings.load_profile("ci")
|
|
||||||
|
@ -1,9 +1,4 @@
|
|||||||
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/
|
||||||
@ -18,6 +13,3 @@ 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"))
|
|
||||||
|
@ -1,7 +0,0 @@
|
|||||||
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,32 +2,13 @@ from django.contrib import admin
|
|||||||
|
|
||||||
from django_object_actions import DjangoObjectActions, action
|
from django_object_actions import DjangoObjectActions, action
|
||||||
|
|
||||||
from .forms import AttributeScheduleRuleForm, DoorAdminForm
|
from .models import Door, HIDEvent
|
||||||
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):
|
||||||
form = DoorAdminForm
|
pass
|
||||||
list_display = ["name", "access_field"]
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(HIDEvent)
|
@admin.register(HIDEvent)
|
||||||
|
@ -8,7 +8,6 @@ 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,
|
||||||
@ -17,13 +16,6 @@ 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"
|
||||||
|
@ -1,41 +0,0 @@
|
|||||||
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__"
|
|
@ -1,292 +0,0 @@
|
|||||||
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):
|
|
@ -1,32 +0,0 @@
|
|||||||
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)
|
|
@ -1,106 +0,0 @@
|
|||||||
# 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,9 +5,7 @@ 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
|
||||||
@ -16,10 +14,6 @@ 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:
|
||||||
@ -32,14 +26,6 @@ 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)
|
||||||
@ -60,46 +46,6 @@ 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
|
||||||
|
@ -1,262 +0,0 @@
|
|||||||
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}",
|
|
||||||
)
|
|
23
membershipworks/routers.py
Normal file
23
membershipworks/routers.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
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 Q
|
from django.db.models import Prefetch, 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,6 +33,10 @@ 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):
|
||||||
@ -44,12 +48,14 @@ 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.sanitized_mailbox()
|
member_cert.member.sanitized_mailbox()
|
||||||
for member in Member.objects.with_is_active().filter(
|
for certification in department.certificationdefinition_set.all()
|
||||||
is_active=True,
|
for version in certification.certificationversion_set.all()
|
||||||
certification__certification_version__definition__department=department,
|
for member_cert in version.certification_set.all()
|
||||||
)
|
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: should select children recursively, instead of specific levels
|
# TODO: could be a lot simpler if membershipworks was in the same database
|
||||||
return self.filter(
|
# TODO: should also select children
|
||||||
Q(shop_lead_flag__members=member)
|
member_flags = list(member.flags.all().values_list("pk", flat=True))
|
||||||
| Q(parent__shop_lead_flag__members=member)
|
return self.prefetch_related("shop_lead_flag__members").filter(
|
||||||
| Q(parent__parent__shop_lead_flag__members=member)
|
shop_lead_flag__in=member_flags
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,5 +1,3 @@
|
|||||||
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
|
||||||
@ -80,42 +78,29 @@ class InstructorOrVendorReportTestCase(PermissionRequiredViewTestCaseMixin, Test
|
|||||||
|
|
||||||
|
|
||||||
@st.composite
|
@st.composite
|
||||||
def random_certifications(
|
def random_certifications(draw):
|
||||||
draw,
|
departments = draw(st.lists(from_model(Department), min_size=1))
|
||||||
) -> list[Certification]:
|
definitions = draw(
|
||||||
def certifications(version: CertificationVersion):
|
st.lists(
|
||||||
return st.lists(
|
|
||||||
from_model(
|
from_model(
|
||||||
Certification,
|
CertificationDefinition, department=st.sampled_from(departments)
|
||||||
number=st.none(),
|
|
||||||
certification_version=st.just(version),
|
|
||||||
),
|
),
|
||||||
max_size=10,
|
min_size=1,
|
||||||
)
|
)
|
||||||
|
|
||||||
def versions_with_certifications(definition: CertificationDefinition):
|
|
||||||
return st.lists(
|
|
||||||
from_model(CertificationVersion, definition=st.just(definition)).flatmap(
|
|
||||||
certifications
|
|
||||||
),
|
|
||||||
max_size=2,
|
|
||||||
)
|
)
|
||||||
|
certification_versions = draw(
|
||||||
def definitions_with_versions(department: Department):
|
st.lists(
|
||||||
return st.lists(
|
from_model(CertificationVersion, definition=st.sampled_from(definitions)),
|
||||||
from_model(CertificationDefinition, department=st.just(department)).flatmap(
|
min_size=1,
|
||||||
versions_with_certifications
|
)
|
||||||
),
|
|
||||||
max_size=2,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return draw(
|
return draw(
|
||||||
st.lists(
|
st.lists(
|
||||||
from_model(Department).flatmap(definitions_with_versions),
|
from_model(
|
||||||
max_size=2,
|
Certification,
|
||||||
).map(
|
number=st.none(),
|
||||||
lambda x: list(
|
certification_version=st.sampled_from(certification_versions),
|
||||||
chain.from_iterable(chain.from_iterable(chain.from_iterable(x)))
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -126,7 +111,7 @@ class CertifiersReportTestCase(PermissionRequiredViewTestCaseMixin, TestCase):
|
|||||||
path = "/paperwork/certifiers"
|
path = "/paperwork/certifiers"
|
||||||
|
|
||||||
@given(certifications=random_certifications())
|
@given(certifications=random_certifications())
|
||||||
def test_certifiers_report(self, certifications: list[Certification]) -> None:
|
def test_certifers_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,17 +3,7 @@ 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 (
|
from django.db.models import Case, Count, Max, Q, Value, When
|
||||||
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
|
||||||
@ -246,24 +236,49 @@ 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 = CertificationVersion.objects.filter(
|
member_list = list(
|
||||||
|
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=Subquery(member_list)),
|
Q(**{access_field: True}) & ~Q(uid__in=member_list),
|
||||||
Value("Has access but no cert"),
|
Value("Has access but no cert"),
|
||||||
),
|
),
|
||||||
When(
|
When(
|
||||||
Q(**{access_field: False}) & Q(uid__in=Subquery(member_list)),
|
Q(**{access_field: False}) & Q(uid__in=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)
|
||||||
@ -292,17 +307,7 @@ 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:d596669dbbecb4da5d8b0091ff6db601670ed9a1251b7e76d7f8329ccddbf43a"
|
content_hash = "sha256:379d72c4be2ef3f09d6a3f9cf0517f784fa18df7367386dfc09cd40fa521a25f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aiohttp"
|
name = "aiohttp"
|
||||||
@ -164,77 +164,6 @@ 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,7 +25,6 @@ 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