generated from bisco/codex-bootstrap
feat: add initial Django domain models
This commit is contained in:
34
backend/bookings/admin.py
Normal file
34
backend/bookings/admin.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import Reservation, ReservationToken
|
||||
|
||||
|
||||
class ReservationTokenInline(admin.TabularInline):
|
||||
model = ReservationToken
|
||||
extra = 0
|
||||
readonly_fields = ("token_hash", "used_at", "created_at")
|
||||
fields = ("purpose", "token_hash", "expires_at", "used_at", "created_at")
|
||||
|
||||
|
||||
@admin.register(Reservation)
|
||||
class ReservationAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"name",
|
||||
"email",
|
||||
"performance",
|
||||
"party_size",
|
||||
"status",
|
||||
"confirmed_at",
|
||||
"created_at",
|
||||
)
|
||||
list_filter = ("status", "performance", "created_at", "confirmed_at")
|
||||
search_fields = ("name", "email", "phone", "performance__show__title")
|
||||
inlines = (ReservationTokenInline,)
|
||||
|
||||
|
||||
@admin.register(ReservationToken)
|
||||
class ReservationTokenAdmin(admin.ModelAdmin):
|
||||
list_display = ("reservation", "purpose", "expires_at", "used_at", "created_at")
|
||||
list_filter = ("purpose", "expires_at", "used_at", "created_at")
|
||||
search_fields = ("reservation__name", "reservation__email", "token_hash")
|
||||
readonly_fields = ("token_hash", "created_at", "used_at")
|
||||
95
backend/bookings/migrations/0001_initial.py
Normal file
95
backend/bookings/migrations/0001_initial.py
Normal file
@@ -0,0 +1,95 @@
|
||||
# Generated by Django 5.2.3 on 2026-04-28
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("shows", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Reservation",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("pending", "Pending"),
|
||||
("confirmed", "Confirmed"),
|
||||
("cancelled", "Cancelled"),
|
||||
("expired", "Expired"),
|
||||
],
|
||||
db_index=True,
|
||||
default="pending",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=200)),
|
||||
("email", models.EmailField(db_index=True, max_length=254)),
|
||||
("phone", models.CharField(blank=True, max_length=40)),
|
||||
("party_size", models.PositiveIntegerField(validators=[django.core.validators.MinValueValidator(1)])),
|
||||
("notes", models.TextField(blank=True)),
|
||||
("confirmed_at", models.DateTimeField(blank=True, null=True)),
|
||||
("qr_code_generated_at", models.DateTimeField(blank=True, null=True)),
|
||||
(
|
||||
"performance",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="reservations",
|
||||
to="shows.performance",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-created_at"],
|
||||
"indexes": [
|
||||
models.Index(fields=["performance", "status"], name="bookings_re_perform_730504_idx"),
|
||||
models.Index(fields=["email"], name="bookings_re_email_924b70_idx"),
|
||||
models.Index(fields=["created_at"], name="bookings_re_created_823f26_idx"),
|
||||
],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ReservationToken",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
(
|
||||
"purpose",
|
||||
models.CharField(
|
||||
choices=[("confirmation", "Confirmation"), ("check_in", "Check-in")],
|
||||
db_index=True,
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
("token_hash", models.CharField(max_length=64, unique=True)),
|
||||
("expires_at", models.DateTimeField()),
|
||||
("used_at", models.DateTimeField(blank=True, null=True)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"reservation",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="tokens",
|
||||
to="bookings.reservation",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-created_at"],
|
||||
"indexes": [
|
||||
models.Index(fields=["reservation", "purpose"], name="bookings_re_reserva_017db4_idx"),
|
||||
models.Index(fields=["purpose", "expires_at"], name="bookings_re_purpose_36fca5_idx"),
|
||||
models.Index(fields=["used_at"], name="bookings_re_used_at_ae1c4d_idx"),
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
1
backend/bookings/migrations/__init__.py
Normal file
1
backend/bookings/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
@@ -1 +1,138 @@
|
||||
# Domain models will be added when booking behavior is implemented.
|
||||
import hashlib
|
||||
import secrets
|
||||
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
from shows.models import Performance, TimeStampedModel
|
||||
|
||||
|
||||
class Reservation(TimeStampedModel):
|
||||
class Status(models.TextChoices):
|
||||
PENDING = "pending", "Pending"
|
||||
CONFIRMED = "confirmed", "Confirmed"
|
||||
CANCELLED = "cancelled", "Cancelled"
|
||||
EXPIRED = "expired", "Expired"
|
||||
|
||||
performance = models.ForeignKey(
|
||||
Performance,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="reservations",
|
||||
)
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
choices=Status.choices,
|
||||
default=Status.PENDING,
|
||||
db_index=True,
|
||||
)
|
||||
name = models.CharField(max_length=200)
|
||||
email = models.EmailField(db_index=True)
|
||||
phone = models.CharField(max_length=40, blank=True)
|
||||
party_size = models.PositiveIntegerField(validators=[MinValueValidator(1)])
|
||||
notes = models.TextField(blank=True)
|
||||
confirmed_at = models.DateTimeField(blank=True, null=True)
|
||||
qr_code_generated_at = models.DateTimeField(blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
indexes = [
|
||||
models.Index(fields=["performance", "status"]),
|
||||
models.Index(fields=["email"]),
|
||||
models.Index(fields=["created_at"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.party_size}) for {self.performance}"
|
||||
|
||||
@property
|
||||
def is_confirmed(self):
|
||||
return self.status == self.Status.CONFIRMED
|
||||
|
||||
def confirm(self):
|
||||
if self.status != self.Status.PENDING:
|
||||
raise ValidationError("Only pending reservations can be confirmed.")
|
||||
if not self.confirmed_at:
|
||||
self.confirmed_at = timezone.now()
|
||||
self.status = self.Status.CONFIRMED
|
||||
self.save(update_fields=["status", "confirmed_at", "updated_at"])
|
||||
|
||||
def confirm_with_token(self, raw_token):
|
||||
token = self.tokens.get_valid_token(raw_token, ReservationToken.Purpose.CONFIRMATION)
|
||||
self.confirm()
|
||||
token.mark_used()
|
||||
return token
|
||||
|
||||
|
||||
class ReservationTokenQuerySet(models.QuerySet):
|
||||
def get_valid_token(self, raw_token, purpose):
|
||||
token_hash = ReservationToken.hash_token(raw_token)
|
||||
now = timezone.now()
|
||||
return self.get(
|
||||
token_hash=token_hash,
|
||||
purpose=purpose,
|
||||
used_at__isnull=True,
|
||||
expires_at__gt=now,
|
||||
)
|
||||
|
||||
|
||||
class ReservationToken(models.Model):
|
||||
class Purpose(models.TextChoices):
|
||||
CONFIRMATION = "confirmation", "Confirmation"
|
||||
CHECK_IN = "check_in", "Check-in"
|
||||
|
||||
reservation = models.ForeignKey(
|
||||
Reservation,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="tokens",
|
||||
)
|
||||
purpose = models.CharField(max_length=20, choices=Purpose.choices, db_index=True)
|
||||
token_hash = models.CharField(max_length=64, unique=True)
|
||||
expires_at = models.DateTimeField()
|
||||
used_at = models.DateTimeField(blank=True, null=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
objects = ReservationTokenQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
indexes = [
|
||||
models.Index(fields=["reservation", "purpose"]),
|
||||
models.Index(fields=["purpose", "expires_at"]),
|
||||
models.Index(fields=["used_at"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.get_purpose_display()} token for reservation {self.reservation_id}"
|
||||
|
||||
@staticmethod
|
||||
def generate_raw_token():
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
@staticmethod
|
||||
def hash_token(raw_token):
|
||||
return hashlib.sha256(raw_token.encode("utf-8")).hexdigest()
|
||||
|
||||
@classmethod
|
||||
def create_token(cls, reservation, purpose, expires_at):
|
||||
raw_token = cls.generate_raw_token()
|
||||
token = cls.objects.create(
|
||||
reservation=reservation,
|
||||
purpose=purpose,
|
||||
token_hash=cls.hash_token(raw_token),
|
||||
expires_at=expires_at,
|
||||
)
|
||||
return token, raw_token
|
||||
|
||||
@property
|
||||
def is_expired(self):
|
||||
return timezone.now() >= self.expires_at
|
||||
|
||||
@property
|
||||
def is_used(self):
|
||||
return self.used_at is not None
|
||||
|
||||
def mark_used(self):
|
||||
self.used_at = timezone.now()
|
||||
self.save(update_fields=["used_at"])
|
||||
|
||||
119
backend/bookings/tests.py
Normal file
119
backend/bookings/tests.py
Normal file
@@ -0,0 +1,119 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import IntegrityError
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
|
||||
from bookings.models import Reservation, ReservationToken
|
||||
from checkins.models import CheckIn
|
||||
from shows.models import Performance, Show, Venue
|
||||
|
||||
|
||||
class DomainModelTests(TestCase):
|
||||
def setUp(self):
|
||||
self.show = Show.objects.create(
|
||||
title="Open Stage",
|
||||
slug="open-stage",
|
||||
is_published=True,
|
||||
)
|
||||
self.venue = Venue.objects.create(
|
||||
name="AzioneLab Theatre",
|
||||
slug="azionelab-theatre",
|
||||
address="Via Example 1",
|
||||
city="Rome",
|
||||
)
|
||||
self.performance = Performance.objects.create(
|
||||
show=self.show,
|
||||
venue=self.venue,
|
||||
starts_at=timezone.now() + timedelta(days=7),
|
||||
room_capacity=10,
|
||||
manually_occupied_seats=2,
|
||||
additional_seats=3,
|
||||
)
|
||||
|
||||
def create_reservation(self, **overrides):
|
||||
data = {
|
||||
"performance": self.performance,
|
||||
"name": "Maria Rossi",
|
||||
"email": "maria@example.com",
|
||||
"party_size": 2,
|
||||
}
|
||||
data.update(overrides)
|
||||
return Reservation.objects.create(**data)
|
||||
|
||||
def test_available_seats_count_only_confirmed_reservations(self):
|
||||
self.create_reservation(party_size=4)
|
||||
self.create_reservation(
|
||||
party_size=3,
|
||||
status=Reservation.Status.CONFIRMED,
|
||||
confirmed_at=timezone.now(),
|
||||
)
|
||||
|
||||
self.assertEqual(self.performance.configured_capacity, 11)
|
||||
self.assertEqual(self.performance.confirmed_seats(), 3)
|
||||
self.assertEqual(self.performance.available_seats(), 8)
|
||||
|
||||
def test_reservation_lifecycle_pending_to_confirmed_with_token(self):
|
||||
reservation = self.create_reservation()
|
||||
token, raw_token = ReservationToken.create_token(
|
||||
reservation=reservation,
|
||||
purpose=ReservationToken.Purpose.CONFIRMATION,
|
||||
expires_at=timezone.now() + timedelta(hours=2),
|
||||
)
|
||||
|
||||
reservation.confirm_with_token(raw_token)
|
||||
reservation.refresh_from_db()
|
||||
token.refresh_from_db()
|
||||
|
||||
self.assertEqual(reservation.status, Reservation.Status.CONFIRMED)
|
||||
self.assertIsNotNone(reservation.confirmed_at)
|
||||
self.assertIsNotNone(token.used_at)
|
||||
|
||||
def test_only_pending_reservations_can_be_confirmed(self):
|
||||
reservation = self.create_reservation(status=Reservation.Status.CANCELLED)
|
||||
token, raw_token = ReservationToken.create_token(
|
||||
reservation=reservation,
|
||||
purpose=ReservationToken.Purpose.CONFIRMATION,
|
||||
expires_at=timezone.now() + timedelta(hours=2),
|
||||
)
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
reservation.confirm_with_token(raw_token)
|
||||
|
||||
token.refresh_from_db()
|
||||
self.assertIsNone(token.used_at)
|
||||
|
||||
def test_capacity_configuration_rejects_impossible_manual_occupancy(self):
|
||||
performance = Performance(
|
||||
show=self.show,
|
||||
venue=self.venue,
|
||||
starts_at=timezone.now() + timedelta(days=8),
|
||||
room_capacity=5,
|
||||
additional_seats=1,
|
||||
manually_occupied_seats=7,
|
||||
)
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
performance.full_clean()
|
||||
|
||||
def test_check_in_requires_confirmed_reservation(self):
|
||||
reservation = self.create_reservation()
|
||||
user = get_user_model().objects.create_user(username="staff", password="test")
|
||||
|
||||
check_in = CheckIn(reservation=reservation, checked_in_by=user)
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
check_in.full_clean()
|
||||
|
||||
def test_reservation_cannot_be_checked_in_twice(self):
|
||||
reservation = self.create_reservation(
|
||||
status=Reservation.Status.CONFIRMED,
|
||||
confirmed_at=timezone.now(),
|
||||
)
|
||||
user = get_user_model().objects.create_user(username="staff", password="test")
|
||||
CheckIn.objects.create(reservation=reservation, checked_in_by=user)
|
||||
|
||||
with self.assertRaises((IntegrityError, ValidationError)):
|
||||
CheckIn.objects.create(reservation=reservation, checked_in_by=user)
|
||||
Reference in New Issue
Block a user