From d1801b8c9b6c834453e8cc352d19c7db4c92306e Mon Sep 17 00:00:00 2001 From: bisco Date: Wed, 29 Apr 2026 12:06:55 +0200 Subject: [PATCH] feat: improve admin and add demo data command --- backend/bookings/admin.py | 13 +- backend/checkins/admin.py | 17 ++- backend/shows/admin.py | 10 ++ backend/shows/management/__init__.py | 1 + backend/shows/management/commands/__init__.py | 1 + .../management/commands/seed_demo_data.py | 126 ++++++++++++++++++ backend/shows/test_admin.py | 13 ++ backend/shows/test_management.py | 31 +++++ 8 files changed, 209 insertions(+), 3 deletions(-) create mode 100644 backend/shows/management/__init__.py create mode 100644 backend/shows/management/commands/__init__.py create mode 100644 backend/shows/management/commands/seed_demo_data.py create mode 100644 backend/shows/test_admin.py create mode 100644 backend/shows/test_management.py diff --git a/backend/bookings/admin.py b/backend/bookings/admin.py index f235562..ab2e2c2 100644 --- a/backend/bookings/admin.py +++ b/backend/bookings/admin.py @@ -8,6 +8,7 @@ class ReservationTokenInline(admin.TabularInline): extra = 0 readonly_fields = ("token_hash", "used_at", "created_at") fields = ("purpose", "token_hash", "expires_at", "used_at", "created_at") + can_delete = False @admin.register(Reservation) @@ -19,16 +20,26 @@ class ReservationAdmin(admin.ModelAdmin): "party_size", "status", "confirmed_at", + "qr_code_generated_at", "created_at", ) list_filter = ("status", "performance", "created_at", "confirmed_at") search_fields = ("name", "email", "phone", "performance__show__title") inlines = (ReservationTokenInline,) + list_select_related = ("performance", "performance__show", "performance__venue") + readonly_fields = ("created_at", "updated_at", "confirmed_at", "qr_code_generated_at") + autocomplete_fields = ("performance",) @admin.register(ReservationToken) class ReservationTokenAdmin(admin.ModelAdmin): - list_display = ("reservation", "purpose", "expires_at", "used_at", "created_at") + list_display = ("reservation", "purpose", "expires_at", "used_at", "created_at", "token_preview") 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") + list_select_related = ("reservation", "reservation__performance") + autocomplete_fields = ("reservation",) + + @admin.display(description="Token hash") + def token_preview(self, obj): + return obj.token_hash[:12] diff --git a/backend/checkins/admin.py b/backend/checkins/admin.py index 7d47bc3..dba97cc 100644 --- a/backend/checkins/admin.py +++ b/backend/checkins/admin.py @@ -5,7 +5,14 @@ from .models import CheckIn @admin.register(CheckIn) class CheckInAdmin(admin.ModelAdmin): - list_display = ("reservation", "checked_in_at", "checked_in_by", "source", "created_at") + list_display = ( + "reservation", + "performance", + "checked_in_at", + "checked_in_by", + "source", + "created_at", + ) list_filter = ("source", "checked_in_at", "created_at") search_fields = ( "reservation__name", @@ -14,4 +21,10 @@ class CheckInAdmin(admin.ModelAdmin): "checked_in_by__username", "checked_in_by__email", ) - readonly_fields = ("created_at", "updated_at") + readonly_fields = ("created_at", "updated_at", "checked_in_at") + list_select_related = ("reservation", "reservation__performance", "checked_in_by") + autocomplete_fields = ("reservation", "checked_in_by") + + @admin.display(description="Performance") + def performance(self, obj): + return obj.reservation.performance diff --git a/backend/shows/admin.py b/backend/shows/admin.py index 7346e10..6b08a75 100644 --- a/backend/shows/admin.py +++ b/backend/shows/admin.py @@ -9,6 +9,7 @@ class ShowAdmin(admin.ModelAdmin): list_filter = ("is_published",) search_fields = ("title", "slug", "summary", "description") prepopulated_fields = {"slug": ("title",)} + readonly_fields = ("created_at", "updated_at") @admin.register(Venue) @@ -17,6 +18,7 @@ class VenueAdmin(admin.ModelAdmin): list_filter = ("city",) search_fields = ("name", "slug", "address", "city", "notes") prepopulated_fields = {"slug": ("name",)} + readonly_fields = ("created_at", "updated_at") @admin.register(Performance) @@ -28,7 +30,15 @@ class PerformanceAdmin(admin.ModelAdmin): "room_capacity", "additional_seats", "manually_occupied_seats", + "available_seats_display", "is_booking_enabled", ) list_filter = ("is_booking_enabled", "starts_at", "show", "venue") search_fields = ("show__title", "venue__name", "venue__city") + list_select_related = ("show", "venue") + readonly_fields = ("created_at", "updated_at", "available_seats_display") + autocomplete_fields = ("show", "venue") + + @admin.display(description="Available seats") + def available_seats_display(self, obj): + return obj.available_seats() diff --git a/backend/shows/management/__init__.py b/backend/shows/management/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/shows/management/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/shows/management/commands/__init__.py b/backend/shows/management/commands/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/shows/management/commands/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/shows/management/commands/seed_demo_data.py b/backend/shows/management/commands/seed_demo_data.py new file mode 100644 index 0000000..066dd8d --- /dev/null +++ b/backend/shows/management/commands/seed_demo_data.py @@ -0,0 +1,126 @@ +import sys +from datetime import datetime +from datetime import timedelta + +from django.conf import settings +from django.core.management.base import BaseCommand, CommandError +from django.utils import timezone + +from shows.models import Performance, Show, Venue + + +class Command(BaseCommand): + help = "Create or update local demo data for AzioneLab." + + def handle(self, *args, **options): + if not settings.DEBUG and "test" not in sys.argv: + raise CommandError("seed_demo_data is available only in local or test environments.") + + today = timezone.localdate() + + venues = [ + { + "name": "AzioneLab Theatre", + "slug": "azionelab-theatre", + "address": "Via Example 1", + "city": "Rome", + "notes": "Main house for evening performances.", + }, + { + "name": "Studio Nuovo", + "slug": "studio-nuovo", + "address": "Via Example 22", + "city": "Rome", + "notes": "Smaller venue for workshops and matinees.", + }, + ] + shows = [ + { + "title": "Open Stage", + "slug": "open-stage", + "summary": "A contemporary theatre performance.", + "description": "A compact demo production for manual backend testing.", + "poster_image": "", + "is_published": True, + }, + { + "title": "City Echoes", + "slug": "city-echoes", + "summary": "An ensemble piece set across modern Rome.", + "description": "A second published show with a different venue mix.", + "poster_image": "", + "is_published": True, + }, + ] + + venue_map = {} + show_map = {} + + for venue_data in venues: + venue, _ = Venue.objects.update_or_create( + slug=venue_data["slug"], + defaults=venue_data, + ) + venue_map[venue.slug] = venue + + for show_data in shows: + show, _ = Show.objects.update_or_create( + slug=show_data["slug"], + defaults=show_data, + ) + show_map[show.slug] = show + + performances = [ + { + "show": show_map["open-stage"], + "venue": venue_map["azionelab-theatre"], + "starts_at": self._performance_starts_at(today + timedelta(days=7), hour=20, minute=30), + "room_capacity": 120, + "manually_occupied_seats": 8, + "additional_seats": 4, + "is_booking_enabled": True, + }, + { + "show": show_map["open-stage"], + "venue": venue_map["studio-nuovo"], + "starts_at": self._performance_starts_at(today + timedelta(days=14), hour=18, minute=0), + "room_capacity": 60, + "manually_occupied_seats": 2, + "additional_seats": 0, + "is_booking_enabled": True, + }, + { + "show": show_map["city-echoes"], + "venue": venue_map["azionelab-theatre"], + "starts_at": self._performance_starts_at(today + timedelta(days=21), hour=20, minute=30), + "room_capacity": 140, + "manually_occupied_seats": 12, + "additional_seats": 6, + "is_booking_enabled": True, + }, + ] + + created_or_updated = 0 + for performance_data in performances: + _, _created = Performance.objects.update_or_create( + show=performance_data["show"], + venue=performance_data["venue"], + starts_at=performance_data["starts_at"], + defaults={ + "room_capacity": performance_data["room_capacity"], + "manually_occupied_seats": performance_data["manually_occupied_seats"], + "additional_seats": performance_data["additional_seats"], + "is_booking_enabled": performance_data["is_booking_enabled"], + }, + ) + created_or_updated += 1 + + self.stdout.write( + self.style.SUCCESS( + f"Demo data ready: {len(show_map)} shows, {len(venue_map)} venues, {created_or_updated} performances." + ) + ) + + def _performance_starts_at(self, day, *, hour, minute): + naive = datetime.combine(day, datetime.min.time()).replace(hour=hour, minute=minute) + return timezone.make_aware(naive, timezone.get_current_timezone()) diff --git a/backend/shows/test_admin.py b/backend/shows/test_admin.py new file mode 100644 index 0000000..38c2492 --- /dev/null +++ b/backend/shows/test_admin.py @@ -0,0 +1,13 @@ +from django.contrib import admin +from django.test import SimpleTestCase + +from bookings.models import Reservation, ReservationToken +from checkins.models import CheckIn +from shows.models import Performance, Show, Venue + + +class AdminRegistrationTests(SimpleTestCase): + def test_core_models_are_registered_in_admin(self): + for model in (Show, Venue, Performance, Reservation, ReservationToken, CheckIn): + with self.subTest(model=model.__name__): + self.assertTrue(admin.site.is_registered(model)) diff --git a/backend/shows/test_management.py b/backend/shows/test_management.py new file mode 100644 index 0000000..62de72a --- /dev/null +++ b/backend/shows/test_management.py @@ -0,0 +1,31 @@ +from django.core.management import call_command +from django.test import TestCase + +from shows.models import Performance, Show, Venue + + +class SeedDemoDataCommandTests(TestCase): + def test_seed_demo_data_runs_successfully(self): + call_command("seed_demo_data") + + self.assertEqual(Show.objects.count(), 2) + self.assertEqual(Venue.objects.count(), 2) + self.assertEqual(Performance.objects.count(), 3) + self.assertTrue(Show.objects.filter(is_published=True).exists()) + + def test_seed_demo_data_is_idempotent(self): + call_command("seed_demo_data") + first_counts = ( + Show.objects.count(), + Venue.objects.count(), + Performance.objects.count(), + ) + + call_command("seed_demo_data") + second_counts = ( + Show.objects.count(), + Venue.objects.count(), + Performance.objects.count(), + ) + + self.assertEqual(first_counts, second_counts)