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