Compare commits

..

29 Commits

Author SHA1 Message Date
e4c6fab011 doorcontrol: Simplify querystring handling using new querystring tag
Some checks failed
Ruff / ruff (push) Successful in 1m4s
Test / test (push) Failing after 5m1s
2024-08-17 00:26:22 -04:00
bf3433bb3d Bump dependencies 2024-08-16 17:56:57 -04:00
6591eee3ba CI: Pin MariaDB version to avoid recently introduced bug
All checks were successful
Ruff / ruff (push) Successful in 44s
Test / test (push) Successful in 5m28s
2024-08-16 00:22:37 -04:00
02e86bd079 CI: Build Vite assets for test workflow 2024-08-16 00:22:32 -04:00
8868c0b5ef membershipworks: Add basic tests for validity of Event financial queries 2024-08-15 23:56:03 -04:00
5ddecaaea8 Add reservations to known first-party packages list 2024-08-15 12:41:30 -04:00
db79a23dc4 Bump dependencies 2024-08-15 12:41:30 -04:00
91da3736ec Add type stubs for Google API client and dateutil 2024-08-14 16:58:41 -04:00
cc31f97bc4 membershipworks: Delete Events that don't exist in membershipworks during scrape 2024-08-14 16:58:27 -04:00
cd054bd716 membershipworks: Ignore deletions of related EventExt in View Models 2024-08-14 16:58:22 -04:00
996331c7a0 doorcontrol: Remove broken hid tests 2024-08-09 01:47:32 -04:00
20fcac99a8 Apply Ruff's flake8-bugbear (B) rules 2024-08-09 01:47:32 -04:00
256c56df04 Apply even more Ruff rules
- flake8-use-pathlib (PTH)
- refurb (FURB)
2024-08-09 01:47:32 -04:00
7b3dfef732 Apply Ruff's flake8-type-checking (TCH) rules 2024-08-09 01:47:32 -04:00
e348e8fbf5 Apply Ruff's flake8-raise (RSE) rules 2024-08-09 01:47:32 -04:00
39df28743b Apply a few more Ruff rules
- flake8-implicit-str-concat (ISC)
- flake8-quotes (Q)
- flake8-pie (PIE)
- flake8-logging (LOG)
2024-08-09 01:47:32 -04:00
bef0191e12 Apply Ruff's flake8-no-pep420 (INP) rules 2024-08-09 01:47:32 -04:00
ab25da0aa1 Apply Ruff's flake8-builtins (A) rules 2024-08-09 01:47:32 -04:00
8fccb3c7fb membershipworks: Use new Django 5.1 __ lookups in admin list_display
https://docs.djangoproject.com/en/5.1/releases/5.1/#django-contrib-admin
2024-08-09 01:47:32 -04:00
a8b8e148fc Bump dependencies 2024-08-09 01:47:32 -04:00
e11e12307a Improve various admin pages performance using select/prefetch related 2024-08-09 01:47:32 -04:00
d792efc084 reservations: Make ReservationAdmin more useful for subclasses 2024-08-09 01:47:32 -04:00
927e2f4b90 reservations: Sync external Google Calendar events into database 2024-08-09 01:47:32 -04:00
e4280361d1 membershipworks: Convert EventMeetingTime to subclass of Reservation 2024-08-09 01:47:32 -04:00
35c063c44e reservations: Add task to sync with Google Calendar 2024-08-09 01:47:32 -04:00
075812face reservations: Add new app with Resource/Reservation/UserReservation models 2024-08-09 01:47:32 -04:00
508baf809c membershipworks: Specify timezone in ticket price policy effective date 2024-08-09 01:47:32 -04:00
54b615c986 Bump dependencies 2024-08-09 01:47:32 -04:00
617b469d85 Generate PDM lock file only for CMS-www environment
This allows newer versions of some dependencies, as well as faster installs
2024-08-09 01:47:32 -04:00
42 changed files with 2079 additions and 1430 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

1857
pdm.lock

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

100
reservations/admin.py Normal file
View 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
View 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)

View File

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

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

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

View File

161
reservations/models.py Normal file
View 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"
)

View File

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

View File

View 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
View File

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

View File

View File