From 3cd5455aa28cbee17c531c256c9674c2495d7c81 Mon Sep 17 00:00:00 2001 From: bisco Date: Tue, 28 Apr 2026 17:08:27 +0200 Subject: [PATCH] feat: add initial Django domain models --- backend/bookings/admin.py | 34 +++++ backend/bookings/migrations/0001_initial.py | 95 +++++++++++++ backend/bookings/migrations/__init__.py | 1 + backend/bookings/models.py | 139 +++++++++++++++++++- backend/bookings/tests.py | 119 +++++++++++++++++ backend/checkins/admin.py | 17 +++ backend/checkins/migrations/0001_initial.py | 54 ++++++++ backend/checkins/migrations/__init__.py | 1 + backend/checkins/models.py | 47 ++++++- backend/shows/admin.py | 34 +++++ backend/shows/migrations/0001_initial.py | 98 ++++++++++++++ backend/shows/migrations/__init__.py | 1 + backend/shows/models.py | 86 +++++++++++- 13 files changed, 723 insertions(+), 3 deletions(-) create mode 100644 backend/bookings/admin.py create mode 100644 backend/bookings/migrations/0001_initial.py create mode 100644 backend/bookings/migrations/__init__.py create mode 100644 backend/bookings/tests.py create mode 100644 backend/checkins/admin.py create mode 100644 backend/checkins/migrations/0001_initial.py create mode 100644 backend/checkins/migrations/__init__.py create mode 100644 backend/shows/admin.py create mode 100644 backend/shows/migrations/0001_initial.py create mode 100644 backend/shows/migrations/__init__.py diff --git a/backend/bookings/admin.py b/backend/bookings/admin.py new file mode 100644 index 0000000..f235562 --- /dev/null +++ b/backend/bookings/admin.py @@ -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") diff --git a/backend/bookings/migrations/0001_initial.py b/backend/bookings/migrations/0001_initial.py new file mode 100644 index 0000000..53735cb --- /dev/null +++ b/backend/bookings/migrations/0001_initial.py @@ -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"), + ], + }, + ), + ] diff --git a/backend/bookings/migrations/__init__.py b/backend/bookings/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/bookings/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/bookings/models.py b/backend/bookings/models.py index 53b6a17..da0ceb2 100644 --- a/backend/bookings/models.py +++ b/backend/bookings/models.py @@ -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"]) diff --git a/backend/bookings/tests.py b/backend/bookings/tests.py new file mode 100644 index 0000000..e83bafd --- /dev/null +++ b/backend/bookings/tests.py @@ -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) diff --git a/backend/checkins/admin.py b/backend/checkins/admin.py new file mode 100644 index 0000000..7d47bc3 --- /dev/null +++ b/backend/checkins/admin.py @@ -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") diff --git a/backend/checkins/migrations/0001_initial.py b/backend/checkins/migrations/0001_initial.py new file mode 100644 index 0000000..d1540eb --- /dev/null +++ b/backend/checkins/migrations/0001_initial.py @@ -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"), + ], + }, + ), + ] diff --git a/backend/checkins/migrations/__init__.py b/backend/checkins/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/checkins/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/checkins/models.py b/backend/checkins/models.py index d433f56..aa44e43 100644 --- a/backend/checkins/models.py +++ b/backend/checkins/models.py @@ -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) diff --git a/backend/shows/admin.py b/backend/shows/admin.py new file mode 100644 index 0000000..7346e10 --- /dev/null +++ b/backend/shows/admin.py @@ -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") diff --git a/backend/shows/migrations/0001_initial.py b/backend/shows/migrations/0001_initial.py new file mode 100644 index 0000000..37b7e7b --- /dev/null +++ b/backend/shows/migrations/0001_initial.py @@ -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", + ), + ], + }, + ), + ] diff --git a/backend/shows/migrations/__init__.py b/backend/shows/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/shows/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/shows/models.py b/backend/shows/models.py index 193bdae..6290e14 100644 --- a/backend/shows/models.py +++ b/backend/shows/models.py @@ -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()