generated from bisco/codex-bootstrap
feat(shows): add uploaded show images
This commit is contained in:
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
17
backend/shows/migrations/0002_show_uploaded_image.py
Normal file
17
backend/shows/migrations/0002_show_uploaded_image.py
Normal 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/"),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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
77
backend/shows/test_api.py
Normal 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")
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export type ShowListItem = {
|
||||
title: string;
|
||||
slug: string;
|
||||
summary: string;
|
||||
image_url: string;
|
||||
poster_image: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 / {
|
||||
|
||||
Reference in New Issue
Block a user