Repository navigation
Expand file tree
/
Copy pathmodels.py
More file actions
335 lines (276 loc) · 12.4 KB
/
Copy pathmodels.py
File metadata and controls
335 lines (276 loc) · 12.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
# SPDX-License-Identifier: Apache-2.0
# Copyright 2026 Universität Osnabrück (virtUOS)
"""Lending domain — the heart of the system (ADR-0003, ADR-0006).
Bookings are time intervals per resource. A PostgreSQL exclusion constraint
guarantees that no two *active* booking items for the same resource overlap.
"""
from django.conf import settings
from django.contrib.postgres.constraints import ExclusionConstraint
from django.contrib.postgres.fields import DateTimeRangeField, RangeOperators
from django.db import models
from django.db.models import Q
from catalog.models import Product
from common.models import TimeStampedModel
# Booking items of on-site products without desk hand-out (#116): never
# handed out or taken back; closed automatically once their period ended
# (``services.complete_self_service_items``).
SELF_SERVICE_ITEM = Q(
resource__product__use_type=Product.UseType.ONSITE,
resource__product__requires_handout=False,
)
class Booking(TimeStampedModel):
"""A borrower's reservation/booking, made up of one or more items."""
class Status(models.TextChoices):
CART = "cart", "Cart" # in progress; holds slots, expires
PENDING = "pending", "Pending" # submitted, awaiting confirmation
CONFIRMED = "confirmed", "Confirmed"
HANDED_OUT = "handed_out", "Handed out"
RETURNED = "returned", "Returned"
CANCELLED = "cancelled", "Cancelled"
borrower = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name="bookings",
)
status = models.CharField(
max_length=12, choices=Status.choices, default=Status.CART
)
# Human-readable reservation number, e.g. "R-00042".
code = models.CharField(max_length=16, blank=True)
# Optional borrower message (e.g. "for the music seminar", "picked up by X").
note = models.TextField(blank=True)
# When a cart's slot hold expires (then the held slots are free again).
expires_at = models.DateTimeField(null=True, blank=True)
# Last time an overdue reminder was emailed (to avoid resending too often).
overdue_reminded_at = models.DateTimeField(null=True, blank=True)
# When the "pickup tomorrow" reminder was emailed (sent once; cleared
# when a closure moves the booking, so the new date is reminded too).
pickup_reminded_at = models.DateTimeField(null=True, blank=True)
# The single pool this reservation belongs to (#26). Only carts may leave
# this empty; a multi-pool cart is split per pool on submit.
resource_pool = models.ForeignKey(
"catalog.ResourcePool", null=True, blank=True, on_delete=models.PROTECT,
related_name="bookings",
)
# Groups the per-pool bookings produced by splitting one multi-pool cart
# on submit (#26); empty for a booking that was never split.
checkout_id = models.UUIDField(null=True, blank=True, db_index=True)
# When this reservation was confirmed (lender action or auto-confirm).
confirmed_at = models.DateTimeField(null=True, blank=True)
# Optional message shown to the borrower alongside the confirmation.
confirmation_message = models.TextField(blank=True)
# When the confirmation mail was actually sent (held until the daily
# send time; #26).
confirmation_mailed_at = models.DateTimeField(null=True, blank=True)
# A lender deliberately booked a period outside the effective min/max
# lending duration at the desk (walk-in override, #109).
duration_override = models.BooleanField(default=False)
class Meta:
ordering = ["-created_at"]
def __str__(self):
return f"Booking {self.code or f'#{self.pk}'} ({self.status})"
def submit(self, note=""):
"""Submit a cart as a reservation: awaiting confirmation, no expiry."""
self.status = self.Status.PENDING
self.note = note
self.expires_at = None
self.save(update_fields=["status", "note", "expires_at", "updated_at"])
def cancel(self):
"""Cancel the booking and release its held slots."""
self.status = self.Status.CANCELLED
self.save(update_fields=["status", "updated_at"])
self.items.update(is_active=False)
def confirm(self, message=""):
"""Confirm a pending reservation (the hold no longer expires)."""
from django.utils import timezone
self.status = self.Status.CONFIRMED
self.expires_at = None
self.confirmed_at = timezone.now()
self.confirmation_message = message or ""
self.save(update_fields=[
"status", "expires_at", "confirmed_at", "confirmation_message", "updated_at",
])
def order_parts(self):
"""All reservations of this checkout (incl. self), pool-ordered (#26)."""
if not self.checkout_id:
return [self]
return list(
Booking.objects.filter(checkout_id=self.checkout_id)
.select_related("resource_pool")
.order_by("resource_pool__position", "resource_pool__name", "id")
)
def _sync_status(self):
"""Roll the booking status up from its items' handout/return state."""
# values_list issues a fresh query, bypassing any prefetched item cache.
# Cancelled/dropped items (inactive, never handed out) don't count —
# e.g. an uncollected loan item next to a used self-service one (#116).
# If nothing counts, the status is kept (only HANDED_OUT/RETURNED
# revert to CONFIRMED).
states = list(
self.items.filter(Q(is_active=True) | Q(handed_out_at__isnull=False))
.values_list("handed_out_at", "returned_at")
)
if states and all(returned for _, returned in states):
new_status = self.Status.RETURNED
elif any(out and not returned for out, returned in states):
new_status = self.Status.HANDED_OUT
elif self.status in (self.Status.HANDED_OUT, self.Status.RETURNED):
new_status = self.Status.CONFIRMED # everything reverted
else:
new_status = self.status
if new_status != self.status:
self.status = new_status
self.save(update_fields=["status", "updated_at"])
def hand_out_items(self, item_ids=None):
"""Hand out items (one appointment); default = all not-yet-handed-out.
Self-service on-site items (#116) are never handed out.
"""
from django.utils import timezone
items = self.items.filter(
is_active=True, handed_out_at__isnull=True
).exclude(SELF_SERVICE_ITEM)
if item_ids is not None:
items = items.filter(id__in=item_ids)
items.update(handed_out_at=timezone.now())
self._sync_status()
def return_items(self, item_ids=None):
"""Take items back (one appointment); frees their slots."""
from django.utils import timezone
items = self.items.filter(
handed_out_at__isnull=False, returned_at__isnull=True
)
if item_ids is not None:
items = items.filter(id__in=item_ids)
items.update(returned_at=timezone.now(), is_active=False)
self._sync_status()
# Whole-booking convenience (used by tests / single-appointment bookings).
def hand_out(self):
self.hand_out_items()
def mark_returned(self):
self.return_items()
class Block(TimeStampedModel):
"""A blocked time range during which affected resources cannot be booked.
Scope is determined by which target is set (most specific wins for the
"scope" label, but a resource is blocked if *any* applicable block exists):
resource > product > pool > system (all targets empty = system-wide).
"""
period = DateTimeRangeField()
reason = models.CharField(max_length=255, blank=True)
resource_pool = models.ForeignKey(
"catalog.ResourcePool", null=True, blank=True, on_delete=models.CASCADE,
related_name="blocks",
)
product = models.ForeignKey(
"catalog.Product", null=True, blank=True, on_delete=models.CASCADE,
related_name="blocks",
)
resource = models.ForeignKey(
"catalog.Resource", null=True, blank=True, on_delete=models.CASCADE,
related_name="blocks",
)
class Meta:
ordering = ["-created_at"]
@property
def scope(self):
if self.resource_id:
return "resource"
if self.product_id:
return "product"
if self.resource_pool_id:
return "pool"
return "system"
def __str__(self):
return f"Block ({self.scope}) {self.period}"
class BookingItem(models.Model):
"""One reserved resource for a time interval."""
booking = models.ForeignKey(Booking, on_delete=models.CASCADE, related_name="items")
resource = models.ForeignKey(
"catalog.Resource",
on_delete=models.PROTECT,
related_name="booking_items",
)
period = DateTimeRangeField()
# Whether this item currently occupies the resource (false once cancelled
# or returned).
is_active = models.BooleanField(default=True)
# Per-appointment handout/return — a multi-period reservation is handed out
# and taken back one appointment at a time.
handed_out_at = models.DateTimeField(null=True, blank=True)
returned_at = models.DateTimeField(null=True, blank=True)
# Set when the "product missing" notice was sent, so the periodic job does
# not mail the same upcoming borrower twice (issue #48).
missing_notified_at = models.DateTimeField(null=True, blank=True)
class Meta:
constraints = [
ExclusionConstraint(
name="no_overlapping_active_bookings",
expressions=[
("resource", RangeOperators.EQUAL),
("period", RangeOperators.OVERLAPS),
],
condition=Q(is_active=True),
)
]
@property
def is_self_service(self):
"""On-site use without desk hand-out (#116)."""
return self.resource.product.is_self_service
@property
def is_dropped(self):
"""Never handed out and no longer active (#116): a desk item dropped
from a live or finished booking by ``cancel_uncollected_bookings``
(or the whole booking cancelled). Not desk work any more."""
return not self.is_active and self.handed_out_at is None
def __str__(self):
return f"{self.resource} @ {self.period}"
class BookingReminder(TimeStampedModel):
"""Audit record of an overdue reminder emailed to the borrower.
``created_at`` is when the reminder was sent.
"""
booking = models.ForeignKey(
Booking, on_delete=models.CASCADE, related_name="reminders"
)
recipient = models.EmailField(blank=True)
overdue_pickups = models.PositiveSmallIntegerField(default=0)
overdue_returns = models.PositiveSmallIntegerField(default=0)
class Meta:
ordering = ["-created_at"]
def __str__(self):
return f"Reminder for booking {self.booking_id} at {self.created_at}"
class HolidaySetting(models.Model):
"""System-wide region used to auto-load public holidays as blocks.
A singleton (pk is forced to 1). Admins set the country and subdivision
(e.g. DE / NI); the system loads public holidays for at least the longest
pool booking horizon (concept §3.5).
"""
country = models.CharField(max_length=2, default="DE")
subdivision = models.CharField(max_length=8, blank=True)
class Meta:
verbose_name = "holiday setting"
def save(self, *args, **kwargs):
self.pk = 1
super().save(*args, **kwargs)
@classmethod
def load(cls):
obj, _ = cls.objects.get_or_create(pk=1)
return obj
def __str__(self):
return f"Holidays: {self.country}/{self.subdivision or '—'}"
class CartSetting(models.Model):
"""System-wide cart configuration (concept §4.5).
A singleton (pk is forced to 1). ``hold_minutes`` is how long a cart holds
its reserved resources before the hold expires; every cart action renews
the hold. When it lapses the resources are freed again.
"""
hold_minutes = models.PositiveIntegerField(default=30)
class Meta:
verbose_name = "cart setting"
def save(self, *args, **kwargs):
self.pk = 1
super().save(*args, **kwargs)
@classmethod
def load(cls):
obj, _ = cls.objects.get_or_create(pk=1)
return obj
def __str__(self):
return f"Cart hold: {self.hold_minutes} min"