feat(shows): add uploaded show images

This commit is contained in:
bisco
2026-04-30 00:05:23 +02:00
parent b51ca9fdbf
commit ded07346a6
14 changed files with 226 additions and 15 deletions

View File

@@ -116,6 +116,8 @@ if "test" in sys.argv:
STATIC_URL = "/static/" STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "staticfiles" STATIC_ROOT = BASE_DIR / "staticfiles"
MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
REST_FRAMEWORK = { REST_FRAMEWORK = {

View File

@@ -1,4 +1,6 @@
from django.contrib import admin from django.contrib import admin
from django.conf import settings
from django.conf.urls.static import static
from django.urls import include, path from django.urls import include, path
from rest_framework.decorators import api_view from rest_framework.decorators import api_view
from rest_framework.response import Response from rest_framework.response import Response
@@ -16,3 +18,6 @@ urlpatterns = [
path("api/", include("bookings.urls")), path("api/", include("bookings.urls")),
path("api/", include("checkins.urls")), path("api/", include("checkins.urls")),
] ]
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

View File

@@ -7,11 +7,36 @@ from .models import Performance, Show, Venue
@admin.register(Show) @admin.register(Show)
class ShowAdmin(admin.ModelAdmin): class ShowAdmin(admin.ModelAdmin):
list_display = ("title", "slug", "is_published", "created_at", "updated_at") list_display = ("title", "slug", "is_published", "image_preview", "created_at", "updated_at")
list_filter = ("is_published",) list_filter = ("is_published",)
search_fields = ("title", "slug", "summary", "description") search_fields = ("title", "slug", "summary", "description")
prepopulated_fields = {"slug": ("title",)} prepopulated_fields = {"slug": ("title",)}
readonly_fields = ("created_at", "updated_at") readonly_fields = ("image_preview", "created_at", "updated_at")
fields = (
"title",
"slug",
"summary",
"description",
"uploaded_image",
"poster_image",
"image_preview",
"is_published",
"created_at",
"updated_at",
)
@admin.display(description="Preview")
def image_preview(self, obj):
if not getattr(obj, "pk", None):
return "-"
image_url = obj.image_url()
if not image_url:
return "No image"
return format_html(
'<img src="{}" alt="{}" style="max-width: 120px; border-radius: 6px;" />',
image_url,
obj.title,
)
@admin.register(Venue) @admin.register(Venue)

View File

@@ -0,0 +1,17 @@
# Generated by Django 5.2.3 on 2026-04-29
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("shows", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="show",
name="uploaded_image",
field=models.ImageField(blank=True, upload_to="shows/"),
),
]

View File

@@ -16,6 +16,7 @@ class Show(TimeStampedModel):
summary = models.TextField(blank=True) summary = models.TextField(blank=True)
description = models.TextField(blank=True) description = models.TextField(blank=True)
poster_image = models.URLField(blank=True) poster_image = models.URLField(blank=True)
uploaded_image = models.ImageField(upload_to="shows/", blank=True)
is_published = models.BooleanField(default=False, db_index=True) is_published = models.BooleanField(default=False, db_index=True)
class Meta: class Meta:
@@ -28,6 +29,14 @@ class Show(TimeStampedModel):
def __str__(self): def __str__(self):
return self.title return self.title
def image_url(self, request=None):
if self.uploaded_image:
image_url = self.uploaded_image.url
if request is not None:
return request.build_absolute_uri(image_url)
return image_url
return self.poster_image
class Venue(TimeStampedModel): class Venue(TimeStampedModel):
name = models.CharField(max_length=200) name = models.CharField(max_length=200)

View File

@@ -1,7 +1,15 @@
from rest_framework import serializers from rest_framework import serializers
class PublicShowListSerializer(serializers.Serializer): class PublicShowImageMixin(serializers.Serializer):
image_url = serializers.SerializerMethodField()
def get_image_url(self, obj):
request = self.context.get("request")
return obj.image_url(request=request)
class PublicShowListSerializer(PublicShowImageMixin, serializers.Serializer):
id = serializers.IntegerField() id = serializers.IntegerField()
title = serializers.CharField() title = serializers.CharField()
slug = serializers.SlugField() slug = serializers.SlugField()
@@ -18,7 +26,7 @@ class PublicVenueDetailSerializer(PublicVenueSummarySerializer):
address = serializers.CharField() address = serializers.CharField()
class PublicShowSummarySerializer(serializers.Serializer): class PublicShowSummarySerializer(PublicShowImageMixin, serializers.Serializer):
title = serializers.CharField() title = serializers.CharField()
slug = serializers.SlugField() slug = serializers.SlugField()
summary = serializers.CharField() summary = serializers.CharField()

77
backend/shows/test_api.py Normal file
View File

@@ -0,0 +1,77 @@
from datetime import timedelta
from tempfile import TemporaryDirectory
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import override_settings
from django.urls import reverse
from django.utils import timezone
from rest_framework import status
from rest_framework.test import APITestCase
from shows.models import Performance, Show, Venue
SMALL_GIF = (
b"GIF89a\x01\x00\x01\x00\x80\x00\x00"
b"\x00\x00\x00\xff\xff\xff!\xf9\x04\x01"
b"\x00\x00\x00\x00,\x00\x00\x00\x00\x01"
b"\x00\x01\x00\x00\x02\x02D\x01\x00;"
)
class ShowApiTests(APITestCase):
def setUp(self):
self.temp_media = TemporaryDirectory()
self.addCleanup(self.temp_media.cleanup)
self.settings_override = override_settings(MEDIA_ROOT=self.temp_media.name)
self.settings_override.enable()
self.addCleanup(self.settings_override.disable)
self.show = Show.objects.create(
title="Open Stage",
slug="open-stage-media",
summary="A contemporary theatre performance.",
description="Full public show description.",
poster_image="https://cdn.example.com/open-stage-poster.jpg",
is_published=True,
)
self.external_only_show = Show.objects.create(
title="External Poster Stage",
slug="external-poster-stage",
summary="External image only.",
description="External image only.",
poster_image="https://cdn.example.com/external-only.jpg",
is_published=True,
)
self.venue = Venue.objects.create(
name="AzioneLab Theatre",
slug="azionelab-theatre-show-api",
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=20,
)
def test_show_list_prefers_uploaded_image_url_when_present(self):
self.show.uploaded_image.save(
"open-stage.gif",
SimpleUploadedFile("open-stage.gif", SMALL_GIF, content_type="image/gif"),
save=True,
)
response = self.client.get(reverse("api-show-list"))
self.assertEqual(response.status_code, status.HTTP_200_OK)
list_item = next(item for item in response.data["results"] if item["slug"] == self.show.slug)
self.assertTrue(list_item["image_url"].endswith("/media/shows/open-stage.gif"))
self.assertEqual(list_item["poster_image"], "https://cdn.example.com/open-stage-poster.jpg")
def test_show_detail_falls_back_to_existing_external_image_url(self):
response = self.client.get(reverse("api-show-detail", kwargs={"slug": self.external_only_show.slug}))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["image_url"], "https://cdn.example.com/external-only.jpg")

View File

@@ -24,7 +24,7 @@ def public_performance_queryset():
@api_view(["GET"]) @api_view(["GET"])
def show_list(request): def show_list(request):
shows = Show.objects.filter(is_published=True).order_by("title") shows = Show.objects.filter(is_published=True).order_by("title")
serializer = PublicShowListSerializer(shows, many=True) serializer = PublicShowListSerializer(shows, many=True, context={"request": request})
return Response({"results": serializer.data}) return Response({"results": serializer.data})
@@ -32,7 +32,7 @@ def show_list(request):
def show_detail(request, slug): def show_detail(request, slug):
show = get_object_or_404(Show, slug=slug, is_published=True) show = get_object_or_404(Show, slug=slug, is_published=True)
show.public_performances = public_performance_queryset().filter(show=show) show.public_performances = public_performance_queryset().filter(show=show)
serializer = PublicShowDetailSerializer(show) serializer = PublicShowDetailSerializer(show, context={"request": request})
return Response(serializer.data) return Response(serializer.data)
@@ -54,12 +54,16 @@ def performance_list(request):
) )
performances = performances.filter(starts_at__gte=starts_from) performances = performances.filter(starts_at__gte=starts_from)
serializer = PublicPerformanceListSerializer(performances.order_by("starts_at"), many=True) serializer = PublicPerformanceListSerializer(
performances.order_by("starts_at"),
many=True,
context={"request": request},
)
return Response({"results": serializer.data}) return Response({"results": serializer.data})
@api_view(["GET"]) @api_view(["GET"])
def performance_detail(request, pk): def performance_detail(request, pk):
performance = get_object_or_404(public_performance_queryset(), pk=pk) performance = get_object_or_404(public_performance_queryset(), pk=pk)
serializer = PublicPerformanceDetailSerializer(performance) serializer = PublicPerformanceDetailSerializer(performance, context={"request": request})
return Response(serializer.data) return Response(serializer.data)

View File

@@ -106,7 +106,7 @@ The database should not be published to the host in production.
Recommended volumes: Recommended volumes:
- `postgres_data`: PostgreSQL data directory; - `postgres_data`: PostgreSQL data directory;
- `media`: uploaded media and generated QR assets if stored on disk; - `media`: uploaded show images and generated QR assets if stored on disk;
- `static`: collected Django static files if served by nginx from a shared volume. - `static`: collected Django static files if served by nginx from a shared volume.
Generated QR codes may also be generated on demand instead of stored as files. If stored, they must not reveal personal data and access must remain controlled. Generated QR codes may also be generated on demand instead of stored as files. If stored, they must not reveal personal data and access must remain controlled.
@@ -150,6 +150,7 @@ Required nginx configuration:
- upstream backend service name and port; - upstream backend service name and port;
- static frontend root; - static frontend root;
- proxy rules for `/api/` and `/admin/`; - proxy rules for `/api/` and `/admin/`;
- media root for `/media/` if uploaded assets are served by nginx from a shared volume;
- TLS certificate paths for production. - TLS certificate paths for production.
Secrets must be provided through deployment-managed environment variables, Docker secrets, or another secret manager. Do not commit real secret values. Secrets must be provided through deployment-managed environment variables, Docker secrets, or another secret manager. Do not commit real secret values.
@@ -214,6 +215,7 @@ Database rollback needs special care once migrations exist. Down migrations or b
## Operational Notes ## Operational Notes
- Configure database backups before accepting real bookings. - Configure database backups before accepting real bookings.
- Back up the shared media volume together with the database if staff uploads show images.
- Monitor backend errors, email delivery failures, and check-in failures. - Monitor backend errors, email delivery failures, and check-in failures.
- Keep container images explicitly versioned; do not use `latest` tags. - Keep container images explicitly versioned; do not use `latest` tags.
- Keep the system small until operational needs justify additional services. - Keep the system small until operational needs justify additional services.

View File

@@ -45,9 +45,16 @@ import { ShowDetail, ShowPerformance, ShowsApiService } from '../services/shows-
</mat-card> </mat-card>
} @else if (show()) { } @else if (show()) {
<header class="page-header"> <header class="page-header">
<p class="eyebrow">Show detail</p> <div class="hero-copy">
<h1>{{ show()!.title }}</h1> <p class="eyebrow">Show detail</p>
<p class="supporting">{{ show()!.description || show()!.summary }}</p> <h1>{{ show()!.title }}</h1>
<p class="supporting">{{ show()!.description || show()!.summary }}</p>
</div>
@if (show()!.image_url) {
<div class="hero-image-wrap">
<img class="hero-image" [src]="show()!.image_url" [alt]="show()!.title" />
</div>
}
</header> </header>
<section class="section"> <section class="section">
@@ -115,9 +122,17 @@ import { ShowDetail, ShowPerformance, ShowsApiService } from '../services/shows-
} }
.page-header { .page-header {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(280px, 420px);
gap: 24px;
align-items: start;
margin-bottom: 28px; margin-bottom: 28px;
} }
.hero-copy {
min-width: 0;
}
.eyebrow { .eyebrow {
margin: 0 0 10px; margin: 0 0 10px;
color: var(--azionelab-accent); color: var(--azionelab-accent);
@@ -138,6 +153,24 @@ import { ShowDetail, ShowPerformance, ShowsApiService } from '../services/shows-
max-width: 64ch; max-width: 64ch;
} }
.hero-image-wrap {
overflow: hidden;
border-radius: 12px;
border: 1px solid var(--azionelab-border);
background:
linear-gradient(135deg, rgba(207, 71, 51, 0.16), rgba(15, 22, 36, 0.08)),
#f8f1ea;
box-shadow: var(--azionelab-shadow);
aspect-ratio: 4 / 5;
}
.hero-image {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.section { .section {
display: grid; display: grid;
gap: 20px; gap: 20px;
@@ -231,6 +264,10 @@ import { ShowDetail, ShowPerformance, ShowsApiService } from '../services/shows-
} }
@media (max-width: 860px) { @media (max-width: 860px) {
.page-header {
grid-template-columns: 1fr;
}
.section-heading { .section-heading {
align-items: flex-start; align-items: flex-start;
flex-direction: column; flex-direction: column;

View File

@@ -59,6 +59,11 @@ import { ShowListItem, ShowsApiService } from '../services/shows-api.service';
<div class="show-grid"> <div class="show-grid">
@for (show of shows(); track show.slug) { @for (show of shows(); track show.slug) {
<mat-card class="show-card"> <mat-card class="show-card">
@if (show.image_url) {
<div class="show-image-wrap">
<img class="show-image" [src]="show.image_url" [alt]="show.title" />
</div>
}
<mat-card-title>{{ show.title }}</mat-card-title> <mat-card-title>{{ show.title }}</mat-card-title>
<mat-card-content> <mat-card-content>
<p>{{ show.summary }}</p> <p>{{ show.summary }}</p>
@@ -157,6 +162,22 @@ import { ShowListItem, ShowsApiService } from '../services/shows-api.service';
box-shadow: var(--azionelab-shadow); box-shadow: var(--azionelab-shadow);
} }
.show-image-wrap {
aspect-ratio: 16 / 10;
overflow: hidden;
border-bottom: 1px solid var(--azionelab-border);
background:
linear-gradient(135deg, rgba(207, 71, 51, 0.14), rgba(15, 22, 36, 0.06)),
#f8f1ea;
}
.show-image {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.show-card mat-card-content { .show-card mat-card-content {
flex: 1; flex: 1;
} }

View File

@@ -9,6 +9,7 @@ export type ShowListItem = {
title: string; title: string;
slug: string; slug: string;
summary: string; summary: string;
image_url: string;
poster_image: string; poster_image: string;
}; };

View File

@@ -21,6 +21,7 @@ services:
- "${BACKEND_PORT:-8000}" - "${BACKEND_PORT:-8000}"
volumes: volumes:
- django_static:/app/staticfiles - django_static:/app/staticfiles
- django_media:/app/media
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
@@ -70,6 +71,7 @@ services:
volumes: volumes:
- ./nginx/templates:/etc/nginx/templates:ro - ./nginx/templates:/etc/nginx/templates:ro
- django_static:/var/www/static:ro - django_static:/var/www/static:ro
- django_media:/var/www/media:ro
depends_on: depends_on:
- backend - backend
- frontend - frontend
@@ -80,6 +82,7 @@ services:
volumes: volumes:
postgres_data: postgres_data:
django_static: django_static:
django_media:
networks: networks:
internal: internal:

View File

@@ -35,9 +35,9 @@ server {
} }
location /media/ { location /media/ {
proxy_pass http://azionelab_backend; alias /var/www/media/;
proxy_set_header Host $host; access_log off;
proxy_set_header X-Forwarded-Proto $scheme; expires 1d;
} }
location / { location / {