Compare commits
29 Commits
1827d10bf4
...
e4c6fab011
Author | SHA1 | Date | |
---|---|---|---|
e4c6fab011 | |||
bf3433bb3d | |||
6591eee3ba | |||
02e86bd079 | |||
8868c0b5ef | |||
5ddecaaea8 | |||
db79a23dc4 | |||
91da3736ec | |||
cc31f97bc4 | |||
cd054bd716 | |||
996331c7a0 | |||
20fcac99a8 | |||
256c56df04 | |||
7b3dfef732 | |||
e348e8fbf5 | |||
39df28743b | |||
bef0191e12 | |||
ab25da0aa1 | |||
8fccb3c7fb | |||
a8b8e148fc | |||
e11e12307a | |||
d792efc084 | |||
927e2f4b90 | |||
e4280361d1 | |||
35c063c44e | |||
075812face | |||
508baf809c | |||
54b615c986 | |||
617b469d85 |
@ -10,7 +10,9 @@ jobs:
|
||||
container: catthehacker/ubuntu:act-latest
|
||||
services:
|
||||
mariadb:
|
||||
image: mariadb:latest
|
||||
# TODO: this is pinned to avoid what apears to be a bug with
|
||||
# MariaDB >= 10.11.9, and collation issues with 11.x.x
|
||||
image: mariadb:10.11.8
|
||||
env:
|
||||
MARIADB_ROOT_PASSWORD: whatever
|
||||
healthcheck:
|
||||
@ -36,5 +38,15 @@ jobs:
|
||||
sudo apt-get -y install build-essential python3-dev libldap2-dev libsasl2-dev mariadb-client
|
||||
- name: Install python dependencies
|
||||
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
|
||||
run: pdm run -v ./manage.py test
|
||||
|
@ -14,13 +14,13 @@ repos:
|
||||
- id: djlint-reformat-django
|
||||
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.5.2
|
||||
rev: v0.6.1
|
||||
hooks:
|
||||
- id: ruff
|
||||
- id: ruff-format
|
||||
|
||||
- repo: https://github.com/pdm-project/pdm
|
||||
rev: 2.17.0
|
||||
rev: 2.18.1
|
||||
hooks:
|
||||
- id: pdm-lock-check
|
||||
|
||||
|
@ -18,6 +18,10 @@ class Base(Configuration):
|
||||
credentials_directory = os.getenv("CREDENTIALS_DIRECTORY")
|
||||
if credentials_directory is not None:
|
||||
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():
|
||||
os.environ.setdefault(credential.name, credential.read_text())
|
||||
|
||||
@ -57,6 +61,7 @@ class Base(Configuration):
|
||||
"paperwork.apps.PaperworkConfig",
|
||||
"doorcontrol.apps.DoorControlConfig",
|
||||
"dashboard.apps.DashboardConfig",
|
||||
"reservations.apps.ReservationsConfig",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
@ -101,6 +106,9 @@ class Base(Configuration):
|
||||
|
||||
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
|
||||
LOGIN_REDIRECT_URL = "/"
|
||||
LOGIN_URL = "/auth/login/"
|
||||
@ -222,6 +230,8 @@ class NonCIBase(Base):
|
||||
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_PASSWORD = values.SecretValue(environ_prefix=None)
|
||||
|
||||
@ -347,6 +357,12 @@ class CI(Base):
|
||||
configure_hypothesis_profiles()
|
||||
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"
|
||||
|
||||
DATABASES = {
|
||||
|
@ -5,10 +5,10 @@ import bitstring
|
||||
|
||||
|
||||
class Credential:
|
||||
def __init__(self, code=None, hex=None):
|
||||
if code is None and hex is None:
|
||||
def __init__(self, code=None, hex_code=None):
|
||||
if code is None and hex_code is None:
|
||||
raise TypeError("Must set either code or hex for a Credential")
|
||||
elif code is not None and hex is not None:
|
||||
elif code is not None and hex_code is not None:
|
||||
raise TypeError("Cannot set both code and hex for a Credential")
|
||||
elif code is not None:
|
||||
self.bits = bitstring.pack(
|
||||
@ -18,8 +18,8 @@ class Credential:
|
||||
)
|
||||
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
|
||||
elif hex is not None:
|
||||
self.bits = bitstring.Bits(hex=hex)
|
||||
elif hex_code is not None:
|
||||
self.bits = bitstring.Bits(hex=hex_code)
|
||||
|
||||
def __repr__(self):
|
||||
return f"Credential({self.code})"
|
||||
|
@ -152,7 +152,7 @@ class DoorController:
|
||||
)
|
||||
return self.doXMLRequest(el)
|
||||
|
||||
def get_records(self, req, count, params={}, stopFunction=None):
|
||||
def get_records(self, req, count, params=None, stopFunction=None):
|
||||
recordCount = 0
|
||||
moreRecords = True
|
||||
|
||||
@ -172,7 +172,7 @@ class DoorController:
|
||||
"recordOffset": str(
|
||||
recordCount - 1 if recordCount > 0 else 0
|
||||
),
|
||||
**params,
|
||||
**(params or {}),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
@ -1,292 +0,0 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from lxml.etree import Element
|
||||
|
||||
from ..DoorController import ROOT, DoorController, E, E_corp, RemoteError
|
||||
|
||||
|
||||
# https://stackoverflow.com/questions/7905380/testing-equivalence-of-xml-etree-elementtree
|
||||
def assert_elements_equal(e1: Element, e2: Element) -> None:
|
||||
assert e1.tag == e2.tag
|
||||
assert e1.text == e2.text
|
||||
assert e1.tail == e2.tail
|
||||
assert e1.attrib == e2.attrib
|
||||
assert len(e1) == len(e2)
|
||||
|
||||
for c1, c2 in zip(e1, e2):
|
||||
assert_elements_equal(c1, c2)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def door_controller():
|
||||
return DoorController("127.0.0.1", "test", "test", name="Test", access="Test")
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_doXMLRequest_bytes(door_controller: DoorController) -> None:
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://127.0.0.1/cgi-bin/vertx_xml.cgi",
|
||||
body='<?xml version="1.0" encoding="UTF-8"?><VertXMessage xmlns:hid="http://www.hidcorp.com/VertX"><hid:Banana></hid:Banana></VertXMessage>',
|
||||
content_type="text/xml",
|
||||
)
|
||||
|
||||
ret = door_controller.doXMLRequest(
|
||||
b"<VertXMessage><hid:TEST></hid:TEST></VertXMessage>"
|
||||
)
|
||||
|
||||
assert (
|
||||
responses.calls[0].request.params["/cgi-bin/vertx_xml.cgi?XML"]
|
||||
== '<?xml version="1.0" encoding="UTF-8"?><VertXMessage><hid:TEST></hid:TEST></VertXMessage>'
|
||||
)
|
||||
|
||||
assert_elements_equal(ret, ROOT(E_corp.Banana()))
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_doXMLRequest_xml(door_controller: DoorController) -> None:
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://127.0.0.1/cgi-bin/vertx_xml.cgi",
|
||||
body='<?xml version="1.0" encoding="UTF-8"?><VertXMessage xmlns:hid="http://www.hidcorp.com/VertX"><hid:Banana></hid:Banana></VertXMessage>',
|
||||
content_type="text/xml",
|
||||
)
|
||||
|
||||
ret = door_controller.doXMLRequest(ROOT(E.TEST()))
|
||||
|
||||
assert (
|
||||
responses.calls[0].request.params["/cgi-bin/vertx_xml.cgi?XML"]
|
||||
== '<?xml version="1.0" encoding="UTF-8"?><VertXMessage xmlns:hid="http://www.hidglobal.com/VertX"><hid:TEST/></VertXMessage>'
|
||||
)
|
||||
|
||||
assert_elements_equal(ret, ROOT(E_corp.Banana()))
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_doXMLRequest_HTTPError(door_controller: DoorController) -> None:
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://127.0.0.1/cgi-bin/vertx_xml.cgi",
|
||||
body="whatever",
|
||||
status=403,
|
||||
)
|
||||
|
||||
with pytest.raises(RemoteError) as excinfo:
|
||||
door_controller.doXMLRequest(ROOT(E.TEST()))
|
||||
|
||||
assert excinfo.value.args[0] == "Door Updating Error: 403 Forbidden\nwhatever"
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_doXMLRequest_XMLerror(door_controller: DoorController) -> None:
|
||||
body = '<?xml version="1.0" encoding="UTF-8"?><VertXMessage xmlns:hid="http://www.hidcorp.com/VertX"><hid:Error action="RS" elementType="hid:TEST" errorCode="72" errorReporter="vertx" errorMessage="Unrecognized element"/></VertXMessage>'
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://127.0.0.1/cgi-bin/vertx_xml.cgi",
|
||||
body=body,
|
||||
status=200,
|
||||
)
|
||||
|
||||
with pytest.raises(RemoteError) as excinfo:
|
||||
door_controller.doXMLRequest(ROOT(E.TEST()))
|
||||
|
||||
assert excinfo.value.args[0] == "Door Updating Error: 200 OK\n" + body
|
||||
|
||||
|
||||
# def doImport(self, params=None, files=None):
|
||||
# def doCSVImport(self, csv):
|
||||
|
||||
|
||||
def test_get_scheduleMap(door_controller: DoorController) -> None:
|
||||
with patch.object(door_controller, "doXMLRequest") as mockXMLRequest:
|
||||
mockXMLRequest.return_value = E_corp.VertXMessage(
|
||||
E_corp.Schedules(
|
||||
{"action": "RL"},
|
||||
E_corp.Schedule({"scheduleID": "1", "scheduleName": "Test1"}),
|
||||
E_corp.Schedule({"scheduleID": "2", "scheduleName": "Test2"}),
|
||||
E_corp.Schedule({"scheduleID": "3", "scheduleName": "Test3"}),
|
||||
)
|
||||
)
|
||||
|
||||
ret = door_controller.get_scheduleMap()
|
||||
assert ret == {"Test1": "1", "Test2": "2", "Test3": "3"}
|
||||
|
||||
|
||||
# TODO: these two methods might want to be reworked: they are a bit clunky
|
||||
# def get_schedules(self):
|
||||
# def set_schedules(self, schedules):
|
||||
|
||||
|
||||
def test_set_cardholder_schedules(door_controller: DoorController) -> None:
|
||||
with patch.object(door_controller, "doXMLRequest") as mockXMLRequest:
|
||||
door_controller._scheduleMap = {"Test1": "1", "Test2": "2", "Test3": "3"}
|
||||
# TODO: should replace with a captured output
|
||||
mockXMLRequest.return_value = ROOT()
|
||||
|
||||
ret = door_controller.set_cardholder_schedules("123", ["Test1", "Test3"])
|
||||
|
||||
assert_elements_equal(
|
||||
door_controller.doXMLRequest.call_args[0][0],
|
||||
ROOT(
|
||||
E.RoleSet(
|
||||
{"action": "UD", "roleSetID": "123"},
|
||||
E.Roles(
|
||||
E.Role({"roleID": "123", "scheduleID": "1", "resourceID": "0"}),
|
||||
E.Role({"roleID": "123", "scheduleID": "3", "resourceID": "0"}),
|
||||
),
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
assert_elements_equal(ret, ROOT())
|
||||
|
||||
|
||||
def test_get_cardFormats(door_controller):
|
||||
with patch.object(door_controller, "doXMLRequest") as mockXMLRequest:
|
||||
mockXMLRequest.return_value = E_corp.VertXMessage(
|
||||
E_corp.CardFormats(
|
||||
{"action": "RL"},
|
||||
E_corp.CardFormat(
|
||||
{
|
||||
"formatID": "1",
|
||||
"formatName": "H10301 26-Bit",
|
||||
"isTemplate": "true",
|
||||
"templateID": "1",
|
||||
}
|
||||
),
|
||||
# irrelevant templates omitted
|
||||
E_corp.CardFormat(
|
||||
{
|
||||
"formatID": "6",
|
||||
"formatName": "A901146A-123",
|
||||
"isTemplate": "false",
|
||||
"templateID": "1",
|
||||
},
|
||||
E_corp.FixedField({"value": "123"}),
|
||||
),
|
||||
E_corp.CardFormat(
|
||||
{
|
||||
"formatID": "7",
|
||||
"formatName": "A901146A-456",
|
||||
"isTemplate": "false",
|
||||
"templateID": "1",
|
||||
},
|
||||
E_corp.FixedField({"value": "456"}),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
ret = door_controller.get_cardFormats()
|
||||
|
||||
assert ret == {"123": "6", "456": "7"}
|
||||
|
||||
|
||||
def test_set_cardFormat(door_controller):
|
||||
with patch.object(door_controller, "doXMLRequest") as mockXMLRequest:
|
||||
# TODO: should replace with a captured output
|
||||
mockXMLRequest.return_value = ROOT()
|
||||
|
||||
ret = door_controller.set_cardFormat("testname", 3, 123)
|
||||
|
||||
assert_elements_equal(
|
||||
door_controller.doXMLRequest.call_args[0][0],
|
||||
ROOT(
|
||||
E.CardFormats(
|
||||
{"action": "AD"},
|
||||
E.CardFormat(
|
||||
{"formatName": "testname", "templateID": "3"},
|
||||
E.FixedField({"value": "123"}),
|
||||
),
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
assert_elements_equal(ret, ROOT())
|
||||
|
||||
|
||||
def test_get_records_no_morerecords(door_controller):
|
||||
"""Test for when all the records fit in one 'page'"""
|
||||
with patch.object(door_controller, "doXMLRequest") as mockXMLRequest:
|
||||
mockXMLRequest.return_value = E_corp.VertXMessage(
|
||||
E_corp.TestElements(
|
||||
{
|
||||
"action": "RL",
|
||||
"recordOffset": "0",
|
||||
"recordCount": "2",
|
||||
"moreRecords": "false",
|
||||
},
|
||||
E_corp.TestElement({"asdf": "a"}),
|
||||
E_corp.TestElement({"qwer": "b"}),
|
||||
)
|
||||
)
|
||||
|
||||
ret = door_controller.get_records(E.TestElements, 12, {"blah": "test"})
|
||||
|
||||
assert_elements_equal(
|
||||
door_controller.doXMLRequest.call_args[0][0],
|
||||
ROOT(
|
||||
E.TestElements(
|
||||
{
|
||||
"action": "LR",
|
||||
# TODO: should really be 12, but isn't for bug workaround
|
||||
"recordCount": "13",
|
||||
"recordOffset": "0",
|
||||
"blah": "test",
|
||||
},
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
assert_elements_equal(ret[0], E_corp.TestElement({"asdf": "a"}))
|
||||
assert_elements_equal(ret[1], E_corp.TestElement({"qwer": "b"}))
|
||||
|
||||
|
||||
# def test_get_records_morerecords(door_controller):
|
||||
# """Test for when all the records span multiple 'pages'"""
|
||||
# pass
|
||||
|
||||
# def test_get_records_morerecords_bad_last_record(door_controller):
|
||||
# """Test for bug in which last record of each 'page' is missing data"""
|
||||
# pass
|
||||
|
||||
# def test_get_records_stopFunction(door_controller):
|
||||
# pass
|
||||
|
||||
|
||||
# def test_get_cardholders(door_controller):
|
||||
|
||||
# door_controller = DoorController(
|
||||
# "172.18.51.11",
|
||||
# "admin",
|
||||
# "PVic6ydFS/",
|
||||
# name="Test",
|
||||
# access="Test",
|
||||
# cert="../../hidglobal.com.pem",
|
||||
# )
|
||||
|
||||
# with patch.object(door_controller, "doXMLRequest") as mockXMLRequest:
|
||||
# mockXMLRequest.return_value = E_corp.VertXMessage(
|
||||
# E_corp.Cardholders(
|
||||
# {"action": "RL"},
|
||||
# E_corp.Cardholder({"scheduleID": "1", "scheduleName": "Test1"}),
|
||||
# E_corp.Cardholder({"scheduleID": "2", "scheduleName": "Test2"}),
|
||||
# E_corp.Cardholder({"scheduleID": "3", "scheduleName": "Test3"}),
|
||||
# )
|
||||
# )
|
||||
|
||||
# for x in [0]:
|
||||
# ret = door_controller.get_cardholders()
|
||||
# assert ret == {"Test1": "1", "Test2": "2", "Test3": "3"}
|
||||
|
||||
|
||||
# def add_cardholder(self, attribs):
|
||||
# def update_cardholder(self, cardholderID, attribs):
|
||||
# def get_credentials(self):
|
||||
# def add_credentials(self, credentials, cardholderID=None):
|
||||
# def assign_credential(self, credential, cardholderID=None):
|
||||
# def get_events(self, threshold):
|
||||
|
||||
# def get_lock(self):
|
||||
# def set_lock(self, lock=True):
|
@ -242,11 +242,11 @@ class HIDEvent(models.Model):
|
||||
field.column: field.attname for field in HIDEvent._meta.get_fields()
|
||||
}
|
||||
|
||||
def attr_to_bool(str):
|
||||
if str is None:
|
||||
def attr_to_bool(attr):
|
||||
if attr is None:
|
||||
return None
|
||||
else:
|
||||
return str == "true"
|
||||
return attr == "true"
|
||||
|
||||
return cls(
|
||||
**{
|
||||
|
@ -108,7 +108,7 @@ class DoorMember:
|
||||
},
|
||||
cardholderID=data.attrib["cardholderID"],
|
||||
credentials={
|
||||
Credential(hex=(c.attrib["rawCardNumber"]))
|
||||
Credential(hex_code=(c.attrib["rawCardNumber"]))
|
||||
for c in data.findall("{*}Credential")
|
||||
},
|
||||
schedules={r.attrib["scheduleName"] for r in data.findall("{*}Role")},
|
||||
@ -136,7 +136,7 @@ class DoorMember:
|
||||
self,
|
||||
existing_door_credentials: set[Credential],
|
||||
all_members: list["DoorMember"],
|
||||
old_credentials: set[Credential] = set(),
|
||||
old_credentials: set[Credential],
|
||||
):
|
||||
# cardholderID should be set on a member before this is called
|
||||
assert self.cardholderID is not None
|
||||
@ -222,7 +222,7 @@ def update_door(door: Door, dry_run: bool = False):
|
||||
}
|
||||
|
||||
existing_door_credentials = {
|
||||
Credential(hex=c.attrib["rawCardNumber"])
|
||||
Credential(hex_code=c.attrib["rawCardNumber"])
|
||||
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_credentials(existing_door_credentials, members)
|
||||
member.update_credentials(existing_door_credentials, members, set())
|
||||
member.update_schedules()
|
||||
|
||||
# cardholder exists, compare contents
|
||||
|
@ -10,7 +10,7 @@
|
||||
{% for report_name, report_url in report_types %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link{% if report_name == selected_report %} active{% endif %}"
|
||||
href="{{ report_url }}?{{ query_params }}">{{ report_name }}</a>
|
||||
href="{{ report_url }}{% querystring page=None %}">{{ report_name }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
@ -1,8 +1,8 @@
|
||||
import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from django.contrib.auth.mixins import PermissionRequiredMixin
|
||||
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.functions import Lead, Trunc
|
||||
from django.urls import path, reverse_lazy
|
||||
@ -27,6 +27,9 @@ from .tables import (
|
||||
UnitTimeTable,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django.core.paginator import Page
|
||||
|
||||
REPORTS = []
|
||||
|
||||
|
||||
@ -95,11 +98,6 @@ class BaseAccessReport(
|
||||
context["selected_report"] = self._selected_report()
|
||||
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
|
||||
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
from django.contrib import admin
|
||||
from django.contrib.humanize.templatetags.humanize import naturaltime
|
||||
from django.http import HttpRequest
|
||||
from django.utils.html import format_html
|
||||
|
||||
from django_object_actions import (
|
||||
@ -95,6 +96,7 @@ class FlagAdmin(BaseMembershipWorksAdmin):
|
||||
@admin.register(Transaction)
|
||||
class TransactionAdmin(BaseMembershipWorksAdmin):
|
||||
list_display = ["timestamp", "member", "name", "type", "sum", "note"]
|
||||
list_select_related = ["member"]
|
||||
list_filter = ["type"]
|
||||
show_facets = admin.ShowFacets.ALWAYS
|
||||
search_fields = ["member", "name", "type", "note"]
|
||||
@ -103,16 +105,18 @@ class TransactionAdmin(BaseMembershipWorksAdmin):
|
||||
|
||||
class EventMeetingTimeInline(admin.TabularInline):
|
||||
model = EventMeetingTime
|
||||
fields = ["start", "end", "duration", "resources"]
|
||||
readonly_fields = ["duration"]
|
||||
autocomplete_fields = ["resources"]
|
||||
extra = 0
|
||||
min_num = 1
|
||||
|
||||
readonly_fields = ["duration"]
|
||||
|
||||
|
||||
@admin.register(EventInstructor)
|
||||
class EventInstructorAdmin(admin.ModelAdmin):
|
||||
autocomplete_fields = ["member"]
|
||||
search_fields = ["name", "member__account_name"]
|
||||
list_select_related = ["member"]
|
||||
|
||||
|
||||
@admin.register(EventInvoice)
|
||||
@ -121,13 +125,14 @@ class EventInvoiceAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"uuid",
|
||||
"event",
|
||||
"_event_start",
|
||||
"_event_end",
|
||||
"_event_instructor",
|
||||
"event__start",
|
||||
"event__end",
|
||||
"event__instructor",
|
||||
"date_submitted",
|
||||
"date_paid",
|
||||
"amount",
|
||||
]
|
||||
list_select_related = ["event__instructor__member"]
|
||||
list_filter = [
|
||||
("date_paid", admin.EmptyFieldListFilter),
|
||||
]
|
||||
@ -142,18 +147,6 @@ class EventInvoiceAdmin(admin.ModelAdmin):
|
||||
]
|
||||
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):
|
||||
model = EventInvoice
|
||||
@ -184,8 +177,7 @@ class EventAdmin(DjangoObjectActions, admin.ModelAdmin):
|
||||
change_actions = ["fetch_details"]
|
||||
actions = ["fetch_details"]
|
||||
|
||||
@property
|
||||
def readonly_fields(self):
|
||||
def get_readonly_fields(self, request: HttpRequest, obj: EventExt) -> list[str]:
|
||||
fields = []
|
||||
for field in Event._meta.get_fields():
|
||||
if field.auto_created or field.many_to_many or not field.concrete:
|
||||
@ -222,3 +214,9 @@ class EventAdmin(DjangoObjectActions, admin.ModelAdmin):
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
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):
|
||||
# TODO: should probably be a decorator or something
|
||||
if self.auth_token is None:
|
||||
raise NotAuthenticatedError()
|
||||
raise NotAuthenticatedError
|
||||
# add auth token to params
|
||||
if "params" not in kwargs:
|
||||
kwargs["params"] = {}
|
||||
@ -135,7 +135,7 @@ class MembershipWorks:
|
||||
in all.js.
|
||||
"""
|
||||
if not self.org_info:
|
||||
raise NotAuthenticatedError()
|
||||
raise NotAuthenticatedError
|
||||
fields = staticFlags.copy()
|
||||
|
||||
# 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.
|
||||
"""
|
||||
if not self.org_info:
|
||||
raise NotAuthenticatedError()
|
||||
raise NotAuthenticatedError
|
||||
ret: dict[str, Any] = {"folders": {}, "levels": {}, "addons": {}, "labels": {}}
|
||||
|
||||
for dek in self.org_info["dek"]:
|
||||
|
@ -0,0 +1,82 @@
|
||||
# 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,6 +1,5 @@
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import TYPE_CHECKING, TypedDict
|
||||
|
||||
import django.core.mail.message
|
||||
@ -30,6 +29,8 @@ import nh3
|
||||
from django_db_views.db_view import DBView
|
||||
from django_stubs_ext import WithAnnotations
|
||||
|
||||
from reservations.models import Reservation
|
||||
|
||||
|
||||
class BaseModel(models.Model):
|
||||
_api_names_override: dict[str, str] = {}
|
||||
@ -379,8 +380,8 @@ class EventCategory(models.Model):
|
||||
return self.title
|
||||
|
||||
@classmethod
|
||||
def from_api_dict(cls, id: int, data):
|
||||
return cls(id=id, title=data["ttl"])
|
||||
def from_api_dict(cls, id_: int, data):
|
||||
return cls(id=id_, title=data["ttl"])
|
||||
|
||||
|
||||
class Event(BaseModel):
|
||||
@ -589,11 +590,12 @@ class EventExt(Event):
|
||||
self.materials_fee_included_in_price is not None
|
||||
or self.materials_fee == 0
|
||||
)
|
||||
and getattr(self, "total_due_to_instructor") is not None
|
||||
and self.total_due_to_instructor is not None
|
||||
)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from decimal import Decimal
|
||||
|
||||
class EventExtAnnotations(TypedDict):
|
||||
meetings: int
|
||||
@ -619,29 +621,30 @@ else:
|
||||
EventExtAnnotatedWithFinancials = WithAnnotations[EventExt]
|
||||
|
||||
|
||||
class EventMeetingTime(models.Model):
|
||||
class EventMeetingTime(Reservation):
|
||||
event = models.ForeignKey(
|
||||
EventExt, on_delete=models.CASCADE, related_name="meeting_times"
|
||||
)
|
||||
start = models.DateTimeField()
|
||||
end = models.DateTimeField()
|
||||
|
||||
duration = models.GeneratedField(
|
||||
expression=F("end") - F("start"),
|
||||
output_field=models.DurationField(),
|
||||
db_persist=False,
|
||||
def get_title(self) -> str:
|
||||
return self.event.unescaped_title
|
||||
|
||||
# TODO: should probably do some validation in python to enforce
|
||||
# - uniqueness and non-overlapping (per event)
|
||||
# - min/max start/end time == event start end
|
||||
|
||||
def make_google_calendar_event(self):
|
||||
status = (
|
||||
"confirmed"
|
||||
if self.event.cap > 0 and self.event.calendar != Event.EventCalendar.HIDDEN
|
||||
else "cancelled"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["event", "start", "end"], name="unique_event_start_end"
|
||||
),
|
||||
models.CheckConstraint(check=Q(end__gt=F("start")), name="end_after_start"),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.start} - {self.end}"
|
||||
return super().make_google_calendar_event() | {
|
||||
# TODO: add event description and links
|
||||
"summary": self.event.unescaped_title,
|
||||
"status": status,
|
||||
}
|
||||
|
||||
|
||||
class EventInvoice(models.Model):
|
||||
@ -705,7 +708,14 @@ class EventTicketTypeManager(models.Manager["EventTicketType"]):
|
||||
# non-member ticket
|
||||
Q(restrict_to__isnull=True)
|
||||
& (
|
||||
Q(event__start__lt=datetime(year=2024, month=7, day=1))
|
||||
Q(
|
||||
event__start__lt=datetime(
|
||||
year=2024,
|
||||
month=7,
|
||||
day=1,
|
||||
tzinfo=timezone.get_default_timezone(),
|
||||
)
|
||||
)
|
||||
| Q(members_price=0)
|
||||
)
|
||||
),
|
||||
@ -752,7 +762,7 @@ class EventTicketType(DBView):
|
||||
objects = EventTicketTypeManager.from_queryset(EventTicketTypeQuerySet)()
|
||||
|
||||
event = models.ForeignKey(
|
||||
EventExt, on_delete=models.CASCADE, related_name="ticket_types"
|
||||
EventExt, on_delete=models.DO_NOTHING, related_name="ticket_types"
|
||||
)
|
||||
label = models.TextField()
|
||||
restrict_to = models.TextField(null=True, blank=True)
|
||||
@ -797,7 +807,7 @@ class EventTicketType(DBView):
|
||||
|
||||
class EventAttendeeStats(DBView):
|
||||
event = models.ForeignKey(
|
||||
EventExt, on_delete=models.CASCADE, related_name="attendee_stats"
|
||||
EventExt, on_delete=models.DO_NOTHING, related_name="attendee_stats"
|
||||
)
|
||||
gross_revenue = models.FloatField()
|
||||
|
||||
@ -817,7 +827,7 @@ class EventAttendeeStats(DBView):
|
||||
|
||||
class EventAttendee(DBView):
|
||||
event = models.ForeignKey(
|
||||
EventExt, on_delete=models.CASCADE, related_name="attendees"
|
||||
EventExt, on_delete=models.DO_NOTHING, related_name="attendees"
|
||||
)
|
||||
uid = models.ForeignKey(Member, on_delete=models.DO_NOTHING)
|
||||
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]:
|
||||
for typ, flags_of_type in mw_flags.items():
|
||||
for name, id in flags_of_type.items():
|
||||
flag = Flag(id=id, name=name, type=typ[:-1])
|
||||
for name, flag_id in flags_of_type.items():
|
||||
flag = Flag(id=flag_id, name=name, type=typ[:-1])
|
||||
flag.save()
|
||||
yield flag
|
||||
|
||||
@ -81,7 +81,9 @@ def scrape_transactions(membershipworks: MembershipWorks):
|
||||
transactions_csv = membershipworks.get_transactions(start_date, now)
|
||||
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
|
||||
transactions = [{**j, **v} for j, v in zip(transactions_csv, transactions_json)]
|
||||
transactions = [
|
||||
{**j, **v} for j, v in zip(transactions_csv, transactions_json, strict=True)
|
||||
]
|
||||
assert all(
|
||||
t["Account ID"] == t.get("uid", "") and t["Payment ID"] == t.get("sid", "")
|
||||
for t in transactions
|
||||
@ -176,3 +178,6 @@ def scrape_events():
|
||||
event_ext.details = membershipworks.get_event_by_eid(event.eid)
|
||||
event_ext.registrations = membershipworks.get_event_registrations(event.eid)
|
||||
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
|
||||
user.props.password = "".join(
|
||||
random.choice(string.ascii_letters + string.digits)
|
||||
for x in range(0, RAND_PW_LEN)
|
||||
for x in range(RAND_PW_LEN)
|
||||
)
|
||||
user.props.pwdChangeNextLogin = True
|
||||
|
||||
|
@ -1 +1,66 @@
|
||||
# Create your tests here.
|
||||
from datetime import datetime
|
||||
|
||||
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",
|
||||
"sass": "^1.77.8",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.3.4"
|
||||
"vite": "^5.4.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@popperjs/core": "^2.11.8",
|
||||
|
@ -57,6 +57,7 @@ class DepartmentAdmin(admin.ModelAdmin):
|
||||
"shop_lead_flag",
|
||||
"list_reply_to_address",
|
||||
]
|
||||
list_select_related = ["shop_lead_flag", "parent"]
|
||||
|
||||
|
||||
class CertificationVersionInline(admin.TabularInline):
|
||||
|
@ -39,7 +39,7 @@ class DepartmentViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = DepartmentSerializer
|
||||
|
||||
@action(detail=False, methods=["get"])
|
||||
def mailing_lists(self, request, format=None):
|
||||
def mailing_lists(self, request, format=None): # noqa: A002
|
||||
"""
|
||||
Generate a mailing list for each department, containing all
|
||||
certified users for tools in that department or child departments
|
||||
|
@ -1,4 +1,4 @@
|
||||
from collections.abc import Iterable
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.decorators import login_required
|
||||
@ -44,6 +44,9 @@ from .tables import (
|
||||
WaiverReportTable,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable
|
||||
|
||||
WIKI_URL = settings.WIKI_URL
|
||||
|
||||
|
||||
|
165
pnpm-lock.yaml
165
pnpm-lock.yaml
@ -40,8 +40,8 @@ importers:
|
||||
specifier: ^5.5.4
|
||||
version: 5.5.4
|
||||
vite:
|
||||
specifier: ^5.3.4
|
||||
version: 5.3.4(sass@1.77.8)
|
||||
specifier: ^5.4.1
|
||||
version: 5.4.1(sass@1.77.8)
|
||||
|
||||
packages:
|
||||
|
||||
@ -198,83 +198,83 @@ packages:
|
||||
'@popperjs/core@2.11.8':
|
||||
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.19.0':
|
||||
resolution: {integrity: sha512-JlPfZ/C7yn5S5p0yKk7uhHTTnFlvTgLetl2VxqE518QgyM7C9bSfFTYvB/Q/ftkq0RIPY4ySxTz+/wKJ/dXC0w==}
|
||||
'@rollup/rollup-android-arm-eabi@4.20.0':
|
||||
resolution: {integrity: sha512-TSpWzflCc4VGAUJZlPpgAJE1+V60MePDQnBd7PPkpuEmOy8i87aL6tinFGKBFKuEDikYpig72QzdT3QPYIi+oA==}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@rollup/rollup-android-arm64@4.19.0':
|
||||
resolution: {integrity: sha512-RDxUSY8D1tWYfn00DDi5myxKgOk6RvWPxhmWexcICt/MEC6yEMr4HNCu1sXXYLw8iAsg0D44NuU+qNq7zVWCrw==}
|
||||
'@rollup/rollup-android-arm64@4.20.0':
|
||||
resolution: {integrity: sha512-u00Ro/nok7oGzVuh/FMYfNoGqxU5CPWz1mxV85S2w9LxHR8OoMQBuSk+3BKVIDYgkpeOET5yXkx90OYFc+ytpQ==}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@rollup/rollup-darwin-arm64@4.19.0':
|
||||
resolution: {integrity: sha512-emvKHL4B15x6nlNTBMtIaC9tLPRpeA5jMvRLXVbl/W9Ie7HhkrE7KQjvgS9uxgatL1HmHWDXk5TTS4IaNJxbAA==}
|
||||
'@rollup/rollup-darwin-arm64@4.20.0':
|
||||
resolution: {integrity: sha512-uFVfvzvsdGtlSLuL0ZlvPJvl6ZmrH4CBwLGEFPe7hUmf7htGAN+aXo43R/V6LATyxlKVC/m6UsLb7jbG+LG39Q==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@rollup/rollup-darwin-x64@4.19.0':
|
||||
resolution: {integrity: sha512-fO28cWA1dC57qCd+D0rfLC4VPbh6EOJXrreBmFLWPGI9dpMlER2YwSPZzSGfq11XgcEpPukPTfEVFtw2q2nYJg==}
|
||||
'@rollup/rollup-darwin-x64@4.20.0':
|
||||
resolution: {integrity: sha512-xbrMDdlev53vNXexEa6l0LffojxhqDTBeL+VUxuuIXys4x6xyvbKq5XqTXBCEUA8ty8iEJblHvFaWRJTk/icAQ==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.19.0':
|
||||
resolution: {integrity: sha512-2Rn36Ubxdv32NUcfm0wB1tgKqkQuft00PtM23VqLuCUR4N5jcNWDoV5iBC9jeGdgS38WK66ElncprqgMUOyomw==}
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.20.0':
|
||||
resolution: {integrity: sha512-jMYvxZwGmoHFBTbr12Xc6wOdc2xA5tF5F2q6t7Rcfab68TT0n+r7dgawD4qhPEvasDsVpQi+MgDzj2faOLsZjA==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.19.0':
|
||||
resolution: {integrity: sha512-gJuzIVdq/X1ZA2bHeCGCISe0VWqCoNT8BvkQ+BfsixXwTOndhtLUpOg0A1Fcx/+eA6ei6rMBzlOz4JzmiDw7JQ==}
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.20.0':
|
||||
resolution: {integrity: sha512-1asSTl4HKuIHIB1GcdFHNNZhxAYEdqML/MW4QmPS4G0ivbEcBr1JKlFLKsIRqjSwOBkdItn3/ZDlyvZ/N6KPlw==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.19.0':
|
||||
resolution: {integrity: sha512-0EkX2HYPkSADo9cfeGFoQ7R0/wTKb7q6DdwI4Yn/ULFE1wuRRCHybxpl2goQrx4c/yzK3I8OlgtBu4xvted0ug==}
|
||||
'@rollup/rollup-linux-arm64-gnu@4.20.0':
|
||||
resolution: {integrity: sha512-COBb8Bkx56KldOYJfMf6wKeYJrtJ9vEgBRAOkfw6Ens0tnmzPqvlpjZiLgkhg6cA3DGzCmLmmd319pmHvKWWlQ==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.19.0':
|
||||
resolution: {integrity: sha512-GlIQRj9px52ISomIOEUq/IojLZqzkvRpdP3cLgIE1wUWaiU5Takwlzpz002q0Nxxr1y2ZgxC2obWxjr13lvxNQ==}
|
||||
'@rollup/rollup-linux-arm64-musl@4.20.0':
|
||||
resolution: {integrity: sha512-+it+mBSyMslVQa8wSPvBx53fYuZK/oLTu5RJoXogjk6x7Q7sz1GNRsXWjn6SwyJm8E/oMjNVwPhmNdIjwP135Q==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-powerpc64le-gnu@4.19.0':
|
||||
resolution: {integrity: sha512-N6cFJzssruDLUOKfEKeovCKiHcdwVYOT1Hs6dovDQ61+Y9n3Ek4zXvtghPPelt6U0AH4aDGnDLb83uiJMkWYzQ==}
|
||||
'@rollup/rollup-linux-powerpc64le-gnu@4.20.0':
|
||||
resolution: {integrity: sha512-yAMvqhPfGKsAxHN8I4+jE0CpLWD8cv4z7CK7BMmhjDuz606Q2tFKkWRY8bHR9JQXYcoLfopo5TTqzxgPUjUMfw==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.19.0':
|
||||
resolution: {integrity: sha512-2DnD3mkS2uuam/alF+I7M84koGwvn3ZVD7uG+LEWpyzo/bq8+kKnus2EVCkcvh6PlNB8QPNFOz6fWd5N8o1CYg==}
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.20.0':
|
||||
resolution: {integrity: sha512-qmuxFpfmi/2SUkAw95TtNq/w/I7Gpjurx609OOOV7U4vhvUhBcftcmXwl3rqAek+ADBwSjIC4IVNLiszoj3dPA==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.19.0':
|
||||
resolution: {integrity: sha512-D6pkaF7OpE7lzlTOFCB2m3Ngzu2ykw40Nka9WmKGUOTS3xcIieHe82slQlNq69sVB04ch73thKYIWz/Ian8DUA==}
|
||||
'@rollup/rollup-linux-s390x-gnu@4.20.0':
|
||||
resolution: {integrity: sha512-I0BtGXddHSHjV1mqTNkgUZLnS3WtsqebAXv11D5BZE/gfw5KoyXSAXVqyJximQXNvNzUo4GKlCK/dIwXlz+jlg==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.19.0':
|
||||
resolution: {integrity: sha512-HBndjQLP8OsdJNSxpNIN0einbDmRFg9+UQeZV1eiYupIRuZsDEoeGU43NQsS34Pp166DtwQOnpcbV/zQxM+rWA==}
|
||||
'@rollup/rollup-linux-x64-gnu@4.20.0':
|
||||
resolution: {integrity: sha512-y+eoL2I3iphUg9tN9GB6ku1FA8kOfmF4oUEWhztDJ4KXJy1agk/9+pejOuZkNFhRwHAOxMsBPLbXPd6mJiCwew==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.19.0':
|
||||
resolution: {integrity: sha512-HxfbvfCKJe/RMYJJn0a12eiOI9OOtAUF4G6ozrFUK95BNyoJaSiBjIOHjZskTUffUrB84IPKkFG9H9nEvJGW6A==}
|
||||
'@rollup/rollup-linux-x64-musl@4.20.0':
|
||||
resolution: {integrity: sha512-hM3nhW40kBNYUkZb/r9k2FKK+/MnKglX7UYd4ZUy5DJs8/sMsIbqWK2piZtVGE3kcXVNj3B2IrUYROJMMCikNg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-win32-arm64-msvc@4.19.0':
|
||||
resolution: {integrity: sha512-HxDMKIhmcguGTiP5TsLNolwBUK3nGGUEoV/BO9ldUBoMLBssvh4J0X8pf11i1fTV7WShWItB1bKAKjX4RQeYmg==}
|
||||
'@rollup/rollup-win32-arm64-msvc@4.20.0':
|
||||
resolution: {integrity: sha512-psegMvP+Ik/Bg7QRJbv8w8PAytPA7Uo8fpFjXyCRHWm6Nt42L+JtoqH8eDQ5hRP7/XW2UiIriy1Z46jf0Oa1kA==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@rollup/rollup-win32-ia32-msvc@4.19.0':
|
||||
resolution: {integrity: sha512-xItlIAZZaiG/u0wooGzRsx11rokP4qyc/79LkAOdznGRAbOFc+SfEdfUOszG1odsHNgwippUJavag/+W/Etc6Q==}
|
||||
'@rollup/rollup-win32-ia32-msvc@4.20.0':
|
||||
resolution: {integrity: sha512-GabekH3w4lgAJpVxkk7hUzUf2hICSQO0a/BLFA11/RMxQT92MabKAqyubzDZmMOC/hcJNlc+rrypzNzYl4Dx7A==}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@rollup/rollup-win32-x64-msvc@4.19.0':
|
||||
resolution: {integrity: sha512-xNo5fV5ycvCCKqiZcpB65VMR11NJB+StnxHz20jdqRAktfdfzhgjTiJ2doTDQE/7dqGaV5I7ZGqKpgph6lCIag==}
|
||||
'@rollup/rollup-win32-x64-msvc@4.20.0':
|
||||
resolution: {integrity: sha512-aJ1EJSuTdGnM6qbVC4B5DSmozPTqIag9fSzXRNNo+humQLG89XpPgdt16Ia56ORD7s+H8Pmyx44uczDQ0yDzpg==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
@ -344,8 +344,8 @@ packages:
|
||||
resolution: {integrity: sha512-s3Fq41ZVh7vbbe2PN3nrW7yC7U7MFVc5c98/iTl9c2GawNMKx/J648KQRW6WKkuU8GIbbh2IXfIRQjOZnXcTnw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
ignore@5.3.1:
|
||||
resolution: {integrity: sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==}
|
||||
ignore@5.3.2:
|
||||
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
immutable@4.3.7:
|
||||
@ -395,8 +395,8 @@ packages:
|
||||
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
|
||||
engines: {node: '>=8.6'}
|
||||
|
||||
postcss@8.4.39:
|
||||
resolution: {integrity: sha512-0vzE+lAiG7hZl1/9I8yzKLx3aR9Xbof3fBHKunvMfOCYAtMhrsnccJY2iTURb9EZd5+pLuiNV9/c/GZJOHsgIw==}
|
||||
postcss@8.4.41:
|
||||
resolution: {integrity: sha512-TesUflQ0WKZqAvg52PWL6kHgLKP6xB6heTOdoYM0Wt2UHyxNa4K25EZZMgKns3BH1RLVbZCREPpLY0rhnNoHVQ==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
prettier@3.3.3:
|
||||
@ -415,8 +415,8 @@ packages:
|
||||
resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==}
|
||||
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
|
||||
|
||||
rollup@4.19.0:
|
||||
resolution: {integrity: sha512-5r7EYSQIowHsK4eTZ0Y81qpZuJz+MUuYeqmmYmRMl1nwhdmbiYqt5jwzf6u7wyOzJgYqtCRMtVRKOtHANBz7rA==}
|
||||
rollup@4.20.0:
|
||||
resolution: {integrity: sha512-6rbWBChcnSGzIlXeIdNIZTopKYad8ZG8ajhl78lGRLsI2rX8IkaotQhVas2Ma+GPxJav19wrSzvRvuiv0YKzWw==}
|
||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||
hasBin: true
|
||||
|
||||
@ -452,8 +452,8 @@ packages:
|
||||
resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
vite@5.3.4:
|
||||
resolution: {integrity: sha512-Cw+7zL3ZG9/NZBB8C+8QbQZmR54GwqIz+WMI4b3JgdYJvX+ny9AjJXqkGQlDXSXRP9rP0B4tbciRMOVEKulVOA==}
|
||||
vite@5.4.1:
|
||||
resolution: {integrity: sha512-1oE6yuNXssjrZdblI9AfBbHCC41nnyoVoEZxQnID6yvQZAFBzxxkqoFLtHUMkYunL8hwOLEjgTuxpkRxvba3kA==}
|
||||
engines: {node: ^18.0.0 || >=20.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@ -461,6 +461,7 @@ packages:
|
||||
less: '*'
|
||||
lightningcss: ^1.21.0
|
||||
sass: '*'
|
||||
sass-embedded: '*'
|
||||
stylus: '*'
|
||||
sugarss: '*'
|
||||
terser: ^5.4.0
|
||||
@ -473,6 +474,8 @@ packages:
|
||||
optional: true
|
||||
sass:
|
||||
optional: true
|
||||
sass-embedded:
|
||||
optional: true
|
||||
stylus:
|
||||
optional: true
|
||||
sugarss:
|
||||
@ -565,52 +568,52 @@ snapshots:
|
||||
|
||||
'@popperjs/core@2.11.8': {}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.19.0':
|
||||
'@rollup/rollup-android-arm-eabi@4.20.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-android-arm64@4.19.0':
|
||||
'@rollup/rollup-android-arm64@4.20.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-darwin-arm64@4.19.0':
|
||||
'@rollup/rollup-darwin-arm64@4.20.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-darwin-x64@4.19.0':
|
||||
'@rollup/rollup-darwin-x64@4.20.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.19.0':
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.20.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.19.0':
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.20.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.19.0':
|
||||
'@rollup/rollup-linux-arm64-gnu@4.20.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.19.0':
|
||||
'@rollup/rollup-linux-arm64-musl@4.20.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-powerpc64le-gnu@4.19.0':
|
||||
'@rollup/rollup-linux-powerpc64le-gnu@4.20.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.19.0':
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.20.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.19.0':
|
||||
'@rollup/rollup-linux-s390x-gnu@4.20.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.19.0':
|
||||
'@rollup/rollup-linux-x64-gnu@4.20.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.19.0':
|
||||
'@rollup/rollup-linux-x64-musl@4.20.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-arm64-msvc@4.19.0':
|
||||
'@rollup/rollup-win32-arm64-msvc@4.20.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-ia32-msvc@4.19.0':
|
||||
'@rollup/rollup-win32-ia32-msvc@4.20.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-x64-msvc@4.19.0':
|
||||
'@rollup/rollup-win32-x64-msvc@4.20.0':
|
||||
optional: true
|
||||
|
||||
'@sindresorhus/merge-streams@2.3.0': {}
|
||||
@ -705,12 +708,12 @@ snapshots:
|
||||
dependencies:
|
||||
'@sindresorhus/merge-streams': 2.3.0
|
||||
fast-glob: 3.3.2
|
||||
ignore: 5.3.1
|
||||
ignore: 5.3.2
|
||||
path-type: 5.0.0
|
||||
slash: 5.1.0
|
||||
unicorn-magic: 0.1.0
|
||||
|
||||
ignore@5.3.1: {}
|
||||
ignore@5.3.2: {}
|
||||
|
||||
immutable@4.3.7: {}
|
||||
|
||||
@ -743,7 +746,7 @@ snapshots:
|
||||
|
||||
picomatch@2.3.1: {}
|
||||
|
||||
postcss@8.4.39:
|
||||
postcss@8.4.41:
|
||||
dependencies:
|
||||
nanoid: 3.3.7
|
||||
picocolors: 1.0.1
|
||||
@ -759,26 +762,26 @@ snapshots:
|
||||
|
||||
reusify@1.0.4: {}
|
||||
|
||||
rollup@4.19.0:
|
||||
rollup@4.20.0:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.5
|
||||
optionalDependencies:
|
||||
'@rollup/rollup-android-arm-eabi': 4.19.0
|
||||
'@rollup/rollup-android-arm64': 4.19.0
|
||||
'@rollup/rollup-darwin-arm64': 4.19.0
|
||||
'@rollup/rollup-darwin-x64': 4.19.0
|
||||
'@rollup/rollup-linux-arm-gnueabihf': 4.19.0
|
||||
'@rollup/rollup-linux-arm-musleabihf': 4.19.0
|
||||
'@rollup/rollup-linux-arm64-gnu': 4.19.0
|
||||
'@rollup/rollup-linux-arm64-musl': 4.19.0
|
||||
'@rollup/rollup-linux-powerpc64le-gnu': 4.19.0
|
||||
'@rollup/rollup-linux-riscv64-gnu': 4.19.0
|
||||
'@rollup/rollup-linux-s390x-gnu': 4.19.0
|
||||
'@rollup/rollup-linux-x64-gnu': 4.19.0
|
||||
'@rollup/rollup-linux-x64-musl': 4.19.0
|
||||
'@rollup/rollup-win32-arm64-msvc': 4.19.0
|
||||
'@rollup/rollup-win32-ia32-msvc': 4.19.0
|
||||
'@rollup/rollup-win32-x64-msvc': 4.19.0
|
||||
'@rollup/rollup-android-arm-eabi': 4.20.0
|
||||
'@rollup/rollup-android-arm64': 4.20.0
|
||||
'@rollup/rollup-darwin-arm64': 4.20.0
|
||||
'@rollup/rollup-darwin-x64': 4.20.0
|
||||
'@rollup/rollup-linux-arm-gnueabihf': 4.20.0
|
||||
'@rollup/rollup-linux-arm-musleabihf': 4.20.0
|
||||
'@rollup/rollup-linux-arm64-gnu': 4.20.0
|
||||
'@rollup/rollup-linux-arm64-musl': 4.20.0
|
||||
'@rollup/rollup-linux-powerpc64le-gnu': 4.20.0
|
||||
'@rollup/rollup-linux-riscv64-gnu': 4.20.0
|
||||
'@rollup/rollup-linux-s390x-gnu': 4.20.0
|
||||
'@rollup/rollup-linux-x64-gnu': 4.20.0
|
||||
'@rollup/rollup-linux-x64-musl': 4.20.0
|
||||
'@rollup/rollup-win32-arm64-msvc': 4.20.0
|
||||
'@rollup/rollup-win32-ia32-msvc': 4.20.0
|
||||
'@rollup/rollup-win32-x64-msvc': 4.20.0
|
||||
fsevents: 2.3.3
|
||||
|
||||
run-parallel@1.2.0:
|
||||
@ -805,11 +808,11 @@ snapshots:
|
||||
|
||||
unicorn-magic@0.1.0: {}
|
||||
|
||||
vite@5.3.4(sass@1.77.8):
|
||||
vite@5.4.1(sass@1.77.8):
|
||||
dependencies:
|
||||
esbuild: 0.21.5
|
||||
postcss: 8.4.39
|
||||
rollup: 4.19.0
|
||||
postcss: 8.4.41
|
||||
rollup: 4.20.0
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
sass: 1.77.8
|
||||
|
@ -6,8 +6,8 @@ authors = [
|
||||
{name = "Adam Goldsmith", email = "contact@adamgoldsmith.name"},
|
||||
]
|
||||
dependencies = [
|
||||
"django~=5.0",
|
||||
"django-admin-logs~=1.2",
|
||||
"django~=5.1",
|
||||
"django-admin-logs~=1.3",
|
||||
"django-auth-ldap~=4.8",
|
||||
"django-markdownx~=4.0",
|
||||
"django-recurrence~=1.11",
|
||||
@ -23,7 +23,7 @@ dependencies = [
|
||||
"semver~=3.0",
|
||||
"djangorestframework~=3.15",
|
||||
"django-q2~=1.6",
|
||||
"lxml~=5.2",
|
||||
"lxml~=5.3",
|
||||
"django-object-actions~=4.2",
|
||||
"bitstring~=4.2",
|
||||
"udm-rest-client~=1.2",
|
||||
@ -32,7 +32,7 @@ dependencies = [
|
||||
"nh3~=0.2",
|
||||
"django-tables2~=2.7",
|
||||
"tablib[ods,xlsx]~=3.6",
|
||||
"django-filter~=24.2",
|
||||
"django-filter~=24.3",
|
||||
"django-db-views~=0.1",
|
||||
"django-mysql~=4.14",
|
||||
"django-weasyprint~=2.3",
|
||||
@ -40,13 +40,17 @@ dependencies = [
|
||||
"django-bootstrap5~=24.2",
|
||||
"django-configurations[database,email]~=2.5",
|
||||
"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"
|
||||
|
||||
[project.optional-dependencies]
|
||||
server = [
|
||||
"uvicorn[standard]~=0.30",
|
||||
"setuptools~=71.1",
|
||||
"setuptools~=72.2",
|
||||
]
|
||||
|
||||
[project.entry-points."djangoq.errorreporters"]
|
||||
@ -56,7 +60,32 @@ admin_email = "cmsmanage.django_q2_admin_email_reporter:AdminEmailReporter"
|
||||
line-length = 88
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E4", "E7", "E9", "F", "I", "C4", "UP", "PERF", "PL", "SIM", "FIX003", "DJ012"]
|
||||
select = [
|
||||
"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]
|
||||
known-first-party = [
|
||||
@ -66,6 +95,7 @@ known-first-party = [
|
||||
"membershipworks",
|
||||
"paperwork",
|
||||
"rentals",
|
||||
"reservations",
|
||||
"tasks",
|
||||
]
|
||||
section-order = ["future", "standard-library", "django", "third-party", "first-party", "local-folder"]
|
||||
@ -80,6 +110,7 @@ indent = 2
|
||||
blank_line_after_tag = "load,extends"
|
||||
max_blank_lines = 1
|
||||
ignore = "T003,H017,H021,H030,H031"
|
||||
custom_blocks = "partialdef"
|
||||
format_css = true
|
||||
format_js = true
|
||||
|
||||
@ -114,12 +145,12 @@ include_packages = ["openapi-client-udm"]
|
||||
[tool.pdm.dev-dependencies]
|
||||
lint = [
|
||||
"djlint~=1.34",
|
||||
"ruff~=0.5",
|
||||
"ruff~=0.6",
|
||||
]
|
||||
typing = [
|
||||
"mypy~=1.10",
|
||||
"django-stubs~=5.0",
|
||||
"setuptools~=71.1",
|
||||
"setuptools~=72.2",
|
||||
"types-bleach~=6.1",
|
||||
"types-requests~=2.32",
|
||||
"types-urllib3~=1.26",
|
||||
@ -127,7 +158,7 @@ typing = [
|
||||
"types-Markdown~=3.6",
|
||||
"types-Pygments~=2.18",
|
||||
"types-psycopg2~=2.9",
|
||||
"types-lxml~=2024.4",
|
||||
"types-lxml~=2024.8",
|
||||
]
|
||||
debug = [
|
||||
"django-debug-toolbar~=4.4",
|
||||
@ -135,8 +166,10 @@ debug = [
|
||||
dev = [
|
||||
"django-extensions~=3.2",
|
||||
"ipython~=8.26",
|
||||
"hypothesis[django]~=6.108",
|
||||
"hypothesis[django]~=6.111",
|
||||
"tblib~=3.0",
|
||||
"google-api-python-client-stubs~=1.27",
|
||||
"types-python-dateutil~=2.9",
|
||||
]
|
||||
|
||||
[tool.pdm.scripts]
|
||||
|
@ -1,6 +1,7 @@
|
||||
from django import forms
|
||||
from django.contrib import admin
|
||||
from django.db.models import Prefetch
|
||||
from django.db.models import Prefetch, QuerySet
|
||||
from django.http import HttpRequest
|
||||
|
||||
from membershipworks.models import Member
|
||||
|
||||
@ -22,11 +23,17 @@ class LockerBankAdmin(admin.ModelAdmin):
|
||||
inlines = [LockerUnitInline]
|
||||
prepopulated_fields = {"slug": ("name",)}
|
||||
|
||||
def get_queryset(self, request: HttpRequest) -> QuerySet[LockerBank]:
|
||||
return super().get_queryset(request).prefetch_related("units")
|
||||
|
||||
|
||||
@admin.register(LockerUnit)
|
||||
class LockerUnitAdmin(admin.ModelAdmin):
|
||||
inlines = [LockerInfoInline]
|
||||
|
||||
def get_queryset(self, request: HttpRequest) -> QuerySet[LockerUnit]:
|
||||
return super().get_queryset(request).prefetch_related("bank")
|
||||
|
||||
|
||||
@admin.register(LockerInfo)
|
||||
class LockerInfoAdmin(admin.ModelAdmin):
|
||||
@ -43,12 +50,15 @@ class LockerInfoAdmin(admin.ModelAdmin):
|
||||
]
|
||||
list_display_links = ["locker_unit", "address"]
|
||||
|
||||
def get_queryset(self, request):
|
||||
return LockerInfo.objects.select_related(
|
||||
"locker_unit", "locker_unit__bank"
|
||||
).prefetch_related(
|
||||
def get_queryset(self, request: HttpRequest) -> QuerySet[LockerInfo]:
|
||||
return (
|
||||
super()
|
||||
.get_queryset(request)
|
||||
.select_related("locker_unit", "locker_unit__bank")
|
||||
.prefetch_related(
|
||||
Prefetch("renter", queryset=Member.objects.only("account_name"))
|
||||
)
|
||||
)
|
||||
|
||||
def get_changelist_formset(self, request, **kwargs):
|
||||
kwargs["widgets"] = {
|
||||
|
0
reservations/__init__.py
Normal file
0
reservations/__init__.py
Normal file
100
reservations/admin.py
Normal file
100
reservations/admin.py
Normal file
@ -0,0 +1,100 @@
|
||||
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
|
24
reservations/apps.py
Normal file
24
reservations/apps.py
Normal file
@ -0,0 +1,24 @@
|
||||
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)
|
0
reservations/management/__init__.py
Normal file
0
reservations/management/__init__.py
Normal file
0
reservations/management/commands/__init__.py
Normal file
0
reservations/management/commands/__init__.py
Normal file
@ -0,0 +1,21 @@
|
||||
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()
|
113
reservations/migrations/0001_initial.py
Normal file
113
reservations/migrations/0001_initial.py
Normal file
@ -0,0 +1,113 @@
|
||||
# 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",),
|
||||
),
|
||||
]
|
31
reservations/migrations/0002_externalreservation.py
Normal file
31
reservations/migrations/0002_externalreservation.py
Normal file
@ -0,0 +1,31 @@
|
||||
# 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",),
|
||||
),
|
||||
]
|
0
reservations/migrations/__init__.py
Normal file
0
reservations/migrations/__init__.py
Normal file
161
reservations/models.py
Normal file
161
reservations/models.py
Normal file
@ -0,0 +1,161 @@
|
||||
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"
|
||||
)
|
0
reservations/tasks/__init__.py
Normal file
0
reservations/tasks/__init__.py
Normal file
205
reservations/tasks/sync_google_calendar.py
Normal file
205
reservations/tasks/sync_google_calendar.py
Normal file
@ -0,0 +1,205 @@
|
||||
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)
|
0
reservations/tests/__init__.py
Normal file
0
reservations/tests/__init__.py
Normal file
116
reservations/tests/test_Reservation.py
Normal file
116
reservations/tests/test_Reservation.py
Normal file
@ -0,0 +1,116 @@
|
||||
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
reservations/views.py
Normal file
1
reservations/views.py
Normal file
@ -0,0 +1 @@
|
||||
# Create your views here.
|
0
tasks/management/__init__.py
Normal file
0
tasks/management/__init__.py
Normal file
0
tasks/management/commands/__init__.py
Normal file
0
tasks/management/commands/__init__.py
Normal file
Loading…
Reference in New Issue
Block a user