Compare commits
No commits in common. "e4c6fab01146a6e0c3d9354edb048a2dbda52f77" and "1827d10bf4318235eee67abfc091bbd84ea513b1" have entirely different histories.
e4c6fab011
...
1827d10bf4
@ -10,9 +10,7 @@ jobs:
|
|||||||
container: catthehacker/ubuntu:act-latest
|
container: catthehacker/ubuntu:act-latest
|
||||||
services:
|
services:
|
||||||
mariadb:
|
mariadb:
|
||||||
# TODO: this is pinned to avoid what apears to be a bug with
|
image: mariadb:latest
|
||||||
# MariaDB >= 10.11.9, and collation issues with 11.x.x
|
|
||||||
image: mariadb:10.11.8
|
|
||||||
env:
|
env:
|
||||||
MARIADB_ROOT_PASSWORD: whatever
|
MARIADB_ROOT_PASSWORD: whatever
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@ -38,15 +36,5 @@ jobs:
|
|||||||
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 -G dev
|
run: pdm sync -d -G dev
|
||||||
|
|
||||||
- name: Setup pnpm
|
|
||||||
uses: pnpm/action-setup@v4
|
|
||||||
with:
|
|
||||||
version: latest
|
|
||||||
run_install: |
|
|
||||||
- args: [--frozen-lockfile, --strict-peer-dependencies]
|
|
||||||
- name: Build JS assets
|
|
||||||
run: pnpm run build
|
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: pdm run -v ./manage.py test
|
run: pdm run -v ./manage.py test
|
||||||
|
@ -14,13 +14,13 @@ repos:
|
|||||||
- id: djlint-reformat-django
|
- id: djlint-reformat-django
|
||||||
|
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
rev: v0.6.1
|
rev: v0.5.2
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff
|
- id: ruff
|
||||||
- id: ruff-format
|
- id: ruff-format
|
||||||
|
|
||||||
- repo: https://github.com/pdm-project/pdm
|
- repo: https://github.com/pdm-project/pdm
|
||||||
rev: 2.18.1
|
rev: 2.17.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: pdm-lock-check
|
- id: pdm-lock-check
|
||||||
|
|
||||||
|
@ -18,10 +18,6 @@ class Base(Configuration):
|
|||||||
credentials_directory = os.getenv("CREDENTIALS_DIRECTORY")
|
credentials_directory = os.getenv("CREDENTIALS_DIRECTORY")
|
||||||
if credentials_directory is not None:
|
if credentials_directory is not None:
|
||||||
for credential in Path(credentials_directory).iterdir():
|
for credential in Path(credentials_directory).iterdir():
|
||||||
if credential.name.endswith("_path"):
|
|
||||||
os.environ.setdefault(
|
|
||||||
credential.name.removesuffix("_path"), str(credential.resolve())
|
|
||||||
)
|
|
||||||
if credential.name.isupper():
|
if credential.name.isupper():
|
||||||
os.environ.setdefault(credential.name, credential.read_text())
|
os.environ.setdefault(credential.name, credential.read_text())
|
||||||
|
|
||||||
@ -61,7 +57,6 @@ class Base(Configuration):
|
|||||||
"paperwork.apps.PaperworkConfig",
|
"paperwork.apps.PaperworkConfig",
|
||||||
"doorcontrol.apps.DoorControlConfig",
|
"doorcontrol.apps.DoorControlConfig",
|
||||||
"dashboard.apps.DashboardConfig",
|
"dashboard.apps.DashboardConfig",
|
||||||
"reservations.apps.ReservationsConfig",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
@ -106,9 +101,6 @@ class Base(Configuration):
|
|||||||
|
|
||||||
WSGI_APPLICATION = "cmsmanage.wsgi.application"
|
WSGI_APPLICATION = "cmsmanage.wsgi.application"
|
||||||
|
|
||||||
# mysql.W003 (unique CharField length) is irrelevant on MariaDB >= 10.4.3
|
|
||||||
SILENCED_SYSTEM_CHECKS = ["mysql.W003"]
|
|
||||||
|
|
||||||
# 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/"
|
||||||
@ -230,8 +222,6 @@ class NonCIBase(Base):
|
|||||||
environ_required=True, environ_prefix=None
|
environ_required=True, environ_prefix=None
|
||||||
)
|
)
|
||||||
|
|
||||||
GOOGLE_SERVICE_ACCOUNT_FILE = values.PathValue(environ_prefix=None)
|
|
||||||
|
|
||||||
HID_DOOR_USERNAME = values.Value(environ_required=True, environ_prefix=None)
|
HID_DOOR_USERNAME = values.Value(environ_required=True, environ_prefix=None)
|
||||||
HID_DOOR_PASSWORD = values.SecretValue(environ_prefix=None)
|
HID_DOOR_PASSWORD = values.SecretValue(environ_prefix=None)
|
||||||
|
|
||||||
@ -357,12 +347,6 @@ class CI(Base):
|
|||||||
configure_hypothesis_profiles()
|
configure_hypothesis_profiles()
|
||||||
settings.load_profile("ci")
|
settings.load_profile("ci")
|
||||||
|
|
||||||
@property
|
|
||||||
def DJANGO_VITE(self):
|
|
||||||
d = super().DJANGO_VITE
|
|
||||||
d["default"]["manifest_path"] = BASE_DIR / "vite-dist" / "manifest.json"
|
|
||||||
return d
|
|
||||||
|
|
||||||
SECRET_KEY = "aed7jee2kai1we9eithae0gaegh9ohthoh4phahk5bau4Ahxaijo3aicheex3qua"
|
SECRET_KEY = "aed7jee2kai1we9eithae0gaegh9ohthoh4phahk5bau4Ahxaijo3aicheex3qua"
|
||||||
|
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
|
@ -5,10 +5,10 @@ import bitstring
|
|||||||
|
|
||||||
|
|
||||||
class Credential:
|
class Credential:
|
||||||
def __init__(self, code=None, hex_code=None):
|
def __init__(self, code=None, hex=None):
|
||||||
if code is None and hex_code is None:
|
if code is None and hex is None:
|
||||||
raise TypeError("Must set either code or hex for a Credential")
|
raise TypeError("Must set either code or hex for a Credential")
|
||||||
elif code is not None and hex_code is not None:
|
elif code is not None and hex is not None:
|
||||||
raise TypeError("Cannot set both code and hex for a Credential")
|
raise TypeError("Cannot set both code and hex for a Credential")
|
||||||
elif code is not None:
|
elif code is not None:
|
||||||
self.bits = bitstring.pack(
|
self.bits = bitstring.pack(
|
||||||
@ -18,8 +18,8 @@ class Credential:
|
|||||||
)
|
)
|
||||||
self.bits[6] = self.bits[7:19].count(1) % 2 # even parity
|
self.bits[6] = self.bits[7:19].count(1) % 2 # even parity
|
||||||
self.bits[31] = not (self.bits[19:31].count(1) % 2) # odd parity
|
self.bits[31] = not (self.bits[19:31].count(1) % 2) # odd parity
|
||||||
elif hex_code is not None:
|
elif hex is not None:
|
||||||
self.bits = bitstring.Bits(hex=hex_code)
|
self.bits = bitstring.Bits(hex=hex)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"Credential({self.code})"
|
return f"Credential({self.code})"
|
||||||
|
@ -152,7 +152,7 @@ class DoorController:
|
|||||||
)
|
)
|
||||||
return self.doXMLRequest(el)
|
return self.doXMLRequest(el)
|
||||||
|
|
||||||
def get_records(self, req, count, params=None, stopFunction=None):
|
def get_records(self, req, count, params={}, stopFunction=None):
|
||||||
recordCount = 0
|
recordCount = 0
|
||||||
moreRecords = True
|
moreRecords = True
|
||||||
|
|
||||||
@ -172,7 +172,7 @@ class DoorController:
|
|||||||
"recordOffset": str(
|
"recordOffset": str(
|
||||||
recordCount - 1 if recordCount > 0 else 0
|
recordCount - 1 if recordCount > 0 else 0
|
||||||
),
|
),
|
||||||
**(params or {}),
|
**params,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
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):
|
@ -242,11 +242,11 @@ class HIDEvent(models.Model):
|
|||||||
field.column: field.attname for field in HIDEvent._meta.get_fields()
|
field.column: field.attname for field in HIDEvent._meta.get_fields()
|
||||||
}
|
}
|
||||||
|
|
||||||
def attr_to_bool(attr):
|
def attr_to_bool(str):
|
||||||
if attr is None:
|
if str is None:
|
||||||
return None
|
return None
|
||||||
else:
|
else:
|
||||||
return attr == "true"
|
return str == "true"
|
||||||
|
|
||||||
return cls(
|
return cls(
|
||||||
**{
|
**{
|
||||||
|
@ -108,7 +108,7 @@ class DoorMember:
|
|||||||
},
|
},
|
||||||
cardholderID=data.attrib["cardholderID"],
|
cardholderID=data.attrib["cardholderID"],
|
||||||
credentials={
|
credentials={
|
||||||
Credential(hex_code=(c.attrib["rawCardNumber"]))
|
Credential(hex=(c.attrib["rawCardNumber"]))
|
||||||
for c in data.findall("{*}Credential")
|
for c in data.findall("{*}Credential")
|
||||||
},
|
},
|
||||||
schedules={r.attrib["scheduleName"] for r in data.findall("{*}Role")},
|
schedules={r.attrib["scheduleName"] for r in data.findall("{*}Role")},
|
||||||
@ -136,7 +136,7 @@ class DoorMember:
|
|||||||
self,
|
self,
|
||||||
existing_door_credentials: set[Credential],
|
existing_door_credentials: set[Credential],
|
||||||
all_members: list["DoorMember"],
|
all_members: list["DoorMember"],
|
||||||
old_credentials: set[Credential],
|
old_credentials: set[Credential] = set(),
|
||||||
):
|
):
|
||||||
# cardholderID should be set on a member before this is called
|
# cardholderID should be set on a member before this is called
|
||||||
assert self.cardholderID is not None
|
assert self.cardholderID is not None
|
||||||
@ -222,7 +222,7 @@ def update_door(door: Door, dry_run: bool = False):
|
|||||||
}
|
}
|
||||||
|
|
||||||
existing_door_credentials = {
|
existing_door_credentials = {
|
||||||
Credential(hex_code=c.attrib["rawCardNumber"])
|
Credential(hex=c.attrib["rawCardNumber"])
|
||||||
for c in door.controller.get_credentials()
|
for c in door.controller.get_credentials()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -240,7 +240,7 @@ def update_door(door: Door, dry_run: bool = False):
|
|||||||
]
|
]
|
||||||
|
|
||||||
member.update_attribs()
|
member.update_attribs()
|
||||||
member.update_credentials(existing_door_credentials, members, set())
|
member.update_credentials(existing_door_credentials, members)
|
||||||
member.update_schedules()
|
member.update_schedules()
|
||||||
|
|
||||||
# cardholder exists, compare contents
|
# cardholder exists, compare contents
|
||||||
|
@ -10,7 +10,7 @@
|
|||||||
{% for report_name, report_url in report_types %}
|
{% for report_name, report_url in report_types %}
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link{% if report_name == selected_report %} active{% endif %}"
|
<a class="nav-link{% if report_name == selected_report %} active{% endif %}"
|
||||||
href="{{ report_url }}{% querystring page=None %}">{{ report_name }}</a>
|
href="{{ report_url }}?{{ query_params }}">{{ report_name }}</a>
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import datetime
|
import datetime
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from django.contrib.auth.mixins import PermissionRequiredMixin
|
from django.contrib.auth.mixins import PermissionRequiredMixin
|
||||||
from django.core.exceptions import BadRequest
|
from django.core.exceptions import BadRequest
|
||||||
|
from django.core.paginator import Page
|
||||||
from django.db.models import Count, F, FloatField, Q, Window
|
from django.db.models import Count, F, FloatField, Q, Window
|
||||||
from django.db.models.functions import Lead, Trunc
|
from django.db.models.functions import Lead, Trunc
|
||||||
from django.urls import path, reverse_lazy
|
from django.urls import path, reverse_lazy
|
||||||
@ -27,9 +27,6 @@ from .tables import (
|
|||||||
UnitTimeTable,
|
UnitTimeTable,
|
||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from django.core.paginator import Page
|
|
||||||
|
|
||||||
REPORTS = []
|
REPORTS = []
|
||||||
|
|
||||||
|
|
||||||
@ -98,6 +95,11 @@ class BaseAccessReport(
|
|||||||
context["selected_report"] = self._selected_report()
|
context["selected_report"] = self._selected_report()
|
||||||
context["items_per_page"] = self.get_paginate_by(None)
|
context["items_per_page"] = self.get_paginate_by(None)
|
||||||
|
|
||||||
|
query_params = self.request.GET.copy()
|
||||||
|
if "page" in query_params:
|
||||||
|
query_params.pop("page")
|
||||||
|
context["query_params"] = query_params.urlencode()
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.contrib.humanize.templatetags.humanize import naturaltime
|
from django.contrib.humanize.templatetags.humanize import naturaltime
|
||||||
from django.http import HttpRequest
|
|
||||||
from django.utils.html import format_html
|
from django.utils.html import format_html
|
||||||
|
|
||||||
from django_object_actions import (
|
from django_object_actions import (
|
||||||
@ -96,7 +95,6 @@ class FlagAdmin(BaseMembershipWorksAdmin):
|
|||||||
@admin.register(Transaction)
|
@admin.register(Transaction)
|
||||||
class TransactionAdmin(BaseMembershipWorksAdmin):
|
class TransactionAdmin(BaseMembershipWorksAdmin):
|
||||||
list_display = ["timestamp", "member", "name", "type", "sum", "note"]
|
list_display = ["timestamp", "member", "name", "type", "sum", "note"]
|
||||||
list_select_related = ["member"]
|
|
||||||
list_filter = ["type"]
|
list_filter = ["type"]
|
||||||
show_facets = admin.ShowFacets.ALWAYS
|
show_facets = admin.ShowFacets.ALWAYS
|
||||||
search_fields = ["member", "name", "type", "note"]
|
search_fields = ["member", "name", "type", "note"]
|
||||||
@ -105,18 +103,16 @@ class TransactionAdmin(BaseMembershipWorksAdmin):
|
|||||||
|
|
||||||
class EventMeetingTimeInline(admin.TabularInline):
|
class EventMeetingTimeInline(admin.TabularInline):
|
||||||
model = EventMeetingTime
|
model = EventMeetingTime
|
||||||
fields = ["start", "end", "duration", "resources"]
|
|
||||||
readonly_fields = ["duration"]
|
|
||||||
autocomplete_fields = ["resources"]
|
|
||||||
extra = 0
|
extra = 0
|
||||||
min_num = 1
|
min_num = 1
|
||||||
|
|
||||||
|
readonly_fields = ["duration"]
|
||||||
|
|
||||||
|
|
||||||
@admin.register(EventInstructor)
|
@admin.register(EventInstructor)
|
||||||
class EventInstructorAdmin(admin.ModelAdmin):
|
class EventInstructorAdmin(admin.ModelAdmin):
|
||||||
autocomplete_fields = ["member"]
|
autocomplete_fields = ["member"]
|
||||||
search_fields = ["name", "member__account_name"]
|
search_fields = ["name", "member__account_name"]
|
||||||
list_select_related = ["member"]
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(EventInvoice)
|
@admin.register(EventInvoice)
|
||||||
@ -125,14 +121,13 @@ class EventInvoiceAdmin(admin.ModelAdmin):
|
|||||||
list_display = [
|
list_display = [
|
||||||
"uuid",
|
"uuid",
|
||||||
"event",
|
"event",
|
||||||
"event__start",
|
"_event_start",
|
||||||
"event__end",
|
"_event_end",
|
||||||
"event__instructor",
|
"_event_instructor",
|
||||||
"date_submitted",
|
"date_submitted",
|
||||||
"date_paid",
|
"date_paid",
|
||||||
"amount",
|
"amount",
|
||||||
]
|
]
|
||||||
list_select_related = ["event__instructor__member"]
|
|
||||||
list_filter = [
|
list_filter = [
|
||||||
("date_paid", admin.EmptyFieldListFilter),
|
("date_paid", admin.EmptyFieldListFilter),
|
||||||
]
|
]
|
||||||
@ -147,6 +142,18 @@ class EventInvoiceAdmin(admin.ModelAdmin):
|
|||||||
]
|
]
|
||||||
date_hierarchy = "date_submitted"
|
date_hierarchy = "date_submitted"
|
||||||
|
|
||||||
|
@admin.display(ordering="event__instructor")
|
||||||
|
def _event_instructor(self, obj):
|
||||||
|
return obj.event.instructor
|
||||||
|
|
||||||
|
@admin.display(ordering="event__start")
|
||||||
|
def _event_start(self, obj):
|
||||||
|
return obj.event.start
|
||||||
|
|
||||||
|
@admin.display(ordering="event__end")
|
||||||
|
def _event_end(self, obj):
|
||||||
|
return obj.event.end
|
||||||
|
|
||||||
|
|
||||||
class EventInvoiceInline(admin.StackedInline):
|
class EventInvoiceInline(admin.StackedInline):
|
||||||
model = EventInvoice
|
model = EventInvoice
|
||||||
@ -177,7 +184,8 @@ class EventAdmin(DjangoObjectActions, admin.ModelAdmin):
|
|||||||
change_actions = ["fetch_details"]
|
change_actions = ["fetch_details"]
|
||||||
actions = ["fetch_details"]
|
actions = ["fetch_details"]
|
||||||
|
|
||||||
def get_readonly_fields(self, request: HttpRequest, obj: EventExt) -> list[str]:
|
@property
|
||||||
|
def readonly_fields(self):
|
||||||
fields = []
|
fields = []
|
||||||
for field in Event._meta.get_fields():
|
for field in Event._meta.get_fields():
|
||||||
if field.auto_created or field.many_to_many or not field.concrete:
|
if field.auto_created or field.many_to_many or not field.concrete:
|
||||||
@ -214,9 +222,3 @@ class EventAdmin(DjangoObjectActions, admin.ModelAdmin):
|
|||||||
|
|
||||||
def has_delete_permission(self, request, obj=None):
|
def has_delete_permission(self, request, obj=None):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@admin.register(EventMeetingTime)
|
|
||||||
class EventMeetingTimeAdmin(admin.ModelAdmin):
|
|
||||||
def has_module_permission(self, request: HttpRequest) -> bool:
|
|
||||||
return False
|
|
||||||
|
@ -112,7 +112,7 @@ class MembershipWorks:
|
|||||||
def _inject_auth(self, kwargs):
|
def _inject_auth(self, kwargs):
|
||||||
# TODO: should probably be a decorator or something
|
# TODO: should probably be a decorator or something
|
||||||
if self.auth_token is None:
|
if self.auth_token is None:
|
||||||
raise NotAuthenticatedError
|
raise NotAuthenticatedError()
|
||||||
# add auth token to params
|
# add auth token to params
|
||||||
if "params" not in kwargs:
|
if "params" not in kwargs:
|
||||||
kwargs["params"] = {}
|
kwargs["params"] = {}
|
||||||
@ -135,7 +135,7 @@ class MembershipWorks:
|
|||||||
in all.js.
|
in all.js.
|
||||||
"""
|
"""
|
||||||
if not self.org_info:
|
if not self.org_info:
|
||||||
raise NotAuthenticatedError
|
raise NotAuthenticatedError()
|
||||||
fields = staticFlags.copy()
|
fields = staticFlags.copy()
|
||||||
|
|
||||||
# TODO: this will take the later option, if the same field
|
# TODO: this will take the later option, if the same field
|
||||||
@ -159,7 +159,7 @@ class MembershipWorks:
|
|||||||
This is terrible, and there might be a better way to do this.
|
This is terrible, and there might be a better way to do this.
|
||||||
"""
|
"""
|
||||||
if not self.org_info:
|
if not self.org_info:
|
||||||
raise NotAuthenticatedError
|
raise NotAuthenticatedError()
|
||||||
ret: dict[str, Any] = {"folders": {}, "levels": {}, "addons": {}, "labels": {}}
|
ret: dict[str, Any] = {"folders": {}, "levels": {}, "addons": {}, "labels": {}}
|
||||||
|
|
||||||
for dek in self.org_info["dek"]:
|
for dek in self.org_info["dek"]:
|
||||||
|
@ -1,82 +0,0 @@
|
|||||||
# Generated by Django 5.0.7 on 2024-07-30 23:10
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
def convert_meetingtimes_to_reservations(apps, schema_editor):
|
|
||||||
Reservation = apps.get_model("reservations", "Reservation")
|
|
||||||
EventMeetingTime = apps.get_model("membershipworks", "EventMeetingTime")
|
|
||||||
for meeting_time in EventMeetingTime.objects.all():
|
|
||||||
reservation = Reservation.objects.create(
|
|
||||||
id=meeting_time.id,
|
|
||||||
start=meeting_time.start,
|
|
||||||
end=meeting_time.end,
|
|
||||||
)
|
|
||||||
meeting_time.reservation_ptr = reservation
|
|
||||||
meeting_time.save()
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
dependencies = [
|
|
||||||
("membershipworks", "0019_eventext_should_survey_eventext_survey_email_sent"),
|
|
||||||
("reservations", "0001_initial"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
# add reservation field
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="eventmeetingtime",
|
|
||||||
name="reservation_ptr",
|
|
||||||
field=models.OneToOneField(
|
|
||||||
auto_created=True,
|
|
||||||
null=True,
|
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
|
||||||
parent_link=True,
|
|
||||||
serialize=False,
|
|
||||||
to="reservations.reservation",
|
|
||||||
),
|
|
||||||
preserve_default=False,
|
|
||||||
),
|
|
||||||
migrations.RunPython(convert_meetingtimes_to_reservations, atomic=True),
|
|
||||||
# remove primary key
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name="eventmeetingtime",
|
|
||||||
name="id",
|
|
||||||
),
|
|
||||||
# make reservation non-nullable
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="eventmeetingtime",
|
|
||||||
name="reservation_ptr",
|
|
||||||
field=models.OneToOneField(
|
|
||||||
auto_created=True,
|
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
|
||||||
parent_link=True,
|
|
||||||
primary_key=True,
|
|
||||||
serialize=False,
|
|
||||||
to="reservations.reservation",
|
|
||||||
),
|
|
||||||
preserve_default=False,
|
|
||||||
),
|
|
||||||
# delete old columns
|
|
||||||
migrations.RemoveConstraint(
|
|
||||||
model_name="eventmeetingtime",
|
|
||||||
name="unique_event_start_end",
|
|
||||||
),
|
|
||||||
migrations.RemoveConstraint(
|
|
||||||
model_name="eventmeetingtime",
|
|
||||||
name="end_after_start",
|
|
||||||
),
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name="eventmeetingtime",
|
|
||||||
name="duration",
|
|
||||||
),
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name="eventmeetingtime",
|
|
||||||
name="end",
|
|
||||||
),
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name="eventmeetingtime",
|
|
||||||
name="start",
|
|
||||||
),
|
|
||||||
]
|
|
@ -1,5 +1,6 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
from decimal import Decimal
|
||||||
from typing import TYPE_CHECKING, TypedDict
|
from typing import TYPE_CHECKING, TypedDict
|
||||||
|
|
||||||
import django.core.mail.message
|
import django.core.mail.message
|
||||||
@ -29,8 +30,6 @@ import nh3
|
|||||||
from django_db_views.db_view import DBView
|
from django_db_views.db_view import DBView
|
||||||
from django_stubs_ext import WithAnnotations
|
from django_stubs_ext import WithAnnotations
|
||||||
|
|
||||||
from reservations.models import Reservation
|
|
||||||
|
|
||||||
|
|
||||||
class BaseModel(models.Model):
|
class BaseModel(models.Model):
|
||||||
_api_names_override: dict[str, str] = {}
|
_api_names_override: dict[str, str] = {}
|
||||||
@ -380,8 +379,8 @@ class EventCategory(models.Model):
|
|||||||
return self.title
|
return self.title
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_api_dict(cls, id_: int, data):
|
def from_api_dict(cls, id: int, data):
|
||||||
return cls(id=id_, title=data["ttl"])
|
return cls(id=id, title=data["ttl"])
|
||||||
|
|
||||||
|
|
||||||
class Event(BaseModel):
|
class Event(BaseModel):
|
||||||
@ -590,12 +589,11 @@ class EventExt(Event):
|
|||||||
self.materials_fee_included_in_price is not None
|
self.materials_fee_included_in_price is not None
|
||||||
or self.materials_fee == 0
|
or self.materials_fee == 0
|
||||||
)
|
)
|
||||||
and self.total_due_to_instructor is not None
|
and getattr(self, "total_due_to_instructor") is not None
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from decimal import Decimal
|
|
||||||
|
|
||||||
class EventExtAnnotations(TypedDict):
|
class EventExtAnnotations(TypedDict):
|
||||||
meetings: int
|
meetings: int
|
||||||
@ -621,30 +619,29 @@ else:
|
|||||||
EventExtAnnotatedWithFinancials = WithAnnotations[EventExt]
|
EventExtAnnotatedWithFinancials = WithAnnotations[EventExt]
|
||||||
|
|
||||||
|
|
||||||
class EventMeetingTime(Reservation):
|
class EventMeetingTime(models.Model):
|
||||||
event = models.ForeignKey(
|
event = models.ForeignKey(
|
||||||
EventExt, on_delete=models.CASCADE, related_name="meeting_times"
|
EventExt, on_delete=models.CASCADE, related_name="meeting_times"
|
||||||
)
|
)
|
||||||
|
start = models.DateTimeField()
|
||||||
|
end = models.DateTimeField()
|
||||||
|
|
||||||
def get_title(self) -> str:
|
duration = models.GeneratedField(
|
||||||
return self.event.unescaped_title
|
expression=F("end") - F("start"),
|
||||||
|
output_field=models.DurationField(),
|
||||||
|
db_persist=False,
|
||||||
|
)
|
||||||
|
|
||||||
# TODO: should probably do some validation in python to enforce
|
class Meta:
|
||||||
# - uniqueness and non-overlapping (per event)
|
constraints = [
|
||||||
# - min/max start/end time == event start end
|
models.UniqueConstraint(
|
||||||
|
fields=["event", "start", "end"], name="unique_event_start_end"
|
||||||
|
),
|
||||||
|
models.CheckConstraint(check=Q(end__gt=F("start")), name="end_after_start"),
|
||||||
|
]
|
||||||
|
|
||||||
def make_google_calendar_event(self):
|
def __str__(self) -> str:
|
||||||
status = (
|
return f"{self.start} - {self.end}"
|
||||||
"confirmed"
|
|
||||||
if self.event.cap > 0 and self.event.calendar != Event.EventCalendar.HIDDEN
|
|
||||||
else "cancelled"
|
|
||||||
)
|
|
||||||
|
|
||||||
return super().make_google_calendar_event() | {
|
|
||||||
# TODO: add event description and links
|
|
||||||
"summary": self.event.unescaped_title,
|
|
||||||
"status": status,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class EventInvoice(models.Model):
|
class EventInvoice(models.Model):
|
||||||
@ -708,14 +705,7 @@ class EventTicketTypeManager(models.Manager["EventTicketType"]):
|
|||||||
# non-member ticket
|
# non-member ticket
|
||||||
Q(restrict_to__isnull=True)
|
Q(restrict_to__isnull=True)
|
||||||
& (
|
& (
|
||||||
Q(
|
Q(event__start__lt=datetime(year=2024, month=7, day=1))
|
||||||
event__start__lt=datetime(
|
|
||||||
year=2024,
|
|
||||||
month=7,
|
|
||||||
day=1,
|
|
||||||
tzinfo=timezone.get_default_timezone(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
| Q(members_price=0)
|
| Q(members_price=0)
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
@ -762,7 +752,7 @@ class EventTicketType(DBView):
|
|||||||
objects = EventTicketTypeManager.from_queryset(EventTicketTypeQuerySet)()
|
objects = EventTicketTypeManager.from_queryset(EventTicketTypeQuerySet)()
|
||||||
|
|
||||||
event = models.ForeignKey(
|
event = models.ForeignKey(
|
||||||
EventExt, on_delete=models.DO_NOTHING, related_name="ticket_types"
|
EventExt, on_delete=models.CASCADE, related_name="ticket_types"
|
||||||
)
|
)
|
||||||
label = models.TextField()
|
label = models.TextField()
|
||||||
restrict_to = models.TextField(null=True, blank=True)
|
restrict_to = models.TextField(null=True, blank=True)
|
||||||
@ -807,7 +797,7 @@ class EventTicketType(DBView):
|
|||||||
|
|
||||||
class EventAttendeeStats(DBView):
|
class EventAttendeeStats(DBView):
|
||||||
event = models.ForeignKey(
|
event = models.ForeignKey(
|
||||||
EventExt, on_delete=models.DO_NOTHING, related_name="attendee_stats"
|
EventExt, on_delete=models.CASCADE, related_name="attendee_stats"
|
||||||
)
|
)
|
||||||
gross_revenue = models.FloatField()
|
gross_revenue = models.FloatField()
|
||||||
|
|
||||||
@ -827,7 +817,7 @@ class EventAttendeeStats(DBView):
|
|||||||
|
|
||||||
class EventAttendee(DBView):
|
class EventAttendee(DBView):
|
||||||
event = models.ForeignKey(
|
event = models.ForeignKey(
|
||||||
EventExt, on_delete=models.DO_NOTHING, related_name="attendees"
|
EventExt, on_delete=models.CASCADE, related_name="attendees"
|
||||||
)
|
)
|
||||||
uid = models.ForeignKey(Member, on_delete=models.DO_NOTHING)
|
uid = models.ForeignKey(Member, on_delete=models.DO_NOTHING)
|
||||||
name = models.CharField(max_length=256)
|
name = models.CharField(max_length=256)
|
||||||
|
@ -33,8 +33,8 @@ def flags_for_member(csv_member, all_flags, folders):
|
|||||||
|
|
||||||
def update_flags(mw_flags) -> Iterable[Flag]:
|
def update_flags(mw_flags) -> Iterable[Flag]:
|
||||||
for typ, flags_of_type in mw_flags.items():
|
for typ, flags_of_type in mw_flags.items():
|
||||||
for name, flag_id in flags_of_type.items():
|
for name, id in flags_of_type.items():
|
||||||
flag = Flag(id=flag_id, name=name, type=typ[:-1])
|
flag = Flag(id=id, name=name, type=typ[:-1])
|
||||||
flag.save()
|
flag.save()
|
||||||
yield flag
|
yield flag
|
||||||
|
|
||||||
@ -81,9 +81,7 @@ def scrape_transactions(membershipworks: MembershipWorks):
|
|||||||
transactions_csv = membershipworks.get_transactions(start_date, now)
|
transactions_csv = membershipworks.get_transactions(start_date, now)
|
||||||
transactions_json = membershipworks.get_transactions(start_date, now, json=True)
|
transactions_json = membershipworks.get_transactions(start_date, now, json=True)
|
||||||
# this is terrible, but as long as the dates are the same, should be fiiiine
|
# this is terrible, but as long as the dates are the same, should be fiiiine
|
||||||
transactions = [
|
transactions = [{**j, **v} for j, v in zip(transactions_csv, transactions_json)]
|
||||||
{**j, **v} for j, v in zip(transactions_csv, transactions_json, strict=True)
|
|
||||||
]
|
|
||||||
assert all(
|
assert all(
|
||||||
t["Account ID"] == t.get("uid", "") and t["Payment ID"] == t.get("sid", "")
|
t["Account ID"] == t.get("uid", "") and t["Payment ID"] == t.get("sid", "")
|
||||||
for t in transactions
|
for t in transactions
|
||||||
@ -178,6 +176,3 @@ def scrape_events():
|
|||||||
event_ext.details = membershipworks.get_event_by_eid(event.eid)
|
event_ext.details = membershipworks.get_event_by_eid(event.eid)
|
||||||
event_ext.registrations = membershipworks.get_event_registrations(event.eid)
|
event_ext.registrations = membershipworks.get_event_registrations(event.eid)
|
||||||
event_ext.save()
|
event_ext.save()
|
||||||
|
|
||||||
# delete all events that did not occur in the event list
|
|
||||||
EventExt.objects.exclude(pk__in=events).delete()
|
|
||||||
|
@ -67,7 +67,7 @@ async def sync_member(user_mod, member: Member):
|
|||||||
# set a random password and ensure it is changed at next login
|
# set a random password and ensure it is changed at next login
|
||||||
user.props.password = "".join(
|
user.props.password = "".join(
|
||||||
random.choice(string.ascii_letters + string.digits)
|
random.choice(string.ascii_letters + string.digits)
|
||||||
for x in range(RAND_PW_LEN)
|
for x in range(0, RAND_PW_LEN)
|
||||||
)
|
)
|
||||||
user.props.pwdChangeNextLogin = True
|
user.props.pwdChangeNextLogin = True
|
||||||
|
|
||||||
|
@ -1,66 +1 @@
|
|||||||
from datetime import datetime
|
# Create your tests here.
|
||||||
|
|
||||||
from django.db.models.functions import TruncYear
|
|
||||||
from django.test import TestCase
|
|
||||||
from django.utils import timezone
|
|
||||||
|
|
||||||
from membershipworks.models import (
|
|
||||||
Event,
|
|
||||||
EventCategory,
|
|
||||||
EventExt,
|
|
||||||
EventInstructor,
|
|
||||||
EventTicketType,
|
|
||||||
Flag,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class EventFinancials(TestCase):
|
|
||||||
def setUp(self) -> None:
|
|
||||||
members_folder = Flag.objects.create(
|
|
||||||
id="members_folder_flag", name="Members", type="folder"
|
|
||||||
)
|
|
||||||
instructor = EventInstructor.objects.create(name="instructor1")
|
|
||||||
category = EventCategory.objects.create(id=1, title="cat1")
|
|
||||||
EventExt.objects.create(
|
|
||||||
eid="event1",
|
|
||||||
url="",
|
|
||||||
title="Test Event 1",
|
|
||||||
instructor=instructor,
|
|
||||||
materials_fee=10,
|
|
||||||
materials_fee_included_in_price=True,
|
|
||||||
instructor_percentage=0.5,
|
|
||||||
start=datetime(2024, 1, 1, 18, 0, tzinfo=timezone.get_default_timezone()),
|
|
||||||
end=datetime(2024, 1, 1, 21, 0, tzinfo=timezone.get_default_timezone()),
|
|
||||||
count=10,
|
|
||||||
category=category,
|
|
||||||
calendar=Event.EventCalendar.PURPLE,
|
|
||||||
details={
|
|
||||||
"tkt": [
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"lbl": "tkt1",
|
|
||||||
"amt": 123.4,
|
|
||||||
"cnt": 1,
|
|
||||||
"dsp": [members_folder.id],
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"usr": [{"sum": 123.4}],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_ticket_type_annotations(self):
|
|
||||||
# TODO: test for correctness
|
|
||||||
list(EventTicketType.objects.all())
|
|
||||||
|
|
||||||
def test_with_financials(self):
|
|
||||||
# TODO: test for correctness
|
|
||||||
list(EventExt.objects.with_financials().all())
|
|
||||||
|
|
||||||
def test_with_financials_summary(self):
|
|
||||||
# TODO: test for correctness
|
|
||||||
list(
|
|
||||||
EventExt.objects.with_financials()
|
|
||||||
.values(year=TruncYear("start"))
|
|
||||||
.summarize()
|
|
||||||
.order_by("year")
|
|
||||||
)
|
|
||||||
|
@ -15,7 +15,7 @@
|
|||||||
"prettier": "^3.3.3",
|
"prettier": "^3.3.3",
|
||||||
"sass": "^1.77.8",
|
"sass": "^1.77.8",
|
||||||
"typescript": "^5.5.4",
|
"typescript": "^5.5.4",
|
||||||
"vite": "^5.4.1"
|
"vite": "^5.3.4"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
|
@ -57,7 +57,6 @@ class DepartmentAdmin(admin.ModelAdmin):
|
|||||||
"shop_lead_flag",
|
"shop_lead_flag",
|
||||||
"list_reply_to_address",
|
"list_reply_to_address",
|
||||||
]
|
]
|
||||||
list_select_related = ["shop_lead_flag", "parent"]
|
|
||||||
|
|
||||||
|
|
||||||
class CertificationVersionInline(admin.TabularInline):
|
class CertificationVersionInline(admin.TabularInline):
|
||||||
|
@ -39,7 +39,7 @@ class DepartmentViewSet(viewsets.ModelViewSet):
|
|||||||
serializer_class = DepartmentSerializer
|
serializer_class = DepartmentSerializer
|
||||||
|
|
||||||
@action(detail=False, methods=["get"])
|
@action(detail=False, methods=["get"])
|
||||||
def mailing_lists(self, request, format=None): # noqa: A002
|
def mailing_lists(self, request, format=None):
|
||||||
"""
|
"""
|
||||||
Generate a mailing list for each department, containing all
|
Generate a mailing list for each department, containing all
|
||||||
certified users for tools in that department or child departments
|
certified users for tools in that department or child departments
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
from typing import TYPE_CHECKING
|
from collections.abc import Iterable
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
@ -44,9 +44,6 @@ from .tables import (
|
|||||||
WaiverReportTable,
|
WaiverReportTable,
|
||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import Iterable
|
|
||||||
|
|
||||||
WIKI_URL = settings.WIKI_URL
|
WIKI_URL = settings.WIKI_URL
|
||||||
|
|
||||||
|
|
||||||
|
165
pnpm-lock.yaml
165
pnpm-lock.yaml
@ -40,8 +40,8 @@ importers:
|
|||||||
specifier: ^5.5.4
|
specifier: ^5.5.4
|
||||||
version: 5.5.4
|
version: 5.5.4
|
||||||
vite:
|
vite:
|
||||||
specifier: ^5.4.1
|
specifier: ^5.3.4
|
||||||
version: 5.4.1(sass@1.77.8)
|
version: 5.3.4(sass@1.77.8)
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
@ -198,83 +198,83 @@ packages:
|
|||||||
'@popperjs/core@2.11.8':
|
'@popperjs/core@2.11.8':
|
||||||
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
|
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
|
||||||
|
|
||||||
'@rollup/rollup-android-arm-eabi@4.20.0':
|
'@rollup/rollup-android-arm-eabi@4.19.0':
|
||||||
resolution: {integrity: sha512-TSpWzflCc4VGAUJZlPpgAJE1+V60MePDQnBd7PPkpuEmOy8i87aL6tinFGKBFKuEDikYpig72QzdT3QPYIi+oA==}
|
resolution: {integrity: sha512-JlPfZ/C7yn5S5p0yKk7uhHTTnFlvTgLetl2VxqE518QgyM7C9bSfFTYvB/Q/ftkq0RIPY4ySxTz+/wKJ/dXC0w==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [android]
|
os: [android]
|
||||||
|
|
||||||
'@rollup/rollup-android-arm64@4.20.0':
|
'@rollup/rollup-android-arm64@4.19.0':
|
||||||
resolution: {integrity: sha512-u00Ro/nok7oGzVuh/FMYfNoGqxU5CPWz1mxV85S2w9LxHR8OoMQBuSk+3BKVIDYgkpeOET5yXkx90OYFc+ytpQ==}
|
resolution: {integrity: sha512-RDxUSY8D1tWYfn00DDi5myxKgOk6RvWPxhmWexcICt/MEC6yEMr4HNCu1sXXYLw8iAsg0D44NuU+qNq7zVWCrw==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [android]
|
os: [android]
|
||||||
|
|
||||||
'@rollup/rollup-darwin-arm64@4.20.0':
|
'@rollup/rollup-darwin-arm64@4.19.0':
|
||||||
resolution: {integrity: sha512-uFVfvzvsdGtlSLuL0ZlvPJvl6ZmrH4CBwLGEFPe7hUmf7htGAN+aXo43R/V6LATyxlKVC/m6UsLb7jbG+LG39Q==}
|
resolution: {integrity: sha512-emvKHL4B15x6nlNTBMtIaC9tLPRpeA5jMvRLXVbl/W9Ie7HhkrE7KQjvgS9uxgatL1HmHWDXk5TTS4IaNJxbAA==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@rollup/rollup-darwin-x64@4.20.0':
|
'@rollup/rollup-darwin-x64@4.19.0':
|
||||||
resolution: {integrity: sha512-xbrMDdlev53vNXexEa6l0LffojxhqDTBeL+VUxuuIXys4x6xyvbKq5XqTXBCEUA8ty8iEJblHvFaWRJTk/icAQ==}
|
resolution: {integrity: sha512-fO28cWA1dC57qCd+D0rfLC4VPbh6EOJXrreBmFLWPGI9dpMlER2YwSPZzSGfq11XgcEpPukPTfEVFtw2q2nYJg==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm-gnueabihf@4.20.0':
|
'@rollup/rollup-linux-arm-gnueabihf@4.19.0':
|
||||||
resolution: {integrity: sha512-jMYvxZwGmoHFBTbr12Xc6wOdc2xA5tF5F2q6t7Rcfab68TT0n+r7dgawD4qhPEvasDsVpQi+MgDzj2faOLsZjA==}
|
resolution: {integrity: sha512-2Rn36Ubxdv32NUcfm0wB1tgKqkQuft00PtM23VqLuCUR4N5jcNWDoV5iBC9jeGdgS38WK66ElncprqgMUOyomw==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm-musleabihf@4.20.0':
|
'@rollup/rollup-linux-arm-musleabihf@4.19.0':
|
||||||
resolution: {integrity: sha512-1asSTl4HKuIHIB1GcdFHNNZhxAYEdqML/MW4QmPS4G0ivbEcBr1JKlFLKsIRqjSwOBkdItn3/ZDlyvZ/N6KPlw==}
|
resolution: {integrity: sha512-gJuzIVdq/X1ZA2bHeCGCISe0VWqCoNT8BvkQ+BfsixXwTOndhtLUpOg0A1Fcx/+eA6ei6rMBzlOz4JzmiDw7JQ==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm64-gnu@4.20.0':
|
'@rollup/rollup-linux-arm64-gnu@4.19.0':
|
||||||
resolution: {integrity: sha512-COBb8Bkx56KldOYJfMf6wKeYJrtJ9vEgBRAOkfw6Ens0tnmzPqvlpjZiLgkhg6cA3DGzCmLmmd319pmHvKWWlQ==}
|
resolution: {integrity: sha512-0EkX2HYPkSADo9cfeGFoQ7R0/wTKb7q6DdwI4Yn/ULFE1wuRRCHybxpl2goQrx4c/yzK3I8OlgtBu4xvted0ug==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm64-musl@4.20.0':
|
'@rollup/rollup-linux-arm64-musl@4.19.0':
|
||||||
resolution: {integrity: sha512-+it+mBSyMslVQa8wSPvBx53fYuZK/oLTu5RJoXogjk6x7Q7sz1GNRsXWjn6SwyJm8E/oMjNVwPhmNdIjwP135Q==}
|
resolution: {integrity: sha512-GlIQRj9px52ISomIOEUq/IojLZqzkvRpdP3cLgIE1wUWaiU5Takwlzpz002q0Nxxr1y2ZgxC2obWxjr13lvxNQ==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@rollup/rollup-linux-powerpc64le-gnu@4.20.0':
|
'@rollup/rollup-linux-powerpc64le-gnu@4.19.0':
|
||||||
resolution: {integrity: sha512-yAMvqhPfGKsAxHN8I4+jE0CpLWD8cv4z7CK7BMmhjDuz606Q2tFKkWRY8bHR9JQXYcoLfopo5TTqzxgPUjUMfw==}
|
resolution: {integrity: sha512-N6cFJzssruDLUOKfEKeovCKiHcdwVYOT1Hs6dovDQ61+Y9n3Ek4zXvtghPPelt6U0AH4aDGnDLb83uiJMkWYzQ==}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@rollup/rollup-linux-riscv64-gnu@4.20.0':
|
'@rollup/rollup-linux-riscv64-gnu@4.19.0':
|
||||||
resolution: {integrity: sha512-qmuxFpfmi/2SUkAw95TtNq/w/I7Gpjurx609OOOV7U4vhvUhBcftcmXwl3rqAek+ADBwSjIC4IVNLiszoj3dPA==}
|
resolution: {integrity: sha512-2DnD3mkS2uuam/alF+I7M84koGwvn3ZVD7uG+LEWpyzo/bq8+kKnus2EVCkcvh6PlNB8QPNFOz6fWd5N8o1CYg==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@rollup/rollup-linux-s390x-gnu@4.20.0':
|
'@rollup/rollup-linux-s390x-gnu@4.19.0':
|
||||||
resolution: {integrity: sha512-I0BtGXddHSHjV1mqTNkgUZLnS3WtsqebAXv11D5BZE/gfw5KoyXSAXVqyJximQXNvNzUo4GKlCK/dIwXlz+jlg==}
|
resolution: {integrity: sha512-D6pkaF7OpE7lzlTOFCB2m3Ngzu2ykw40Nka9WmKGUOTS3xcIieHe82slQlNq69sVB04ch73thKYIWz/Ian8DUA==}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-gnu@4.20.0':
|
'@rollup/rollup-linux-x64-gnu@4.19.0':
|
||||||
resolution: {integrity: sha512-y+eoL2I3iphUg9tN9GB6ku1FA8kOfmF4oUEWhztDJ4KXJy1agk/9+pejOuZkNFhRwHAOxMsBPLbXPd6mJiCwew==}
|
resolution: {integrity: sha512-HBndjQLP8OsdJNSxpNIN0einbDmRFg9+UQeZV1eiYupIRuZsDEoeGU43NQsS34Pp166DtwQOnpcbV/zQxM+rWA==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-musl@4.20.0':
|
'@rollup/rollup-linux-x64-musl@4.19.0':
|
||||||
resolution: {integrity: sha512-hM3nhW40kBNYUkZb/r9k2FKK+/MnKglX7UYd4ZUy5DJs8/sMsIbqWK2piZtVGE3kcXVNj3B2IrUYROJMMCikNg==}
|
resolution: {integrity: sha512-HxfbvfCKJe/RMYJJn0a12eiOI9OOtAUF4G6ozrFUK95BNyoJaSiBjIOHjZskTUffUrB84IPKkFG9H9nEvJGW6A==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@rollup/rollup-win32-arm64-msvc@4.20.0':
|
'@rollup/rollup-win32-arm64-msvc@4.19.0':
|
||||||
resolution: {integrity: sha512-psegMvP+Ik/Bg7QRJbv8w8PAytPA7Uo8fpFjXyCRHWm6Nt42L+JtoqH8eDQ5hRP7/XW2UiIriy1Z46jf0Oa1kA==}
|
resolution: {integrity: sha512-HxDMKIhmcguGTiP5TsLNolwBUK3nGGUEoV/BO9ldUBoMLBssvh4J0X8pf11i1fTV7WShWItB1bKAKjX4RQeYmg==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@rollup/rollup-win32-ia32-msvc@4.20.0':
|
'@rollup/rollup-win32-ia32-msvc@4.19.0':
|
||||||
resolution: {integrity: sha512-GabekH3w4lgAJpVxkk7hUzUf2hICSQO0a/BLFA11/RMxQT92MabKAqyubzDZmMOC/hcJNlc+rrypzNzYl4Dx7A==}
|
resolution: {integrity: sha512-xItlIAZZaiG/u0wooGzRsx11rokP4qyc/79LkAOdznGRAbOFc+SfEdfUOszG1odsHNgwippUJavag/+W/Etc6Q==}
|
||||||
cpu: [ia32]
|
cpu: [ia32]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@rollup/rollup-win32-x64-msvc@4.20.0':
|
'@rollup/rollup-win32-x64-msvc@4.19.0':
|
||||||
resolution: {integrity: sha512-aJ1EJSuTdGnM6qbVC4B5DSmozPTqIag9fSzXRNNo+humQLG89XpPgdt16Ia56ORD7s+H8Pmyx44uczDQ0yDzpg==}
|
resolution: {integrity: sha512-xNo5fV5ycvCCKqiZcpB65VMR11NJB+StnxHz20jdqRAktfdfzhgjTiJ2doTDQE/7dqGaV5I7ZGqKpgph6lCIag==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
@ -344,8 +344,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-s3Fq41ZVh7vbbe2PN3nrW7yC7U7MFVc5c98/iTl9c2GawNMKx/J648KQRW6WKkuU8GIbbh2IXfIRQjOZnXcTnw==}
|
resolution: {integrity: sha512-s3Fq41ZVh7vbbe2PN3nrW7yC7U7MFVc5c98/iTl9c2GawNMKx/J648KQRW6WKkuU8GIbbh2IXfIRQjOZnXcTnw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
ignore@5.3.2:
|
ignore@5.3.1:
|
||||||
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
resolution: {integrity: sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==}
|
||||||
engines: {node: '>= 4'}
|
engines: {node: '>= 4'}
|
||||||
|
|
||||||
immutable@4.3.7:
|
immutable@4.3.7:
|
||||||
@ -395,8 +395,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
|
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
|
||||||
engines: {node: '>=8.6'}
|
engines: {node: '>=8.6'}
|
||||||
|
|
||||||
postcss@8.4.41:
|
postcss@8.4.39:
|
||||||
resolution: {integrity: sha512-TesUflQ0WKZqAvg52PWL6kHgLKP6xB6heTOdoYM0Wt2UHyxNa4K25EZZMgKns3BH1RLVbZCREPpLY0rhnNoHVQ==}
|
resolution: {integrity: sha512-0vzE+lAiG7hZl1/9I8yzKLx3aR9Xbof3fBHKunvMfOCYAtMhrsnccJY2iTURb9EZd5+pLuiNV9/c/GZJOHsgIw==}
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
|
|
||||||
prettier@3.3.3:
|
prettier@3.3.3:
|
||||||
@ -415,8 +415,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==}
|
resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==}
|
||||||
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
|
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
|
||||||
|
|
||||||
rollup@4.20.0:
|
rollup@4.19.0:
|
||||||
resolution: {integrity: sha512-6rbWBChcnSGzIlXeIdNIZTopKYad8ZG8ajhl78lGRLsI2rX8IkaotQhVas2Ma+GPxJav19wrSzvRvuiv0YKzWw==}
|
resolution: {integrity: sha512-5r7EYSQIowHsK4eTZ0Y81qpZuJz+MUuYeqmmYmRMl1nwhdmbiYqt5jwzf6u7wyOzJgYqtCRMtVRKOtHANBz7rA==}
|
||||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
@ -452,8 +452,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==}
|
resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
vite@5.4.1:
|
vite@5.3.4:
|
||||||
resolution: {integrity: sha512-1oE6yuNXssjrZdblI9AfBbHCC41nnyoVoEZxQnID6yvQZAFBzxxkqoFLtHUMkYunL8hwOLEjgTuxpkRxvba3kA==}
|
resolution: {integrity: sha512-Cw+7zL3ZG9/NZBB8C+8QbQZmR54GwqIz+WMI4b3JgdYJvX+ny9AjJXqkGQlDXSXRP9rP0B4tbciRMOVEKulVOA==}
|
||||||
engines: {node: ^18.0.0 || >=20.0.0}
|
engines: {node: ^18.0.0 || >=20.0.0}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -461,7 +461,6 @@ packages:
|
|||||||
less: '*'
|
less: '*'
|
||||||
lightningcss: ^1.21.0
|
lightningcss: ^1.21.0
|
||||||
sass: '*'
|
sass: '*'
|
||||||
sass-embedded: '*'
|
|
||||||
stylus: '*'
|
stylus: '*'
|
||||||
sugarss: '*'
|
sugarss: '*'
|
||||||
terser: ^5.4.0
|
terser: ^5.4.0
|
||||||
@ -474,8 +473,6 @@ packages:
|
|||||||
optional: true
|
optional: true
|
||||||
sass:
|
sass:
|
||||||
optional: true
|
optional: true
|
||||||
sass-embedded:
|
|
||||||
optional: true
|
|
||||||
stylus:
|
stylus:
|
||||||
optional: true
|
optional: true
|
||||||
sugarss:
|
sugarss:
|
||||||
@ -568,52 +565,52 @@ snapshots:
|
|||||||
|
|
||||||
'@popperjs/core@2.11.8': {}
|
'@popperjs/core@2.11.8': {}
|
||||||
|
|
||||||
'@rollup/rollup-android-arm-eabi@4.20.0':
|
'@rollup/rollup-android-arm-eabi@4.19.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-android-arm64@4.20.0':
|
'@rollup/rollup-android-arm64@4.19.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-darwin-arm64@4.20.0':
|
'@rollup/rollup-darwin-arm64@4.19.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-darwin-x64@4.20.0':
|
'@rollup/rollup-darwin-x64@4.19.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm-gnueabihf@4.20.0':
|
'@rollup/rollup-linux-arm-gnueabihf@4.19.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm-musleabihf@4.20.0':
|
'@rollup/rollup-linux-arm-musleabihf@4.19.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm64-gnu@4.20.0':
|
'@rollup/rollup-linux-arm64-gnu@4.19.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm64-musl@4.20.0':
|
'@rollup/rollup-linux-arm64-musl@4.19.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-linux-powerpc64le-gnu@4.20.0':
|
'@rollup/rollup-linux-powerpc64le-gnu@4.19.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-linux-riscv64-gnu@4.20.0':
|
'@rollup/rollup-linux-riscv64-gnu@4.19.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-linux-s390x-gnu@4.20.0':
|
'@rollup/rollup-linux-s390x-gnu@4.19.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-gnu@4.20.0':
|
'@rollup/rollup-linux-x64-gnu@4.19.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-musl@4.20.0':
|
'@rollup/rollup-linux-x64-musl@4.19.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-win32-arm64-msvc@4.20.0':
|
'@rollup/rollup-win32-arm64-msvc@4.19.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-win32-ia32-msvc@4.20.0':
|
'@rollup/rollup-win32-ia32-msvc@4.19.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-win32-x64-msvc@4.20.0':
|
'@rollup/rollup-win32-x64-msvc@4.19.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@sindresorhus/merge-streams@2.3.0': {}
|
'@sindresorhus/merge-streams@2.3.0': {}
|
||||||
@ -708,12 +705,12 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@sindresorhus/merge-streams': 2.3.0
|
'@sindresorhus/merge-streams': 2.3.0
|
||||||
fast-glob: 3.3.2
|
fast-glob: 3.3.2
|
||||||
ignore: 5.3.2
|
ignore: 5.3.1
|
||||||
path-type: 5.0.0
|
path-type: 5.0.0
|
||||||
slash: 5.1.0
|
slash: 5.1.0
|
||||||
unicorn-magic: 0.1.0
|
unicorn-magic: 0.1.0
|
||||||
|
|
||||||
ignore@5.3.2: {}
|
ignore@5.3.1: {}
|
||||||
|
|
||||||
immutable@4.3.7: {}
|
immutable@4.3.7: {}
|
||||||
|
|
||||||
@ -746,7 +743,7 @@ snapshots:
|
|||||||
|
|
||||||
picomatch@2.3.1: {}
|
picomatch@2.3.1: {}
|
||||||
|
|
||||||
postcss@8.4.41:
|
postcss@8.4.39:
|
||||||
dependencies:
|
dependencies:
|
||||||
nanoid: 3.3.7
|
nanoid: 3.3.7
|
||||||
picocolors: 1.0.1
|
picocolors: 1.0.1
|
||||||
@ -762,26 +759,26 @@ snapshots:
|
|||||||
|
|
||||||
reusify@1.0.4: {}
|
reusify@1.0.4: {}
|
||||||
|
|
||||||
rollup@4.20.0:
|
rollup@4.19.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/estree': 1.0.5
|
'@types/estree': 1.0.5
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@rollup/rollup-android-arm-eabi': 4.20.0
|
'@rollup/rollup-android-arm-eabi': 4.19.0
|
||||||
'@rollup/rollup-android-arm64': 4.20.0
|
'@rollup/rollup-android-arm64': 4.19.0
|
||||||
'@rollup/rollup-darwin-arm64': 4.20.0
|
'@rollup/rollup-darwin-arm64': 4.19.0
|
||||||
'@rollup/rollup-darwin-x64': 4.20.0
|
'@rollup/rollup-darwin-x64': 4.19.0
|
||||||
'@rollup/rollup-linux-arm-gnueabihf': 4.20.0
|
'@rollup/rollup-linux-arm-gnueabihf': 4.19.0
|
||||||
'@rollup/rollup-linux-arm-musleabihf': 4.20.0
|
'@rollup/rollup-linux-arm-musleabihf': 4.19.0
|
||||||
'@rollup/rollup-linux-arm64-gnu': 4.20.0
|
'@rollup/rollup-linux-arm64-gnu': 4.19.0
|
||||||
'@rollup/rollup-linux-arm64-musl': 4.20.0
|
'@rollup/rollup-linux-arm64-musl': 4.19.0
|
||||||
'@rollup/rollup-linux-powerpc64le-gnu': 4.20.0
|
'@rollup/rollup-linux-powerpc64le-gnu': 4.19.0
|
||||||
'@rollup/rollup-linux-riscv64-gnu': 4.20.0
|
'@rollup/rollup-linux-riscv64-gnu': 4.19.0
|
||||||
'@rollup/rollup-linux-s390x-gnu': 4.20.0
|
'@rollup/rollup-linux-s390x-gnu': 4.19.0
|
||||||
'@rollup/rollup-linux-x64-gnu': 4.20.0
|
'@rollup/rollup-linux-x64-gnu': 4.19.0
|
||||||
'@rollup/rollup-linux-x64-musl': 4.20.0
|
'@rollup/rollup-linux-x64-musl': 4.19.0
|
||||||
'@rollup/rollup-win32-arm64-msvc': 4.20.0
|
'@rollup/rollup-win32-arm64-msvc': 4.19.0
|
||||||
'@rollup/rollup-win32-ia32-msvc': 4.20.0
|
'@rollup/rollup-win32-ia32-msvc': 4.19.0
|
||||||
'@rollup/rollup-win32-x64-msvc': 4.20.0
|
'@rollup/rollup-win32-x64-msvc': 4.19.0
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
|
|
||||||
run-parallel@1.2.0:
|
run-parallel@1.2.0:
|
||||||
@ -808,11 +805,11 @@ snapshots:
|
|||||||
|
|
||||||
unicorn-magic@0.1.0: {}
|
unicorn-magic@0.1.0: {}
|
||||||
|
|
||||||
vite@5.4.1(sass@1.77.8):
|
vite@5.3.4(sass@1.77.8):
|
||||||
dependencies:
|
dependencies:
|
||||||
esbuild: 0.21.5
|
esbuild: 0.21.5
|
||||||
postcss: 8.4.41
|
postcss: 8.4.39
|
||||||
rollup: 4.20.0
|
rollup: 4.19.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
sass: 1.77.8
|
sass: 1.77.8
|
||||||
|
@ -6,8 +6,8 @@ authors = [
|
|||||||
{name = "Adam Goldsmith", email = "contact@adamgoldsmith.name"},
|
{name = "Adam Goldsmith", email = "contact@adamgoldsmith.name"},
|
||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"django~=5.1",
|
"django~=5.0",
|
||||||
"django-admin-logs~=1.3",
|
"django-admin-logs~=1.2",
|
||||||
"django-auth-ldap~=4.8",
|
"django-auth-ldap~=4.8",
|
||||||
"django-markdownx~=4.0",
|
"django-markdownx~=4.0",
|
||||||
"django-recurrence~=1.11",
|
"django-recurrence~=1.11",
|
||||||
@ -23,7 +23,7 @@ dependencies = [
|
|||||||
"semver~=3.0",
|
"semver~=3.0",
|
||||||
"djangorestframework~=3.15",
|
"djangorestframework~=3.15",
|
||||||
"django-q2~=1.6",
|
"django-q2~=1.6",
|
||||||
"lxml~=5.3",
|
"lxml~=5.2",
|
||||||
"django-object-actions~=4.2",
|
"django-object-actions~=4.2",
|
||||||
"bitstring~=4.2",
|
"bitstring~=4.2",
|
||||||
"udm-rest-client~=1.2",
|
"udm-rest-client~=1.2",
|
||||||
@ -32,7 +32,7 @@ dependencies = [
|
|||||||
"nh3~=0.2",
|
"nh3~=0.2",
|
||||||
"django-tables2~=2.7",
|
"django-tables2~=2.7",
|
||||||
"tablib[ods,xlsx]~=3.6",
|
"tablib[ods,xlsx]~=3.6",
|
||||||
"django-filter~=24.3",
|
"django-filter~=24.2",
|
||||||
"django-db-views~=0.1",
|
"django-db-views~=0.1",
|
||||||
"django-mysql~=4.14",
|
"django-mysql~=4.14",
|
||||||
"django-weasyprint~=2.3",
|
"django-weasyprint~=2.3",
|
||||||
@ -40,17 +40,13 @@ dependencies = [
|
|||||||
"django-bootstrap5~=24.2",
|
"django-bootstrap5~=24.2",
|
||||||
"django-configurations[database,email]~=2.5",
|
"django-configurations[database,email]~=2.5",
|
||||||
"django-vite~=3.0",
|
"django-vite~=3.0",
|
||||||
"django-template-partials~=24.4",
|
|
||||||
"google-api-python-client~=2.141",
|
|
||||||
"google-auth-oauthlib~=1.2",
|
|
||||||
"django-model-utils~=4.5",
|
|
||||||
]
|
]
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
server = [
|
server = [
|
||||||
"uvicorn[standard]~=0.30",
|
"uvicorn[standard]~=0.30",
|
||||||
"setuptools~=72.2",
|
"setuptools~=71.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.entry-points."djangoq.errorreporters"]
|
[project.entry-points."djangoq.errorreporters"]
|
||||||
@ -60,32 +56,7 @@ admin_email = "cmsmanage.django_q2_admin_email_reporter:AdminEmailReporter"
|
|||||||
line-length = 88
|
line-length = 88
|
||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
select = [
|
select = ["E4", "E7", "E9", "F", "I", "C4", "UP", "PERF", "PL", "SIM", "FIX003", "DJ012"]
|
||||||
"E4",
|
|
||||||
"E7",
|
|
||||||
"E9",
|
|
||||||
"F",
|
|
||||||
"I",
|
|
||||||
"C4",
|
|
||||||
"UP",
|
|
||||||
"PERF",
|
|
||||||
"PL",
|
|
||||||
"SIM",
|
|
||||||
"FIX003",
|
|
||||||
"DJ012",
|
|
||||||
"A",
|
|
||||||
"INP",
|
|
||||||
"ISC",
|
|
||||||
"Q",
|
|
||||||
"PIE",
|
|
||||||
"LOG",
|
|
||||||
"RSE",
|
|
||||||
"TCH",
|
|
||||||
"PTH",
|
|
||||||
"FURB",
|
|
||||||
"B",
|
|
||||||
]
|
|
||||||
ignore = ["ISC001"]
|
|
||||||
|
|
||||||
[tool.ruff.lint.isort]
|
[tool.ruff.lint.isort]
|
||||||
known-first-party = [
|
known-first-party = [
|
||||||
@ -95,7 +66,6 @@ known-first-party = [
|
|||||||
"membershipworks",
|
"membershipworks",
|
||||||
"paperwork",
|
"paperwork",
|
||||||
"rentals",
|
"rentals",
|
||||||
"reservations",
|
|
||||||
"tasks",
|
"tasks",
|
||||||
]
|
]
|
||||||
section-order = ["future", "standard-library", "django", "third-party", "first-party", "local-folder"]
|
section-order = ["future", "standard-library", "django", "third-party", "first-party", "local-folder"]
|
||||||
@ -110,7 +80,6 @@ indent = 2
|
|||||||
blank_line_after_tag = "load,extends"
|
blank_line_after_tag = "load,extends"
|
||||||
max_blank_lines = 1
|
max_blank_lines = 1
|
||||||
ignore = "T003,H017,H021,H030,H031"
|
ignore = "T003,H017,H021,H030,H031"
|
||||||
custom_blocks = "partialdef"
|
|
||||||
format_css = true
|
format_css = true
|
||||||
format_js = true
|
format_js = true
|
||||||
|
|
||||||
@ -145,12 +114,12 @@ include_packages = ["openapi-client-udm"]
|
|||||||
[tool.pdm.dev-dependencies]
|
[tool.pdm.dev-dependencies]
|
||||||
lint = [
|
lint = [
|
||||||
"djlint~=1.34",
|
"djlint~=1.34",
|
||||||
"ruff~=0.6",
|
"ruff~=0.5",
|
||||||
]
|
]
|
||||||
typing = [
|
typing = [
|
||||||
"mypy~=1.10",
|
"mypy~=1.10",
|
||||||
"django-stubs~=5.0",
|
"django-stubs~=5.0",
|
||||||
"setuptools~=72.2",
|
"setuptools~=71.1",
|
||||||
"types-bleach~=6.1",
|
"types-bleach~=6.1",
|
||||||
"types-requests~=2.32",
|
"types-requests~=2.32",
|
||||||
"types-urllib3~=1.26",
|
"types-urllib3~=1.26",
|
||||||
@ -158,7 +127,7 @@ typing = [
|
|||||||
"types-Markdown~=3.6",
|
"types-Markdown~=3.6",
|
||||||
"types-Pygments~=2.18",
|
"types-Pygments~=2.18",
|
||||||
"types-psycopg2~=2.9",
|
"types-psycopg2~=2.9",
|
||||||
"types-lxml~=2024.8",
|
"types-lxml~=2024.4",
|
||||||
]
|
]
|
||||||
debug = [
|
debug = [
|
||||||
"django-debug-toolbar~=4.4",
|
"django-debug-toolbar~=4.4",
|
||||||
@ -166,10 +135,8 @@ debug = [
|
|||||||
dev = [
|
dev = [
|
||||||
"django-extensions~=3.2",
|
"django-extensions~=3.2",
|
||||||
"ipython~=8.26",
|
"ipython~=8.26",
|
||||||
"hypothesis[django]~=6.111",
|
"hypothesis[django]~=6.108",
|
||||||
"tblib~=3.0",
|
"tblib~=3.0",
|
||||||
"google-api-python-client-stubs~=1.27",
|
|
||||||
"types-python-dateutil~=2.9",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.pdm.scripts]
|
[tool.pdm.scripts]
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.db.models import Prefetch, QuerySet
|
from django.db.models import Prefetch
|
||||||
from django.http import HttpRequest
|
|
||||||
|
|
||||||
from membershipworks.models import Member
|
from membershipworks.models import Member
|
||||||
|
|
||||||
@ -23,17 +22,11 @@ class LockerBankAdmin(admin.ModelAdmin):
|
|||||||
inlines = [LockerUnitInline]
|
inlines = [LockerUnitInline]
|
||||||
prepopulated_fields = {"slug": ("name",)}
|
prepopulated_fields = {"slug": ("name",)}
|
||||||
|
|
||||||
def get_queryset(self, request: HttpRequest) -> QuerySet[LockerBank]:
|
|
||||||
return super().get_queryset(request).prefetch_related("units")
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(LockerUnit)
|
@admin.register(LockerUnit)
|
||||||
class LockerUnitAdmin(admin.ModelAdmin):
|
class LockerUnitAdmin(admin.ModelAdmin):
|
||||||
inlines = [LockerInfoInline]
|
inlines = [LockerInfoInline]
|
||||||
|
|
||||||
def get_queryset(self, request: HttpRequest) -> QuerySet[LockerUnit]:
|
|
||||||
return super().get_queryset(request).prefetch_related("bank")
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(LockerInfo)
|
@admin.register(LockerInfo)
|
||||||
class LockerInfoAdmin(admin.ModelAdmin):
|
class LockerInfoAdmin(admin.ModelAdmin):
|
||||||
@ -50,14 +43,11 @@ class LockerInfoAdmin(admin.ModelAdmin):
|
|||||||
]
|
]
|
||||||
list_display_links = ["locker_unit", "address"]
|
list_display_links = ["locker_unit", "address"]
|
||||||
|
|
||||||
def get_queryset(self, request: HttpRequest) -> QuerySet[LockerInfo]:
|
def get_queryset(self, request):
|
||||||
return (
|
return LockerInfo.objects.select_related(
|
||||||
super()
|
"locker_unit", "locker_unit__bank"
|
||||||
.get_queryset(request)
|
).prefetch_related(
|
||||||
.select_related("locker_unit", "locker_unit__bank")
|
Prefetch("renter", queryset=Member.objects.only("account_name"))
|
||||||
.prefetch_related(
|
|
||||||
Prefetch("renter", queryset=Member.objects.only("account_name"))
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_changelist_formset(self, request, **kwargs):
|
def get_changelist_formset(self, request, **kwargs):
|
||||||
|
@ -1,100 +0,0 @@
|
|||||||
from typing import Any
|
|
||||||
|
|
||||||
from django.contrib import admin
|
|
||||||
from django.contrib.admin.views.main import ChangeList
|
|
||||||
from django.db import models
|
|
||||||
from django.db.models import QuerySet
|
|
||||||
from django.http import HttpRequest
|
|
||||||
from django.urls import reverse
|
|
||||||
|
|
||||||
from .models import ExternalReservation, Reservation, Resource, UserReservation
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Resource)
|
|
||||||
class ResourceAdmin(admin.ModelAdmin):
|
|
||||||
list_display = ["name", "parent", "min_length", "max_length"]
|
|
||||||
list_filter = ["parent"]
|
|
||||||
search_fields = ["name"]
|
|
||||||
show_facets = admin.ShowFacets.ALWAYS
|
|
||||||
|
|
||||||
|
|
||||||
class ReservationTypeFilter(admin.SimpleListFilter):
|
|
||||||
"""Filter by subclass"""
|
|
||||||
|
|
||||||
title = "Reservation Type"
|
|
||||||
parameter_name = "type"
|
|
||||||
|
|
||||||
# TODO: this could be automatic
|
|
||||||
_subtypes = {
|
|
||||||
"eventmeetingtime": "Event Meeting Time",
|
|
||||||
"userreservation": "User Reservation",
|
|
||||||
"externalreservation": "External Reservation",
|
|
||||||
}
|
|
||||||
|
|
||||||
def lookups(
|
|
||||||
self, request: HttpRequest, model_admin: admin.ModelAdmin
|
|
||||||
) -> list[tuple[str, str]]:
|
|
||||||
return [
|
|
||||||
("generic", "Generic Reservation"),
|
|
||||||
] + list(self._subtypes.items())
|
|
||||||
|
|
||||||
def queryset(self, request: HttpRequest, queryset: QuerySet) -> QuerySet:
|
|
||||||
if self.value() in self._subtypes:
|
|
||||||
return queryset.filter(**{f"{self.value()}__isnull": False})
|
|
||||||
elif self.value() == "generic":
|
|
||||||
return queryset.filter(
|
|
||||||
**{f"{subtype}__isnull": True for subtype in self._subtypes}
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
return queryset
|
|
||||||
|
|
||||||
|
|
||||||
class SubclassChangeList(ChangeList):
|
|
||||||
def url_for_result(self, result: models.Model) -> str:
|
|
||||||
opts = type(result)._meta
|
|
||||||
return reverse(
|
|
||||||
f"admin:{opts.app_label}_{opts.model_name}_change",
|
|
||||||
args=(result.pk,),
|
|
||||||
current_app=self.model_admin.admin_site.name,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Reservation)
|
|
||||||
class ReservationAdmin(admin.ModelAdmin):
|
|
||||||
list_display = ["_title", "_resources", "start", "end"]
|
|
||||||
readonly_fields = ["google_calendar_event_id"]
|
|
||||||
list_filter = ["resources", ReservationTypeFilter]
|
|
||||||
show_facets = admin.ShowFacets.ALWAYS
|
|
||||||
date_hierarchy = "start"
|
|
||||||
|
|
||||||
def get_queryset(self, request):
|
|
||||||
return (
|
|
||||||
super()
|
|
||||||
.get_queryset(request)
|
|
||||||
.select_related("eventmeetingtime__event")
|
|
||||||
.prefetch_related("resources")
|
|
||||||
.select_subclasses()
|
|
||||||
)
|
|
||||||
|
|
||||||
@admin.display()
|
|
||||||
def _title(self, obj: Reservation):
|
|
||||||
return obj.get_title()
|
|
||||||
|
|
||||||
@admin.display()
|
|
||||||
def _resources(self, obj: Reservation):
|
|
||||||
return list(obj.resources.all()) or None
|
|
||||||
|
|
||||||
def get_changelist(self, request: HttpRequest, **kwargs: Any) -> type[ChangeList]:
|
|
||||||
return SubclassChangeList
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(UserReservation)
|
|
||||||
class UserReservationAdmin(admin.ModelAdmin):
|
|
||||||
def has_module_permission(self, request: HttpRequest) -> bool:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(ExternalReservation)
|
|
||||||
class ExternalReservationAdmin(admin.ModelAdmin):
|
|
||||||
def has_module_permission(self, request: HttpRequest) -> bool:
|
|
||||||
return False
|
|
@ -1,24 +0,0 @@
|
|||||||
from django.apps import AppConfig
|
|
||||||
from django.db.models.signals import post_migrate
|
|
||||||
|
|
||||||
|
|
||||||
def post_migrate_callback(sender, **kwargs):
|
|
||||||
from django_q.models import Schedule
|
|
||||||
|
|
||||||
from cmsmanage.django_q2_helper import ensure_scheduled
|
|
||||||
|
|
||||||
from .tasks.sync_google_calendar import sync_reservations_with_google_calendar
|
|
||||||
|
|
||||||
ensure_scheduled(
|
|
||||||
sync_reservations_with_google_calendar,
|
|
||||||
schedule_type=Schedule.MINUTES,
|
|
||||||
minutes=5,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ReservationsConfig(AppConfig):
|
|
||||||
default_auto_field = "django.db.models.BigAutoField"
|
|
||||||
name = "reservations"
|
|
||||||
|
|
||||||
def ready(self):
|
|
||||||
post_migrate.connect(post_migrate_callback, sender=self)
|
|
@ -1,21 +0,0 @@
|
|||||||
import logging
|
|
||||||
|
|
||||||
from django.core.management.base import BaseCommand
|
|
||||||
|
|
||||||
from reservations.tasks.sync_google_calendar import (
|
|
||||||
logger,
|
|
||||||
sync_reservations_with_google_calendar,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
|
||||||
def handle(self, *args, 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))
|
|
||||||
|
|
||||||
sync_reservations_with_google_calendar()
|
|
@ -1,113 +0,0 @@
|
|||||||
# Generated by Django 5.0.7 on 2024-08-05 20:43
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
import django.db.models.expressions
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
initial = True
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name="Resource",
|
|
||||||
fields=[
|
|
||||||
(
|
|
||||||
"id",
|
|
||||||
models.BigAutoField(
|
|
||||||
auto_created=True,
|
|
||||||
primary_key=True,
|
|
||||||
serialize=False,
|
|
||||||
verbose_name="ID",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
("name", models.CharField(max_length=256)),
|
|
||||||
("min_length", models.DurationField()),
|
|
||||||
("max_length", models.DurationField()),
|
|
||||||
("google_calendar", models.CharField(max_length=1024, unique=True)),
|
|
||||||
(
|
|
||||||
"parent",
|
|
||||||
models.ForeignKey(
|
|
||||||
blank=True,
|
|
||||||
null=True,
|
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
|
||||||
related_name="children",
|
|
||||||
to="reservations.resource",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name="Reservation",
|
|
||||||
fields=[
|
|
||||||
(
|
|
||||||
"id",
|
|
||||||
models.BigAutoField(
|
|
||||||
auto_created=True,
|
|
||||||
primary_key=True,
|
|
||||||
serialize=False,
|
|
||||||
verbose_name="ID",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
("start", models.DateTimeField()),
|
|
||||||
("end", models.DateTimeField()),
|
|
||||||
(
|
|
||||||
"google_calendar_event_id",
|
|
||||||
models.CharField(
|
|
||||||
blank=True, max_length=1024, null=True, unique=True
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"duration",
|
|
||||||
models.GeneratedField(
|
|
||||||
db_persist=False,
|
|
||||||
expression=django.db.models.expressions.CombinedExpression(
|
|
||||||
models.F("end"), "-", models.F("start")
|
|
||||||
),
|
|
||||||
output_field=models.DurationField(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"resources",
|
|
||||||
models.ManyToManyField(blank=True, to="reservations.resource"),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
migrations.AddConstraint(
|
|
||||||
model_name="reservation",
|
|
||||||
constraint=models.CheckConstraint(
|
|
||||||
check=models.Q(("end__gt", models.F("start"))), name="end_after_start"
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name="UserReservation",
|
|
||||||
fields=[
|
|
||||||
(
|
|
||||||
"reservation_ptr",
|
|
||||||
models.OneToOneField(
|
|
||||||
auto_created=True,
|
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
|
||||||
parent_link=True,
|
|
||||||
primary_key=True,
|
|
||||||
serialize=False,
|
|
||||||
to="reservations.reservation",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"user",
|
|
||||||
models.ForeignKey(
|
|
||||||
blank=True,
|
|
||||||
null=True,
|
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
|
||||||
to=settings.AUTH_USER_MODEL,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
bases=("reservations.reservation",),
|
|
||||||
),
|
|
||||||
]
|
|
@ -1,31 +0,0 @@
|
|||||||
# Generated by Django 5.0.7 on 2024-08-06 17:17
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
dependencies = [
|
|
||||||
("reservations", "0001_initial"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name="ExternalReservation",
|
|
||||||
fields=[
|
|
||||||
(
|
|
||||||
"reservation_ptr",
|
|
||||||
models.OneToOneField(
|
|
||||||
auto_created=True,
|
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
|
||||||
parent_link=True,
|
|
||||||
primary_key=True,
|
|
||||||
serialize=False,
|
|
||||||
to="reservations.reservation",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
("title", models.CharField(max_length=1024)),
|
|
||||||
],
|
|
||||||
bases=("reservations.reservation",),
|
|
||||||
),
|
|
||||||
]
|
|
@ -1,161 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
|
||||||
from django.db import models
|
|
||||||
from django.db.models import F, Q, QuerySet
|
|
||||||
from django.utils import timezone
|
|
||||||
|
|
||||||
from model_utils.managers import InheritanceQuerySetMixin
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import Iterable
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
|
|
||||||
class Resource(models.Model):
|
|
||||||
name = models.CharField(max_length=256)
|
|
||||||
parent = models.ForeignKey(
|
|
||||||
"Resource",
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
blank=True,
|
|
||||||
null=True,
|
|
||||||
related_name="children",
|
|
||||||
)
|
|
||||||
min_length = models.DurationField()
|
|
||||||
max_length = models.DurationField()
|
|
||||||
google_calendar = models.CharField(max_length=1024, unique=True)
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
if self.parent:
|
|
||||||
return f"{self.parent} / {self.name}"
|
|
||||||
else:
|
|
||||||
return self.name
|
|
||||||
|
|
||||||
def get_recursive_parents(self) -> Iterable[Resource]:
|
|
||||||
if self.parent:
|
|
||||||
yield self.parent
|
|
||||||
yield from self.parent.get_recursive_parents()
|
|
||||||
|
|
||||||
def get_recursive_children(self) -> Iterable[Resource]:
|
|
||||||
for child in self.children.all():
|
|
||||||
yield child
|
|
||||||
yield from child.get_recursive_children()
|
|
||||||
|
|
||||||
def get_related(self) -> Iterable[Resource]:
|
|
||||||
yield self
|
|
||||||
yield from self.get_recursive_parents()
|
|
||||||
yield from self.get_recursive_children()
|
|
||||||
|
|
||||||
|
|
||||||
class ReservationQuerySet(InheritanceQuerySetMixin, QuerySet):
|
|
||||||
def filter_after(self, start: datetime) -> ReservationQuerySet:
|
|
||||||
"""
|
|
||||||
Selects events that are after the specified datetime, including
|
|
||||||
overlaps but excluding exactly matching endpoints.
|
|
||||||
"""
|
|
||||||
return self.filter(Q(start__gt=start) | Q(end__gt=start))
|
|
||||||
|
|
||||||
def filter_before(self, end: datetime) -> ReservationQuerySet:
|
|
||||||
"""
|
|
||||||
Selects events that are before the specified datetime, including
|
|
||||||
overlaps but excluding exactly matching endpoints.
|
|
||||||
"""
|
|
||||||
return self.filter(Q(start__lt=end) | Q(end__lt=end))
|
|
||||||
|
|
||||||
def filter_between(self, start: datetime, end: datetime) -> ReservationQuerySet:
|
|
||||||
"""
|
|
||||||
Selects events that are between the specified datetime, including
|
|
||||||
overlaps but excluding exactly matching endpoints.
|
|
||||||
"""
|
|
||||||
return self.filter(
|
|
||||||
Q(start__gt=start, start__lt=end)
|
|
||||||
| Q(end__gt=start, end__lt=end)
|
|
||||||
| (Q(start__lt=start) & Q(end__gt=end))
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Reservation(models.Model):
|
|
||||||
resources = models.ManyToManyField(Resource, blank=True)
|
|
||||||
start = models.DateTimeField()
|
|
||||||
end = models.DateTimeField()
|
|
||||||
google_calendar_event_id = models.CharField(
|
|
||||||
max_length=1024, null=True, blank=True, unique=True
|
|
||||||
)
|
|
||||||
|
|
||||||
duration = models.GeneratedField(
|
|
||||||
expression=F("end") - F("start"),
|
|
||||||
output_field=models.DurationField(),
|
|
||||||
db_persist=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
objects = ReservationQuerySet.as_manager()
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
constraints = [
|
|
||||||
models.CheckConstraint(check=Q(end__gt=F("start")), name="end_after_start"),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
resources = ", ".join(str(resource) for resource in self.resources.all())
|
|
||||||
return f"{resources}: {self.start} - {self.end}"
|
|
||||||
|
|
||||||
def get_title(self) -> str:
|
|
||||||
return "Unknown Reservation"
|
|
||||||
|
|
||||||
def make_google_calendar_event(self):
|
|
||||||
event = {
|
|
||||||
"summary": "CMSManage Reservation",
|
|
||||||
"start": {
|
|
||||||
"dateTime": self.start.isoformat(),
|
|
||||||
"timeZone": timezone.get_default_timezone_name(),
|
|
||||||
},
|
|
||||||
"end": {
|
|
||||||
"dateTime": self.end.isoformat(),
|
|
||||||
"timeZone": timezone.get_default_timezone_name(),
|
|
||||||
},
|
|
||||||
"extendedProperties": {"private": {"cmsmanage": "1"}},
|
|
||||||
"status": "confirmed",
|
|
||||||
}
|
|
||||||
|
|
||||||
# use existing id if it exists, otherwise let Google generate it
|
|
||||||
if self.google_calendar_event_id:
|
|
||||||
event["id"] = self.google_calendar_event_id
|
|
||||||
|
|
||||||
return event
|
|
||||||
|
|
||||||
|
|
||||||
class UserReservation(Reservation):
|
|
||||||
user = models.ForeignKey(
|
|
||||||
get_user_model(), on_delete=models.CASCADE, null=True, blank=True
|
|
||||||
)
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return f"{self.user} | {super().__str__()}"
|
|
||||||
|
|
||||||
def get_title(self) -> str:
|
|
||||||
return str(self.user)
|
|
||||||
|
|
||||||
def make_google_calendar_event(self):
|
|
||||||
return super().make_google_calendar_event() | {
|
|
||||||
"summary": str(self.user),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class ExternalReservation(Reservation):
|
|
||||||
"""Reservations created by something else in Google Calendar"""
|
|
||||||
|
|
||||||
title = models.CharField(max_length=1024)
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return f'External "{self.title}" | {super().__str__()}'
|
|
||||||
|
|
||||||
def get_title(self) -> str:
|
|
||||||
return str(self.title)
|
|
||||||
|
|
||||||
def make_google_calendar_event(self):
|
|
||||||
"""This should never be called, as these are reservations from Google Calendar and shouldn't be synced back"""
|
|
||||||
raise AttributeError(
|
|
||||||
"External Reservations should not be pushed back to Google Calendar"
|
|
||||||
)
|
|
@ -1,205 +0,0 @@
|
|||||||
import logging
|
|
||||||
from datetime import date, datetime
|
|
||||||
from http import HTTPStatus
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.utils import timezone
|
|
||||||
|
|
||||||
from google.oauth2 import service_account
|
|
||||||
from googleapiclient.discovery import build
|
|
||||||
from googleapiclient.errors import HttpError
|
|
||||||
|
|
||||||
from cmsmanage.django_q2_helper import q_task_group
|
|
||||||
from reservations.models import ExternalReservation, Reservation, Resource
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
SCOPES = ["https://www.googleapis.com/auth/calendar"]
|
|
||||||
|
|
||||||
|
|
||||||
def parse_google_calendar_datetime(dt) -> date | datetime:
|
|
||||||
if "date" in dt:
|
|
||||||
return date.fromisoformat(dt["date"])
|
|
||||||
elif "dateTime" in dt:
|
|
||||||
return datetime.fromisoformat(dt["dateTime"])
|
|
||||||
else:
|
|
||||||
raise Exception("Google Calendar event with out a start/end date/dateTime")
|
|
||||||
|
|
||||||
|
|
||||||
def update_calendar_event(
|
|
||||||
service, resource: Resource, existing_event, reservation: Reservation
|
|
||||||
):
|
|
||||||
changes = reservation.make_google_calendar_event()
|
|
||||||
# skip update if no changes are needed
|
|
||||||
if (
|
|
||||||
parse_google_calendar_datetime(existing_event["start"]) != reservation.start
|
|
||||||
or parse_google_calendar_datetime(existing_event["end"]) != reservation.end
|
|
||||||
or any(
|
|
||||||
existing_event[k] != v
|
|
||||||
for k, v in changes.items()
|
|
||||||
if k not in ("start", "end")
|
|
||||||
)
|
|
||||||
):
|
|
||||||
logger.debug("Updating event")
|
|
||||||
new_event = existing_event | changes
|
|
||||||
service.events().update(
|
|
||||||
calendarId=resource.google_calendar,
|
|
||||||
eventId=reservation.google_calendar_event_id,
|
|
||||||
body=new_event,
|
|
||||||
).execute()
|
|
||||||
|
|
||||||
|
|
||||||
def insert_calendar_event(service, resource: Resource, reservation: Reservation):
|
|
||||||
new_gcal_event = reservation.make_google_calendar_event()
|
|
||||||
created_event = (
|
|
||||||
service.events()
|
|
||||||
.insert(
|
|
||||||
calendarId=resource.google_calendar,
|
|
||||||
body=new_gcal_event,
|
|
||||||
)
|
|
||||||
.execute()
|
|
||||||
)
|
|
||||||
reservation.google_calendar_event_id = created_event["id"]
|
|
||||||
reservation.save()
|
|
||||||
|
|
||||||
|
|
||||||
def sync_resource_from_google_calendar(
|
|
||||||
service, resource: Resource, now: datetime
|
|
||||||
) -> set[str]:
|
|
||||||
request = (
|
|
||||||
service.events()
|
|
||||||
.list(
|
|
||||||
calendarId=resource.google_calendar,
|
|
||||||
timeMin=now.isoformat(timespec="seconds"),
|
|
||||||
maxResults=2500,
|
|
||||||
)
|
|
||||||
.execute()
|
|
||||||
)
|
|
||||||
if "nextPageToken" in request:
|
|
||||||
# TODO: implement pagination
|
|
||||||
raise Exception(
|
|
||||||
"More events than fit on a page, and pagination not implemented"
|
|
||||||
)
|
|
||||||
events = request["items"]
|
|
||||||
|
|
||||||
for event in events:
|
|
||||||
if (
|
|
||||||
"extendedProperties" in event
|
|
||||||
and "private" in event["extendedProperties"]
|
|
||||||
and event["extendedProperties"]["private"].get("cmsmanage") == "1"
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
reservation = resource.reservation_set.get_subclass(
|
|
||||||
google_calendar_event_id=event["id"]
|
|
||||||
)
|
|
||||||
# event exists in both Google Calendar and database, check for update
|
|
||||||
logger.debug(
|
|
||||||
"Event in Google Calendar found in database: checking for update | %s",
|
|
||||||
event["id"],
|
|
||||||
)
|
|
||||||
update_calendar_event(service, resource, event, reservation)
|
|
||||||
except Reservation.DoesNotExist:
|
|
||||||
# reservation deleted in database, so remove from Google Calendar
|
|
||||||
logger.info(
|
|
||||||
"Event in Google Calendar not found in database: deleting | %s",
|
|
||||||
event["id"],
|
|
||||||
)
|
|
||||||
service.events().delete(
|
|
||||||
calendarId=resource.google_calendar,
|
|
||||||
eventId=event["id"],
|
|
||||||
sendUpdates="none",
|
|
||||||
).execute()
|
|
||||||
else:
|
|
||||||
logger.debug(
|
|
||||||
"Event in Google Calendar not originated by CMSManage: adding/updating as external reservation | %s",
|
|
||||||
event["id"],
|
|
||||||
)
|
|
||||||
# TODO: this might cause issues if something external
|
|
||||||
# creates events with matching IDs in different calendars
|
|
||||||
reservation, created = ExternalReservation.objects.update_or_create(
|
|
||||||
google_calendar_event_id=event["id"],
|
|
||||||
defaults={
|
|
||||||
"title": event["summary"],
|
|
||||||
"start": parse_google_calendar_datetime(event["start"]),
|
|
||||||
"end": parse_google_calendar_datetime(event["end"]),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
reservation.resources.add(resource)
|
|
||||||
|
|
||||||
return {event["id"] for event in events}
|
|
||||||
|
|
||||||
|
|
||||||
def sync_resource_from_database(
|
|
||||||
service, resource: Resource, now: datetime, existing_event_ids: set[str]
|
|
||||||
):
|
|
||||||
reservations = resource.reservation_set.filter(end__gt=now).select_subclasses()
|
|
||||||
# TODO: this could probably be more efficient?
|
|
||||||
for reservation in reservations:
|
|
||||||
if not reservation.google_calendar_event_id:
|
|
||||||
logger.info(
|
|
||||||
"Event in database has no Google Calendar event ID: inserting | %s",
|
|
||||||
reservation.google_calendar_event_id,
|
|
||||||
)
|
|
||||||
insert_calendar_event(service, resource, reservation)
|
|
||||||
|
|
||||||
# reservation has an event id, so check if we already handled it earlier
|
|
||||||
elif reservation.google_calendar_event_id not in existing_event_ids:
|
|
||||||
if isinstance(reservation, ExternalReservation):
|
|
||||||
logger.info(
|
|
||||||
"External event in database did not exist in future of Google Calendar: deleting locally | %s",
|
|
||||||
reservation.google_calendar_event_id,
|
|
||||||
)
|
|
||||||
reservation.delete()
|
|
||||||
else:
|
|
||||||
# this event was in Google Calendar at some point (possibly for a different
|
|
||||||
# resource/calendar), but did not appear in list(). Try to update it, then
|
|
||||||
# fall back to insert
|
|
||||||
logger.info(
|
|
||||||
"Reservation with event id not in Google Calendar: trying update | %s",
|
|
||||||
reservation.google_calendar_event_id,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
event = (
|
|
||||||
service.events()
|
|
||||||
.get(
|
|
||||||
calendarId=resource.google_calendar,
|
|
||||||
eventId=reservation.google_calendar_event_id,
|
|
||||||
)
|
|
||||||
.execute()
|
|
||||||
)
|
|
||||||
update_calendar_event(service, resource, event, reservation)
|
|
||||||
except HttpError as error:
|
|
||||||
if error.status_code == HTTPStatus.NOT_FOUND:
|
|
||||||
logger.info(
|
|
||||||
"Event in database not in Google Calendar: inserting | %s",
|
|
||||||
reservation.google_calendar_event_id,
|
|
||||||
)
|
|
||||||
insert_calendar_event(service, resource, reservation)
|
|
||||||
else:
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
def sync_resource(service, resource: Resource, now: datetime):
|
|
||||||
logger.info(
|
|
||||||
"Checking calendar %s for resource %s", resource.google_calendar, resource
|
|
||||||
)
|
|
||||||
|
|
||||||
existing_event_ids = sync_resource_from_google_calendar(service, resource, now)
|
|
||||||
sync_resource_from_database(service, resource, now, existing_event_ids)
|
|
||||||
|
|
||||||
|
|
||||||
@q_task_group("Sync Reservations with Google Calendar")
|
|
||||||
def sync_reservations_with_google_calendar():
|
|
||||||
service = build(
|
|
||||||
"calendar",
|
|
||||||
"v3",
|
|
||||||
credentials=service_account.Credentials.from_service_account_file(
|
|
||||||
settings.GOOGLE_SERVICE_ACCOUNT_FILE,
|
|
||||||
scopes=SCOPES,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
now = timezone.now()
|
|
||||||
|
|
||||||
for resource in Resource.objects.all():
|
|
||||||
sync_resource(service, resource, now)
|
|
@ -1,116 +0,0 @@
|
|||||||
from datetime import datetime, timedelta
|
|
||||||
|
|
||||||
from django.test import TestCase
|
|
||||||
from django.utils import timezone
|
|
||||||
|
|
||||||
from reservations.models import Reservation, Resource
|
|
||||||
|
|
||||||
|
|
||||||
class ReservationRangesTestCase(TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
tz = timezone.get_current_timezone()
|
|
||||||
test_resource = Resource.objects.create(
|
|
||||||
name="test resource",
|
|
||||||
min_length=timedelta(minutes=15),
|
|
||||||
max_length=timedelta(hours=4),
|
|
||||||
)
|
|
||||||
test_reservation = Reservation.objects.create(
|
|
||||||
start=datetime(2021, 12, 8, 12, 00, tzinfo=tz),
|
|
||||||
end=datetime(2021, 12, 8, 14, 0, tzinfo=tz),
|
|
||||||
)
|
|
||||||
test_reservation.resources.add(test_resource)
|
|
||||||
|
|
||||||
def test_after(self):
|
|
||||||
tz = timezone.get_current_timezone()
|
|
||||||
|
|
||||||
self.assertTrue(
|
|
||||||
Reservation.objects.filter_after(datetime(2021, 12, 8, 11, 0, tzinfo=tz))
|
|
||||||
)
|
|
||||||
self.assertTrue(
|
|
||||||
Reservation.objects.filter_after(datetime(2021, 12, 8, 12, 0, tzinfo=tz))
|
|
||||||
)
|
|
||||||
self.assertTrue(
|
|
||||||
Reservation.objects.filter_after(datetime(2021, 12, 8, 13, 0, tzinfo=tz))
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertFalse(
|
|
||||||
Reservation.objects.filter_after(datetime(2021, 12, 8, 14, 00, tzinfo=tz))
|
|
||||||
)
|
|
||||||
self.assertFalse(
|
|
||||||
Reservation.objects.filter_after(datetime(2021, 12, 8, 15, 00, tzinfo=tz))
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_before(self):
|
|
||||||
tz = timezone.get_current_timezone()
|
|
||||||
|
|
||||||
self.assertTrue(
|
|
||||||
Reservation.objects.filter_before(datetime(2021, 12, 8, 13, 0, tzinfo=tz))
|
|
||||||
)
|
|
||||||
self.assertTrue(
|
|
||||||
Reservation.objects.filter_before(datetime(2021, 12, 8, 14, 0, tzinfo=tz))
|
|
||||||
)
|
|
||||||
self.assertTrue(
|
|
||||||
Reservation.objects.filter_before(datetime(2021, 12, 8, 15, 0, tzinfo=tz))
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertFalse(
|
|
||||||
Reservation.objects.filter_before(datetime(2021, 12, 8, 11, 00, tzinfo=tz))
|
|
||||||
)
|
|
||||||
self.assertFalse(
|
|
||||||
Reservation.objects.filter_before(datetime(2021, 12, 8, 12, 00, tzinfo=tz))
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_between(self):
|
|
||||||
tz = timezone.get_current_timezone()
|
|
||||||
|
|
||||||
# contained (reservation entirely inside both start and end)
|
|
||||||
self.assertTrue(
|
|
||||||
Reservation.objects.filter_between(
|
|
||||||
datetime(2021, 12, 8, 11, 0, tzinfo=tz),
|
|
||||||
datetime(2021, 12, 8, 15, 0, tzinfo=tz),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# overlapping edges
|
|
||||||
self.assertTrue(
|
|
||||||
Reservation.objects.filter_between(
|
|
||||||
datetime(2021, 12, 8, 11, 0, tzinfo=tz),
|
|
||||||
datetime(2021, 12, 8, 13, 0, tzinfo=tz),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
self.assertTrue(
|
|
||||||
Reservation.objects.filter_between(
|
|
||||||
datetime(2021, 12, 8, 13, 0, tzinfo=tz),
|
|
||||||
datetime(2021, 12, 8, 15, 0, tzinfo=tz),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# containing (reservation contains both start and end)
|
|
||||||
self.assertTrue(
|
|
||||||
Reservation.objects.filter_between(
|
|
||||||
datetime(2021, 12, 8, 12, 30, tzinfo=tz),
|
|
||||||
datetime(2021, 12, 8, 13, 30, tzinfo=tz),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# on boundries
|
|
||||||
self.assertFalse(
|
|
||||||
Reservation.objects.filter_between(
|
|
||||||
datetime(2021, 12, 8, 11, 0, tzinfo=tz),
|
|
||||||
datetime(2021, 12, 8, 12, 0, tzinfo=tz),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
self.assertFalse(
|
|
||||||
Reservation.objects.filter_between(
|
|
||||||
datetime(2021, 12, 8, 14, 0, tzinfo=tz),
|
|
||||||
datetime(2021, 12, 8, 15, 0, tzinfo=tz),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# outside
|
|
||||||
self.assertFalse(
|
|
||||||
Reservation.objects.filter_between(
|
|
||||||
datetime(2021, 12, 8, 15, 0, tzinfo=tz),
|
|
||||||
datetime(2021, 12, 8, 17, 0, tzinfo=tz),
|
|
||||||
)
|
|
||||||
)
|
|
@ -1 +0,0 @@
|
|||||||
# Create your views here.
|
|
Loading…
Reference in New Issue
Block a user