Compare commits

..

No commits in common. "e4c6fab01146a6e0c3d9354edb048a2dbda52f77" and "1827d10bf4318235eee67abfc091bbd84ea513b1" have entirely different histories.

42 changed files with 1428 additions and 2077 deletions

View File

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

View File

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

View File

@ -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 = {

View File

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

View File

@ -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,
} }
) )
) )

View File

@ -0,0 +1,292 @@
from unittest.mock import patch
import pytest
import responses
from lxml.etree import Element
from ..DoorController import ROOT, DoorController, E, E_corp, RemoteError
# https://stackoverflow.com/questions/7905380/testing-equivalence-of-xml-etree-elementtree
def assert_elements_equal(e1: Element, e2: Element) -> None:
assert e1.tag == e2.tag
assert e1.text == e2.text
assert e1.tail == e2.tail
assert e1.attrib == e2.attrib
assert len(e1) == len(e2)
for c1, c2 in zip(e1, e2):
assert_elements_equal(c1, c2)
@pytest.fixture
def door_controller():
return DoorController("127.0.0.1", "test", "test", name="Test", access="Test")
@responses.activate
def test_doXMLRequest_bytes(door_controller: DoorController) -> None:
responses.add(
responses.GET,
"https://127.0.0.1/cgi-bin/vertx_xml.cgi",
body='<?xml version="1.0" encoding="UTF-8"?><VertXMessage xmlns:hid="http://www.hidcorp.com/VertX"><hid:Banana></hid:Banana></VertXMessage>',
content_type="text/xml",
)
ret = door_controller.doXMLRequest(
b"<VertXMessage><hid:TEST></hid:TEST></VertXMessage>"
)
assert (
responses.calls[0].request.params["/cgi-bin/vertx_xml.cgi?XML"]
== '<?xml version="1.0" encoding="UTF-8"?><VertXMessage><hid:TEST></hid:TEST></VertXMessage>'
)
assert_elements_equal(ret, ROOT(E_corp.Banana()))
@responses.activate
def test_doXMLRequest_xml(door_controller: DoorController) -> None:
responses.add(
responses.GET,
"https://127.0.0.1/cgi-bin/vertx_xml.cgi",
body='<?xml version="1.0" encoding="UTF-8"?><VertXMessage xmlns:hid="http://www.hidcorp.com/VertX"><hid:Banana></hid:Banana></VertXMessage>',
content_type="text/xml",
)
ret = door_controller.doXMLRequest(ROOT(E.TEST()))
assert (
responses.calls[0].request.params["/cgi-bin/vertx_xml.cgi?XML"]
== '<?xml version="1.0" encoding="UTF-8"?><VertXMessage xmlns:hid="http://www.hidglobal.com/VertX"><hid:TEST/></VertXMessage>'
)
assert_elements_equal(ret, ROOT(E_corp.Banana()))
@responses.activate
def test_doXMLRequest_HTTPError(door_controller: DoorController) -> None:
responses.add(
responses.GET,
"https://127.0.0.1/cgi-bin/vertx_xml.cgi",
body="whatever",
status=403,
)
with pytest.raises(RemoteError) as excinfo:
door_controller.doXMLRequest(ROOT(E.TEST()))
assert excinfo.value.args[0] == "Door Updating Error: 403 Forbidden\nwhatever"
@responses.activate
def test_doXMLRequest_XMLerror(door_controller: DoorController) -> None:
body = '<?xml version="1.0" encoding="UTF-8"?><VertXMessage xmlns:hid="http://www.hidcorp.com/VertX"><hid:Error action="RS" elementType="hid:TEST" errorCode="72" errorReporter="vertx" errorMessage="Unrecognized element"/></VertXMessage>'
responses.add(
responses.GET,
"https://127.0.0.1/cgi-bin/vertx_xml.cgi",
body=body,
status=200,
)
with pytest.raises(RemoteError) as excinfo:
door_controller.doXMLRequest(ROOT(E.TEST()))
assert excinfo.value.args[0] == "Door Updating Error: 200 OK\n" + body
# def doImport(self, params=None, files=None):
# def doCSVImport(self, csv):
def test_get_scheduleMap(door_controller: DoorController) -> None:
with patch.object(door_controller, "doXMLRequest") as mockXMLRequest:
mockXMLRequest.return_value = E_corp.VertXMessage(
E_corp.Schedules(
{"action": "RL"},
E_corp.Schedule({"scheduleID": "1", "scheduleName": "Test1"}),
E_corp.Schedule({"scheduleID": "2", "scheduleName": "Test2"}),
E_corp.Schedule({"scheduleID": "3", "scheduleName": "Test3"}),
)
)
ret = door_controller.get_scheduleMap()
assert ret == {"Test1": "1", "Test2": "2", "Test3": "3"}
# TODO: these two methods might want to be reworked: they are a bit clunky
# def get_schedules(self):
# def set_schedules(self, schedules):
def test_set_cardholder_schedules(door_controller: DoorController) -> None:
with patch.object(door_controller, "doXMLRequest") as mockXMLRequest:
door_controller._scheduleMap = {"Test1": "1", "Test2": "2", "Test3": "3"}
# TODO: should replace with a captured output
mockXMLRequest.return_value = ROOT()
ret = door_controller.set_cardholder_schedules("123", ["Test1", "Test3"])
assert_elements_equal(
door_controller.doXMLRequest.call_args[0][0],
ROOT(
E.RoleSet(
{"action": "UD", "roleSetID": "123"},
E.Roles(
E.Role({"roleID": "123", "scheduleID": "1", "resourceID": "0"}),
E.Role({"roleID": "123", "scheduleID": "3", "resourceID": "0"}),
),
)
),
)
assert_elements_equal(ret, ROOT())
def test_get_cardFormats(door_controller):
with patch.object(door_controller, "doXMLRequest") as mockXMLRequest:
mockXMLRequest.return_value = E_corp.VertXMessage(
E_corp.CardFormats(
{"action": "RL"},
E_corp.CardFormat(
{
"formatID": "1",
"formatName": "H10301 26-Bit",
"isTemplate": "true",
"templateID": "1",
}
),
# irrelevant templates omitted
E_corp.CardFormat(
{
"formatID": "6",
"formatName": "A901146A-123",
"isTemplate": "false",
"templateID": "1",
},
E_corp.FixedField({"value": "123"}),
),
E_corp.CardFormat(
{
"formatID": "7",
"formatName": "A901146A-456",
"isTemplate": "false",
"templateID": "1",
},
E_corp.FixedField({"value": "456"}),
),
)
)
ret = door_controller.get_cardFormats()
assert ret == {"123": "6", "456": "7"}
def test_set_cardFormat(door_controller):
with patch.object(door_controller, "doXMLRequest") as mockXMLRequest:
# TODO: should replace with a captured output
mockXMLRequest.return_value = ROOT()
ret = door_controller.set_cardFormat("testname", 3, 123)
assert_elements_equal(
door_controller.doXMLRequest.call_args[0][0],
ROOT(
E.CardFormats(
{"action": "AD"},
E.CardFormat(
{"formatName": "testname", "templateID": "3"},
E.FixedField({"value": "123"}),
),
)
),
)
assert_elements_equal(ret, ROOT())
def test_get_records_no_morerecords(door_controller):
"""Test for when all the records fit in one 'page'"""
with patch.object(door_controller, "doXMLRequest") as mockXMLRequest:
mockXMLRequest.return_value = E_corp.VertXMessage(
E_corp.TestElements(
{
"action": "RL",
"recordOffset": "0",
"recordCount": "2",
"moreRecords": "false",
},
E_corp.TestElement({"asdf": "a"}),
E_corp.TestElement({"qwer": "b"}),
)
)
ret = door_controller.get_records(E.TestElements, 12, {"blah": "test"})
assert_elements_equal(
door_controller.doXMLRequest.call_args[0][0],
ROOT(
E.TestElements(
{
"action": "LR",
# TODO: should really be 12, but isn't for bug workaround
"recordCount": "13",
"recordOffset": "0",
"blah": "test",
},
)
),
)
assert_elements_equal(ret[0], E_corp.TestElement({"asdf": "a"}))
assert_elements_equal(ret[1], E_corp.TestElement({"qwer": "b"}))
# def test_get_records_morerecords(door_controller):
# """Test for when all the records span multiple 'pages'"""
# pass
# def test_get_records_morerecords_bad_last_record(door_controller):
# """Test for bug in which last record of each 'page' is missing data"""
# pass
# def test_get_records_stopFunction(door_controller):
# pass
# def test_get_cardholders(door_controller):
# door_controller = DoorController(
# "172.18.51.11",
# "admin",
# "PVic6ydFS/",
# name="Test",
# access="Test",
# cert="../../hidglobal.com.pem",
# )
# with patch.object(door_controller, "doXMLRequest") as mockXMLRequest:
# mockXMLRequest.return_value = E_corp.VertXMessage(
# E_corp.Cardholders(
# {"action": "RL"},
# E_corp.Cardholder({"scheduleID": "1", "scheduleName": "Test1"}),
# E_corp.Cardholder({"scheduleID": "2", "scheduleName": "Test2"}),
# E_corp.Cardholder({"scheduleID": "3", "scheduleName": "Test3"}),
# )
# )
# for x in [0]:
# ret = door_controller.get_cardholders()
# assert ret == {"Test1": "1", "Test2": "2", "Test3": "3"}
# def add_cardholder(self, attribs):
# def update_cardholder(self, cardholderID, attribs):
# def get_credentials(self):
# def add_credentials(self, credentials, cardholderID=None):
# def assign_credential(self, credential, cardholderID=None):
# def get_events(self, threshold):
# def get_lock(self):
# def set_lock(self, lock=True):

View File

@ -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(
**{ **{

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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()

View File

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

View File

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

View File

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

View File

@ -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):

View File

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

View File

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

1853
pdm.lock

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -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):

View File

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

View File

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

View File

@ -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()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1 +0,0 @@
# Create your views here.