Compare commits
13 Commits
9a300ba65c
...
b030f29b37
Author | SHA1 | Date | |
---|---|---|---|
b030f29b37 | |||
ed3019bb92 | |||
efb15dd118 | |||
ccc7a595ba | |||
69defab388 | |||
59d2ff4cb7 | |||
d19b2d19fb | |||
d25f1e673a | |||
f2a17d3ea4 | |||
56f49f8784 | |||
9198503572 | |||
8c424c7e49 | |||
1348bd8fdf |
3
.gitignore
vendored
3
.gitignore
vendored
@ -5,6 +5,9 @@ __pycache__/
|
||||
/media/
|
||||
/settings.*.env
|
||||
/staticfiles/
|
||||
/.hypothesis/
|
||||
/.pdm-python
|
||||
/.coverage
|
||||
|
||||
# Logs
|
||||
/logs
|
||||
|
@ -14,7 +14,7 @@ repos:
|
||||
- id: djlint-reformat-django
|
||||
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.6.3
|
||||
rev: v0.6.4
|
||||
hooks:
|
||||
- id: ruff
|
||||
- id: ruff-format
|
||||
|
@ -1,3 +1,6 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import admin
|
||||
from django.contrib.humanize.templatetags.humanize import naturaltime
|
||||
from django.http import HttpRequest
|
||||
@ -10,6 +13,7 @@ from django_object_actions import (
|
||||
)
|
||||
from django_q.models import Task
|
||||
from django_q.tasks import async_task
|
||||
from django_vite.templatetags.django_vite import vite_asset_url
|
||||
from simple_history.admin import SimpleHistoryAdmin
|
||||
|
||||
from .models import (
|
||||
@ -111,6 +115,7 @@ class TransactionAdmin(BaseMembershipWorksAdmin):
|
||||
|
||||
class EventMeetingTimeInline(admin.TabularInline):
|
||||
model = EventMeetingTime
|
||||
show_change_link = True
|
||||
fields = ["start", "end", "duration", "resources"]
|
||||
readonly_fields = ["duration"]
|
||||
autocomplete_fields = ["resources"]
|
||||
@ -168,6 +173,7 @@ class EventAdmin(DjangoObjectActions, admin.ModelAdmin):
|
||||
"count",
|
||||
"cap",
|
||||
"category",
|
||||
"meeting_times_match_event",
|
||||
]
|
||||
list_filter = [
|
||||
"category",
|
||||
@ -194,6 +200,8 @@ class EventAdmin(DjangoObjectActions, admin.ModelAdmin):
|
||||
"instructor_percentage",
|
||||
"instructor_flat_rate",
|
||||
("should_survey", "survey_email_sent"),
|
||||
"links",
|
||||
"meeting_times_match_event",
|
||||
]
|
||||
},
|
||||
),
|
||||
@ -203,7 +211,7 @@ class EventAdmin(DjangoObjectActions, admin.ModelAdmin):
|
||||
"classes": ["collapse"],
|
||||
"fields": [
|
||||
"eid",
|
||||
"_url",
|
||||
"url",
|
||||
"start",
|
||||
"end",
|
||||
"duration",
|
||||
@ -225,6 +233,23 @@ class EventAdmin(DjangoObjectActions, admin.ModelAdmin):
|
||||
),
|
||||
]
|
||||
|
||||
class Media:
|
||||
@dataclass(frozen=True)
|
||||
class LazyViteAssetUrl(str):
|
||||
asset: str
|
||||
|
||||
def __str__(self) -> str:
|
||||
return vite_asset_url(self.asset).removeprefix(settings.STATIC_URL)
|
||||
|
||||
js = [
|
||||
LazyViteAssetUrl(
|
||||
"membershipworks/js/event_meeting_time_admin_helper.entry.ts"
|
||||
)
|
||||
]
|
||||
|
||||
def get_queryset(self, request):
|
||||
return EventExt.objects.with_meeting_times_match_event()
|
||||
|
||||
@property
|
||||
def refresh_membershipworks_data(self):
|
||||
return run_task_action(self, "Refresh Data", scrape_events)
|
||||
@ -234,12 +259,16 @@ class EventAdmin(DjangoObjectActions, admin.ModelAdmin):
|
||||
for field in Event._meta.get_fields():
|
||||
if field.auto_created or field.many_to_many or not field.concrete:
|
||||
continue
|
||||
elif field.name == "url":
|
||||
fields.append("_url")
|
||||
else:
|
||||
fields.append(field.name)
|
||||
fields.insert(fields.index("end") + 1, "duration")
|
||||
fields += ["details_timestamp", "details", "registrations"]
|
||||
fields += [
|
||||
"links",
|
||||
"details_timestamp",
|
||||
"details",
|
||||
"registrations",
|
||||
"meeting_times_match_event",
|
||||
]
|
||||
return fields
|
||||
|
||||
@admin.display(ordering="title")
|
||||
@ -250,10 +279,19 @@ class EventAdmin(DjangoObjectActions, admin.ModelAdmin):
|
||||
def duration(self, obj):
|
||||
return obj.duration
|
||||
|
||||
@admin.display(description="URL")
|
||||
def _url(self, obj):
|
||||
@admin.display(
|
||||
boolean=True,
|
||||
description="Meeting times match event start/end",
|
||||
ordering="meeting_times_match_event",
|
||||
)
|
||||
def meeting_times_match_event(self, obj) -> bool:
|
||||
return obj.meeting_times_match_event
|
||||
|
||||
@admin.display(description="MembershipWorks links")
|
||||
def links(self, obj):
|
||||
return format_html(
|
||||
'<a href="https://claremontmakerspace.org/events/#!event/{0}">{0}</a>',
|
||||
'<a href="https://membershipworks.com/admin/#!event/admin/{0}">Admin</a> | '
|
||||
'<a href="https://claremontmakerspace.org/events/#!event/{0}">Event List</a>',
|
||||
obj.url,
|
||||
)
|
||||
|
||||
|
25
membershipworks/js/event_meeting_time_admin_helper.entry.ts
Normal file
25
membershipworks/js/event_meeting_time_admin_helper.entry.ts
Normal file
@ -0,0 +1,25 @@
|
||||
function maybeUpdateEndField(start_date_field: HTMLInputElement) {
|
||||
let end_date_field: HTMLInputElement | null | undefined = start_date_field
|
||||
?.closest(".form-row")
|
||||
?.querySelector(".field-end .vDateField");
|
||||
if (end_date_field && !end_date_field.value) {
|
||||
end_date_field.value = start_date_field.value;
|
||||
}
|
||||
}
|
||||
|
||||
function setupListeners(element: ParentNode) {
|
||||
for (let start_date_field of element.querySelectorAll(
|
||||
"#meeting_times-group .field-start .vDateField",
|
||||
) as NodeListOf<HTMLInputElement>) {
|
||||
start_date_field.addEventListener("change", () => {
|
||||
maybeUpdateEndField(start_date_field);
|
||||
});
|
||||
// element is focused after selecting a date using Django's DateTimeShortcuts
|
||||
start_date_field.addEventListener("focus", () => {
|
||||
maybeUpdateEndField(start_date_field);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$(() => setupListeners(document));
|
||||
$(document).on("formset:added", (event) => setupListeners(event.target));
|
@ -503,6 +503,28 @@ class EventExtQuerySet(models.QuerySet["EventExtAnnotated"]):
|
||||
net_revenue=F("gross_revenue") - F("total_due_to_instructor"),
|
||||
)
|
||||
|
||||
def with_meeting_times_match_event(self):
|
||||
return self.annotate(
|
||||
meeting_times_match_event=(
|
||||
Q(
|
||||
start=Subquery(
|
||||
EventMeetingTime.objects.filter(event=OuterRef("pk"))
|
||||
.order_by("start")
|
||||
.values("start")[:1],
|
||||
output_field=models.DateTimeField(),
|
||||
)
|
||||
)
|
||||
& Q(
|
||||
end=Subquery(
|
||||
EventMeetingTime.objects.filter(event=OuterRef("pk"))
|
||||
.order_by("-end")
|
||||
.values("end")[:1],
|
||||
output_field=models.DateTimeField(),
|
||||
)
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class EventExtManager(models.Manager):
|
||||
def get_queryset(self) -> EventExtQuerySet:
|
||||
|
@ -21,6 +21,23 @@ class DurationColumn(tables.Column):
|
||||
return value.total_seconds() / 60 / 60
|
||||
|
||||
|
||||
class MoneyColumn(tables.columns.Column):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.attrs["cell"] = {"class": "text-end", **self.attrs.get("cell", {})}
|
||||
|
||||
def render(self, value) -> str:
|
||||
return f"{super().render(value):.2f}"
|
||||
|
||||
def value(self, **kwargs):
|
||||
return kwargs["value"]
|
||||
|
||||
|
||||
class CurrencySymbolMoneyColumn(MoneyColumn):
|
||||
def render(self, value) -> str:
|
||||
return f"${super().render(value)}"
|
||||
|
||||
|
||||
class EventTable(tables.Table):
|
||||
title = tables.TemplateColumn(
|
||||
template_code=(
|
||||
@ -36,9 +53,9 @@ class EventTable(tables.Table):
|
||||
duration = DurationColumn()
|
||||
person_hours = DurationColumn()
|
||||
meetings = tables.Column()
|
||||
gross_revenue = tables.Column()
|
||||
total_due_to_instructor = tables.Column()
|
||||
net_revenue = tables.Column()
|
||||
gross_revenue = MoneyColumn()
|
||||
total_due_to_instructor = MoneyColumn()
|
||||
net_revenue = MoneyColumn()
|
||||
invoice__date_submitted = tables.DateColumn(verbose_name="Invoice Submitted")
|
||||
invoice__date_paid = tables.DateColumn(verbose_name="Invoice Paid")
|
||||
|
||||
@ -103,9 +120,9 @@ class EventSummaryTable(tables.Table):
|
||||
meetings__sum = tables.Column("Meetings")
|
||||
duration__sum = DurationColumn("Class Hours")
|
||||
person_hours__sum = DurationColumn("Person Hours")
|
||||
gross_revenue__sum = tables.Column("Gross Revenue")
|
||||
total_due_to_instructor__sum = tables.Column("Total Due to Instructor")
|
||||
net_revenue__sum = tables.Column("Net Revenue")
|
||||
gross_revenue__sum = MoneyColumn("Gross Revenue")
|
||||
total_due_to_instructor__sum = MoneyColumn("Total Due to Instructor")
|
||||
net_revenue__sum = MoneyColumn("Net Revenue")
|
||||
|
||||
|
||||
class UserEventTable(EventTable):
|
||||
@ -136,12 +153,7 @@ class CurrentAndUpcomingEventTable(EventTable):
|
||||
sequence = ("title", "start", "next_meeting")
|
||||
|
||||
|
||||
class InvoiceMoneyColumn(tables.columns.Column):
|
||||
def render(self, value):
|
||||
return f"${super().render(value):.2f}"
|
||||
|
||||
|
||||
class InvoiceMoneyFooterColumn(InvoiceMoneyColumn):
|
||||
class MoneyFooterColumn(MoneyColumn):
|
||||
def render_footer(self, bound_column, table):
|
||||
value = getattr(table.event, bound_column.accessor)
|
||||
if value is not None:
|
||||
@ -164,23 +176,23 @@ class InvoiceTable(tables.Table):
|
||||
)
|
||||
|
||||
label = tables.Column("Ticket Type", footer="Subtotals")
|
||||
list_price = InvoiceMoneyColumn("Ticket Price")
|
||||
actual_price = InvoiceMoneyColumn(_math_header("Actual Price", "P"))
|
||||
list_price = CurrencySymbolMoneyColumn("Ticket Price")
|
||||
actual_price = CurrencySymbolMoneyColumn(_math_header("Actual Price", "P"))
|
||||
quantity = tables.Column(
|
||||
_math_header("Quantity", "Q"),
|
||||
footer=lambda table: table.event.quantity,
|
||||
)
|
||||
amount = InvoiceMoneyFooterColumn(_math_header("Amount", "A=P*Q"))
|
||||
materials = InvoiceMoneyFooterColumn(
|
||||
amount = CurrencySymbolMoneyColumn(_math_header("Amount", "A=P*Q"))
|
||||
materials = CurrencySymbolMoneyColumn(
|
||||
_math_header("CMS Collected Materials Fee", "M=m*Q")
|
||||
)
|
||||
amount_without_materials = InvoiceMoneyFooterColumn(
|
||||
amount_without_materials = CurrencySymbolMoneyColumn(
|
||||
_math_header("Event Revenue Base", "B=A-M")
|
||||
)
|
||||
instructor_revenue = InvoiceMoneyFooterColumn(
|
||||
_math_header("Instructor Percentage Revenue", "R=B*I")
|
||||
instructor_revenue = CurrencySymbolMoneyColumn(
|
||||
_math_header("Instructor Percentage Revenue", "R=B*I"),
|
||||
)
|
||||
instructor_amount = InvoiceMoneyFooterColumn(
|
||||
instructor_amount = CurrencySymbolMoneyColumn(
|
||||
_math_header("Amount Due to Instructor", "R+M")
|
||||
)
|
||||
|
||||
|
@ -386,7 +386,6 @@ class EventDetailView(
|
||||
date_submitted=pdf_context["now"],
|
||||
amount=event.total_due_to_instructor,
|
||||
)
|
||||
# removed), currently used in event_invoice_admin.dj.html.
|
||||
|
||||
emails = make_invoice_emails(
|
||||
invoice, pdf, self.request.build_absolute_uri(event.get_absolute_url())
|
||||
|
@ -10,12 +10,13 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bootstrap": "^5.2.10",
|
||||
"@types/jquery": "^3.5.30",
|
||||
"@types/tabulator-tables": "^6.2.3",
|
||||
"globby": "^14.0.2",
|
||||
"prettier": "^3.3.3",
|
||||
"sass": "^1.77.8",
|
||||
"sass": "^1.78.0",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.4.2"
|
||||
"vite": "^5.4.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@popperjs/core": "^2.11.8",
|
||||
|
64
pdm.lock
generated
64
pdm.lock
generated
@ -5,7 +5,7 @@
|
||||
groups = ["default", "debug", "dev", "lint", "server", "typing"]
|
||||
strategy = ["inherit_metadata"]
|
||||
lock_version = "4.5.0"
|
||||
content_hash = "sha256:2352a5fdfb84e6efba254f0874182ba0b5ff2522a6d6384d3610c2c18671008f"
|
||||
content_hash = "sha256:ee03f713c8983dc55cb85aacb388f8df8b4f910a2704d3ecd32e4bd203c2a3dd"
|
||||
|
||||
[[metadata.targets]]
|
||||
requires_python = "==3.11.*"
|
||||
@ -267,6 +267,18 @@ files = [
|
||||
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.6.1"
|
||||
requires_python = ">=3.8"
|
||||
summary = "Code coverage measurement for Python"
|
||||
groups = ["dev"]
|
||||
marker = "python_version == \"3.11\""
|
||||
files = [
|
||||
{file = "coverage-7.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0420b573964c760df9e9e86d1a9a622d0d27f417e1a949a8a66dd7bcee7bc6"},
|
||||
{file = "coverage-7.6.1.tar.gz", hash = "sha256:953510dfb7b12ab69d20135a0662397f077c59b1e6379a768e97c59d852ee51d"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cssbeautifier"
|
||||
version = "1.15.1"
|
||||
@ -548,7 +560,7 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "django-model-utils"
|
||||
version = "4.5.1"
|
||||
version = "5.0.0"
|
||||
requires_python = ">=3.8"
|
||||
summary = "Django model mixins and utilities"
|
||||
groups = ["default"]
|
||||
@ -557,8 +569,8 @@ dependencies = [
|
||||
"Django>=3.2",
|
||||
]
|
||||
files = [
|
||||
{file = "django_model_utils-4.5.1-py3-none-any.whl", hash = "sha256:f1141fc71796242edeffed5ad53a8cc57f00d345eb5a3a63e3f69401cd562ee2"},
|
||||
{file = "django_model_utils-4.5.1.tar.gz", hash = "sha256:1220f22d9a467d53a1e0f4cda4857df0b2f757edf9a29955c42461988caa648a"},
|
||||
{file = "django_model_utils-5.0.0-py3-none-any.whl", hash = "sha256:fec78e6c323d565a221f7c4edc703f4567d7bb1caeafe1acd16a80c5ff82056b"},
|
||||
{file = "django_model_utils-5.0.0.tar.gz", hash = "sha256:041cdd6230d2fbf6cd943e1969318bce762272077f4ecd333ab2263924b4e5eb"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1018,7 +1030,7 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "google-api-python-client"
|
||||
version = "2.143.0"
|
||||
version = "2.144.0"
|
||||
requires_python = ">=3.7"
|
||||
summary = "Google API Client Library for Python"
|
||||
groups = ["default", "typing"]
|
||||
@ -1031,8 +1043,8 @@ dependencies = [
|
||||
"uritemplate<5,>=3.0.1",
|
||||
]
|
||||
files = [
|
||||
{file = "google_api_python_client-2.143.0-py2.py3-none-any.whl", hash = "sha256:d5654134522b9b574b82234e96f7e0aeeabcbf33643fbabcd449ef0068e3a476"},
|
||||
{file = "google_api_python_client-2.143.0.tar.gz", hash = "sha256:6a75441f9078e6e2fcdf4946a153fda1e2cc81b5e9c8d6e8c0750c85c7f8a566"},
|
||||
{file = "google_api_python_client-2.144.0-py2.py3-none-any.whl", hash = "sha256:f9c333ac4454a012adca90c297f9a22611a8953f3aae5481f90b3a56b9bdd413"},
|
||||
{file = "google_api_python_client-2.144.0.tar.gz", hash = "sha256:fe00851b257157bca600e1692ed8a54762c4a5c7d9eb7f6d4822059424b0d0a9"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1200,7 +1212,7 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "hypothesis"
|
||||
version = "6.111.2"
|
||||
version = "6.112.0"
|
||||
requires_python = ">=3.8"
|
||||
summary = "A library for property-based testing"
|
||||
groups = ["dev"]
|
||||
@ -1211,13 +1223,13 @@ dependencies = [
|
||||
"sortedcontainers<3.0.0,>=2.1.0",
|
||||
]
|
||||
files = [
|
||||
{file = "hypothesis-6.111.2-py3-none-any.whl", hash = "sha256:055e8228958e22178d6077e455fd86a72044d02dac130dbf9c8b31e161b9809c"},
|
||||
{file = "hypothesis-6.111.2.tar.gz", hash = "sha256:0496ad28c7240ee9ba89fcc7fb1dc74e89f3e40fbcbbb5f73c0091558dec8e6e"},
|
||||
{file = "hypothesis-6.112.0-py3-none-any.whl", hash = "sha256:1e6adbd9534c0d691690b5006904327ea37c851d4e15262a22094aa77879e84d"},
|
||||
{file = "hypothesis-6.112.0.tar.gz", hash = "sha256:06ea8857e1e711a1a6f24154a3c8c4eab04b041993206aaa267f98b859fd6ef5"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hypothesis"
|
||||
version = "6.111.2"
|
||||
version = "6.112.0"
|
||||
extras = ["django"]
|
||||
requires_python = ">=3.8"
|
||||
summary = "A library for property-based testing"
|
||||
@ -1225,11 +1237,11 @@ groups = ["dev"]
|
||||
marker = "python_version == \"3.11\""
|
||||
dependencies = [
|
||||
"django>=3.2",
|
||||
"hypothesis==6.111.2",
|
||||
"hypothesis==6.112.0",
|
||||
]
|
||||
files = [
|
||||
{file = "hypothesis-6.111.2-py3-none-any.whl", hash = "sha256:055e8228958e22178d6077e455fd86a72044d02dac130dbf9c8b31e161b9809c"},
|
||||
{file = "hypothesis-6.111.2.tar.gz", hash = "sha256:0496ad28c7240ee9ba89fcc7fb1dc74e89f3e40fbcbbb5f73c0091558dec8e6e"},
|
||||
{file = "hypothesis-6.112.0-py3-none-any.whl", hash = "sha256:1e6adbd9534c0d691690b5006904327ea37c851d4e15262a22094aa77879e84d"},
|
||||
{file = "hypothesis-6.112.0.tar.gz", hash = "sha256:06ea8857e1e711a1a6f24154a3c8c4eab04b041993206aaa267f98b859fd6ef5"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1949,14 +1961,14 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.6.3"
|
||||
version = "0.6.4"
|
||||
requires_python = ">=3.7"
|
||||
summary = "An extremely fast Python linter and code formatter, written in Rust."
|
||||
groups = ["lint"]
|
||||
marker = "python_version == \"3.11\""
|
||||
files = [
|
||||
{file = "ruff-0.6.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70452a10eb2d66549de8e75f89ae82462159855e983ddff91bc0bce6511d0470"},
|
||||
{file = "ruff-0.6.3.tar.gz", hash = "sha256:183b99e9edd1ef63be34a3b51fee0a9f4ab95add123dbf89a71f7b1f0c991983"},
|
||||
{file = "ruff-0.6.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b52387d3289ccd227b62102c24714ed75fbba0b16ecc69a923a37e3b5e0aaaa"},
|
||||
{file = "ruff-0.6.4.tar.gz", hash = "sha256:ac3b5bfbee99973f80aa1b7cbd1c9cbce200883bdd067300c22a6cc1c7fba212"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1973,14 +1985,14 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "setuptools"
|
||||
version = "74.1.1"
|
||||
version = "74.1.2"
|
||||
requires_python = ">=3.8"
|
||||
summary = "Easily download, build, install, upgrade, and uninstall Python packages"
|
||||
groups = ["server", "typing"]
|
||||
marker = "python_version == \"3.11\""
|
||||
files = [
|
||||
{file = "setuptools-74.1.1-py3-none-any.whl", hash = "sha256:fc91b5f89e392ef5b77fe143b17e32f65d3024744fba66dc3afe07201684d766"},
|
||||
{file = "setuptools-74.1.1.tar.gz", hash = "sha256:2353af060c06388be1cecbf5953dcdb1f38362f87a2356c480b6b4d5fcfc8847"},
|
||||
{file = "setuptools-74.1.2-py3-none-any.whl", hash = "sha256:5f4c08aa4d3ebcb57a50c33b1b07e94315d7fc7230f7115e47fc99776c8ce308"},
|
||||
{file = "setuptools-74.1.2.tar.gz", hash = "sha256:95b40ed940a1c67eb70fc099094bd6e99c6ee7c23aa2306f4d2697ba7916f9c6"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -2267,14 +2279,14 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "types-python-dateutil"
|
||||
version = "2.9.0.20240821"
|
||||
version = "2.9.0.20240906"
|
||||
requires_python = ">=3.8"
|
||||
summary = "Typing stubs for python-dateutil"
|
||||
groups = ["typing"]
|
||||
marker = "python_version == \"3.11\""
|
||||
files = [
|
||||
{file = "types-python-dateutil-2.9.0.20240821.tar.gz", hash = "sha256:9649d1dcb6fef1046fb18bebe9ea2aa0028b160918518c34589a46045f6ebd98"},
|
||||
{file = "types_python_dateutil-2.9.0.20240821-py3-none-any.whl", hash = "sha256:f5889fcb4e63ed4aaa379b44f93c32593d50b9a94c9a60a0c854d8cc3511cd57"},
|
||||
{file = "types-python-dateutil-2.9.0.20240906.tar.gz", hash = "sha256:9706c3b68284c25adffc47319ecc7947e5bb86b3773f843c73906fd598bc176e"},
|
||||
{file = "types_python_dateutil-2.9.0.20240906-py3-none-any.whl", hash = "sha256:27c8cc2d058ccb14946eebcaaa503088f4f6dbc4fb6093d3d456a49aef2753f6"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -2291,7 +2303,7 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "types-requests"
|
||||
version = "2.32.0.20240712"
|
||||
version = "2.32.0.20240907"
|
||||
requires_python = ">=3.8"
|
||||
summary = "Typing stubs for requests"
|
||||
groups = ["typing"]
|
||||
@ -2300,8 +2312,8 @@ dependencies = [
|
||||
"urllib3>=2",
|
||||
]
|
||||
files = [
|
||||
{file = "types-requests-2.32.0.20240712.tar.gz", hash = "sha256:90c079ff05e549f6bf50e02e910210b98b8ff1ebdd18e19c873cd237737c1358"},
|
||||
{file = "types_requests-2.32.0.20240712-py3-none-any.whl", hash = "sha256:f754283e152c752e46e70942fa2a146b5bc70393522257bb85bd1ef7e019dcc3"},
|
||||
{file = "types-requests-2.32.0.20240907.tar.gz", hash = "sha256:ff33935f061b5e81ec87997e91050f7b4af4f82027a7a7a9d9aaea04a963fdf8"},
|
||||
{file = "types_requests-2.32.0.20240907-py3-none-any.whl", hash = "sha256:1d1e79faeaf9d42def77f3c304893dea17a97cae98168ac69f3cb465516ee8da"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
112
pnpm-lock.yaml
generated
112
pnpm-lock.yaml
generated
@ -24,6 +24,9 @@ importers:
|
||||
'@types/bootstrap':
|
||||
specifier: ^5.2.10
|
||||
version: 5.2.10
|
||||
'@types/jquery':
|
||||
specifier: ^3.5.30
|
||||
version: 3.5.30
|
||||
'@types/tabulator-tables':
|
||||
specifier: ^6.2.3
|
||||
version: 6.2.3
|
||||
@ -34,14 +37,14 @@ importers:
|
||||
specifier: ^3.3.3
|
||||
version: 3.3.3
|
||||
sass:
|
||||
specifier: ^1.77.8
|
||||
version: 1.77.8
|
||||
specifier: ^1.78.0
|
||||
version: 1.79.4
|
||||
typescript:
|
||||
specifier: ^5.5.4
|
||||
version: 5.5.4
|
||||
vite:
|
||||
specifier: ^5.4.2
|
||||
version: 5.4.2(sass@1.77.8)
|
||||
specifier: ^5.4.3
|
||||
version: 5.4.3(sass@1.79.4)
|
||||
|
||||
packages:
|
||||
|
||||
@ -288,17 +291,15 @@ packages:
|
||||
'@types/estree@1.0.5':
|
||||
resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==}
|
||||
|
||||
'@types/jquery@3.5.30':
|
||||
resolution: {integrity: sha512-nbWKkkyb919DOUxjmRVk8vwtDb0/k8FKncmUKFi+NY+QXqWltooxTrswvz4LspQwxvLdvzBN1TImr6cw3aQx2A==}
|
||||
|
||||
'@types/sizzle@2.3.8':
|
||||
resolution: {integrity: sha512-0vWLNK2D5MT9dg0iOo8GlKguPAU02QjmZitPEsXRuJXU/OGIOt9vT9Fc26wtYuavLxtO45v9PGleoL9Z0k1LHg==}
|
||||
|
||||
'@types/tabulator-tables@6.2.3':
|
||||
resolution: {integrity: sha512-ZeRF/WvtwFXml/4aT7kzfkHEiwbjHZdlIsjrgqcfdmpkl9GQ9XBHY6u9BblUaHX4NUiOlBeHrQKjvai6/bQH0g==}
|
||||
|
||||
anymatch@3.1.3:
|
||||
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
binary-extensions@2.3.0:
|
||||
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
bootstrap-icons@1.11.3:
|
||||
resolution: {integrity: sha512-+3lpHrCw/it2/7lBL15VR0HEumaBss0+f/Lb6ZvHISn1mlK83jjFpooTLsMWbIjJMDjDjOExMsTxnXSIT4k4ww==}
|
||||
|
||||
@ -311,9 +312,9 @@ packages:
|
||||
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
chokidar@3.6.0:
|
||||
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
|
||||
engines: {node: '>= 8.10.0'}
|
||||
chokidar@4.0.1:
|
||||
resolution: {integrity: sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==}
|
||||
engines: {node: '>= 14.16.0'}
|
||||
|
||||
esbuild@0.21.5:
|
||||
resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
|
||||
@ -351,10 +352,6 @@ packages:
|
||||
immutable@4.3.7:
|
||||
resolution: {integrity: sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==}
|
||||
|
||||
is-binary-path@2.1.0:
|
||||
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
is-extglob@2.1.1:
|
||||
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@ -380,10 +377,6 @@ packages:
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
normalize-path@3.0.0:
|
||||
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
path-type@5.0.0:
|
||||
resolution: {integrity: sha512-5HviZNaZcfqP95rwpv+1HDgUamezbqdSYTyzjTvwtJSnIH+3vnbmWsItli8OFEndS984VT55M3jduxZbX351gg==}
|
||||
engines: {node: '>=12'}
|
||||
@ -395,8 +388,8 @@ packages:
|
||||
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
|
||||
engines: {node: '>=8.6'}
|
||||
|
||||
postcss@8.4.44:
|
||||
resolution: {integrity: sha512-Aweb9unOEpQ3ezu4Q00DPvvM2ZTUitJdNKeP/+uQgr1IBIqu574IaZoURId7BKtWMREwzKa9OgzPzezWGPWFQw==}
|
||||
postcss@8.4.45:
|
||||
resolution: {integrity: sha512-7KTLTdzdZZYscUc65XmjFiB73vBhBfbPztCYdUNvlaso9PrzjzcmjqBPR0lNGkcVlcO4BjiO5rK/qNz+XAen1Q==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
prettier@3.3.3:
|
||||
@ -407,9 +400,9 @@ packages:
|
||||
queue-microtask@1.2.3:
|
||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||
|
||||
readdirp@3.6.0:
|
||||
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
|
||||
engines: {node: '>=8.10.0'}
|
||||
readdirp@4.0.1:
|
||||
resolution: {integrity: sha512-GkMg9uOTpIWWKbSsgwb5fA4EavTR+SG/PMPoAY8hkhHfEEY0/vqljY+XHqtDf2cr2IJtoNRDbrrEpZUiZCkYRw==}
|
||||
engines: {node: '>= 14.16.0'}
|
||||
|
||||
reusify@1.0.4:
|
||||
resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==}
|
||||
@ -423,8 +416,8 @@ packages:
|
||||
run-parallel@1.2.0:
|
||||
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
|
||||
|
||||
sass@1.77.8:
|
||||
resolution: {integrity: sha512-4UHg6prsrycW20fqLGPShtEvo/WyHRVRHwOP4DzkUrObWoWI05QBSfzU71TVB7PFaL104TwNaHpjlWXAZbQiNQ==}
|
||||
sass@1.79.4:
|
||||
resolution: {integrity: sha512-K0QDSNPXgyqO4GZq2HO5Q70TLxTH6cIT59RdoCHMivrC8rqzaTw5ab9prjz9KUN1El4FLXrBXJhik61JR4HcGg==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
hasBin: true
|
||||
|
||||
@ -432,8 +425,8 @@ packages:
|
||||
resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==}
|
||||
engines: {node: '>=14.16'}
|
||||
|
||||
source-map-js@1.2.0:
|
||||
resolution: {integrity: sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==}
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
tabulator-tables@6.2.5:
|
||||
@ -452,8 +445,8 @@ packages:
|
||||
resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
vite@5.4.2:
|
||||
resolution: {integrity: sha512-dDrQTRHp5C1fTFzcSaMxjk6vdpKvT+2/mIdE07Gw2ykehT49O0z/VHS3zZ8iV/Gh8BJJKHWOe5RjaNrW5xf/GA==}
|
||||
vite@5.4.3:
|
||||
resolution: {integrity: sha512-IH+nl64eq9lJjFqU+/yrRnrHPVTlgy42/+IzbOdaFDVlyLgI/wDlf+FCobXLX1cT0X5+7LMyH1mIy2xJdLfo8Q==}
|
||||
engines: {node: ^18.0.0 || >=20.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@ -624,14 +617,13 @@ snapshots:
|
||||
|
||||
'@types/estree@1.0.5': {}
|
||||
|
||||
'@types/tabulator-tables@6.2.3': {}
|
||||
|
||||
anymatch@3.1.3:
|
||||
'@types/jquery@3.5.30':
|
||||
dependencies:
|
||||
normalize-path: 3.0.0
|
||||
picomatch: 2.3.1
|
||||
'@types/sizzle': 2.3.8
|
||||
|
||||
binary-extensions@2.3.0: {}
|
||||
'@types/sizzle@2.3.8': {}
|
||||
|
||||
'@types/tabulator-tables@6.2.3': {}
|
||||
|
||||
bootstrap-icons@1.11.3: {}
|
||||
|
||||
@ -643,17 +635,9 @@ snapshots:
|
||||
dependencies:
|
||||
fill-range: 7.1.1
|
||||
|
||||
chokidar@3.6.0:
|
||||
chokidar@4.0.1:
|
||||
dependencies:
|
||||
anymatch: 3.1.3
|
||||
braces: 3.0.3
|
||||
glob-parent: 5.1.2
|
||||
is-binary-path: 2.1.0
|
||||
is-glob: 4.0.3
|
||||
normalize-path: 3.0.0
|
||||
readdirp: 3.6.0
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
readdirp: 4.0.1
|
||||
|
||||
esbuild@0.21.5:
|
||||
optionalDependencies:
|
||||
@ -717,10 +701,6 @@ snapshots:
|
||||
|
||||
immutable@4.3.7: {}
|
||||
|
||||
is-binary-path@2.1.0:
|
||||
dependencies:
|
||||
binary-extensions: 2.3.0
|
||||
|
||||
is-extglob@2.1.1: {}
|
||||
|
||||
is-glob@4.0.3:
|
||||
@ -738,27 +718,23 @@ snapshots:
|
||||
|
||||
nanoid@3.3.7: {}
|
||||
|
||||
normalize-path@3.0.0: {}
|
||||
|
||||
path-type@5.0.0: {}
|
||||
|
||||
picocolors@1.1.0: {}
|
||||
|
||||
picomatch@2.3.1: {}
|
||||
|
||||
postcss@8.4.44:
|
||||
postcss@8.4.45:
|
||||
dependencies:
|
||||
nanoid: 3.3.7
|
||||
picocolors: 1.1.0
|
||||
source-map-js: 1.2.0
|
||||
source-map-js: 1.2.1
|
||||
|
||||
prettier@3.3.3: {}
|
||||
|
||||
queue-microtask@1.2.3: {}
|
||||
|
||||
readdirp@3.6.0:
|
||||
dependencies:
|
||||
picomatch: 2.3.1
|
||||
readdirp@4.0.1: {}
|
||||
|
||||
reusify@1.0.4: {}
|
||||
|
||||
@ -788,15 +764,15 @@ snapshots:
|
||||
dependencies:
|
||||
queue-microtask: 1.2.3
|
||||
|
||||
sass@1.77.8:
|
||||
sass@1.79.4:
|
||||
dependencies:
|
||||
chokidar: 3.6.0
|
||||
chokidar: 4.0.1
|
||||
immutable: 4.3.7
|
||||
source-map-js: 1.2.0
|
||||
source-map-js: 1.2.1
|
||||
|
||||
slash@5.1.0: {}
|
||||
|
||||
source-map-js@1.2.0: {}
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
tabulator-tables@6.2.5: {}
|
||||
|
||||
@ -808,11 +784,11 @@ snapshots:
|
||||
|
||||
unicorn-magic@0.1.0: {}
|
||||
|
||||
vite@5.4.2(sass@1.77.8):
|
||||
vite@5.4.3(sass@1.79.4):
|
||||
dependencies:
|
||||
esbuild: 0.21.5
|
||||
postcss: 8.4.44
|
||||
postcss: 8.4.45
|
||||
rollup: 4.21.2
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
sass: 1.77.8
|
||||
sass: 1.79.4
|
||||
|
@ -39,9 +39,9 @@ dependencies = [
|
||||
"django-configurations[database,email]~=2.5",
|
||||
"django-vite~=3.0",
|
||||
"django-template-partials~=24.4",
|
||||
"google-api-python-client~=2.143",
|
||||
"google-api-python-client~=2.144",
|
||||
"google-auth-oauthlib~=1.2",
|
||||
"django-model-utils~=4.5",
|
||||
"django-model-utils~=5.0",
|
||||
"psycopg[binary,pool]~=3.2",
|
||||
"django-simple-history~=3.7",
|
||||
"django-postgres-metrics~=0.15",
|
||||
@ -132,6 +132,20 @@ plugins = [
|
||||
django_settings_module = "cmsmanage.settings"
|
||||
strict_settings = false
|
||||
|
||||
[tool.coverage.run]
|
||||
source = [
|
||||
"cmsmanage",
|
||||
"dashboard",
|
||||
"doorcontrol",
|
||||
"membershipworks",
|
||||
"paperwork",
|
||||
"rentals",
|
||||
"reservations",
|
||||
]
|
||||
omit = [
|
||||
"*/migrations/*",
|
||||
]
|
||||
|
||||
[[tool.pdm.source]]
|
||||
url = "https://pypi.org/simple"
|
||||
verify_ssl = true
|
||||
@ -169,8 +183,9 @@ debug = [
|
||||
dev = [
|
||||
"django-extensions~=3.2",
|
||||
"ipython~=8.27",
|
||||
"hypothesis[django]~=6.111",
|
||||
"hypothesis[django]~=6.112",
|
||||
"tblib~=3.0",
|
||||
"coverage~=7.6",
|
||||
]
|
||||
|
||||
[tool.pdm.scripts]
|
||||
|
24
reservations/migrations/0003_reservation_timespan.py
Normal file
24
reservations/migrations/0003_reservation_timespan.py
Normal file
@ -0,0 +1,24 @@
|
||||
# Generated by Django 5.1.1 on 2024-09-09 21:32
|
||||
|
||||
import django.contrib.postgres.fields.ranges
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("reservations", "0002_externalreservation"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="reservation",
|
||||
name="timespan",
|
||||
field=models.GeneratedField(
|
||||
db_persist=True,
|
||||
expression=models.Func(
|
||||
models.F("start"), models.F("end"), function="tstzrange"
|
||||
),
|
||||
output_field=django.contrib.postgres.fields.ranges.DateTimeRangeField(),
|
||||
),
|
||||
),
|
||||
]
|
@ -3,11 +3,13 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.postgres.fields import DateTimeRangeField
|
||||
from django.db import models
|
||||
from django.db.models import F, Q, QuerySet
|
||||
from django.utils import timezone
|
||||
|
||||
from model_utils.managers import InheritanceQuerySetMixin
|
||||
from psycopg.types.range import Range
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable
|
||||
@ -55,31 +57,32 @@ class ReservationQuerySet(InheritanceQuerySetMixin, QuerySet):
|
||||
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))
|
||||
return self.filter(timespan__overlap=Range(start, None))
|
||||
|
||||
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))
|
||||
return self.filter(timespan__overlap=Range(None, 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))
|
||||
)
|
||||
return self.filter(timespan__overlap=Range(start, end))
|
||||
|
||||
|
||||
class Reservation(models.Model):
|
||||
resources = models.ManyToManyField(Resource, blank=True)
|
||||
start = models.DateTimeField()
|
||||
end = models.DateTimeField()
|
||||
timespan = models.GeneratedField(
|
||||
expression=models.Func(F("start"), F("end"), function="tstzrange"),
|
||||
output_field=DateTimeRangeField(),
|
||||
db_persist=True,
|
||||
)
|
||||
google_calendar_event_id = models.CharField(
|
||||
max_length=1024, null=True, blank=True, unique=True
|
||||
)
|
||||
@ -101,6 +104,10 @@ class Reservation(models.Model):
|
||||
resources = ", ".join(str(resource) for resource in self.resources.all())
|
||||
return f"{resources}: {self.start} - {self.end}"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Redefined to avoid __str__ and therefore .resources, which was causing recursion issues"""
|
||||
return f"<{self.__class__.__name__}: {self.start} - {self.end}>"
|
||||
|
||||
def get_title(self) -> str:
|
||||
return "Unknown Reservation"
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user