feat: add initial Django domain models

This commit is contained in:
2026-04-28 17:08:27 +02:00
parent 01e6023112
commit 3cd5455aa2
13 changed files with 723 additions and 3 deletions

34
backend/bookings/admin.py Normal file
View 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")

View 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"),
],
},
),
]

View File

@@ -0,0 +1 @@

View File

@@ -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
View 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
View 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")

View 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"),
],
},
),
]

View File

@@ -0,0 +1 @@

View File

@@ -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
View 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")

View 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",
),
],
},
),
]

View File

@@ -0,0 +1 @@

View File

@@ -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()