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)
|
||||||
17
backend/checkins/admin.py
Normal file
17
backend/checkins/admin.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from .models import CheckIn
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(CheckIn)
|
||||||
|
class CheckInAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("reservation", "checked_in_at", "checked_in_by", "source", "created_at")
|
||||||
|
list_filter = ("source", "checked_in_at", "created_at")
|
||||||
|
search_fields = (
|
||||||
|
"reservation__name",
|
||||||
|
"reservation__email",
|
||||||
|
"reservation__performance__show__title",
|
||||||
|
"checked_in_by__username",
|
||||||
|
"checked_in_by__email",
|
||||||
|
)
|
||||||
|
readonly_fields = ("created_at", "updated_at")
|
||||||
54
backend/checkins/migrations/0001_initial.py
Normal file
54
backend/checkins/migrations/0001_initial.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# Generated by Django 5.2.3 on 2026-04-28
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django.utils.timezone
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookings", "0001_initial"),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="CheckIn",
|
||||||
|
fields=[
|
||||||
|
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||||
|
("checked_in_at", models.DateTimeField(default=django.utils.timezone.now)),
|
||||||
|
(
|
||||||
|
"source",
|
||||||
|
models.CharField(choices=[("qr_scan", "QR scan"), ("manual", "Manual")], default="qr_scan", max_length=20),
|
||||||
|
),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
(
|
||||||
|
"checked_in_by",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name="checkins",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"reservation",
|
||||||
|
models.OneToOneField(
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name="check_in",
|
||||||
|
to="bookings.reservation",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["-checked_in_at"],
|
||||||
|
"indexes": [
|
||||||
|
models.Index(fields=["checked_in_at"], name="checkins_ch_checked_761e33_idx"),
|
||||||
|
models.Index(fields=["checked_in_by"], name="checkins_ch_checked_becaae_idx"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
1
backend/checkins/migrations/__init__.py
Normal file
1
backend/checkins/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -1 +1,46 @@
|
|||||||
# Domain models will be added when check-in behavior is implemented.
|
from django.conf import settings
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.db import models
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from bookings.models import Reservation
|
||||||
|
|
||||||
|
|
||||||
|
class CheckIn(models.Model):
|
||||||
|
class Source(models.TextChoices):
|
||||||
|
QR_SCAN = "qr_scan", "QR scan"
|
||||||
|
MANUAL = "manual", "Manual"
|
||||||
|
|
||||||
|
reservation = models.OneToOneField(
|
||||||
|
Reservation,
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
related_name="check_in",
|
||||||
|
)
|
||||||
|
checked_in_at = models.DateTimeField(default=timezone.now)
|
||||||
|
checked_in_by = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
related_name="checkins",
|
||||||
|
)
|
||||||
|
source = models.CharField(max_length=20, choices=Source.choices, default=Source.QR_SCAN)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-checked_in_at"]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["checked_in_at"]),
|
||||||
|
models.Index(fields=["checked_in_by"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Check-in for reservation {self.reservation_id}"
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
super().clean()
|
||||||
|
if self.reservation_id and not self.reservation.is_confirmed:
|
||||||
|
raise ValidationError({"reservation": "Only confirmed reservations can be checked in."})
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
self.full_clean()
|
||||||
|
return super().save(*args, **kwargs)
|
||||||
|
|||||||
34
backend/shows/admin.py
Normal file
34
backend/shows/admin.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from .models import Performance, Show, Venue
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Show)
|
||||||
|
class ShowAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("title", "slug", "is_published", "created_at", "updated_at")
|
||||||
|
list_filter = ("is_published",)
|
||||||
|
search_fields = ("title", "slug", "summary", "description")
|
||||||
|
prepopulated_fields = {"slug": ("title",)}
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Venue)
|
||||||
|
class VenueAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("name", "slug", "city", "address", "created_at", "updated_at")
|
||||||
|
list_filter = ("city",)
|
||||||
|
search_fields = ("name", "slug", "address", "city", "notes")
|
||||||
|
prepopulated_fields = {"slug": ("name",)}
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Performance)
|
||||||
|
class PerformanceAdmin(admin.ModelAdmin):
|
||||||
|
list_display = (
|
||||||
|
"show",
|
||||||
|
"venue",
|
||||||
|
"starts_at",
|
||||||
|
"room_capacity",
|
||||||
|
"additional_seats",
|
||||||
|
"manually_occupied_seats",
|
||||||
|
"is_booking_enabled",
|
||||||
|
)
|
||||||
|
list_filter = ("is_booking_enabled", "starts_at", "show", "venue")
|
||||||
|
search_fields = ("show__title", "venue__name", "venue__city")
|
||||||
98
backend/shows/migrations/0001_initial.py
Normal file
98
backend/shows/migrations/0001_initial.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
# Generated by Django 5.2.3 on 2026-04-28
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
from django.db.models import F, Q
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = []
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Show",
|
||||||
|
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)),
|
||||||
|
("title", models.CharField(max_length=200)),
|
||||||
|
("slug", models.SlugField(max_length=220, unique=True)),
|
||||||
|
("summary", models.TextField(blank=True)),
|
||||||
|
("description", models.TextField(blank=True)),
|
||||||
|
("poster_image", models.URLField(blank=True)),
|
||||||
|
("is_published", models.BooleanField(db_index=True, default=False)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["title"],
|
||||||
|
"indexes": [
|
||||||
|
models.Index(fields=["slug"], name="shows_show_slug_83daa9_idx"),
|
||||||
|
models.Index(fields=["is_published"], name="shows_show_is_publ_63247e_idx"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Venue",
|
||||||
|
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)),
|
||||||
|
("name", models.CharField(max_length=200)),
|
||||||
|
("slug", models.SlugField(max_length=220, unique=True)),
|
||||||
|
("address", models.CharField(max_length=255)),
|
||||||
|
("city", models.CharField(max_length=120)),
|
||||||
|
("notes", models.TextField(blank=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["name"],
|
||||||
|
"indexes": [
|
||||||
|
models.Index(fields=["slug"], name="shows_venue_slug_0717a3_idx"),
|
||||||
|
models.Index(fields=["city"], name="shows_venue_city_acfb26_idx"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Performance",
|
||||||
|
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)),
|
||||||
|
("starts_at", models.DateTimeField(db_index=True)),
|
||||||
|
("room_capacity", models.PositiveIntegerField()),
|
||||||
|
("manually_occupied_seats", models.PositiveIntegerField(default=0)),
|
||||||
|
("additional_seats", models.PositiveIntegerField(default=0)),
|
||||||
|
("is_booking_enabled", models.BooleanField(db_index=True, default=True)),
|
||||||
|
(
|
||||||
|
"show",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name="performances",
|
||||||
|
to="shows.show",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"venue",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name="performances",
|
||||||
|
to="shows.venue",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["starts_at"],
|
||||||
|
"indexes": [
|
||||||
|
models.Index(fields=["show", "starts_at"], name="shows_perfo_show_id_bae2ea_idx"),
|
||||||
|
models.Index(fields=["venue", "starts_at"], name="shows_perfo_venue_i_fcdf27_idx"),
|
||||||
|
models.Index(fields=["is_booking_enabled", "starts_at"], name="shows_perfo_is_book_9371e4_idx"),
|
||||||
|
],
|
||||||
|
"constraints": [
|
||||||
|
models.CheckConstraint(
|
||||||
|
condition=Q(("manually_occupied_seats__lte", F("room_capacity") + F("additional_seats"))),
|
||||||
|
name="performance_manual_seats_within_capacity",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
1
backend/shows/migrations/__init__.py
Normal file
1
backend/shows/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -1 +1,85 @@
|
|||||||
# Domain models will be added when show management is implemented.
|
from django.db import models
|
||||||
|
from django.db.models import F, Q, Sum
|
||||||
|
|
||||||
|
|
||||||
|
class TimeStampedModel(models.Model):
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
abstract = True
|
||||||
|
|
||||||
|
|
||||||
|
class Show(TimeStampedModel):
|
||||||
|
title = models.CharField(max_length=200)
|
||||||
|
slug = models.SlugField(max_length=220, unique=True)
|
||||||
|
summary = models.TextField(blank=True)
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
poster_image = models.URLField(blank=True)
|
||||||
|
is_published = models.BooleanField(default=False, db_index=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["title"]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["slug"]),
|
||||||
|
models.Index(fields=["is_published"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.title
|
||||||
|
|
||||||
|
|
||||||
|
class Venue(TimeStampedModel):
|
||||||
|
name = models.CharField(max_length=200)
|
||||||
|
slug = models.SlugField(max_length=220, unique=True)
|
||||||
|
address = models.CharField(max_length=255)
|
||||||
|
city = models.CharField(max_length=120)
|
||||||
|
notes = models.TextField(blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["name"]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["slug"]),
|
||||||
|
models.Index(fields=["city"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class Performance(TimeStampedModel):
|
||||||
|
show = models.ForeignKey(Show, on_delete=models.PROTECT, related_name="performances")
|
||||||
|
venue = models.ForeignKey(Venue, on_delete=models.PROTECT, related_name="performances")
|
||||||
|
starts_at = models.DateTimeField(db_index=True)
|
||||||
|
room_capacity = models.PositiveIntegerField()
|
||||||
|
manually_occupied_seats = models.PositiveIntegerField(default=0)
|
||||||
|
additional_seats = models.PositiveIntegerField(default=0)
|
||||||
|
is_booking_enabled = models.BooleanField(default=True, db_index=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["starts_at"]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["show", "starts_at"]),
|
||||||
|
models.Index(fields=["venue", "starts_at"]),
|
||||||
|
models.Index(fields=["is_booking_enabled", "starts_at"]),
|
||||||
|
]
|
||||||
|
constraints = [
|
||||||
|
models.CheckConstraint(
|
||||||
|
condition=Q(manually_occupied_seats__lte=F("room_capacity") + F("additional_seats")),
|
||||||
|
name="performance_manual_seats_within_capacity",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.show} at {self.starts_at:%Y-%m-%d %H:%M}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def configured_capacity(self):
|
||||||
|
return self.room_capacity + self.additional_seats - self.manually_occupied_seats
|
||||||
|
|
||||||
|
def confirmed_seats(self):
|
||||||
|
result = self.reservations.filter(status="confirmed").aggregate(total=Sum("party_size"))
|
||||||
|
return result["total"] or 0
|
||||||
|
|
||||||
|
def available_seats(self):
|
||||||
|
return self.configured_capacity - self.confirmed_seats()
|
||||||
|
|||||||
Reference in New Issue
Block a user