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
services:
mariadb:
# 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
image: mariadb:latest
env:
MARIADB_ROOT_PASSWORD: whatever
healthcheck:
@ -38,15 +36,5 @@ 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.6.1
rev: v0.5.2
hooks:
- id: ruff
- id: ruff-format
- repo: https://github.com/pdm-project/pdm
rev: 2.18.1
rev: 2.17.0
hooks:
- id: pdm-lock-check

View File

@ -18,10 +18,6 @@ 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())
@ -61,7 +57,6 @@ class Base(Configuration):
"paperwork.apps.PaperworkConfig",
"doorcontrol.apps.DoorControlConfig",
"dashboard.apps.DashboardConfig",
"reservations.apps.ReservationsConfig",
]
MIDDLEWARE = [
@ -106,9 +101,6 @@ 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/"
@ -230,8 +222,6 @@ 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)
@ -357,12 +347,6 @@ 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_code=None):
if code is None and hex_code is None:
def __init__(self, code=None, hex=None):
if code is None and hex is None:
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")
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_code is not None:
self.bits = bitstring.Bits(hex=hex_code)
elif hex is not None:
self.bits = bitstring.Bits(hex=hex)
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=None, stopFunction=None):
def get_records(self, req, count, params={}, stopFunction=None):
recordCount = 0
moreRecords = True
@ -172,7 +172,7 @@ class DoorController:
"recordOffset": str(
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()
}
def attr_to_bool(attr):
if attr is None:
def attr_to_bool(str):
if str is None:
return None
else:
return attr == "true"
return str == "true"
return cls(
**{

View File

@ -108,7 +108,7 @@ class DoorMember:
},
cardholderID=data.attrib["cardholderID"],
credentials={
Credential(hex_code=(c.attrib["rawCardNumber"]))
Credential(hex=(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],
old_credentials: set[Credential] = set(),
):
# 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_code=c.attrib["rawCardNumber"])
Credential(hex=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, set())
member.update_credentials(existing_door_credentials, members)
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 }}{% querystring page=None %}">{{ report_name }}</a>
href="{{ report_url }}?{{ query_params }}">{{ 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,9 +27,6 @@ from .tables import (
UnitTimeTable,
)
if TYPE_CHECKING:
from django.core.paginator import Page
REPORTS = []
@ -98,6 +95,11 @@ 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,6 +1,5 @@
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 (
@ -96,7 +95,6 @@ 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"]
@ -105,18 +103,16 @@ 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)
@ -125,14 +121,13 @@ 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),
]
@ -147,6 +142,18 @@ 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
@ -177,7 +184,8 @@ class EventAdmin(DjangoObjectActions, admin.ModelAdmin):
change_actions = ["fetch_details"]
actions = ["fetch_details"]
def get_readonly_fields(self, request: HttpRequest, obj: EventExt) -> list[str]:
@property
def readonly_fields(self):
fields = []
for field in Event._meta.get_fields():
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):
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

@ -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
from datetime import datetime, timedelta
from decimal import Decimal
from typing import TYPE_CHECKING, TypedDict
import django.core.mail.message
@ -29,8 +30,6 @@ 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] = {}
@ -380,8 +379,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):
@ -590,12 +589,11 @@ class EventExt(Event):
self.materials_fee_included_in_price is not None
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:
from decimal import Decimal
class EventExtAnnotations(TypedDict):
meetings: int
@ -621,30 +619,29 @@ else:
EventExtAnnotatedWithFinancials = WithAnnotations[EventExt]
class EventMeetingTime(Reservation):
class EventMeetingTime(models.Model):
event = models.ForeignKey(
EventExt, on_delete=models.CASCADE, related_name="meeting_times"
)
start = models.DateTimeField()
end = models.DateTimeField()
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"
duration = models.GeneratedField(
expression=F("end") - F("start"),
output_field=models.DurationField(),
db_persist=False,
)
return super().make_google_calendar_event() | {
# TODO: add event description and links
"summary": self.event.unescaped_title,
"status": status,
}
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}"
class EventInvoice(models.Model):
@ -708,14 +705,7 @@ class EventTicketTypeManager(models.Manager["EventTicketType"]):
# non-member ticket
Q(restrict_to__isnull=True)
& (
Q(
event__start__lt=datetime(
year=2024,
month=7,
day=1,
tzinfo=timezone.get_default_timezone(),
)
)
Q(event__start__lt=datetime(year=2024, month=7, day=1))
| Q(members_price=0)
)
),
@ -762,7 +752,7 @@ class EventTicketType(DBView):
objects = EventTicketTypeManager.from_queryset(EventTicketTypeQuerySet)()
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()
restrict_to = models.TextField(null=True, blank=True)
@ -807,7 +797,7 @@ class EventTicketType(DBView):
class EventAttendeeStats(DBView):
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()
@ -827,7 +817,7 @@ class EventAttendeeStats(DBView):
class EventAttendee(DBView):
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)
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, flag_id in flags_of_type.items():
flag = Flag(id=flag_id, name=name, type=typ[:-1])
for name, id in flags_of_type.items():
flag = Flag(id=id, name=name, type=typ[:-1])
flag.save()
yield flag
@ -81,9 +81,7 @@ 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, strict=True)
]
transactions = [{**j, **v} for j, v in zip(transactions_csv, transactions_json)]
assert all(
t["Account ID"] == t.get("uid", "") and t["Payment ID"] == t.get("sid", "")
for t in transactions
@ -178,6 +176,3 @@ 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(RAND_PW_LEN)
for x in range(0, RAND_PW_LEN)
)
user.props.pwdChangeNextLogin = True

View File

@ -1,66 +1 @@
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")
)
# Create your tests here.

View File

@ -15,7 +15,7 @@
"prettier": "^3.3.3",
"sass": "^1.77.8",
"typescript": "^5.5.4",
"vite": "^5.4.1"
"vite": "^5.3.4"
},
"dependencies": {
"@popperjs/core": "^2.11.8",

View File

@ -57,7 +57,6 @@ 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): # noqa: A002
def mailing_lists(self, request, format=None):
"""
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 typing import TYPE_CHECKING
from collections.abc import Iterable
from django.conf import settings
from django.contrib.auth.decorators import login_required
@ -44,9 +44,6 @@ from .tables import (
WaiverReportTable,
)
if TYPE_CHECKING:
from collections.abc import Iterable
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
version: 5.5.4
vite:
specifier: ^5.4.1
version: 5.4.1(sass@1.77.8)
specifier: ^5.3.4
version: 5.3.4(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.20.0':
resolution: {integrity: sha512-TSpWzflCc4VGAUJZlPpgAJE1+V60MePDQnBd7PPkpuEmOy8i87aL6tinFGKBFKuEDikYpig72QzdT3QPYIi+oA==}
'@rollup/rollup-android-arm-eabi@4.19.0':
resolution: {integrity: sha512-JlPfZ/C7yn5S5p0yKk7uhHTTnFlvTgLetl2VxqE518QgyM7C9bSfFTYvB/Q/ftkq0RIPY4ySxTz+/wKJ/dXC0w==}
cpu: [arm]
os: [android]
'@rollup/rollup-android-arm64@4.20.0':
resolution: {integrity: sha512-u00Ro/nok7oGzVuh/FMYfNoGqxU5CPWz1mxV85S2w9LxHR8OoMQBuSk+3BKVIDYgkpeOET5yXkx90OYFc+ytpQ==}
'@rollup/rollup-android-arm64@4.19.0':
resolution: {integrity: sha512-RDxUSY8D1tWYfn00DDi5myxKgOk6RvWPxhmWexcICt/MEC6yEMr4HNCu1sXXYLw8iAsg0D44NuU+qNq7zVWCrw==}
cpu: [arm64]
os: [android]
'@rollup/rollup-darwin-arm64@4.20.0':
resolution: {integrity: sha512-uFVfvzvsdGtlSLuL0ZlvPJvl6ZmrH4CBwLGEFPe7hUmf7htGAN+aXo43R/V6LATyxlKVC/m6UsLb7jbG+LG39Q==}
'@rollup/rollup-darwin-arm64@4.19.0':
resolution: {integrity: sha512-emvKHL4B15x6nlNTBMtIaC9tLPRpeA5jMvRLXVbl/W9Ie7HhkrE7KQjvgS9uxgatL1HmHWDXk5TTS4IaNJxbAA==}
cpu: [arm64]
os: [darwin]
'@rollup/rollup-darwin-x64@4.20.0':
resolution: {integrity: sha512-xbrMDdlev53vNXexEa6l0LffojxhqDTBeL+VUxuuIXys4x6xyvbKq5XqTXBCEUA8ty8iEJblHvFaWRJTk/icAQ==}
'@rollup/rollup-darwin-x64@4.19.0':
resolution: {integrity: sha512-fO28cWA1dC57qCd+D0rfLC4VPbh6EOJXrreBmFLWPGI9dpMlER2YwSPZzSGfq11XgcEpPukPTfEVFtw2q2nYJg==}
cpu: [x64]
os: [darwin]
'@rollup/rollup-linux-arm-gnueabihf@4.20.0':
resolution: {integrity: sha512-jMYvxZwGmoHFBTbr12Xc6wOdc2xA5tF5F2q6t7Rcfab68TT0n+r7dgawD4qhPEvasDsVpQi+MgDzj2faOLsZjA==}
'@rollup/rollup-linux-arm-gnueabihf@4.19.0':
resolution: {integrity: sha512-2Rn36Ubxdv32NUcfm0wB1tgKqkQuft00PtM23VqLuCUR4N5jcNWDoV5iBC9jeGdgS38WK66ElncprqgMUOyomw==}
cpu: [arm]
os: [linux]
'@rollup/rollup-linux-arm-musleabihf@4.20.0':
resolution: {integrity: sha512-1asSTl4HKuIHIB1GcdFHNNZhxAYEdqML/MW4QmPS4G0ivbEcBr1JKlFLKsIRqjSwOBkdItn3/ZDlyvZ/N6KPlw==}
'@rollup/rollup-linux-arm-musleabihf@4.19.0':
resolution: {integrity: sha512-gJuzIVdq/X1ZA2bHeCGCISe0VWqCoNT8BvkQ+BfsixXwTOndhtLUpOg0A1Fcx/+eA6ei6rMBzlOz4JzmiDw7JQ==}
cpu: [arm]
os: [linux]
'@rollup/rollup-linux-arm64-gnu@4.20.0':
resolution: {integrity: sha512-COBb8Bkx56KldOYJfMf6wKeYJrtJ9vEgBRAOkfw6Ens0tnmzPqvlpjZiLgkhg6cA3DGzCmLmmd319pmHvKWWlQ==}
'@rollup/rollup-linux-arm64-gnu@4.19.0':
resolution: {integrity: sha512-0EkX2HYPkSADo9cfeGFoQ7R0/wTKb7q6DdwI4Yn/ULFE1wuRRCHybxpl2goQrx4c/yzK3I8OlgtBu4xvted0ug==}
cpu: [arm64]
os: [linux]
'@rollup/rollup-linux-arm64-musl@4.20.0':
resolution: {integrity: sha512-+it+mBSyMslVQa8wSPvBx53fYuZK/oLTu5RJoXogjk6x7Q7sz1GNRsXWjn6SwyJm8E/oMjNVwPhmNdIjwP135Q==}
'@rollup/rollup-linux-arm64-musl@4.19.0':
resolution: {integrity: sha512-GlIQRj9px52ISomIOEUq/IojLZqzkvRpdP3cLgIE1wUWaiU5Takwlzpz002q0Nxxr1y2ZgxC2obWxjr13lvxNQ==}
cpu: [arm64]
os: [linux]
'@rollup/rollup-linux-powerpc64le-gnu@4.20.0':
resolution: {integrity: sha512-yAMvqhPfGKsAxHN8I4+jE0CpLWD8cv4z7CK7BMmhjDuz606Q2tFKkWRY8bHR9JQXYcoLfopo5TTqzxgPUjUMfw==}
'@rollup/rollup-linux-powerpc64le-gnu@4.19.0':
resolution: {integrity: sha512-N6cFJzssruDLUOKfEKeovCKiHcdwVYOT1Hs6dovDQ61+Y9n3Ek4zXvtghPPelt6U0AH4aDGnDLb83uiJMkWYzQ==}
cpu: [ppc64]
os: [linux]
'@rollup/rollup-linux-riscv64-gnu@4.20.0':
resolution: {integrity: sha512-qmuxFpfmi/2SUkAw95TtNq/w/I7Gpjurx609OOOV7U4vhvUhBcftcmXwl3rqAek+ADBwSjIC4IVNLiszoj3dPA==}
'@rollup/rollup-linux-riscv64-gnu@4.19.0':
resolution: {integrity: sha512-2DnD3mkS2uuam/alF+I7M84koGwvn3ZVD7uG+LEWpyzo/bq8+kKnus2EVCkcvh6PlNB8QPNFOz6fWd5N8o1CYg==}
cpu: [riscv64]
os: [linux]
'@rollup/rollup-linux-s390x-gnu@4.20.0':
resolution: {integrity: sha512-I0BtGXddHSHjV1mqTNkgUZLnS3WtsqebAXv11D5BZE/gfw5KoyXSAXVqyJximQXNvNzUo4GKlCK/dIwXlz+jlg==}
'@rollup/rollup-linux-s390x-gnu@4.19.0':
resolution: {integrity: sha512-D6pkaF7OpE7lzlTOFCB2m3Ngzu2ykw40Nka9WmKGUOTS3xcIieHe82slQlNq69sVB04ch73thKYIWz/Ian8DUA==}
cpu: [s390x]
os: [linux]
'@rollup/rollup-linux-x64-gnu@4.20.0':
resolution: {integrity: sha512-y+eoL2I3iphUg9tN9GB6ku1FA8kOfmF4oUEWhztDJ4KXJy1agk/9+pejOuZkNFhRwHAOxMsBPLbXPd6mJiCwew==}
'@rollup/rollup-linux-x64-gnu@4.19.0':
resolution: {integrity: sha512-HBndjQLP8OsdJNSxpNIN0einbDmRFg9+UQeZV1eiYupIRuZsDEoeGU43NQsS34Pp166DtwQOnpcbV/zQxM+rWA==}
cpu: [x64]
os: [linux]
'@rollup/rollup-linux-x64-musl@4.20.0':
resolution: {integrity: sha512-hM3nhW40kBNYUkZb/r9k2FKK+/MnKglX7UYd4ZUy5DJs8/sMsIbqWK2piZtVGE3kcXVNj3B2IrUYROJMMCikNg==}
'@rollup/rollup-linux-x64-musl@4.19.0':
resolution: {integrity: sha512-HxfbvfCKJe/RMYJJn0a12eiOI9OOtAUF4G6ozrFUK95BNyoJaSiBjIOHjZskTUffUrB84IPKkFG9H9nEvJGW6A==}
cpu: [x64]
os: [linux]
'@rollup/rollup-win32-arm64-msvc@4.20.0':
resolution: {integrity: sha512-psegMvP+Ik/Bg7QRJbv8w8PAytPA7Uo8fpFjXyCRHWm6Nt42L+JtoqH8eDQ5hRP7/XW2UiIriy1Z46jf0Oa1kA==}
'@rollup/rollup-win32-arm64-msvc@4.19.0':
resolution: {integrity: sha512-HxDMKIhmcguGTiP5TsLNolwBUK3nGGUEoV/BO9ldUBoMLBssvh4J0X8pf11i1fTV7WShWItB1bKAKjX4RQeYmg==}
cpu: [arm64]
os: [win32]
'@rollup/rollup-win32-ia32-msvc@4.20.0':
resolution: {integrity: sha512-GabekH3w4lgAJpVxkk7hUzUf2hICSQO0a/BLFA11/RMxQT92MabKAqyubzDZmMOC/hcJNlc+rrypzNzYl4Dx7A==}
'@rollup/rollup-win32-ia32-msvc@4.19.0':
resolution: {integrity: sha512-xItlIAZZaiG/u0wooGzRsx11rokP4qyc/79LkAOdznGRAbOFc+SfEdfUOszG1odsHNgwippUJavag/+W/Etc6Q==}
cpu: [ia32]
os: [win32]
'@rollup/rollup-win32-x64-msvc@4.20.0':
resolution: {integrity: sha512-aJ1EJSuTdGnM6qbVC4B5DSmozPTqIag9fSzXRNNo+humQLG89XpPgdt16Ia56ORD7s+H8Pmyx44uczDQ0yDzpg==}
'@rollup/rollup-win32-x64-msvc@4.19.0':
resolution: {integrity: sha512-xNo5fV5ycvCCKqiZcpB65VMR11NJB+StnxHz20jdqRAktfdfzhgjTiJ2doTDQE/7dqGaV5I7ZGqKpgph6lCIag==}
cpu: [x64]
os: [win32]
@ -344,8 +344,8 @@ packages:
resolution: {integrity: sha512-s3Fq41ZVh7vbbe2PN3nrW7yC7U7MFVc5c98/iTl9c2GawNMKx/J648KQRW6WKkuU8GIbbh2IXfIRQjOZnXcTnw==}
engines: {node: '>=18'}
ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
ignore@5.3.1:
resolution: {integrity: sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==}
engines: {node: '>= 4'}
immutable@4.3.7:
@ -395,8 +395,8 @@ packages:
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
engines: {node: '>=8.6'}
postcss@8.4.41:
resolution: {integrity: sha512-TesUflQ0WKZqAvg52PWL6kHgLKP6xB6heTOdoYM0Wt2UHyxNa4K25EZZMgKns3BH1RLVbZCREPpLY0rhnNoHVQ==}
postcss@8.4.39:
resolution: {integrity: sha512-0vzE+lAiG7hZl1/9I8yzKLx3aR9Xbof3fBHKunvMfOCYAtMhrsnccJY2iTURb9EZd5+pLuiNV9/c/GZJOHsgIw==}
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.20.0:
resolution: {integrity: sha512-6rbWBChcnSGzIlXeIdNIZTopKYad8ZG8ajhl78lGRLsI2rX8IkaotQhVas2Ma+GPxJav19wrSzvRvuiv0YKzWw==}
rollup@4.19.0:
resolution: {integrity: sha512-5r7EYSQIowHsK4eTZ0Y81qpZuJz+MUuYeqmmYmRMl1nwhdmbiYqt5jwzf6u7wyOzJgYqtCRMtVRKOtHANBz7rA==}
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.4.1:
resolution: {integrity: sha512-1oE6yuNXssjrZdblI9AfBbHCC41nnyoVoEZxQnID6yvQZAFBzxxkqoFLtHUMkYunL8hwOLEjgTuxpkRxvba3kA==}
vite@5.3.4:
resolution: {integrity: sha512-Cw+7zL3ZG9/NZBB8C+8QbQZmR54GwqIz+WMI4b3JgdYJvX+ny9AjJXqkGQlDXSXRP9rP0B4tbciRMOVEKulVOA==}
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
peerDependencies:
@ -461,7 +461,6 @@ packages:
less: '*'
lightningcss: ^1.21.0
sass: '*'
sass-embedded: '*'
stylus: '*'
sugarss: '*'
terser: ^5.4.0
@ -474,8 +473,6 @@ packages:
optional: true
sass:
optional: true
sass-embedded:
optional: true
stylus:
optional: true
sugarss:
@ -568,52 +565,52 @@ snapshots:
'@popperjs/core@2.11.8': {}
'@rollup/rollup-android-arm-eabi@4.20.0':
'@rollup/rollup-android-arm-eabi@4.19.0':
optional: true
'@rollup/rollup-android-arm64@4.20.0':
'@rollup/rollup-android-arm64@4.19.0':
optional: true
'@rollup/rollup-darwin-arm64@4.20.0':
'@rollup/rollup-darwin-arm64@4.19.0':
optional: true
'@rollup/rollup-darwin-x64@4.20.0':
'@rollup/rollup-darwin-x64@4.19.0':
optional: true
'@rollup/rollup-linux-arm-gnueabihf@4.20.0':
'@rollup/rollup-linux-arm-gnueabihf@4.19.0':
optional: true
'@rollup/rollup-linux-arm-musleabihf@4.20.0':
'@rollup/rollup-linux-arm-musleabihf@4.19.0':
optional: true
'@rollup/rollup-linux-arm64-gnu@4.20.0':
'@rollup/rollup-linux-arm64-gnu@4.19.0':
optional: true
'@rollup/rollup-linux-arm64-musl@4.20.0':
'@rollup/rollup-linux-arm64-musl@4.19.0':
optional: true
'@rollup/rollup-linux-powerpc64le-gnu@4.20.0':
'@rollup/rollup-linux-powerpc64le-gnu@4.19.0':
optional: true
'@rollup/rollup-linux-riscv64-gnu@4.20.0':
'@rollup/rollup-linux-riscv64-gnu@4.19.0':
optional: true
'@rollup/rollup-linux-s390x-gnu@4.20.0':
'@rollup/rollup-linux-s390x-gnu@4.19.0':
optional: true
'@rollup/rollup-linux-x64-gnu@4.20.0':
'@rollup/rollup-linux-x64-gnu@4.19.0':
optional: true
'@rollup/rollup-linux-x64-musl@4.20.0':
'@rollup/rollup-linux-x64-musl@4.19.0':
optional: true
'@rollup/rollup-win32-arm64-msvc@4.20.0':
'@rollup/rollup-win32-arm64-msvc@4.19.0':
optional: true
'@rollup/rollup-win32-ia32-msvc@4.20.0':
'@rollup/rollup-win32-ia32-msvc@4.19.0':
optional: true
'@rollup/rollup-win32-x64-msvc@4.20.0':
'@rollup/rollup-win32-x64-msvc@4.19.0':
optional: true
'@sindresorhus/merge-streams@2.3.0': {}
@ -708,12 +705,12 @@ snapshots:
dependencies:
'@sindresorhus/merge-streams': 2.3.0
fast-glob: 3.3.2
ignore: 5.3.2
ignore: 5.3.1
path-type: 5.0.0
slash: 5.1.0
unicorn-magic: 0.1.0
ignore@5.3.2: {}
ignore@5.3.1: {}
immutable@4.3.7: {}
@ -746,7 +743,7 @@ snapshots:
picomatch@2.3.1: {}
postcss@8.4.41:
postcss@8.4.39:
dependencies:
nanoid: 3.3.7
picocolors: 1.0.1
@ -762,26 +759,26 @@ snapshots:
reusify@1.0.4: {}
rollup@4.20.0:
rollup@4.19.0:
dependencies:
'@types/estree': 1.0.5
optionalDependencies:
'@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
'@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
fsevents: 2.3.3
run-parallel@1.2.0:
@ -808,11 +805,11 @@ snapshots:
unicorn-magic@0.1.0: {}
vite@5.4.1(sass@1.77.8):
vite@5.3.4(sass@1.77.8):
dependencies:
esbuild: 0.21.5
postcss: 8.4.41
rollup: 4.20.0
postcss: 8.4.39
rollup: 4.19.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.1",
"django-admin-logs~=1.3",
"django~=5.0",
"django-admin-logs~=1.2",
"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.3",
"lxml~=5.2",
"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.3",
"django-filter~=24.2",
"django-db-views~=0.1",
"django-mysql~=4.14",
"django-weasyprint~=2.3",
@ -40,17 +40,13 @@ 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~=72.2",
"setuptools~=71.1",
]
[project.entry-points."djangoq.errorreporters"]
@ -60,32 +56,7 @@ 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",
"A",
"INP",
"ISC",
"Q",
"PIE",
"LOG",
"RSE",
"TCH",
"PTH",
"FURB",
"B",
]
ignore = ["ISC001"]
select = ["E4", "E7", "E9", "F", "I", "C4", "UP", "PERF", "PL", "SIM", "FIX003", "DJ012"]
[tool.ruff.lint.isort]
known-first-party = [
@ -95,7 +66,6 @@ known-first-party = [
"membershipworks",
"paperwork",
"rentals",
"reservations",
"tasks",
]
section-order = ["future", "standard-library", "django", "third-party", "first-party", "local-folder"]
@ -110,7 +80,6 @@ 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
@ -145,12 +114,12 @@ include_packages = ["openapi-client-udm"]
[tool.pdm.dev-dependencies]
lint = [
"djlint~=1.34",
"ruff~=0.6",
"ruff~=0.5",
]
typing = [
"mypy~=1.10",
"django-stubs~=5.0",
"setuptools~=72.2",
"setuptools~=71.1",
"types-bleach~=6.1",
"types-requests~=2.32",
"types-urllib3~=1.26",
@ -158,7 +127,7 @@ typing = [
"types-Markdown~=3.6",
"types-Pygments~=2.18",
"types-psycopg2~=2.9",
"types-lxml~=2024.8",
"types-lxml~=2024.4",
]
debug = [
"django-debug-toolbar~=4.4",
@ -166,10 +135,8 @@ debug = [
dev = [
"django-extensions~=3.2",
"ipython~=8.26",
"hypothesis[django]~=6.111",
"hypothesis[django]~=6.108",
"tblib~=3.0",
"google-api-python-client-stubs~=1.27",
"types-python-dateutil~=2.9",
]
[tool.pdm.scripts]

View File

@ -1,7 +1,6 @@
from django import forms
from django.contrib import admin
from django.db.models import Prefetch, QuerySet
from django.http import HttpRequest
from django.db.models import Prefetch
from membershipworks.models import Member
@ -23,17 +22,11 @@ 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):
@ -50,15 +43,12 @@ class LockerInfoAdmin(admin.ModelAdmin):
]
list_display_links = ["locker_unit", "address"]
def get_queryset(self, request: HttpRequest) -> QuerySet[LockerInfo]:
return (
super()
.get_queryset(request)
.select_related("locker_unit", "locker_unit__bank")
.prefetch_related(
def get_queryset(self, request):
return LockerInfo.objects.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"] = {

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.