diff --git a/backend/azionelab/settings.py b/backend/azionelab/settings.py index 8f5f621..e617ceb 100644 --- a/backend/azionelab/settings.py +++ b/backend/azionelab/settings.py @@ -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 = { diff --git a/backend/azionelab/urls.py b/backend/azionelab/urls.py index bda6372..e546aec 100644 --- a/backend/azionelab/urls.py +++ b/backend/azionelab/urls.py @@ -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) diff --git a/backend/shows/admin.py b/backend/shows/admin.py index 4395594..b0996e6 100644 --- a/backend/shows/admin.py +++ b/backend/shows/admin.py @@ -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( + '{}', + image_url, + obj.title, + ) @admin.register(Venue) diff --git a/backend/shows/migrations/0002_show_uploaded_image.py b/backend/shows/migrations/0002_show_uploaded_image.py new file mode 100644 index 0000000..45bc336 --- /dev/null +++ b/backend/shows/migrations/0002_show_uploaded_image.py @@ -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/"), + ), + ] diff --git a/backend/shows/models.py b/backend/shows/models.py index 6290e14..2d2532d 100644 --- a/backend/shows/models.py +++ b/backend/shows/models.py @@ -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) diff --git a/backend/shows/serializers.py b/backend/shows/serializers.py index e6971de..a3a2320 100644 --- a/backend/shows/serializers.py +++ b/backend/shows/serializers.py @@ -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() diff --git a/backend/shows/test_api.py b/backend/shows/test_api.py new file mode 100644 index 0000000..755d223 --- /dev/null +++ b/backend/shows/test_api.py @@ -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") diff --git a/backend/shows/views.py b/backend/shows/views.py index b9b69ff..0735d46 100644 --- a/backend/shows/views.py +++ b/backend/shows/views.py @@ -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) diff --git a/docs/deployment.md b/docs/deployment.md index eb40ff3..edadd5b 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -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. diff --git a/frontend/src/app/pages/show-detail-placeholder-page.component.ts b/frontend/src/app/pages/show-detail-placeholder-page.component.ts index 9949a00..fe32853 100644 --- a/frontend/src/app/pages/show-detail-placeholder-page.component.ts +++ b/frontend/src/app/pages/show-detail-placeholder-page.component.ts @@ -45,9 +45,16 @@ import { ShowDetail, ShowPerformance, ShowsApiService } from '../services/shows- } @else if (show()) {
@@ -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; diff --git a/frontend/src/app/pages/show-list-page.component.ts b/frontend/src/app/pages/show-list-page.component.ts index 280f548..c0fe47e 100644 --- a/frontend/src/app/pages/show-list-page.component.ts +++ b/frontend/src/app/pages/show-list-page.component.ts @@ -59,6 +59,11 @@ import { ShowListItem, ShowsApiService } from '../services/shows-api.service';
@for (show of shows(); track show.slug) { + @if (show.image_url) { +
+ +
+ } {{ show.title }}

{{ show.summary }}

@@ -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; } diff --git a/frontend/src/app/services/shows-api.service.ts b/frontend/src/app/services/shows-api.service.ts index b369313..7f569d7 100644 --- a/frontend/src/app/services/shows-api.service.ts +++ b/frontend/src/app/services/shows-api.service.ts @@ -9,6 +9,7 @@ export type ShowListItem = { title: string; slug: string; summary: string; + image_url: string; poster_image: string; }; diff --git a/infra/docker/compose.yml b/infra/docker/compose.yml index 0d26a35..e7a49bb 100644 --- a/infra/docker/compose.yml +++ b/infra/docker/compose.yml @@ -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: diff --git a/infra/docker/nginx/templates/default.conf.template b/infra/docker/nginx/templates/default.conf.template index 50a3f3d..85fcc90 100644 --- a/infra/docker/nginx/templates/default.conf.template +++ b/infra/docker/nginx/templates/default.conf.template @@ -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 / {