Merge branch 'feature/show-image-upload' into develop

This commit is contained in:
bisco
2026-04-30 00:08:27 +02:00
14 changed files with 226 additions and 15 deletions

View File

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

View File

@@ -1,4 +1,6 @@
from django.contrib import admin
from django.conf import settings
from django.conf.urls.static import static
from django.urls import include, path
from rest_framework.decorators import api_view
from rest_framework.response import Response
@@ -16,3 +18,6 @@ urlpatterns = [
path("api/", include("bookings.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)
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",)
search_fields = ("title", "slug", "summary", "description")
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)

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)
description = models.TextField(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)
class Meta:
@@ -28,6 +29,14 @@ class Show(TimeStampedModel):
def __str__(self):
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):
name = models.CharField(max_length=200)

View File

@@ -1,7 +1,15 @@
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()
title = serializers.CharField()
slug = serializers.SlugField()
@@ -18,7 +26,7 @@ class PublicVenueDetailSerializer(PublicVenueSummarySerializer):
address = serializers.CharField()
class PublicShowSummarySerializer(serializers.Serializer):
class PublicShowSummarySerializer(PublicShowImageMixin, serializers.Serializer):
title = serializers.CharField()
slug = serializers.SlugField()
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"])
def show_list(request):
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})
@@ -32,7 +32,7 @@ def show_list(request):
def show_detail(request, slug):
show = get_object_or_404(Show, slug=slug, is_published=True)
show.public_performances = public_performance_queryset().filter(show=show)
serializer = PublicShowDetailSerializer(show)
serializer = PublicShowDetailSerializer(show, context={"request": request})
return Response(serializer.data)
@@ -54,12 +54,16 @@ def performance_list(request):
)
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})
@api_view(["GET"])
def performance_detail(request, pk):
performance = get_object_or_404(public_performance_queryset(), pk=pk)
serializer = PublicPerformanceDetailSerializer(performance)
serializer = PublicPerformanceDetailSerializer(performance, context={"request": request})
return Response(serializer.data)

View File

@@ -106,7 +106,7 @@ The database should not be published to the host in production.
Recommended volumes:
- `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.
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;
- static frontend root;
- 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.
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
- 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.
- Keep container images explicitly versioned; do not use `latest` tags.
- 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>
} @else if (show()) {
<header class="page-header">
<p class="eyebrow">Show detail</p>
<h1>{{ show()!.title }}</h1>
<p class="supporting">{{ show()!.description || show()!.summary }}</p>
<div class="hero-copy">
<p class="eyebrow">Show detail</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>
<section class="section">
@@ -115,9 +122,17 @@ import { ShowDetail, ShowPerformance, ShowsApiService } from '../services/shows-
}
.page-header {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(280px, 420px);
gap: 24px;
align-items: start;
margin-bottom: 28px;
}
.hero-copy {
min-width: 0;
}
.eyebrow {
margin: 0 0 10px;
color: var(--azionelab-accent);
@@ -138,6 +153,24 @@ import { ShowDetail, ShowPerformance, ShowsApiService } from '../services/shows-
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 {
display: grid;
gap: 20px;
@@ -231,6 +264,10 @@ import { ShowDetail, ShowPerformance, ShowsApiService } from '../services/shows-
}
@media (max-width: 860px) {
.page-header {
grid-template-columns: 1fr;
}
.section-heading {
align-items: flex-start;
flex-direction: column;

View File

@@ -59,6 +59,11 @@ import { ShowListItem, ShowsApiService } from '../services/shows-api.service';
<div class="show-grid">
@for (show of shows(); track show.slug) {
<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-content>
<p>{{ show.summary }}</p>
@@ -157,6 +162,22 @@ import { ShowListItem, ShowsApiService } from '../services/shows-api.service';
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 {
flex: 1;
}

View File

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

View File

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

View File

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