8 Commits

Author SHA1 Message Date
bisco 84d08dd1f1 fix: suppress application healthcheck logs 2026-06-24 11:26:00 +02:00
bisco 719cdce9c1 feat: add optional letsencrypt tls 2026-06-24 11:06:33 +02:00
bisco 1e3685bab8 test: add functional portal suite 2026-06-24 09:28:24 +02:00
bisco 4886516aa3 chore: rename postgres service to db 2026-06-24 09:10:18 +02:00
bisco 6dd508aa9c chore: rename nginx service to proxy 2026-06-24 09:06:12 +02:00
bisco b36cd2a754 feat: add nginx reverse proxy 2026-06-24 08:59:35 +02:00
bisco 8155d94fee fix: update theatre laboratory details 2026-06-22 19:56:10 +02:00
bisco cff9a17c3c feat: build headless theatre laboratory website 2026-06-22 19:42:43 +02:00
81 changed files with 10109 additions and 128 deletions
+10 -6
View File
@@ -4,9 +4,9 @@ Edit this file for each repository.
## Project identity ## Project identity
Project name: `CHANGE_ME` Project name: `Azione!Lab`
Project description: `CHANGE_ME` Project description: `Headless Wagtail CMS and Astro single-page website for a theatre workshop.`
Primary language/runtime: `CHANGE_ME` Primary language/runtime: `Python 3.12 and Node.js 22`
## Project mode ## Project mode
@@ -14,7 +14,6 @@ Choose one:
```text ```text
project_mode: personal project_mode: personal
project_mode: work
``` ```
Rules: Rules:
@@ -29,7 +28,6 @@ Enable only the profiles that apply to this repository:
```text ```text
enabled_profiles: enabled_profiles:
- docker - docker
- ansible
- python - python
``` ```
@@ -77,7 +75,13 @@ All tests MUST be executed inside Docker containers.
Configure the canonical test command for this repository: Configure the canonical test command for this repository:
```bash ```bash
CHANGE_ME docker compose run --rm backend python manage.py test
docker compose run --rm frontend npm run check
docker compose run --rm frontend npm run build
docker compose run --rm proxy nginx -t
docker compose -f docker-compose.yml -f docker-compose.test.yml --profile test run --rm backend python manage.py seed_demo
docker compose -f docker-compose.yml -f docker-compose.test.yml --profile test run --build --rm functional-tests
docker compose config --quiet
``` ```
Examples: Examples:
+20
View File
@@ -0,0 +1,20 @@
# Development-only values. Replace every secret outside local development.
POSTGRES_DB=theatre_lab
POSTGRES_USER=theatre_lab
POSTGRES_PASSWORD=replace-with-a-local-password
DATABASE_URL=postgresql://theatre_lab:replace-with-a-local-password@db:5432/theatre_lab
DJANGO_SECRET_KEY=replace-with-a-long-random-development-key
DJANGO_DEBUG=true
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,backend,azionelab.org
WAGTAILADMIN_BASE_URL=http://azionelab.org:8080
PUBLIC_CMS_API_URL=http://backend:8000
NGINX_BIND_ADDRESS=127.0.0.1
NGINX_HTTP_PORT=8080
NGINX_HTTPS_PORT=8443
LETSENCRYPT_ENABLED=0
LETSENCRYPT_DOMAIN=azionelab.org
LETSENCRYPT_EMAIL=operator@example.org
LETSENCRYPT_STAGING=1
LETSENCRYPT_RENEW_INTERVAL_SECONDS=43200
LETSENCRYPT_RETRY_SECONDS=300
TLS_RELOAD_INTERVAL_SECONDS=30
+24
View File
@@ -0,0 +1,24 @@
.env
.env.*
!.env.example
.DS_Store
__pycache__/
*.py[cod]
.coverage
.coverage.*
htmlcov/
.pytest_cache/
.ruff_cache/
backend/media/
backend/static/
backend/staticfiles/
frontend/.astro/
frontend/dist/
frontend/node_modules/
tests/functional/node_modules/
tests/functional/test-results/
tests/functional/playwright-report/
backups/
media-backup/
*.sql
*.dump
+193 -59
View File
@@ -1,73 +1,207 @@
# codex-bootstrap # Azione!Lab
A repository template for AI-assisted development with Codex. A warm, editorial single-page website for a contemporary theatre workshop. Wagtail
provides an editor-friendly headless CMS, Astro renders the public site, PostgreSQL
stores content, and Docker Compose supplies a reproducible local environment.
This template defines a repeatable workflow for using Codex as an autonomous coding agent that can create branches, modify code, run Docker-based tests, update documentation, write ADRs, and commit changes using Conventional Commits. ## Architecture
## Purpose - `backend/`: Django and Wagtail admin, content models, media uploads, and the
aggregate read-only API at `GET /api/site/home/`.
- `frontend/`: Astro components, responsive design system, typed API adapter, and
Italian fallback content for development when the CMS is unavailable.
- `nginx/`: reverse proxy for `azionelab.org`, with optional HTTPS termination and
automatic reload after certificate renewal.
- `certbot/`: optional Let's Encrypt HTTP-01 issue/renew loop for direct deployments.
- `db`: persistent PostgreSQL database.
- Docker volumes persist PostgreSQL, media, ACME challenges, and certificates.
Use this template to bootstrap repositories where Codex must operate with clear rules, minimal changes, pragmatic TDD, security guardrails, and explicit documentation requirements. See [the architecture document](docs/architecture.md),
[ADR-0001](docs/adr/0001-headless-wagtail-astro.md), and
[ADR-0002](docs/adr/0002-nginx-reverse-proxy.md), and
[ADR-0003](docs/adr/0003-optional-letsencrypt.md) for the rationale.
## Repository structure ## Prerequisites
- Docker Engine with Docker Compose v2.
- Ports `8080`, `8443`, `4321`, and `8000` available on localhost.
- A local hosts-file entry mapping `azionelab.org` to `127.0.0.1`.
No host Python or Node.js installation is required.
## Start locally
```bash
cp .env.example .env
docker compose up --build -d
docker compose ps
docker compose exec backend python manage.py seed_demo
```
For local domain resolution, add this line to `/etc/hosts` using your normal system
administration workflow:
```text
127.0.0.1 azionelab.org
```
Open:
- public site: <http://azionelab.org:8080>
- Wagtail admin: <http://azionelab.org:8080/admin/>
- aggregate API: <http://azionelab.org:8080/api/site/home/>
Direct loopback ports `4321` and `8000` remain available for local diagnostics only.
The frontend remains usable with curated fallback content if the API is unavailable.
On first startup the backend applies migrations and collects static files before
starting Gunicorn. Wait until the four default services report healthy. The optional
`certbot` service has zero replicas while `LETSENCRYPT_ENABLED=0`.
## Optional HTTPS with Let's Encrypt
Keep `LETSENCRYPT_ENABLED=0` when a load balancer terminates TLS. For direct public
exposure, first point the domain DNS records to the host and make TCP ports 80 and 443
reachable, then set:
```dotenv
LETSENCRYPT_ENABLED=1
LETSENCRYPT_DOMAIN=azionelab.org
LETSENCRYPT_EMAIL=operator@example.org
LETSENCRYPT_STAGING=1
NGINX_BIND_ADDRESS=0.0.0.0
NGINX_HTTP_PORT=80
NGINX_HTTPS_PORT=443
WAGTAILADMIN_BASE_URL=https://azionelab.org
DJANGO_DEBUG=false
```
Start with the staging CA to validate DNS and firewall configuration without consuming
production rate limits. Then change `LETSENCRYPT_STAGING=0` and remove the staging
certificate volume before requesting the trusted certificate; staging certificates
cannot be converted in place. See [deployment](docs/deployment.md) for the exact reset
and production commands.
## Create an editor account
```bash
docker compose exec backend python manage.py createsuperuser
```
Sign in to Wagtail at <http://azionelab.org:8080/admin/>. The public site has no user
accounts or authentication.
## Demo content
The seed command is idempotent and creates or updates the initial home page, site
settings, participation cards, teacher, lessons, shows, gallery entries, and local
placeholder images:
```bash
docker compose exec backend python manage.py seed_demo
```
Re-run it only when you intentionally want to restore the demo records. Content edited
after seeding may be overwritten for records managed by the command.
## Editing content
1. Open Wagtail admin and select **Pages** to edit the home hero, introduction,
laboratory section, and the three participation cards.
2. Select **Settings > Site settings** for the workshop name and contact details.
3. Use **Snippets** for the teacher, lessons, shows, and gallery.
4. Publish page changes. Snippet and settings changes are returned immediately by the
aggregate endpoint.
The frontend reads `PUBLIC_CMS_API_URL`; inside Compose it defaults to
`http://backend:8000`. Media URLs use `WAGTAILADMIN_BASE_URL` so browsers receive the
public reverse-proxy address. Change both values for another environment.
The standard published-page endpoint is also available at `/api/v2/pages/`.
The PostgreSQL service is named `db`. Existing `.env` files should replace `@postgres:`
with `@db:` in `DATABASE_URL`; a compatibility network alias keeps older local values
working during the transition.
## Useful commands
```bash
# Follow application logs
docker compose logs -f db backend frontend proxy certbot
# Run database migrations
docker compose exec backend python manage.py migrate
# Run all required checks in containers
docker compose run --rm backend python manage.py test
docker compose run --rm frontend npm run check
docker compose run --rm frontend npm run build
docker compose run --rm proxy nginx -t
docker compose -f docker-compose.yml -f docker-compose.test.yml --profile test run --rm backend python manage.py seed_demo
docker compose -f docker-compose.yml -f docker-compose.test.yml --profile test run --build --rm functional-tests
docker compose config --quiet
LETSENCRYPT_ENABLED=1 docker compose config --quiet
# Stop the stack without deleting content
docker compose down
# Stop and permanently delete local database and uploaded media
docker compose down --volumes
```
The final command is destructive. It is useful only when intentionally resetting the
local environment; run the seed command again after the next startup.
## Directory structure
```text ```text
. .
├── AGENTS.md ├── backend/ # Wagtail/Django CMS and API
├── README.md ├── certbot/ # Optional Let's Encrypt renewal loop
├── .codex/ ├── frontend/ # Astro public website
│ ├── project.md ├── nginx/ # Reverse proxy and dynamic TLS configuration
│ ├── workflow.md ├── tests/functional/ # Playwright browser tests
│ ├── security.md ├── docker-compose.test.yml # Functional-test Compose override
│ ├── quality.md ├── docs/ # Architecture, operations, security, and ADRs
│ ├── orchestration.md ├── docker-compose.yml
│ ├── prompts/ ├── .env.example
│ │ ├── task.md └── README.md
│ │ ├── bugfix.md
│ │ ├── refactor.md
│ │ ├── security-review.md
│ │ └── documentation.md
│ ├── agents/
│ │ ├── architect.md
│ │ ├── developer.md
│ │ ├── reviewer.md
│ │ ├── security-reviewer.md
│ │ ├── test-engineer.md
│ │ └── documentation-writer.md
│ └── profiles/
│ ├── docker.md
│ ├── ansible.md
│ └── python.md
└── docs/
├── adr/
│ └── 0000-template.md
├── architecture.md
├── deployment.md
├── operations.md
├── security.md
├── testing.md
└── runbook.md
``` ```
## How to use ## Functional tests
1. Copy this template into a new or existing repository. The Compose test override builds a pinned Playwright image, exposes `azionelab.org` on
2. Edit `.codex/project.md` and configure: the internal Docker network, and runs the browser suite against NGINX. It replaces the
- project mode; database mount with the isolated `test_postgres_data` volume, so the normal CMS data is
- enabled profiles; not changed. Refresh the idempotent test seed first:
- Docker-based test command;
- branch naming rules if needed.
3. Add project-specific details to the documentation under `docs/`.
4. When asking Codex to work on a task, use one of the prompt templates under `.codex/prompts/`.
## Core rules ```bash
docker compose -f docker-compose.yml -f docker-compose.test.yml --profile test run --rm backend python manage.py seed_demo
docker compose -f docker-compose.yml -f docker-compose.test.yml --profile test run --build --rm functional-tests
```
Codex must: The suite covers page content and section order, contact actions, mobile navigation,
responsive overflow, semantic landmarks, image alternatives, the aggregate API and
media, Wagtail admin routing, and rejection of unknown virtual hosts.
- start work from `develop`; ## Database and media backups
- create a dedicated `feature/`, `fix/`, or `hotfix/` branch;
- use pragmatic TDD; Database rows and uploaded files must be backed up together:
- keep changes minimal and focused;
- run the configured Docker-based test command before completion; ```bash
- update documentation and ADRs when needed; mkdir -p backups/media
- produce a final report with summary, tests, risks, and rollback notes; docker compose stop frontend backend
- commit using Conventional Commits. docker compose exec -T db sh -c 'pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB"' > backups/database.sql
docker compose cp backend:/app/media/. backups/media/
docker compose start backend frontend
```
This brief maintenance window keeps database rows and uploaded files in sync. See
[operations](docs/operations.md) for the destructive restore procedure and its
cautions. Do not commit database dumps, `.env`, or uploaded media.
## Production TODOs
This repository deliberately targets a simple, working local deployment. Before a
public production launch, configure either the optional direct TLS mode or TLS at the
load balancer, plus production-grade static/media serving, off-host backups,
monitoring, and a deployment-specific secret manager.
+8
View File
@@ -0,0 +1,8 @@
__pycache__/
*.py[cod]
.pytest_cache/
.ruff_cache/
.venv/
db.sqlite3
media/
static/
+24
View File
@@ -0,0 +1,24 @@
FROM python:3.12.12-slim-bookworm
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /app
RUN addgroup --system django \
&& adduser --system --ingroup django --home /home/django django
COPY requirements.txt ./
RUN pip install --no-cache-dir --requirement requirements.txt
COPY --chown=django:django . .
RUN chmod 755 entrypoint.sh \
&& mkdir -p media static \
&& chown -R django:django media static
USER django
EXPOSE 8000
ENTRYPOINT ["./entrypoint.sh"]
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "2", "--log-level", "warning"]
+1
View File
@@ -0,0 +1 @@
+124
View File
@@ -0,0 +1,124 @@
import os
from pathlib import Path
import dj_database_url
BASE_DIR = Path(__file__).resolve().parent.parent
def env_bool(name: str, default: bool = False) -> bool:
return os.getenv(name, str(default)).lower() in {"1", "true", "yes", "on"}
DEBUG = env_bool("DJANGO_DEBUG", False)
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "")
if not SECRET_KEY:
if DEBUG:
SECRET_KEY = "development-only-secret-key"
else:
raise RuntimeError("DJANGO_SECRET_KEY must be set when DJANGO_DEBUG is false")
ALLOWED_HOSTS = [
host.strip()
for host in os.getenv("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1").split(",")
if host.strip()
]
if "azionelab.org" not in ALLOWED_HOSTS:
ALLOWED_HOSTS.append("azionelab.org")
INSTALLED_APPS = [
"home",
"wagtail.api.v2",
"wagtail.contrib.forms",
"wagtail.contrib.redirects",
"wagtail.contrib.settings",
"wagtail.embeds",
"wagtail.sites",
"wagtail.users",
"wagtail.snippets",
"wagtail.documents",
"wagtail.images",
"wagtail.search",
"wagtail.admin",
"wagtail",
"modelcluster",
"taggit",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"wagtail.contrib.redirects.middleware.RedirectMiddleware",
]
ROOT_URLCONF = "config.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [BASE_DIR / "templates"],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
}
]
WSGI_APPLICATION = "config.wsgi.application"
DATABASES = {
"default": dj_database_url.parse(
os.getenv("DATABASE_URL", f"sqlite:///{BASE_DIR / 'db.sqlite3'}"),
conn_max_age=60,
conn_health_checks=True,
)
}
AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]
LANGUAGE_CODE = "it-it"
TIME_ZONE = "Europe/Rome"
USE_I18N = True
USE_TZ = True
STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "static"
STORAGES = {
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"
},
}
MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
WAGTAIL_SITE_NAME = "Azione!Lab"
WAGTAILADMIN_BASE_URL = os.getenv("WAGTAILADMIN_BASE_URL", "http://localhost:8000")
WAGTAIL_I18N_ENABLED = False
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
DATA_UPLOAD_MAX_MEMORY_SIZE = 10 * 1024 * 1024
FILE_UPLOAD_MAX_MEMORY_SIZE = 10 * 1024 * 1024
+26
View File
@@ -0,0 +1,26 @@
from django.conf import settings
from django.conf.urls.static import static
from django.urls import include, path
from wagtail import urls as wagtail_urls
from wagtail.admin import urls as wagtailadmin_urls
from wagtail.api.v2.router import WagtailAPIRouter
from wagtail.api.v2.views import PagesAPIViewSet
from wagtail.documents import urls as wagtaildocs_urls
from home.views import health, home_api
api_router = WagtailAPIRouter("wagtailapi")
api_router.register_endpoint("pages", PagesAPIViewSet)
urlpatterns = [
path("health/", health, name="health"),
path("api/site/home/", home_api, name="home-api"),
path("api/v2/", api_router.urls),
path("admin/", include(wagtailadmin_urls)),
path("documents/", include(wagtaildocs_urls)),
path("", include(wagtail_urls)),
]
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
+7
View File
@@ -0,0 +1,7 @@
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
application = get_wsgi_application()
+15
View File
@@ -0,0 +1,15 @@
#!/bin/sh
set -eu
attempt=1
until python manage.py migrate --noinput --verbosity 0; do
if [ "$attempt" -ge 10 ]; then
echo "Database migrations failed after $attempt attempts." >&2
exit 1
fi
attempt=$((attempt + 1))
sleep 2
done
python manage.py collectstatic --noinput --verbosity 0
exec "$@"
+1
View File
@@ -0,0 +1 @@
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class HomeConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "home"
+1
View File
@@ -0,0 +1 @@
@@ -0,0 +1 @@
@@ -0,0 +1,216 @@
from io import BytesIO
from django.core.files.base import ContentFile
from django.core.management.base import BaseCommand
from PIL import Image as PillowImage
from wagtail.images import get_image_model
from wagtail.models import Page, Site
from home.models import GalleryItem, HomePage, LessonInfo, Show, SiteSettings, TeacherProfile
class Command(BaseCommand):
help = "Create or refresh the Italian demo content without creating duplicates."
def placeholder(self, title: str, filename: str, color: str):
image_model = get_image_model()
existing = image_model.objects.filter(title=title).first()
if existing:
return existing
output = BytesIO()
PillowImage.new("RGB", (1200, 800), color=color).save(
output,
format="JPEG",
quality=85,
)
return image_model.objects.create(
title=title,
file=ContentFile(output.getvalue(), name=filename),
)
def handle(self, *args, **options):
hero_image = self.placeholder("Prova in sala", "demo-hero.jpg", "#D8C7AE")
laboratory_image = self.placeholder(
"Esercizio di gruppo", "demo-laboratory.jpg", "#A9A497"
)
teacher_image = self.placeholder("Il maestro", "demo-teacher.jpg", "#B8674C")
show_image = self.placeholder("Locandina Tracce", "demo-show.jpg", "#515D53")
second_show_image = self.placeholder(
"Locandina Sottovoce", "demo-show-sottovoce.jpg", "#B8674C"
)
gallery_images = [
self.placeholder("Lezione", "demo-gallery-lesson.jpg", "#D8C7AE"),
self.placeholder("Backstage", "demo-gallery-backstage.jpg", "#A4513B"),
self.placeholder("In scena", "demo-gallery-show.jpg", "#515D53"),
self.placeholder("Il gruppo", "demo-gallery-group.jpg", "#4A433A"),
]
homepage = HomePage.objects.first()
if homepage is None:
homepage = HomePage(
title="Home",
slug="laboratorio",
hero_title="Il teatro diventa presenza.",
hero_subtitle=(
"Un laboratorio dove corpo, voce, ascolto e relazione "
"diventano scena."
),
)
Page.get_first_root_node().add_child(instance=homepage)
homepage.hero_title = "Il teatro diventa presenza."
homepage.hero_subtitle = (
"Un laboratorio dove corpo, voce, ascolto e relazione diventano scena."
)
homepage.hero_image = hero_image
homepage.intro_title = "Uno spazio per esserci davvero"
homepage.intro_body = (
"<p>Ogni incontro è uno spazio di pratica: si prova, si sbaglia, "
"si ascolta, si ricomincia. Il gruppo diventa materia viva, la scena "
"diventa occasione di scoperta.</p>"
)
homepage.cta_primary_label = "Vieni a conoscerci"
homepage.cta_primary_url = "#contatti"
homepage.cta_secondary_label = "Guarda la galleria"
homepage.cta_secondary_url = "#galleria"
homepage.laboratory_title = "Il laboratorio"
homepage.laboratory_body = (
"<p>Corpo, voce e improvvisazione sono strumenti per allenare "
"l'ascolto, la relazione e una presenza scenica autentica.</p>"
)
homepage.laboratory_image = laboratory_image
homepage.save_revision().publish()
homepage.feature_cards.all().delete()
cards = [
(
"Per chi inizia",
"Non serve esperienza: servono curiosità e voglia di mettersi in gioco.",
),
(
"Per chi vuole crescere",
"Una pratica costante per conoscere meglio corpo, voce e presenza.",
),
(
"Per chi ama il gruppo",
"La scena nasce dall'ascolto e dalla fiducia costruita insieme.",
),
]
for order, (title, text) in enumerate(cards):
homepage.feature_cards.create(title=title, text=text, sort_order=order)
homepage.save_revision().publish()
site = Site.objects.filter(is_default_site=True).first()
if site is None:
site = Site.objects.create(
hostname="localhost",
port=8000,
site_name="Azione!Lab",
root_page=homepage,
is_default_site=True,
)
else:
update_fields = []
if site.root_page_id != homepage.id:
site.root_page = homepage
update_fields.append("root_page")
if site.site_name != "Azione!Lab":
site.site_name = "Azione!Lab"
update_fields.append("site_name")
if update_fields:
site.save(update_fields=update_fields)
settings, _ = SiteSettings.objects.get_or_create(site=site)
settings.laboratory_name = "Azione!Lab"
settings.short_claim = "Il teatro diventa presenza."
settings.email = "ciao@laboratorioteatrale.it"
settings.phone = "+39 333 123 4567"
settings.whatsapp = "+39 333 123 4567"
settings.instagram = "https://www.instagram.com/"
settings.facebook = "https://www.facebook.com/"
settings.address = "Via dell'Epomeo 9999, Napoli"
settings.footer_text = "Uno spazio di ricerca, ascolto e presenza."
settings.save()
teacher = TeacherProfile.objects.order_by("id").first() or TeacherProfile()
teacher.name = "Ernesto Estatico"
teacher.photo = teacher_image
teacher.short_bio = (
"Attore, regista e formatore. Accompagna gruppi e persone "
"nella ricerca di una presenza scenica sincera."
)
teacher.full_bio = (
"<p>Da oltre quindici anni lavora tra formazione, creazione "
"collettiva e spettacolo dal vivo.</p>"
)
teacher.quote = "Il teatro non è fingere: è imparare a essere presenti."
teacher.save()
LessonInfo.objects.update_or_create(
day_time="Ogni mercoledì, 19:3022:00",
defaults={
"location": "Spazio Teatro, Via dell'Epomeo 9999, Napoli",
"audience": "Adulti, con o senza esperienza precedente.",
"description": (
"<p>Training fisico e vocale, improvvisazione, ascolto, "
"composizione e lavoro sulla scena.</p>"
),
"trial_lesson": "La prima lezione di prova è gratuita su prenotazione.",
"notes": "Abbigliamento comodo consigliato.",
},
)
Show.objects.update_or_create(
title="Tracce",
year=2025,
defaults={
"short_description": (
"Un lavoro corale sulle storie che lasciamo e che ci attraversano."
),
"description": (
"<p>Esito scenico nato dalle improvvisazioni e dalle scritture "
"del gruppo.</p>"
),
"poster": show_image,
"location": "Teatro di Quartiere",
"cast": "Allieve e allievi del laboratorio",
"order": 0,
"visible_on_home": True,
},
)
Show.objects.update_or_create(
title="Sottovoce",
year=2024,
defaults={
"short_description": (
"Un attraversamento delicato delle parole che restano inascoltate."
),
"description": (
"<p>Un progetto costruito attraverso voce, silenzio e movimento.</p>"
),
"poster": second_show_image,
"location": "Spazio Teatro",
"cast": "Laboratorio annuale",
"order": 1,
"visible_on_home": True,
},
)
gallery_entries = [
(GalleryItem.Category.LESSONS, "Ascolto e movimento"),
(GalleryItem.Category.BACKSTAGE, "Prima dello spettacolo"),
(GalleryItem.Category.SHOWS, "Una scena di Tracce"),
(GalleryItem.Category.GROUP, "Il laboratorio, insieme"),
]
for order, (image, (category, caption)) in enumerate(
zip(gallery_images, gallery_entries, strict=True)
):
GalleryItem.objects.update_or_create(
image=image,
defaults={
"category": category,
"caption": caption,
"order": order,
"visible_on_home": True,
},
)
self.stdout.write(self.style.SUCCESS("Demo content is ready."))
+116
View File
@@ -0,0 +1,116 @@
import django.db.models.deletion
import modelcluster.fields
import wagtail.fields
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("wagtailcore", "0001_squashed_0016_change_page_url_path_to_text_field"),
("wagtailimages", "0001_squashed_0021"),
]
operations = [
migrations.CreateModel(
name="GalleryItem",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("category", models.CharField(choices=[("lessons", "Lessons"), ("backstage", "Backstage"), ("shows", "Shows"), ("group", "Group")], max_length=20)),
("caption", models.CharField(blank=True, max_length=255)),
("order", models.PositiveIntegerField(default=0)),
("visible_on_home", models.BooleanField(default=True)),
("image", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="+", to="wagtailimages.image")),
],
options={"ordering": ["order", "id"]},
),
migrations.CreateModel(
name="HomePage",
fields=[
("page_ptr", models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to="wagtailcore.page")),
("hero_title", models.CharField(max_length=180)),
("hero_subtitle", models.TextField()),
("intro_title", models.CharField(blank=True, max_length=180)),
("intro_body", wagtail.fields.RichTextField(blank=True)),
("cta_primary_label", models.CharField(blank=True, max_length=80)),
("cta_primary_url", models.CharField(blank=True, max_length=255)),
("cta_secondary_label", models.CharField(blank=True, max_length=80)),
("cta_secondary_url", models.CharField(blank=True, max_length=255)),
("laboratory_title", models.CharField(blank=True, max_length=180)),
("laboratory_body", wagtail.fields.RichTextField(blank=True)),
("hero_image", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="+", to="wagtailimages.image")),
("laboratory_image", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="+", to="wagtailimages.image")),
],
bases=("wagtailcore.page",),
),
migrations.CreateModel(
name="LessonInfo",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("day_time", models.CharField(max_length=180)),
("location", models.CharField(max_length=255)),
("audience", models.TextField()),
("description", wagtail.fields.RichTextField()),
("trial_lesson", models.CharField(blank=True, max_length=255)),
("notes", models.TextField(blank=True)),
],
options={"verbose_name": "lesson information", "verbose_name_plural": "lesson information"},
),
migrations.CreateModel(
name="Show",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("title", models.CharField(max_length=180)),
("year", models.PositiveSmallIntegerField()),
("short_description", models.TextField()),
("description", wagtail.fields.RichTextField(blank=True)),
("location", models.CharField(blank=True, max_length=255)),
("cast", models.TextField(blank=True)),
("order", models.PositiveIntegerField(default=0)),
("visible_on_home", models.BooleanField(default=True)),
("poster", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="+", to="wagtailimages.image")),
],
options={"ordering": ["order", "-year", "title"]},
),
migrations.CreateModel(
name="SiteSettings",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("laboratory_name", models.CharField(default="Laboratorio Teatrale", max_length=120)),
("short_claim", models.CharField(blank=True, max_length=180)),
("email", models.EmailField(blank=True, max_length=254)),
("phone", models.CharField(blank=True, max_length=40)),
("whatsapp", models.CharField(blank=True, max_length=40)),
("instagram", models.URLField(blank=True)),
("facebook", models.URLField(blank=True)),
("address", models.CharField(blank=True, max_length=255)),
("footer_text", models.CharField(blank=True, max_length=255)),
("site", models.OneToOneField(editable=False, on_delete=django.db.models.deletion.CASCADE, to="wagtailcore.site")),
],
options={"verbose_name": "site settings", "abstract": False},
),
migrations.CreateModel(
name="TeacherProfile",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("name", models.CharField(max_length=120)),
("short_bio", models.TextField()),
("full_bio", wagtail.fields.RichTextField(blank=True)),
("quote", models.TextField(blank=True)),
("photo", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="+", to="wagtailimages.image")),
],
options={"verbose_name": "teacher profile"},
),
migrations.CreateModel(
name="FeatureCard",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("sort_order", models.IntegerField(blank=True, editable=False, null=True)),
("title", models.CharField(max_length=120)),
("text", models.TextField()),
("page", modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name="feature_cards", to="home.homepage")),
],
options={"ordering": ["sort_order"], "abstract": False},
),
]
@@ -0,0 +1,15 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("home", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="sitesettings",
name="laboratory_name",
field=models.CharField(default="Azione!Lab", max_length=120),
),
]
+1
View File
@@ -0,0 +1 @@
+245
View File
@@ -0,0 +1,245 @@
from django.db import models
from modelcluster.fields import ParentalKey
from wagtail.admin.panels import FieldPanel, InlinePanel, MultiFieldPanel
from wagtail.contrib.settings.models import BaseSiteSetting, register_setting
from wagtail.fields import RichTextField
from wagtail.models import Orderable, Page
from wagtail.snippets.models import register_snippet
@register_setting(icon="cog")
class SiteSettings(BaseSiteSetting):
laboratory_name = models.CharField(max_length=120, default="Azione!Lab")
short_claim = models.CharField(max_length=180, blank=True)
email = models.EmailField(blank=True)
phone = models.CharField(max_length=40, blank=True)
whatsapp = models.CharField(max_length=40, blank=True)
instagram = models.URLField(blank=True)
facebook = models.URLField(blank=True)
address = models.CharField(max_length=255, blank=True)
footer_text = models.CharField(max_length=255, blank=True)
panels = [
MultiFieldPanel(
[FieldPanel("laboratory_name"), FieldPanel("short_claim")],
heading="Identity",
),
MultiFieldPanel(
[
FieldPanel("email"),
FieldPanel("phone"),
FieldPanel("whatsapp"),
FieldPanel("instagram"),
FieldPanel("facebook"),
FieldPanel("address"),
],
heading="Contacts",
),
FieldPanel("footer_text"),
]
class Meta:
verbose_name = "site settings"
class HomePage(Page):
hero_title = models.CharField(max_length=180)
hero_subtitle = models.TextField()
hero_image = models.ForeignKey(
"wagtailimages.Image",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="+",
)
intro_title = models.CharField(max_length=180, blank=True)
intro_body = RichTextField(blank=True)
cta_primary_label = models.CharField(max_length=80, blank=True)
cta_primary_url = models.CharField(max_length=255, blank=True)
cta_secondary_label = models.CharField(max_length=80, blank=True)
cta_secondary_url = models.CharField(max_length=255, blank=True)
laboratory_title = models.CharField(max_length=180, blank=True)
laboratory_body = RichTextField(blank=True)
laboratory_image = models.ForeignKey(
"wagtailimages.Image",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="+",
)
max_count = 1
subpage_types: list[str] = []
content_panels = Page.content_panels + [
MultiFieldPanel(
[
FieldPanel("hero_title"),
FieldPanel("hero_subtitle"),
FieldPanel("hero_image"),
],
heading="Hero",
),
MultiFieldPanel(
[FieldPanel("intro_title"), FieldPanel("intro_body")],
heading="Manifesto",
),
MultiFieldPanel(
[
FieldPanel("cta_primary_label"),
FieldPanel("cta_primary_url"),
FieldPanel("cta_secondary_label"),
FieldPanel("cta_secondary_url"),
],
heading="Calls to action",
),
InlinePanel("feature_cards", label="Feature card", max_num=3),
MultiFieldPanel(
[
FieldPanel("laboratory_title"),
FieldPanel("laboratory_body"),
FieldPanel("laboratory_image"),
],
heading="Laboratory",
),
]
class FeatureCard(Orderable):
page = ParentalKey(
HomePage,
on_delete=models.CASCADE,
related_name="feature_cards",
)
title = models.CharField(max_length=120)
text = models.TextField()
panels = [FieldPanel("title"), FieldPanel("text")]
def __str__(self) -> str:
return self.title
@register_snippet
class TeacherProfile(models.Model):
name = models.CharField(max_length=120)
photo = models.ForeignKey(
"wagtailimages.Image",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="+",
)
short_bio = models.TextField()
full_bio = RichTextField(blank=True)
quote = models.TextField(blank=True)
panels = [
FieldPanel("name"),
FieldPanel("photo"),
FieldPanel("short_bio"),
FieldPanel("full_bio"),
FieldPanel("quote"),
]
class Meta:
verbose_name = "teacher profile"
def __str__(self) -> str:
return self.name
@register_snippet
class LessonInfo(models.Model):
day_time = models.CharField(max_length=180)
location = models.CharField(max_length=255)
audience = models.TextField()
description = RichTextField()
trial_lesson = models.CharField(max_length=255, blank=True)
notes = models.TextField(blank=True)
panels = [
FieldPanel("day_time"),
FieldPanel("location"),
FieldPanel("audience"),
FieldPanel("description"),
FieldPanel("trial_lesson"),
FieldPanel("notes"),
]
class Meta:
verbose_name = "lesson information"
verbose_name_plural = "lesson information"
def __str__(self) -> str:
return self.day_time
@register_snippet
class Show(models.Model):
title = models.CharField(max_length=180)
year = models.PositiveSmallIntegerField()
short_description = models.TextField()
description = RichTextField(blank=True)
poster = models.ForeignKey(
"wagtailimages.Image",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="+",
)
location = models.CharField(max_length=255, blank=True)
cast = models.TextField(blank=True)
order = models.PositiveIntegerField(default=0)
visible_on_home = models.BooleanField(default=True)
panels = [
FieldPanel("title"),
FieldPanel("year"),
FieldPanel("short_description"),
FieldPanel("description"),
FieldPanel("poster"),
FieldPanel("location"),
FieldPanel("cast"),
FieldPanel("order"),
FieldPanel("visible_on_home"),
]
class Meta:
ordering = ["order", "-year", "title"]
def __str__(self) -> str:
return f"{self.title} ({self.year})"
@register_snippet
class GalleryItem(models.Model):
class Category(models.TextChoices):
LESSONS = "lessons", "Lessons"
BACKSTAGE = "backstage", "Backstage"
SHOWS = "shows", "Shows"
GROUP = "group", "Group"
image = models.ForeignKey(
"wagtailimages.Image",
on_delete=models.CASCADE,
related_name="+",
)
category = models.CharField(max_length=20, choices=Category.choices)
caption = models.CharField(max_length=255, blank=True)
order = models.PositiveIntegerField(default=0)
visible_on_home = models.BooleanField(default=True)
panels = [
FieldPanel("image"),
FieldPanel("category"),
FieldPanel("caption"),
FieldPanel("order"),
FieldPanel("visible_on_home"),
]
class Meta:
ordering = ["order", "id"]
def __str__(self) -> str:
return self.caption or self.image.title
+1
View File
@@ -0,0 +1 @@
+87
View File
@@ -0,0 +1,87 @@
import tempfile
from django.core.management import call_command
from django.test import TestCase, override_settings
from home.models import GalleryItem, HomePage, Show, TeacherProfile
@override_settings(WAGTAILADMIN_BASE_URL="https://cms.example.test")
class HomeApiTests(TestCase):
def setUp(self):
self.media_directory = tempfile.TemporaryDirectory()
self.settings_override = override_settings(MEDIA_ROOT=self.media_directory.name)
self.settings_override.enable()
call_command("seed_demo", verbosity=0)
def tearDown(self):
self.settings_override.disable()
self.media_directory.cleanup()
def test_home_endpoint_returns_aggregated_content(self):
response = self.client.get("/api/site/home/", HTTP_HOST="localhost")
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(
set(payload),
{
"settings",
"homepage",
"feature_cards",
"teacher",
"lesson_info",
"shows",
"gallery_items",
},
)
self.assertEqual(payload["homepage"]["hero_title"], "Il teatro diventa presenza.")
self.assertEqual(payload["settings"]["laboratory_name"], "Azione!Lab")
self.assertEqual(payload["settings"]["address"], "Via dell'Epomeo 9999, Napoli")
self.assertEqual(payload["teacher"]["name"], "Ernesto Estatico")
self.assertEqual(
payload["lesson_info"]["location"],
"Spazio Teatro, Via dell'Epomeo 9999, Napoli",
)
self.assertEqual(len(payload["feature_cards"]), 3)
self.assertTrue(
payload["homepage"]["hero_image"]["url"].startswith(
"https://cms.example.test/media/"
)
)
self.assertEqual(payload["homepage"]["hero_image"]["alt"], "Prova in sala")
def test_home_endpoint_only_includes_visible_items(self):
Show.objects.update(visible_on_home=False)
GalleryItem.objects.update(visible_on_home=False)
payload = self.client.get("/api/site/home/", HTTP_HOST="localhost").json()
self.assertEqual(payload["shows"], [])
self.assertEqual(payload["gallery_items"], [])
def test_seed_command_is_idempotent(self):
call_command("seed_demo", verbosity=0)
self.assertEqual(HomePage.objects.count(), 1)
self.assertEqual(HomePage.objects.first().feature_cards.count(), 3)
self.assertEqual(Show.objects.count(), 2)
self.assertEqual(GalleryItem.objects.count(), 4)
def test_seed_updates_the_existing_teacher_without_creating_a_duplicate(self):
teacher = TeacherProfile.objects.get()
teacher.name = "Andrea Rossi"
teacher.save(update_fields=["name"])
call_command("seed_demo", verbosity=0)
self.assertEqual(TeacherProfile.objects.count(), 1)
self.assertEqual(TeacherProfile.objects.get().name, "Ernesto Estatico")
class HealthTests(TestCase):
def test_health_endpoint_checks_database(self):
response = self.client.get("/health/")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"status": "ok"})
+139
View File
@@ -0,0 +1,139 @@
from urllib.parse import urljoin
from django.conf import settings as django_settings
from django.db import DatabaseError, connection
from django.http import JsonResponse
from django.views.decorators.http import require_GET
from wagtail.models import Site
from .models import GalleryItem, HomePage, LessonInfo, Show, SiteSettings, TeacherProfile
def serialize_image(request, image):
if image is None:
return None
public_base_url = django_settings.WAGTAILADMIN_BASE_URL or request.build_absolute_uri("/")
return {
"url": urljoin(f"{public_base_url.rstrip('/')}/", image.file.url),
"alt": image.title,
}
def find_site(request):
return Site.find_for_request(request) or Site.objects.filter(is_default_site=True).first()
@require_GET
def health(request):
try:
with connection.cursor() as cursor:
cursor.execute("SELECT 1")
cursor.fetchone()
except DatabaseError:
return JsonResponse({"status": "unavailable"}, status=503)
return JsonResponse({"status": "ok"})
@require_GET
def home_api(request):
site = find_site(request)
settings = SiteSettings.for_site(site) if site else None
homepage = (
HomePage.objects.live().filter(id=site.root_page_id).first()
if site
else HomePage.objects.live().first()
)
teacher = TeacherProfile.objects.order_by("id").first()
lesson = LessonInfo.objects.order_by("id").first()
settings_data = None
if settings:
settings_data = {
"laboratory_name": settings.laboratory_name,
"short_claim": settings.short_claim,
"email": settings.email,
"phone": settings.phone,
"whatsapp": settings.whatsapp,
"instagram": settings.instagram,
"facebook": settings.facebook,
"address": settings.address,
"footer_text": settings.footer_text,
}
homepage_data = None
feature_cards = []
if homepage:
homepage_data = {
"hero_title": homepage.hero_title,
"hero_subtitle": homepage.hero_subtitle,
"hero_image": serialize_image(request, homepage.hero_image),
"intro_title": homepage.intro_title,
"intro_body": str(homepage.intro_body),
"cta_primary_label": homepage.cta_primary_label,
"cta_primary_url": homepage.cta_primary_url,
"cta_secondary_label": homepage.cta_secondary_label,
"cta_secondary_url": homepage.cta_secondary_url,
"laboratory_title": homepage.laboratory_title,
"laboratory_body": str(homepage.laboratory_body),
"laboratory_image": serialize_image(request, homepage.laboratory_image),
}
feature_cards = [
{"title": card.title, "text": card.text, "order": card.sort_order}
for card in homepage.feature_cards.all()
]
teacher_data = None
if teacher:
teacher_data = {
"name": teacher.name,
"photo": serialize_image(request, teacher.photo),
"short_bio": teacher.short_bio,
"full_bio": str(teacher.full_bio),
"quote": teacher.quote,
}
lesson_data = None
if lesson:
lesson_data = {
"day_time": lesson.day_time,
"location": lesson.location,
"audience": lesson.audience,
"description": str(lesson.description),
"trial_lesson": lesson.trial_lesson,
"notes": lesson.notes,
}
shows = [
{
"title": show.title,
"year": show.year,
"short_description": show.short_description,
"description": str(show.description),
"poster": serialize_image(request, show.poster),
"location": show.location,
"cast": show.cast,
"order": show.order,
}
for show in Show.objects.filter(visible_on_home=True).select_related("poster")
]
gallery_items = [
{
"image": serialize_image(request, item.image),
"category": item.category,
"caption": item.caption,
"order": item.order,
}
for item in GalleryItem.objects.filter(visible_on_home=True).select_related("image")
]
return JsonResponse(
{
"settings": settings_data,
"homepage": homepage_data,
"feature_cards": feature_cards,
"teacher": teacher_data,
"lesson_info": lesson_data,
"shows": shows,
"gallery_items": gallery_items,
}
)
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env python
import os
import sys
def main() -> None:
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
from django.core.management import execute_from_command_line
execute_from_command_line(sys.argv)
if __name__ == "__main__":
main()
+6
View File
@@ -0,0 +1,6 @@
Django==5.2.13
Wagtail==7.4.2
dj-database-url==2.3.0
gunicorn==23.0.0
psycopg[binary]==3.2.4
whitenoise==6.9.0
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ page.title }}</title>
</head>
<body>
<main>
<h1>{{ page.hero_title }}</h1>
<p>This content is published through the headless CMS API.</p>
<p><a href="/api/site/home/">Open the home API</a></p>
</main>
</body>
</html>
+77
View File
@@ -0,0 +1,77 @@
#!/bin/sh
set -eu
domain="${LETSENCRYPT_DOMAIN:-azionelab.org}"
email="${LETSENCRYPT_EMAIL:-}"
staging="${LETSENCRYPT_STAGING:-0}"
renew_interval="${LETSENCRYPT_RENEW_INTERVAL_SECONDS:-43200}"
retry_interval="${LETSENCRYPT_RETRY_SECONDS:-300}"
case "$domain" in
"" | *[!A-Za-z0-9.-]* | .* | *. | *..* | -* | *- | *.-* | *-.*)
echo "Invalid LETSENCRYPT_DOMAIN: $domain" >&2
exit 1
;;
esac
case "$domain" in
*.*) ;;
*)
echo "LETSENCRYPT_DOMAIN must be a fully qualified domain name." >&2
exit 1
;;
esac
if [ -z "$email" ]; then
echo "LETSENCRYPT_EMAIL is required when Let's Encrypt is enabled." >&2
exit 1
fi
case "$staging" in
0 | 1) ;;
*)
echo "LETSENCRYPT_STAGING must be 0 or 1." >&2
exit 1
;;
esac
case "$renew_interval:$retry_interval" in
*[!0-9:]* | :* | *:)
echo "Certbot intervals must be positive integers." >&2
exit 1
;;
esac
if [ "$renew_interval" -eq 0 ] || [ "$retry_interval" -eq 0 ]; then
echo "Certbot intervals must be positive integers." >&2
exit 1
fi
staging_argument=""
if [ "$staging" = "1" ]; then
staging_argument="--staging"
fi
trap 'exit 0' TERM INT
while :; do
if certbot certonly \
--webroot \
--webroot-path /var/www/certbot \
--preferred-challenges http \
--cert-name "$domain" \
--domain "$domain" \
--email "$email" \
--agree-tos \
--non-interactive \
--keep-until-expiring \
$staging_argument; then
delay="$renew_interval"
else
echo "Certificate request failed; retrying after ${retry_interval}s." >&2
delay="$retry_interval"
fi
sleep "$delay" &
wait $! || true
done
+33
View File
@@ -0,0 +1,33 @@
services:
db:
volumes:
- test_postgres_data:/var/lib/postgresql/data
backend:
environment:
WAGTAILADMIN_BASE_URL: http://azionelab.org
proxy:
networks:
default:
aliases:
- azionelab.org
functional-tests:
build:
context: ./tests/functional
init: true
environment:
BASE_URL: http://azionelab.org
INVALID_HOST_URL: http://proxy
CI: "true"
depends_on:
proxy:
condition: service_healthy
profiles: ["test"]
shm_size: 1gb
security_opt:
- no-new-privileges:true
volumes:
test_postgres_data:
+144
View File
@@ -0,0 +1,144 @@
services:
db:
image: postgres:16.9-alpine
restart: unless-stopped
init: true
environment:
POSTGRES_DB: ${POSTGRES_DB:-theatre_lab}
POSTGRES_USER: ${POSTGRES_USER:-theatre_lab}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-replace-with-a-local-password}
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
default:
aliases:
- postgres
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 10
start_period: 5s
security_opt:
- no-new-privileges:true
backend:
build:
context: ./backend
restart: unless-stopped
init: true
environment:
DATABASE_URL: ${DATABASE_URL:-postgresql://theatre_lab:replace-with-a-local-password@db:5432/theatre_lab}
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:-replace-with-a-long-random-development-key}
DJANGO_DEBUG: ${DJANGO_DEBUG:-true}
DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS:-localhost,127.0.0.1,backend,azionelab.org}
WAGTAILADMIN_BASE_URL: ${WAGTAILADMIN_BASE_URL:-http://azionelab.org:8080}
volumes:
- media_data:/app/media
ports:
- "127.0.0.1:8000:8000"
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health/', timeout=2)"]
interval: 10s
timeout: 5s
retries: 10
start_period: 20s
security_opt:
- no-new-privileges:true
frontend:
build:
context: ./frontend
restart: unless-stopped
init: true
environment:
PUBLIC_CMS_API_URL: ${PUBLIC_CMS_API_URL:-http://backend:8000}
ports:
- "127.0.0.1:4321:4321"
depends_on:
backend:
condition: service_healthy
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://localhost:4321/favicon.svg').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))",
]
interval: 10s
timeout: 5s
retries: 10
start_period: 20s
security_opt:
- no-new-privileges:true
proxy:
build:
context: ./nginx
restart: unless-stopped
init: true
environment:
LETSENCRYPT_ENABLED: ${LETSENCRYPT_ENABLED:-0}
LETSENCRYPT_DOMAIN: ${LETSENCRYPT_DOMAIN:-azionelab.org}
TLS_RELOAD_INTERVAL_SECONDS: ${TLS_RELOAD_INTERVAL_SECONDS:-30}
volumes:
- letsencrypt_data:/etc/letsencrypt:ro
- certbot_challenges:/var/www/certbot:ro
ports:
- "${NGINX_BIND_ADDRESS:-127.0.0.1}:${NGINX_HTTP_PORT:-8080}:80"
- "${NGINX_BIND_ADDRESS:-127.0.0.1}:${NGINX_HTTPS_PORT:-8443}:443"
depends_on:
backend:
condition: service_healthy
frontend:
condition: service_healthy
healthcheck:
test:
[
"CMD-SHELL",
"wget -q -O /dev/null http://127.0.0.1/nginx-health || exit 1",
]
interval: 10s
timeout: 5s
retries: 10
start_period: 10s
security_opt:
- no-new-privileges:true
certbot:
image: certbot/certbot:v5.6.0
restart: unless-stopped
init: true
read_only: true
environment:
LETSENCRYPT_DOMAIN: ${LETSENCRYPT_DOMAIN:-azionelab.org}
LETSENCRYPT_EMAIL: "${LETSENCRYPT_EMAIL:-}"
LETSENCRYPT_STAGING: ${LETSENCRYPT_STAGING:-1}
LETSENCRYPT_RENEW_INTERVAL_SECONDS: ${LETSENCRYPT_RENEW_INTERVAL_SECONDS:-43200}
LETSENCRYPT_RETRY_SECONDS: ${LETSENCRYPT_RETRY_SECONDS:-300}
entrypoint: ["/bin/sh", "/opt/certbot/renew.sh"]
volumes:
- ./certbot/renew.sh:/opt/certbot/renew.sh:ro
- letsencrypt_data:/etc/letsencrypt
- certbot_challenges:/var/www/certbot
tmpfs:
- /tmp
- /var/lib/letsencrypt
- /var/log/letsencrypt
depends_on:
proxy:
condition: service_healthy
deploy:
replicas: ${LETSENCRYPT_ENABLED:-0}
security_opt:
- no-new-privileges:true
volumes:
postgres_data:
media_data:
letsencrypt_data:
certbot_challenges:
+56
View File
@@ -0,0 +1,56 @@
# ADR-0001: Headless Wagtail with an Astro frontend
Date: 2026-06-22
Status: Accepted
## Context
The theatre workshop needs an editorially polished public single page whose core
content can be updated without code changes. The required stack is Wagtail/Django,
Astro, PostgreSQL, and Docker Compose. A small team must be able to run and maintain
the system locally without unnecessary services.
## Decision
Use Wagtail as a headless CMS backed by PostgreSQL and Astro as the public frontend.
Wagtail owns uploaded media and exposes a read-only aggregate endpoint at
`/api/site/home/`. Astro renders the page server-side in development/build time and
uses curated Italian fallback content when the CMS is temporarily unavailable.
Docker Compose runs PostgreSQL, Django, and Astro on one private default network.
The content model uses one Wagtail `HomePage` with three ordered feature cards, site
settings, and reusable snippets for teacher details, lesson details, shows, and gallery
items. This keeps editing simple and avoids a custom frontend state layer.
## Consequences
- Editors use the authenticated Wagtail admin while the public API remains read-only.
- The public page needs only one API request and degrades gracefully during CMS outages.
- Uploaded media and PostgreSQL data require separate persistent volumes and backups.
- Local frontend rendering depends on container networking; browser-facing media URLs
are generated from the configured public Wagtail base URL.
## Alternatives considered
- Calling the standard Wagtail endpoints separately was rejected because it adds
frontend orchestration for a single page.
- Embedding the frontend in Django templates was rejected because Astro is required.
- A JavaScript SPA and client-side state library were rejected as unnecessary.
## Security impact
The aggregate endpoint exposes published editorial content only. Wagtail admin keeps
its normal authentication and CSRF protections. Services bind to loopback for local
development, containers are not privileged, and secrets are supplied through the
environment.
## Operational impact
Operators must back up the PostgreSQL and media volumes. CMS schema changes require
normal Django migrations. Health checks order local startup.
## Rollback
Revert the feature commit and remove the Compose volumes only if their stored content
is no longer needed. Keep database and media backups when rolling back code alone.
+59
View File
@@ -0,0 +1,59 @@
# ADR-0002: NGINX reverse proxy for the public virtual host
Date: 2026-06-24
Status: Accepted
TLS aspects of this decision are extended by
[ADR-0003](0003-optional-letsencrypt.md).
## Context
The Compose stack exposes Astro and Wagtail on separate local ports. Azione!Lab needs
one domain-aware HTTP entry point for `azionelab.org` while keeping application routing
and service discovery inside Docker Compose.
## Decision
Add a pinned NGINX Alpine container. Its `azionelab.org` virtual host routes Wagtail
admin, API, documents, health, static files, and media to Django; all other paths go to
Astro. An explicit default virtual host returns 404 for unknown host names.
For local development NGINX binds to `127.0.0.1:8080`, configurable through
`NGINX_BIND_ADDRESS` and `NGINX_HTTP_PORT`. Existing loopback-only application ports
remain available for diagnostics. PostgreSQL remains private to the Compose network.
TLS termination is outside this local implementation.
## Consequences
- The preferred local URL becomes `http://azionelab.org:8080` after a hosts-file entry.
- Wagtail receives the original host and standard forwarded client/protocol headers.
- Media URLs must use the reverse-proxy address through `WAGTAILADMIN_BASE_URL`.
- Production deployment still requires TLS, trusted proxy policy, and removal or
firewalling of diagnostic application ports.
## Alternatives considered
- Routing only the frontend was rejected because Wagtail admin, API, and media should
share the public domain.
- Replacing Astro or Django with static files served directly by NGINX was rejected as
unnecessary for the requested local stack.
- Adding automatic certificates was initially deferred because DNS and certificate
ownership were not part of this task; ADR-0003 later adds an opt-in implementation.
## Security impact
Unknown host names are rejected. NGINX adds basic content-type and referrer headers,
runs without added capabilities or privileged mode, and is bound to loopback by default.
No TLS guarantee is made by this local configuration.
## Operational impact
NGINX depends on healthy frontend and backend services and has its own health check.
Operators must configure DNS/hosts, the public Wagtail base URL, and the published port.
## Rollback
Remove the NGINX service and configuration, restore direct application URLs, and revert
the related allowed-host and public-base-URL values. Database and media data are not
changed.
+71
View File
@@ -0,0 +1,71 @@
# ADR-0003: Optional Let's Encrypt termination for direct deployments
Date: 2026-06-24
Status: Accepted
## Context
Azione!Lab may be deployed either behind a load balancer that already terminates TLS or
directly on a public host. Running an ACME client unconditionally would duplicate edge
responsibilities in the first topology, while direct exposure still needs automated
certificate issuance and renewal.
## Decision
Add an opt-in Certbot service controlled by `LETSENCRYPT_ENABLED`. Docker Compose gives
that service zero replicas by default and one replica only when the value is `1`.
Certbot uses the HTTP-01 webroot method and shares separate challenge and certificate
volumes with NGINX.
NGINX always serves the ACME challenge path over HTTP. When TLS is enabled but no
certificate exists, application traffic remains available over HTTP. An entrypoint
watcher detects certificate creation or renewal, renders the HTTPS virtual host,
validates the configuration, reloads NGINX, and redirects non-ACME HTTP traffic to
HTTPS. No Docker socket or container-control privilege is required.
The direct-deployment operator is responsible for public DNS, inbound ports 80 and 443,
a valid contact email, and selecting the staging or production ACME endpoint. Behind a
load balancer, Certbot stays disabled and the load balancer owns certificates.
## Consequences
- One Compose definition supports both deployment topologies.
- Direct deployments gain automated issue and renewal without manual certificate copy.
- The first direct request can use HTTP until issuance completes; operators must not
publish sensitive workflows before TLS has been verified.
- Certificate and challenge volumes add persistent operational state.
- HTTP-01 cannot issue when port 80 or DNS is controlled by another edge.
## Alternatives considered
- Always running Certbot was rejected because it conflicts with load-balancer-managed
certificates and creates unnecessary ACME traffic.
- Separate Compose files per topology were rejected as avoidable configuration drift
for this small stack.
- Giving Certbot access to the Docker socket to reload NGINX was rejected because that
privilege is disproportionate; NGINX can safely watch its read-only certificate
volume.
- DNS-01 was not selected because it requires provider-specific credentials and
dependencies. It remains a future option for wildcard certificates or closed port 80.
## Security impact
The certificate private key is writable only by Certbot and read-only to NGINX. Certbot
uses a read-only root filesystem, temporary runtime mounts, `no-new-privileges`, and no
Docker socket. Production must restrict application ports and the proxy trust boundary;
Django accepts the secure forwarded-protocol header for load-balancer deployments.
## Operational impact
Operators must monitor both Certbot issue/renew logs and NGINX reload logs, protect the
certificate volume, and test DNS/firewall changes with the staging CA. Renewals are
checked every 12 hours and NGINX detects certificate changes every 30 seconds by
default. These intervals are configurable.
## Rollback
Set `LETSENCRYPT_ENABLED=0` and recreate the stack to stop Certbot while retaining
certificate state. Terminate TLS at a load balancer if public service must continue.
The certificate volumes can be removed only after confirming they are no longer needed;
database and media volumes are independent.
+29 -9
View File
@@ -1,13 +1,33 @@
# Architecture # Architecture
Describe the project architecture here. The system has four default runtime components and one optional component on the Docker
Compose network:
Include: 1. NGINX accepts requests for `azionelab.org` and routes them by path.
2. Astro renders the public single page and requests one aggregate JSON document.
3. Wagtail/Django manages editorial content, serves media in development, and exposes
the read-only `/api/site/home/` endpoint.
4. PostgreSQL persists Wagtail content and metadata.
5. When explicitly enabled, Certbot obtains and renews a Let's Encrypt certificate by
writing HTTP-01 challenges and certificate files to shared named volumes.
- main components; NGINX sends `/admin`, `/api`, `/documents`, `/health`, `/media`, and `/static` paths to
- runtime dependencies; Wagtail. All remaining paths go to Astro. Unknown virtual hosts receive a 404 response.
- data flow; The ACME challenge path is served directly from its shared volume. If automatic TLS is
- persistence; enabled, NGINX starts in HTTP mode, detects the first certificate, adds its HTTPS
- external integrations; virtual host, redirects application traffic to HTTPS, and reloads after renewals.
- deployment topology;
- relevant ADRs. The aggregate response contains `settings`, `homepage`, `feature_cards`, `teacher`,
`lesson_info`, `shows`, and `gallery_items`. Image values contain browser-facing URLs
and alternative text. Only published home content and records marked for home display
are exposed. Astro validates/normalizes the response and falls back to checked-in
Italian demo content if fetching fails.
Wagtail's authenticated admin remains the only editing interface. The public frontend
has no database access and no public write API. Uploaded media is stored in a dedicated
Compose volume.
The deployment topology and content model are recorded in
[ADR-0001](adr/0001-headless-wagtail-astro.md) and
[ADR-0002](adr/0002-nginx-reverse-proxy.md). Optional TLS termination is recorded in
[ADR-0003](adr/0003-optional-letsencrypt.md).
+91 -11
View File
@@ -1,15 +1,95 @@
# Deployment # Deployment
Describe how this project is deployed. ## Local environment
Include: Copy `.env.example` to `.env`, replace the development placeholders, then run:
- environments; ```bash
- Docker/Compose usage; cp .env.example .env
- required configuration; docker compose up --build -d
- secrets handling; docker compose ps
- exposed ports; docker compose exec backend python manage.py seed_demo
- volumes; ```
- networks;
- deployment commands; The backend applies migrations and collects static files before starting Gunicorn.
- rollback procedure. All services have health checks; wait for healthy status before opening the site.
Add `127.0.0.1 azionelab.org` to the local hosts file, then use
`http://azionelab.org:8080`. NGINX binds to loopback port `8080` by default and routes
the domain to Astro or Wagtail. Their direct loopback ports `4321` and `8000` remain
available for diagnostics. PostgreSQL is available only on the Compose network.
`postgres_data` and `media_data` are persistent named volumes. Local HTTPS is disabled
by default; `letsencrypt_data` and `certbot_challenges` remain empty unless used.
The stack uses explicit PostgreSQL 16.9, Python 3.12.12, Node.js 22.20, and NGINX 1.30.0
image versions. Containers are not privileged and use `no-new-privileges`.
Required runtime variables are `DATABASE_URL`, `DJANGO_SECRET_KEY`, `DJANGO_DEBUG`,
`DJANGO_ALLOWED_HOSTS`, `WAGTAILADMIN_BASE_URL`, `PUBLIC_CMS_API_URL`,
`NGINX_BIND_ADDRESS`, `NGINX_HTTP_PORT`, and `NGINX_HTTPS_PORT`. The optional certificate
variables and PostgreSQL bootstrap variables are documented in `.env.example`. Do not
use the example credentials outside local development.
`WAGTAILADMIN_BASE_URL` must be browser-reachable because it is used for media URLs.
`PUBLIC_CMS_API_URL` must be reachable by Astro; within Compose it is
`http://backend:8000`.
The PostgreSQL Compose service is `db`, so new `DATABASE_URL` values use `db:5432`.
The internal `postgres` alias is retained only for compatibility with existing local
`.env` files and should not be used in new configuration.
## TLS deployment modes
When an external load balancer terminates TLS, leave `LETSENCRYPT_ENABLED=0`. The
`certbot` service then has zero replicas and NGINX serves HTTP to the trusted internal
network. Configure the load balancer to set `X-Forwarded-Proto: https`, keep application
ports private, and restrict proxy access to the load balancer network.
For direct exposure, the HTTP-01 challenge requires public DNS for
`LETSENCRYPT_DOMAIN` to resolve to this host and inbound TCP ports 80 and 443 to reach
NGINX. Use a real operator address and production-safe Django settings:
```dotenv
LETSENCRYPT_ENABLED=1
LETSENCRYPT_DOMAIN=azionelab.org
LETSENCRYPT_EMAIL=operator@example.org
LETSENCRYPT_STAGING=1
NGINX_BIND_ADDRESS=0.0.0.0
NGINX_HTTP_PORT=80
NGINX_HTTPS_PORT=443
WAGTAILADMIN_BASE_URL=https://azionelab.org
DJANGO_DEBUG=false
DJANGO_ALLOWED_HOSTS=azionelab.org
```
Then run `docker compose up --build -d` and inspect `docker compose logs certbot proxy`.
NGINX serves HTTP until a certificate exists, then reloads and redirects normal HTTP
requests to HTTPS. The ACME path remains available over HTTP for renewal.
Use the Let's Encrypt staging CA first. Before switching to the production CA, stop the
stack and remove only the staging certificate volume after checking its exact Compose
project name:
```bash
docker compose down
docker volume ls --filter label=com.docker.compose.volume=letsencrypt_data
docker volume rm PROJECT_letsencrypt_data
```
Set `LETSENCRYPT_STAGING=0`, restart, and verify the certificate issuer in a browser or
TLS inspection tool. Never use `docker compose down --volumes` on an environment whose
database, media, or certificates must be retained.
## Production boundary
The Compose stack remains a minimal deployment base. A public environment still needs
production static/media serving, restricted admin access, managed secrets, off-host
backups, monitoring, firewall rules, and an explicit domain/allowed-host policy.
## Rollback
Set `LETSENCRYPT_ENABLED=0` to disable the certificate service without deleting
certificates, or terminate TLS at the load balancer. Revert the application commit and
rebuild images for a full rollback. Keep database, media, and certificate volumes unless
deletion is intentional. Schema rollback must be evaluated per Django migration; take
coordinated database and media backups first.
+74 -9
View File
@@ -1,13 +1,78 @@
# Operations # Operations
Describe operational procedures. ## Routine commands
Include: ```bash
docker compose up --build -d
docker compose ps
docker compose logs -f proxy backend frontend db
docker compose down
```
- startup and shutdown; Compose health checks all services. Through the virtual host, the public health endpoint
- health checks; is `http://azionelab.org:8080/health/`; the aggregate content endpoint is
- logs; `http://azionelab.org:8080/api/site/home/`.
- monitoring;
- backup and restore; Frontend and backend container logs suppress successful request and informational
- routine maintenance; entries by default, while warnings and errors remain visible. NGINX retains its access
- known operational risks. log for request-level diagnostics. The frontend health check requests a static asset so
it does not render the home page or query the CMS.
Test routing without changing the hosts file:
```bash
curl --resolve azionelab.org:8080:127.0.0.1 http://azionelab.org:8080/health/
```
Apply schema changes with `docker compose exec backend python manage.py migrate`.
Create editors with `createsuperuser`; use Wagtail permissions for later non-superuser
accounts.
The backend applies migrations at container startup. To permanently reset local state,
use `docker compose down --volumes`; this deletes database, media, ACME challenges, and
certificate state.
## Certificate operations
With `LETSENCRYPT_ENABLED=1`, Certbot checks the certificate every 12 hours by default
and renews it when due. NGINX checks the read-only certificate volume every 30 seconds
and reloads only after its configuration validates. Adjust these intervals only for a
documented operational reason.
```bash
docker compose ps -a certbot proxy
docker compose logs --tail=200 certbot proxy
docker compose run --rm --no-deps --entrypoint certbot certbot certificates
```
Certificate state is stored in `letsencrypt_data`; include it in protected host backups
if recovery must preserve the same private key. Never copy its content into the
repository or general application logs. When TLS is terminated by a load balancer,
keep the service disabled and manage certificates at that edge instead.
## Backup and restore
Back up the database and media at the same logical point in time:
```bash
mkdir -p backups/media
docker compose stop frontend backend
docker compose exec -T db sh -c 'pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB"' > backups/database.sql
docker compose cp backend:/app/media/. backups/media/
docker compose start backend frontend
```
Restore is destructive: verify the backup in a disposable environment and take a fresh
backup first. Then stop both applications, recreate and restore the database, replace
the media content, restart, and verify health, API, admin login, and several images.
Keep real backups encrypted and off-host with a defined retention policy.
## Known risks
- Frontend fallback content can mask a CMS outage, so monitor backend health directly.
- Local named volumes are not off-host backups.
- Development media serving is unsuitable for production traffic.
- Automatic issuance depends on public DNS, inbound port 80, Let's Encrypt
availability, and its rate limits. Test with the staging CA first.
- Deleting `letsencrypt_data` loses certificate account/key state and triggers a new
issuance attempt when the service is enabled again.
+61 -10
View File
@@ -1,19 +1,70 @@
# Runbook # Runbook
Operational runbook for this project. ## Site shows fallback content
## Common tasks 1. Check `docker compose ps` and the backend health status.
2. Request `http://azionelab.org:8080/api/site/home/` through NGINX.
3. Inspect `docker compose logs backend db`.
4. Verify `PUBLIC_CMS_API_URL` resolves from the frontend container.
5. Restart the frontend after recovery so its static page is rebuilt from CMS data.
Document routine operational tasks here. ## Wagtail does not start
## Troubleshooting 1. Confirm PostgreSQL is healthy.
2. Check `DATABASE_URL` and allowed hosts without printing secret values in shared logs.
3. Run `docker compose exec backend python manage.py migrate`.
4. Review backend logs for a specific migration or configuration error.
Document known issues, symptoms, checks, and remediation steps. ## Images are missing
1. Verify the `media_data` volume is mounted at `/app/media`.
2. Check `WAGTAILADMIN_BASE_URL` uses a URL reachable by the browser.
3. Confirm the image still exists in Wagtail and has not been removed from the volume.
## A service is unhealthy
1. Run `docker compose ps` and inspect `docker compose logs --tail=200 SERVICE`.
2. For PostgreSQL, check volume capacity and database/user configuration.
3. For the backend, check `/health/`, migrations, and database connectivity.
4. For the frontend, request port `4321`, then check the API separately because
fallback content can mask a CMS outage.
## NGINX returns 404 or 502
1. Confirm the request host is exactly `azionelab.org`; unknown hosts intentionally
receive 404.
2. Run `docker compose exec proxy nginx -t`.
3. Check `docker compose ps` and verify both backend and frontend are healthy.
4. Inspect `docker compose logs --tail=200 proxy backend frontend`.
5. Verify local DNS or `/etc/hosts` maps `azionelab.org` to the proxy address.
## Let's Encrypt does not issue a certificate
1. Confirm `LETSENCRYPT_ENABLED=1` and that `docker compose ps -a certbot proxy` shows
both containers running and the proxy healthy.
2. Check that the domain's public A/AAAA records point to this host. Remove an AAAA
record if IPv6 does not actually reach it.
3. Verify inbound TCP port 80 reaches NGINX; HTTP-01 cannot use only port 443.
4. Request `http://azionelab.org/.well-known/acme-challenge/missing`: a 404 from NGINX
confirms the challenge route is reachable, while a timeout or another server does
not.
5. Inspect `docker compose logs --tail=200 certbot proxy` for ACME validation or rate
limit errors. Do not repeatedly retry the production CA; use staging while fixing
connectivity.
## HTTPS is not activated after issuance
1. Run `docker compose run --rm --no-deps --entrypoint certbot certbot certificates`.
2. Confirm `LETSENCRYPT_DOMAIN` exactly matches the certificate name used by both
services.
3. Wait for `TLS_RELOAD_INTERVAL_SECONDS`, then inspect proxy logs for `nginx -t` or
reload errors.
4. Run `docker compose exec proxy nginx -t` and check HTTPS locally with an explicit
DNS override.
## Rollback ## Rollback
Document rollback procedures here. Disable Certbot with `LETSENCRYPT_ENABLED=0` if TLS is moving to a load balancer, then
recreate the affected services. Revert the application commit and rebuild containers
## Emergency contacts for a full rollback. Preserve database, media, and certificate volumes. Before
reversing migrations or deleting volumes, make and validate coordinated backups.
Document project-specific escalation paths if appropriate.
+36 -13
View File
@@ -1,16 +1,39 @@
# Security # Security
Describe security assumptions and controls. - Wagtail admin uses Django authentication, authorization, sessions, CSRF protection,
and password hashing. The public site does not add accounts or write endpoints.
- The aggregate API is intentionally unauthenticated and returns published editorial
content only; secrets and unpublished drafts must never be serialized.
- PostgreSQL has no host-published port. NGINX and diagnostic application ports bind to
loopback for local use. Unknown NGINX virtual hosts are rejected with 404.
- Containers are unprivileged at the Compose level: no privileged mode, host network,
Docker socket, or added capabilities are used. `no-new-privileges` is enabled and
the application images run as non-root users.
- `.env` is ignored. `.env.example` contains replaceable development placeholders,
never production credentials. Use a deployment secret manager outside local use.
- `DJANGO_DEBUG` must be false and allowed hosts explicit outside development. Public
traffic must use either the optional direct TLS mode or TLS at a load balancer.
- Database and uploaded media backups may contain personal data. Restrict, encrypt,
retain, and delete them according to the operator's privacy policy.
- Avoid placing personal phone numbers or private contact details in logs. The API
legitimately exposes only contact details approved for publication.
- Frontend and backend informational/access logs are suppressed by default, reducing
routine client metadata in application logs without hiding warnings or errors. NGINX
remains the request-level access log and must receive the same retention controls.
- Dependency and image versions are explicit. Operators remain responsible for patch
upgrades, vulnerability scans, and production digest pinning.
- NGINX forwards the original host and standard client/protocol headers. Django trusts
`X-Forwarded-Proto: https`; therefore direct proxy access must be limited to trusted
networks when a load balancer supplies that header. The NGINX mapping accepts only
the literal `https` value as secure.
- Optional Certbot uses a pinned image, a read-only root filesystem, no Docker socket,
and only the certificate/challenge volumes. NGINX reads private keys from the
certificate volume but cannot modify them. Restrict and back up that volume as
sensitive material.
- The Playwright image is pinned and enabled only through the test Compose profile. It
receives no credentials, publishes no host ports, and tests only the local portal.
The override uses a separate PostgreSQL volume so its seed cannot overwrite normal
CMS content.
Include: Manual production hardening remains required for proxy trust boundaries, media
storage, backup retention, monitoring, firewalling, and admin network policy.
- authentication;
- authorization;
- network exposure;
- TLS/certificates;
- secrets management;
- logging of sensitive data;
- container privileges;
- filesystem permissions;
- dependency management;
- relevant ADRs.
+31 -11
View File
@@ -1,23 +1,43 @@
# Testing # Testing
Describe how tests are executed.
All tests should run inside Docker containers. All tests should run inside Docker containers.
## Canonical test command ## Canonical test command
```bash ```bash
CHANGE_ME docker compose run --rm backend python manage.py test
docker compose run --rm frontend npm run check
docker compose run --rm frontend npm run build
docker compose run --rm proxy nginx -t
docker compose run --rm --no-deps --entrypoint certbot certbot --version
docker compose -f docker-compose.yml -f docker-compose.test.yml --profile test run --rm backend python manage.py seed_demo
docker compose -f docker-compose.yml -f docker-compose.test.yml --profile test run --build --rm functional-tests
docker compose config --quiet
LETSENCRYPT_ENABLED=1 docker compose config --quiet
``` ```
## Test categories ## Test categories
Describe applicable categories: The test suite covers:
- unit tests; - Django model and API integration tests;
- integration tests; - Astro static type and template validation;
- linting; - production frontend build;
- formatting checks; - NGINX syntax and upstream configuration validation;
- Ansible syntax checks; - Certbot image availability/version and optional Compose service rendering;
- Docker/Compose validation; - Playwright functional browser tests through the NGINX virtual host;
- smoke tests. - Docker Compose configuration validation.
All commands run in containers. The backend test container starts PostgreSQL through
Compose; the frontend checks use the checked-in lockfile for reproducible installs.
`docker-compose.test.yml` is an override used only for functional verification. It
adds an internal `azionelab.org` alias to the proxy and starts a pinned Playwright
container. Run the idempotent demo seed through the merged backend service immediately
before the browser suite. The override uses `test_postgres_data` instead of the normal
database volume and does not publish additional host ports.
The browser suite verifies the public content and section order, CTA/contact links,
mobile navigation, horizontal overflow, semantic landmarks, image alternatives, API
contract, uploaded media, Wagtail admin routing, and rejection of unknown hosts. A
manual visual review is still recommended for subjective layout and design quality.
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
.astro
npm-debug.log
+18
View File
@@ -0,0 +1,18 @@
FROM node:22.20-alpine
ENV ASTRO_TELEMETRY_DISABLED=1 \
ASTRO_DISABLE_UPDATE_CHECK=true
WORKDIR /app
RUN chown node:node /app
USER node
COPY --chown=node:node package.json package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY --chown=node:node . .
EXPOSE 4321
CMD ["./node_modules/.bin/astro", "dev"]
+26
View File
@@ -0,0 +1,26 @@
import { defineConfig } from "astro/config";
export default defineConfig(({ command }) => ({
output: "static",
experimental:
command === "dev"
? {
logger: {
entrypoint: "astro/logger/node",
config: {
level: "warn",
},
},
}
: undefined,
server: {
host: true,
port: 4321,
},
vite: {
logLevel: command === "dev" ? "warn" : undefined,
server: {
allowedHosts: ["azionelab.org"],
},
},
}));
+5793
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
{
"name": "laboratorio-teatrale-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "astro dev --host 0.0.0.0",
"build": "astro build",
"preview": "astro preview --host 0.0.0.0",
"check": "astro check"
},
"dependencies": {
"astro": "6.4.6"
},
"devDependencies": {
"@astrojs/check": "0.9.4",
"typescript": "5.7.3"
}
}
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="10" fill="#A4513B"/>
<path d="M18 16h28v7H36v25h-8V23H18z" fill="#FFF8EF"/>
</svg>

After

Width:  |  Height:  |  Size: 181 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 860 700" role="img" aria-labelledby="title"><title id="title">Il laboratorio insieme</title><rect width="860" height="700" fill="#FFF8EF"/><circle cx="430" cy="350" r="292" fill="#D8C7AE"/><circle cx="229" cy="250" r="58" fill="#A4513B"/><circle cx="430" cy="188" r="58" fill="#515D53"/><circle cx="631" cy="250" r="58" fill="#4A433A"/><path d="M111 615c15-171 54-259 117-265 73 4 116 93 130 265z" fill="#4A433A"/><path d="M313 615c13-203 52-304 117-305 75 2 115 104 120 305z" fill="#A4513B"/><path d="M514 615c17-171 56-259 117-265 73 4 116 93 130 265z" fill="#515D53"/></svg>

After

Width:  |  Height:  |  Size: 631 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 860 700" role="img" aria-labelledby="title"><title id="title">Ascolto e movimento</title><rect width="860" height="700" fill="#D8C7AE"/><circle cx="220" cy="232" r="90" fill="#FFF8EF"/><path d="m72 700 92-366c82-28 148 13 198 122l-41 244z" fill="#515D53"/><circle cx="628" cy="175" r="78" fill="#A4513B"/><path d="m500 700 69-411c77-24 143 35 199 176l-2 235z" fill="#4A433A"/><path d="M305 418c116-87 221-122 316-104" fill="none" stroke="#B8674C" stroke-width="18" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 549 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 860 700" role="img" aria-labelledby="title"><title id="title">Le cose che restano in scena</title><rect width="860" height="700" fill="#515D53"/><circle cx="430" cy="360" r="251" fill="#B8674C"/><path d="M0 560c234-153 497-163 860-41v181H0z" fill="#D8C7AE"/><path d="M157 560c33-136 90-212 171-228 92 26 151 104 177 236zM496 550c11-121 58-202 142-244 93 43 140 128 141 254z" fill="#4A433A"/><circle cx="326" cy="270" r="52" fill="#FFF8EF"/><circle cx="639" cy="251" r="52" fill="#A4513B"/></svg>

After

Width:  |  Height:  |  Size: 549 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 860 700" role="img" aria-labelledby="title"><title id="title">Prima di entrare in scena</title><rect width="860" height="700" fill="#4A433A"/><path d="M0 0h237v700H0zM623 0h237v700H623z" fill="#A4513B"/><ellipse cx="430" cy="720" rx="275" ry="346" fill="#D8C7AE"/><circle cx="430" cy="286" r="88" fill="#FFF8EF"/><path d="M290 700c24-192 71-298 140-318 82 25 129 131 140 318z" fill="#515D53"/></svg>

After

Width:  |  Height:  |  Size: 453 B

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 1120" role="img" aria-labelledby="title desc">
<title id="title">Il gruppo durante un esercizio teatrale</title><desc id="desc">Composizione astratta calda con figure in movimento nello spazio.</desc>
<rect width="960" height="1120" fill="#D8C7AE"/><path d="M0 0h420v1120H0z" fill="#B8674C" opacity=".55"/><circle cx="746" cy="222" r="178" fill="#FFF8EF" opacity=".5"/>
<path d="M192 926c42-210 43-404 5-580 87-83 174-75 261 23-54 180-52 368 8 564z" fill="#4A433A"/><circle cx="326" cy="254" r="102" fill="#F3EBDD"/>
<path d="M494 979c50-169 70-325 60-470 72-79 154-70 245 29-34 120-26 273 24 458z" fill="#515D53"/><circle cx="651" cy="427" r="86" fill="#A4513B"/>
<path d="M405 427c114 25 209 7 283-55" fill="none" stroke="#FFF8EF" stroke-width="22" stroke-linecap="round" opacity=".8"/>
</svg>

After

Width:  |  Height:  |  Size: 863 B

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 760" role="img" aria-labelledby="title desc">
<title id="title">Esercizio di movimento nello spazio</title><desc id="desc">Sagome astratte dialogano in uno spazio color sabbia.</desc>
<rect width="960" height="760" fill="#FFF8EF"/><path d="M0 538 960 208v552H0z" fill="#D8C7AE"/>
<circle cx="248" cy="195" r="73" fill="#A4513B"/><path d="M158 630c13-139 35-251 68-335 74 1 123 40 147 117l-57 218z" fill="#515D53"/>
<circle cx="671" cy="245" r="65" fill="#4A433A"/><path d="m572 635 78-310c73 11 119 59 138 145l-19 165z" fill="#B8674C"/>
<path d="M329 391c115 62 214 43 297-58" fill="none" stroke="#4A433A" stroke-width="16" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 723 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 700 880" role="img" aria-labelledby="title desc">
<title id="title">Le cose che restano</title><desc id="desc">Locandina astratta dello spettacolo.</desc>
<rect width="700" height="880" fill="#515D53"/><circle cx="350" cy="427" r="227" fill="#D8C7AE"/><path d="M0 672c163-175 366-220 700-138v346H0z" fill="#A4513B"/>
<path d="M239 595c19-127 52-235 99-324 80 36 127 139 140 309z" fill="#4A433A"/><circle cx="362" cy="218" r="62" fill="#FFF8EF"/>
</svg>

After

Width:  |  Height:  |  Size: 512 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 700 880" role="img" aria-labelledby="title desc">
<title id="title">Fuori campo</title><desc id="desc">Locandina geometrica dello spettacolo.</desc>
<rect width="700" height="880" fill="#FFF8EF"/><path d="M95 0h510v880H95z" fill="#D8C7AE"/><circle cx="350" cy="390" r="203" fill="#A4513B"/>
<path d="M0 605 700 319v561H0z" fill="#4A433A" opacity=".92"/><path d="M264 680c11-153 36-263 77-330 76 57 117 154 123 289z" fill="#F3EBDD"/>
</svg>

After

Width:  |  Height:  |  Size: 499 B

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 900" role="img" aria-labelledby="title desc">
<title id="title">Ritratto del maestro</title><desc id="desc">Ritratto editoriale astratto nei toni terracotta e salvia.</desc>
<rect width="760" height="900" fill="#A4513B"/><circle cx="375" cy="289" r="166" fill="#D8C7AE"/><path d="M127 900c15-268 95-405 240-411 159 9 249 146 268 411z" fill="#515D53"/>
<path d="M310 279c40-34 84-34 131 0M335 354c29 16 57 16 85-1" fill="none" stroke="#4A433A" stroke-width="14" stroke-linecap="round"/>
<circle cx="313" cy="268" r="9" fill="#4A433A"/><circle cx="448" cy="268" r="9" fill="#4A433A"/>
<path d="M208 146c61-94 197-127 292-41 26 24 45 58 56 102-89-68-197-87-348-61z" fill="#4A433A"/>
</svg>

After

Width:  |  Height:  |  Size: 754 B

@@ -0,0 +1,26 @@
---
import type { SiteSettings } from "../lib/api";
interface Props { settings: SiteSettings }
const { settings } = Astro.props;
const tel = settings.phone.replace(/[^+\d]/g, "");
const whatsapp = settings.whatsapp.replace(/\D/g, "");
const hasContacts = settings.email || settings.phone || settings.whatsapp;
---
<section class="contact section" id="contatti" aria-labelledby="contact-title">
<div class="container contact-grid">
<div class="contact-intro">
<p class="eyebrow">Facciamo conoscenza</p>
<h2 id="contact-title">Vieni a trovarci</h2>
<p class="lead">Se vuoi capire se questo percorso fa per te, scrivici o chiamaci. Ti risponderemo con calma, senza formule automatiche.</p>
{settings.address && <address>{settings.address}</address>}
</div>
{hasContacts ? (
<div class="contact-actions" aria-label="Canali di contatto">
{settings.email && <a href={`mailto:${settings.email}`}><span>Scrivi una mail</span><strong>{settings.email}</strong><span aria-hidden="true">↗</span></a>}
{settings.phone && <a href={`tel:${tel}`}><span>Chiama</span><strong>{settings.phone}</strong><span aria-hidden="true">↗</span></a>}
{settings.whatsapp && <a href={`https://wa.me/${whatsapp}`} target="_blank" rel="noreferrer"><span>WhatsApp</span><strong>{settings.whatsapp}</strong><span aria-hidden="true">↗</span></a>}
</div>
) : <p class="empty-state contact-empty">I contatti saranno disponibili presto.</p>}
</div>
</section>
@@ -0,0 +1,25 @@
---
import type { FeatureCardContent } from "../lib/api";
interface Props { cards: FeatureCardContent[] }
const { cards } = Astro.props;
---
<section class="features section section-light" aria-labelledby="features-title">
<div class="container">
<div class="section-heading">
<p class="eyebrow">Tre modi per cominciare</p>
<h2 id="features-title">Perché partecipare</h2>
</div>
{cards.length ? (
<div class="feature-grid">
{cards.map((card, index) => (
<article class="feature-card">
<span class="card-number" aria-hidden="true">0{index + 1}</span>
<h3>{card.title}</h3>
<p>{card.text}</p>
</article>
))}
</div>
) : <p class="empty-state">I percorsi del laboratorio saranno pubblicati presto.</p>}
</div>
</section>
+20
View File
@@ -0,0 +1,20 @@
---
import type { SiteSettings } from "../lib/api";
interface Props { settings: SiteSettings }
const { settings } = Astro.props;
const year = new Date().getFullYear();
---
<footer class="site-footer">
<div class="container footer-main">
<div><a class="wordmark" href="#inizio">{settings.site_name}</a><p>{settings.footer_text}</p></div>
<nav aria-label="Social media">
{settings.instagram && <a href={settings.instagram} target="_blank" rel="noreferrer">Instagram</a>}
{settings.facebook && <a href={settings.facebook} target="_blank" rel="noreferrer">Facebook</a>}
</nav>
</div>
<div class="container footer-bottom">
<p>© {year} {settings.site_name}</p>
<p>Progetto realizzato con cura · <span>Privacy (TODO)</span></p>
</div>
</footer>
@@ -0,0 +1,21 @@
---
import type { GalleryItemContent } from "../lib/api";
interface Props { items: GalleryItemContent[] }
const { items } = Astro.props;
---
<section class="gallery section" id="galleria" aria-labelledby="gallery-title">
<div class="container">
<div class="section-heading"><p class="eyebrow">Dentro il laboratorio</p><h2 id="gallery-title">Galleria</h2></div>
{items.length ? (
<div class="gallery-grid">
{items.map((item) => (
<figure class="gallery-item">
<img src={item.image.url} alt={item.image.alt} width="860" height="700" loading="lazy" />
<figcaption><span>{item.category}</span>{item.caption}</figcaption>
</figure>
))}
</div>
) : <p class="empty-state">Le fotografie del laboratorio arriveranno presto.</p>}
</div>
</section>
+33
View File
@@ -0,0 +1,33 @@
---
interface Props {
siteName: string;
ctaLabel: string;
ctaUrl: string;
}
const { siteName, ctaLabel, ctaUrl } = Astro.props;
const links = [
["Il laboratorio", "#laboratorio"],
["Il maestro", "#maestro"],
["Le lezioni", "#lezioni"],
["Progetti", "#spettacoli"],
["Galleria", "#galleria"],
];
---
<header class="site-header">
<div class="container header-inner">
<a class="wordmark" href="#inizio" aria-label={`${siteName}, torna allinizio`}>{siteName}</a>
<nav class="desktop-nav" aria-label="Navigazione principale">
{links.map(([label, href]) => <a href={href}>{label}</a>)}
</nav>
<a class="button button-small desktop-cta" href={ctaUrl}>{ctaLabel}</a>
<details class="mobile-menu">
<summary aria-label="Apri il menu"><span aria-hidden="true"></span><span class="menu-label">Menu</span></summary>
<nav aria-label="Navigazione mobile">
{links.map(([label, href]) => <a href={href}>{label}</a>)}
<a class="button button-small" href={ctaUrl}>{ctaLabel}</a>
</nav>
</details>
</div>
</header>
+23
View File
@@ -0,0 +1,23 @@
---
import type { HomePageContent } from "../lib/api";
interface Props { content: HomePageContent }
const { content } = Astro.props;
---
<section class="hero section" id="inizio" aria-labelledby="hero-title">
<div class="container hero-grid">
<div class="hero-copy">
<p class="eyebrow">Laboratorio teatrale · corpo, voce, relazione</p>
<h1 id="hero-title">{content.hero_title}</h1>
<p class="hero-subtitle">{content.hero_subtitle}</p>
<div class="button-row">
<a class="button" href={content.cta_primary_url}>{content.cta_primary_label}</a>
<a class="text-link" href={content.cta_secondary_url}>{content.cta_secondary_label}<span aria-hidden="true"> ↓</span></a>
</div>
</div>
<figure class="hero-visual">
<img src={content.hero_image.url} alt={content.hero_image.alt} width="960" height="1120" fetchpriority="high" />
<figcaption>Il gruppo è materia viva.</figcaption>
</figure>
</div>
</section>
@@ -0,0 +1,21 @@
---
import type { HomePageContent } from "../lib/api";
interface Props { content: HomePageContent }
const { content } = Astro.props;
---
<section class="split-section section" id="laboratorio" aria-labelledby="laboratory-title">
<div class="container split-grid">
<div class="editorial-image">
<img src={content.laboratory_image.url} alt={content.laboratory_image.alt} width="960" height="760" loading="lazy" />
</div>
<div class="split-copy">
<p class="eyebrow">Pratica e ricerca</p>
<h2 id="laboratory-title">{content.laboratory_title}</h2>
<div class="lead rich-text" set:html={content.laboratory_body} />
<ul class="practice-list" aria-label="Aree di lavoro">
<li>Corpo</li><li>Voce</li><li>Improvvisazione</li><li>Ascolto</li><li>Presenza scenica</li>
</ul>
</div>
</div>
</section>
@@ -0,0 +1,30 @@
---
import type { LessonContent } from "../lib/api";
interface Props { lesson: LessonContent | null; contactUrl: string }
const { lesson, contactUrl } = Astro.props;
---
<section class="lessons section" id="lezioni" aria-labelledby="lessons-title">
<div class="container">
<div class="section-heading horizontal-heading">
<div><p class="eyebrow">Informazioni pratiche</p><h2 id="lessons-title">Le lezioni</h2></div>
<p>Un appuntamento settimanale per allenarsi con continuità, senza fretta.</p>
</div>
{lesson ? (
<div class="lesson-panel">
<dl class="lesson-details">
<div><dt>Quando</dt><dd>{lesson.schedule}</dd></div>
<div><dt>Dove</dt><dd>{lesson.location}</dd></div>
<div><dt>Per chi</dt><dd>{lesson.audience}</dd></div>
<div><dt>Cosa facciamo</dt><dd><div class="rich-text" set:html={lesson.description} /></dd></div>
</dl>
<aside class="trial-card" aria-label="Lezione di prova">
<p class="eyebrow">Puoi provare</p>
<h3>{lesson.trial_lesson}</h3>
{lesson.notes && <p>{lesson.notes}</p>}
<a class="button" href={contactUrl}>Prenota un incontro</a>
</aside>
</div>
) : <p class="empty-state">Il calendario delle lezioni sarà pubblicato presto.</p>}
</div>
</section>
+12
View File
@@ -0,0 +1,12 @@
---
interface Props { title: string; body: string }
const { title, body } = Astro.props;
---
<section class="manifesto section" aria-labelledby="manifesto-title">
<div class="container narrow">
<p class="eyebrow">Il nostro modo di lavorare</p>
<h2 id="manifesto-title">{title}</h2>
<div class="manifesto-text rich-text" set:html={body} />
</div>
</section>
@@ -0,0 +1,29 @@
---
import type { ShowContent } from "../lib/api";
interface Props { shows: ShowContent[] }
const { shows } = Astro.props;
---
<section class="shows section section-light" id="spettacoli" aria-labelledby="shows-title">
<div class="container">
<div class="section-heading horizontal-heading">
<div><p class="eyebrow">La ricerca incontra il pubblico</p><h2 id="shows-title">Spettacoli e progetti</h2></div>
<p>Esiti, attraversamenti e storie nate durante il lavoro in sala.</p>
</div>
{shows.length ? (
<div class="show-grid">
{shows.map((show) => (
<article class="show-card">
<div class="show-image"><img src={show.image.url} alt={show.image.alt} width="700" height="880" loading="lazy" /></div>
<div class="show-copy">
<p class="show-meta"><span>{show.year}</span>{show.location && <span>{show.location}</span>}</p>
<h3>{show.title}</h3>
<p>{show.short_description}</p>
{show.cast && <p class="muted">{show.cast}</p>}
</div>
</article>
))}
</div>
) : <p class="empty-state">Nuovi spettacoli e progetti sono in preparazione.</p>}
</div>
</section>
@@ -0,0 +1,25 @@
---
import type { TeacherContent } from "../lib/api";
interface Props { teacher: TeacherContent | null }
const { teacher } = Astro.props;
---
<section class="teacher section section-sand" id="maestro" aria-labelledby="teacher-title">
<div class="container">
{teacher ? (
<div class="teacher-grid">
<div class="teacher-photo"><img src={teacher.photo.url} alt={teacher.photo.alt} width="760" height="900" loading="lazy" /></div>
<div class="teacher-copy">
<p class="eyebrow">La guida del percorso</p>
<h2 id="teacher-title">Il maestro</h2>
<h3>{teacher.name}</h3>
<p class="lead">{teacher.short_bio}</p>
{teacher.full_bio && <div class="rich-text" set:html={teacher.full_bio} />}
{teacher.quote && <blockquote>“{teacher.quote}”</blockquote>}
</div>
</div>
) : (
<div class="empty-state"><h2 id="teacher-title">Il maestro</h2><p>Il profilo dellinsegnante sarà disponibile presto.</p></div>
)}
</div>
</section>
+9
View File
@@ -0,0 +1,9 @@
/// <reference types="astro/client" />
interface ImportMetaEnv {
readonly PUBLIC_CMS_API_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+26
View File
@@ -0,0 +1,26 @@
---
import "../styles/global.css";
interface Props {
title: string;
description: string;
}
const { title, description } = Astro.props;
---
<!doctype html>
<html lang="it">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<meta name="description" content={description} />
<meta name="theme-color" content="#F3EBDD" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>{title}</title>
</head>
<body>
<a class="skip-link" href="#contenuto">Vai al contenuto</a>
<slot />
</body>
</html>
+321
View File
@@ -0,0 +1,321 @@
export interface CmsImage {
url: string;
alt: string;
}
export interface SiteSettings {
site_name: string;
short_claim: string;
email: string;
phone: string;
whatsapp: string;
instagram: string;
facebook: string;
address: string;
footer_text: string;
}
export interface HomePageContent {
hero_title: string;
hero_subtitle: string;
hero_image: CmsImage;
intro_title: string;
intro_body: string;
laboratory_title: string;
laboratory_body: string;
laboratory_image: CmsImage;
cta_primary_label: string;
cta_primary_url: string;
cta_secondary_label: string;
cta_secondary_url: string;
}
export interface FeatureCardContent {
title: string;
text: string;
order: number;
}
export interface TeacherContent {
name: string;
photo: CmsImage;
short_bio: string;
full_bio: string;
quote: string;
}
export interface LessonContent {
schedule: string;
location: string;
audience: string;
description: string;
trial_lesson: string;
notes: string;
}
export interface ShowContent {
title: string;
year: string;
short_description: string;
description: string;
image: CmsImage;
location: string;
cast: string;
order: number;
}
export interface GalleryItemContent {
image: CmsImage;
category: string;
caption: string;
order: number;
}
export interface HomeData {
settings: SiteSettings;
homepage: HomePageContent;
feature_cards: FeatureCardContent[];
teacher: TeacherContent | null;
lesson_info: LessonContent | null;
shows: ShowContent[];
gallery_items: GalleryItemContent[];
}
const placeholder = (name: string, alt: string): CmsImage => ({
url: `/images/${name}.svg`,
alt,
});
export const fallbackHomeData: HomeData = {
settings: {
site_name: "Azione!Lab",
short_claim: "Il teatro diventa presenza.",
email: "ciao@laboratorioteatrale.it",
phone: "+39 333 123 4567",
whatsapp: "+39 333 123 4567",
instagram: "https://instagram.com/",
facebook: "https://facebook.com/",
address: "Via dell'Epomeo 9999, Napoli",
footer_text: "Uno spazio aperto a chi desidera incontrare il teatro, insieme.",
},
homepage: {
hero_title: "Il teatro diventa presenza.",
hero_subtitle: "Un laboratorio dove corpo, voce, ascolto e relazione diventano scena.",
hero_image: placeholder("hero", "Il gruppo durante un esercizio teatrale"),
intro_title: "Uno spazio per esserci davvero",
intro_body:
"Ogni incontro è uno spazio di pratica: si prova, si sbaglia, si ascolta, si ricomincia. Il gruppo diventa materia viva, la scena diventa occasione di scoperta.",
laboratory_title: "Il laboratorio",
laboratory_body:
"Lavoriamo con il corpo, la voce e limprovvisazione per allenare ascolto e presenza scenica. Attraverso esercizi individuali e di gruppo, ogni persona trova un modo autentico di stare nello spazio e nella relazione.",
laboratory_image: placeholder("laboratory", "Esercizio di movimento nello spazio"),
cta_primary_label: "Vieni a conoscerci",
cta_primary_url: "#contatti",
cta_secondary_label: "Guarda la galleria",
cta_secondary_url: "#galleria",
},
feature_cards: [
{
title: "Per chi inizia",
text: "Non serve esperienza: serve curiosità, ascolto e voglia di mettersi in gioco.",
order: 1,
},
{
title: "Per chi vuole crescere",
text: "Un tempo regolare per approfondire strumenti, consapevolezza e libertà espressiva.",
order: 2,
},
{
title: "Per chi ama il gruppo",
text: "La scena nasce dalla fiducia: si crea insieme, rispettando tempi e sensibilità diverse.",
order: 3,
},
],
teacher: {
name: "Ernesto Estatico",
photo: placeholder("teacher", "Ritratto del maestro Ernesto Estatico"),
short_bio:
"Attore, regista e formatore, accompagna gruppi di ogni esperienza con cura e concretezza.",
full_bio:
"Da oltre quindici anni conduce percorsi dedicati alla ricerca dellautenticità scenica, intrecciando pedagogia teatrale, movimento e lavoro sulla voce.",
quote: "Il teatro non è fingere: è imparare a essere presenti.",
},
lesson_info: {
schedule: "Ogni martedì, dalle 20:00 alle 22:30",
location: "Spazio Teatro, Via dell'Epomeo 9999, Napoli",
audience: "Adulti, con o senza esperienza",
description: "Training fisico e vocale, improvvisazione, ascolto, costruzione del personaggio e lavoro di scena.",
trial_lesson: "La prima lezione di prova è gratuita, su prenotazione.",
notes: "Abiti comodi e calze antiscivolo consigliati.",
},
shows: [
{
title: "Le cose che restano",
year: "2025",
short_description: "Un lavoro corale su memoria, gesti quotidiani e piccoli cambiamenti.",
description: "",
image: placeholder("show-one", "Locandina dello spettacolo Le cose che restano"),
location: "Teatro di Quartiere",
cast: "Gruppo avanzato",
order: 1,
},
{
title: "Fuori campo",
year: "2024",
short_description: "Storie ai margini della scena che chiedono, finalmente, di essere ascoltate.",
description: "",
image: placeholder("show-two", "Locandina dello spettacolo Fuori campo"),
location: "Spazio Scena",
cast: "Laboratorio annuale",
order: 2,
},
],
gallery_items: [
{ image: placeholder("gallery-one", "Esercizio di gruppo durante una lezione"), category: "Lezioni", caption: "Ascolto e movimento", order: 1 },
{ image: placeholder("gallery-two", "Il gruppo dietro le quinte"), category: "Backstage", caption: "Prima di entrare in scena", order: 2 },
{ image: placeholder("gallery-three", "Una scena dello spettacolo finale"), category: "Spettacoli", caption: "Le cose che restano", order: 3 },
{ image: placeholder("gallery-four", "Ritratto del gruppo teatrale"), category: "Gruppo", caption: "Il laboratorio, insieme", order: 4 },
],
};
type UnknownRecord = Record<string, unknown>;
const record = (value: unknown): UnknownRecord =>
value !== null && typeof value === "object" ? (value as UnknownRecord) : {};
const text = (value: unknown, fallback = ""): string =>
typeof value === "string" && value.trim() ? value : fallback;
const optionalText = (value: unknown, fallback = ""): string =>
typeof value === "string" ? value.trim() : fallback;
const number = (value: unknown, fallback = 0): number =>
typeof value === "number" ? value : Number(value) || fallback;
const image = (value: unknown, fallback: CmsImage): CmsImage => {
if (typeof value === "string" && value) return { url: value, alt: fallback.alt };
const item = record(value);
return {
url: text(item.url, text(item.src, fallback.url)),
alt: text(item.alt, text(item.title, fallback.alt)),
};
};
const absoluteImage = (value: CmsImage, baseUrl: string): CmsImage => {
if (!value.url.startsWith("/media/")) return value;
try {
return { ...value, url: new URL(value.url, baseUrl).toString() };
} catch {
return value;
}
};
function normalizeHomeData(payload: unknown, baseUrl: string): HomeData {
const source = record(payload);
const settings = record(source.settings);
const homepage = record(source.homepage);
const teacher = source.teacher === null ? null : record(source.teacher);
const lesson = source.lesson_info === null ? null : record(source.lesson_info);
const fallback = fallbackHomeData;
const resolveImage = (value: unknown, defaultImage: CmsImage) =>
absoluteImage(image(value, defaultImage), baseUrl);
return {
settings: {
site_name: text(settings.site_name, text(settings.laboratory_name, text(settings.name, fallback.settings.site_name))),
short_claim: optionalText(settings.short_claim, optionalText(settings.claim, fallback.settings.short_claim)),
email: optionalText(settings.email, fallback.settings.email),
phone: optionalText(settings.phone, fallback.settings.phone),
whatsapp: optionalText(settings.whatsapp, fallback.settings.whatsapp),
instagram: optionalText(settings.instagram, fallback.settings.instagram),
facebook: optionalText(settings.facebook, fallback.settings.facebook),
address: optionalText(settings.address, fallback.settings.address),
footer_text: optionalText(settings.footer_text, fallback.settings.footer_text),
},
homepage: {
hero_title: text(homepage.hero_title, fallback.homepage.hero_title),
hero_subtitle: text(homepage.hero_subtitle, fallback.homepage.hero_subtitle),
hero_image: resolveImage(homepage.hero_image, fallback.homepage.hero_image),
intro_title: text(homepage.intro_title, fallback.homepage.intro_title),
intro_body: text(homepage.intro_body, fallback.homepage.intro_body),
laboratory_title: text(homepage.laboratory_title, fallback.homepage.laboratory_title),
laboratory_body: text(homepage.laboratory_body, fallback.homepage.laboratory_body),
laboratory_image: resolveImage(homepage.laboratory_image, fallback.homepage.laboratory_image),
cta_primary_label: text(homepage.cta_primary_label, fallback.homepage.cta_primary_label),
cta_primary_url: text(homepage.cta_primary_url, fallback.homepage.cta_primary_url),
cta_secondary_label: text(homepage.cta_secondary_label, fallback.homepage.cta_secondary_label),
cta_secondary_url: text(homepage.cta_secondary_url, fallback.homepage.cta_secondary_url),
},
feature_cards: Array.isArray(source.feature_cards)
? source.feature_cards.map((item, index) => {
const card = record(item);
return { title: text(card.title), text: text(card.text, text(card.body)), order: number(card.order, index + 1) };
}).filter((item) => item.title).sort((a, b) => a.order - b.order)
: fallback.feature_cards,
teacher: teacher
? {
name: text(teacher.name, fallback.teacher?.name),
photo: resolveImage(teacher.photo, fallback.teacher?.photo ?? placeholder("teacher", "Ritratto del maestro")),
short_bio: text(teacher.short_bio, text(teacher.bio_breve, fallback.teacher?.short_bio)),
full_bio: text(teacher.full_bio, text(teacher.bio_completa, fallback.teacher?.full_bio)),
quote: text(teacher.quote, text(teacher.citazione, fallback.teacher?.quote)),
}
: null,
lesson_info: lesson
? {
schedule: text(lesson.schedule, text(lesson.day_time, text(lesson.giorno_orario, fallback.lesson_info?.schedule))),
location: text(lesson.location, text(lesson.luogo, fallback.lesson_info?.location)),
audience: text(lesson.audience, text(lesson.destinatari, fallback.lesson_info?.audience)),
description: text(lesson.description, text(lesson.descrizione, fallback.lesson_info?.description)),
trial_lesson: text(lesson.trial_lesson, text(lesson.lezione_di_prova, fallback.lesson_info?.trial_lesson)),
notes: text(lesson.notes, text(lesson.note, fallback.lesson_info?.notes)),
}
: null,
shows: Array.isArray(source.shows)
? source.shows.map((item, index) => {
const show = record(item);
const fallbackImage = placeholder(index % 2 ? "show-two" : "show-one", "Locandina dello spettacolo");
return {
title: text(show.title, text(show.titolo)),
year: text(show.year, text(show.anno)),
short_description: text(show.short_description, text(show.descrizione_breve)),
description: text(show.description, text(show.descrizione)),
image: resolveImage(show.image ?? show.poster ?? show.immagine, fallbackImage),
location: text(show.location, text(show.luogo)),
cast: text(show.cast),
order: number(show.order, text(show.ordine) ? number(show.ordine) : index + 1),
};
}).filter((item) => item.title).sort((a, b) => a.order - b.order)
: fallback.shows,
gallery_items: Array.isArray(source.gallery_items)
? source.gallery_items.map((item, index) => {
const galleryItem = record(item);
const fallbackImage = placeholder(`gallery-${["one", "two", "three", "four"][index % 4]}`, "Immagine dal laboratorio");
return {
image: resolveImage(galleryItem.image ?? galleryItem.immagine, fallbackImage),
category: text(galleryItem.category, text(galleryItem.categoria, "Laboratorio")),
caption: text(galleryItem.caption, text(galleryItem.didascalia)),
order: number(galleryItem.order, text(galleryItem.ordine) ? number(galleryItem.ordine) : index + 1),
};
}).sort((a, b) => a.order - b.order)
: fallback.gallery_items,
};
}
export async function getHomeData(): Promise<HomeData> {
const baseUrl = import.meta.env.PUBLIC_CMS_API_URL?.replace(/\/$/, "");
if (!baseUrl) return fallbackHomeData;
try {
const response = await fetch(`${baseUrl}/api/site/home/`, {
headers: { Accept: "application/json" },
signal: AbortSignal.timeout(3500),
});
if (!response.ok) throw new Error(`CMS returned ${response.status}`);
return normalizeHomeData(await response.json(), baseUrl);
} catch (error) {
console.warn("CMS unavailable; using local demo content.", error);
return fallbackHomeData;
}
}
+33
View File
@@ -0,0 +1,33 @@
---
import BaseLayout from "../layouts/BaseLayout.astro";
import Header from "../components/Header.astro";
import Hero from "../components/Hero.astro";
import Manifesto from "../components/Manifesto.astro";
import FeatureCards from "../components/FeatureCards.astro";
import LaboratorySection from "../components/LaboratorySection.astro";
import TeacherSection from "../components/TeacherSection.astro";
import LessonsSection from "../components/LessonsSection.astro";
import ShowsSection from "../components/ShowsSection.astro";
import GallerySection from "../components/GallerySection.astro";
import ContactSection from "../components/ContactSection.astro";
import Footer from "../components/Footer.astro";
import { getHomeData } from "../lib/api";
const data = await getHomeData();
---
<BaseLayout title={`${data.settings.site_name} · ${data.settings.short_claim}`} description={data.homepage.hero_subtitle}>
<Header siteName={data.settings.site_name} ctaLabel={data.homepage.cta_primary_label} ctaUrl={data.homepage.cta_primary_url} />
<main id="contenuto">
<Hero content={data.homepage} />
<Manifesto title={data.homepage.intro_title} body={data.homepage.intro_body} />
<FeatureCards cards={data.feature_cards} />
<LaboratorySection content={data.homepage} />
<TeacherSection teacher={data.teacher} />
<LessonsSection lesson={data.lesson_info} contactUrl="#contatti" />
<ShowsSection shows={data.shows} />
<GallerySection items={data.gallery_items} />
<ContactSection settings={data.settings} />
</main>
<Footer settings={data.settings} />
</BaseLayout>
+972
View File
@@ -0,0 +1,972 @@
:root {
--color-bg: #f3ebdd;
--color-bg-light: #fff8ef;
--color-dark: #4a433a;
--color-dark-soft: #5a5046;
--color-dark-text: #1d1a17;
--color-primary: #a4513b;
--color-primary-soft: #b8674c;
--color-sage: #515d53;
--color-sand: #d8c7ae;
--color-muted: #7a7067;
--color-border: #e6d8c8;
--font-display: "Cormorant Garamond", "Iowan Old Style", "Palatino Linotype", Georgia, serif;
--font-body: "Source Sans 3", Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--shadow-soft: 0 24px 60px rgb(74 67 58 / 10%);
--radius: 0.35rem;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
scroll-padding-top: 5rem;
}
body {
margin: 0;
min-width: 320px;
overflow-x: hidden;
background: var(--color-bg);
color: var(--color-dark-text);
font-family: var(--font-body);
font-size: 1rem;
line-height: 1.65;
text-rendering: optimizeLegibility;
}
body::before {
position: fixed;
z-index: -1;
inset: 0;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.8' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.035'/%3E%3C/svg%3E");
content: "";
pointer-events: none;
}
img {
display: block;
width: 100%;
max-width: 100%;
height: auto;
}
a {
color: inherit;
text-underline-offset: 0.2em;
}
a:hover {
color: var(--color-primary);
}
:focus-visible {
border-radius: 0.15rem;
outline: 3px solid var(--color-primary);
outline-offset: 4px;
}
h1,
h2,
h3,
p,
figure,
blockquote,
dl,
dd {
margin-top: 0;
}
h1,
h2,
h3 {
font-family: var(--font-display);
font-weight: 500;
line-height: 1.02;
letter-spacing: -0.025em;
overflow-wrap: anywhere;
}
h1 {
max-width: 11ch;
margin-bottom: 1.5rem;
font-size: clamp(3.5rem, 15vw, 7.5rem);
}
h2 {
margin-bottom: 1.5rem;
font-size: clamp(2.7rem, 10vw, 5.3rem);
}
h3 {
margin-bottom: 0.75rem;
font-size: clamp(1.7rem, 5vw, 2.35rem);
}
.container {
width: min(100% - 2.5rem, 76rem);
margin-inline: auto;
}
.narrow {
max-width: 58rem;
}
.section {
padding-block: clamp(5rem, 12vw, 9.5rem);
}
.section-light {
background: var(--color-bg-light);
}
.section-sand {
background: color-mix(in srgb, var(--color-sand) 68%, var(--color-bg));
}
.eyebrow {
margin-bottom: 1rem;
color: var(--color-primary);
font-size: 0.73rem;
font-weight: 700;
letter-spacing: 0.16em;
line-height: 1.4;
text-transform: uppercase;
}
.lead {
color: var(--color-dark-soft);
font-size: clamp(1.15rem, 2vw, 1.38rem);
line-height: 1.65;
}
.rich-text > :last-child {
margin-bottom: 0;
}
.rich-text a {
color: var(--color-primary);
}
.muted {
color: var(--color-dark-soft);
}
.skip-link {
position: fixed;
z-index: 100;
top: 1rem;
left: 1rem;
padding: 0.65rem 1rem;
transform: translateY(-180%);
background: var(--color-dark-text);
color: var(--color-bg-light);
}
.skip-link:focus {
transform: translateY(0);
}
.site-header {
position: absolute;
z-index: 20;
top: 0;
right: 0;
left: 0;
padding-block: 1.25rem;
}
.header-inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.wordmark {
max-width: 10rem;
color: var(--color-dark-text);
font-family: var(--font-display);
font-size: 1.25rem;
font-weight: 600;
letter-spacing: -0.02em;
line-height: 1;
text-decoration: none;
}
.desktop-nav,
.desktop-cta {
display: none;
}
.mobile-menu {
position: relative;
}
.mobile-menu summary {
display: flex;
align-items: center;
gap: 0.55rem;
min-height: 2.75rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-dark-text);
cursor: pointer;
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.08em;
list-style: none;
text-transform: uppercase;
}
.mobile-menu summary::-webkit-details-marker {
display: none;
}
.mobile-menu summary > span:first-child,
.mobile-menu summary > span:first-child::before,
.mobile-menu summary > span:first-child::after {
display: block;
width: 1rem;
height: 1px;
background: currentcolor;
content: "";
}
.mobile-menu summary > span:first-child::before {
transform: translateY(-0.3rem);
}
.mobile-menu summary > span:first-child::after {
transform: translateY(0.25rem);
}
.mobile-menu[open] summary > span:first-child {
background: transparent;
}
.mobile-menu[open] summary > span:first-child::before {
transform: translateY(0.05rem) rotate(45deg);
}
.mobile-menu[open] summary > span:first-child::after {
transform: translateY(-0.02rem) rotate(-45deg);
}
.mobile-menu nav {
position: absolute;
top: calc(100% + 0.6rem);
right: 0;
display: grid;
width: min(18rem, calc(100vw - 2.5rem));
padding: 1rem;
border: 1px solid var(--color-border);
background: var(--color-bg-light);
box-shadow: var(--shadow-soft);
}
.mobile-menu nav > a:not(.button) {
padding: 0.7rem 0.25rem;
border-bottom: 1px solid var(--color-border);
text-decoration: none;
}
.mobile-menu .button {
margin-top: 1rem;
}
.button {
display: inline-flex;
min-height: 3.15rem;
align-items: center;
justify-content: center;
padding: 0.75rem 1.35rem;
border: 1px solid var(--color-primary);
border-radius: var(--radius);
background: var(--color-primary);
color: #fff8ef;
font-size: 0.83rem;
font-weight: 700;
letter-spacing: 0.05em;
line-height: 1.2;
text-align: center;
text-decoration: none;
text-transform: uppercase;
transition: background 160ms ease, border-color 160ms ease, transform 160ms ease;
}
.button:hover {
border-color: var(--color-dark-text);
background: var(--color-dark-text);
color: var(--color-bg-light);
transform: translateY(-2px);
}
.button-small {
min-height: 2.65rem;
padding: 0.65rem 1rem;
font-size: 0.73rem;
}
.button-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1.25rem;
}
.text-link {
padding-block: 0.65rem;
font-size: 0.86rem;
font-weight: 700;
letter-spacing: 0.04em;
text-decoration-thickness: 1px;
text-transform: uppercase;
}
.hero {
position: relative;
min-height: min(48rem, 100svh);
padding-bottom: 4.5rem;
padding-top: 7.5rem;
overflow: hidden;
}
.hero::after {
position: absolute;
z-index: -1;
top: 7%;
right: -12rem;
width: 23rem;
height: 23rem;
border: 1px solid rgb(164 81 59 / 20%);
border-radius: 50%;
content: "";
}
.hero-grid {
display: grid;
gap: 3rem;
}
.hero-copy {
align-self: center;
}
.hero-subtitle {
max-width: 34rem;
margin-bottom: 2rem;
color: var(--color-dark-soft);
font-size: clamp(1.18rem, 2vw, 1.45rem);
}
.hero-visual {
position: relative;
margin-bottom: 0;
}
.hero-visual::before {
position: absolute;
z-index: -1;
inset: -1rem 1rem 1rem -1rem;
border: 1px solid var(--color-sand);
content: "";
}
.hero-visual img {
max-height: 28rem;
aspect-ratio: 4 / 3;
object-fit: cover;
object-position: center 42%;
}
.hero-visual figcaption {
position: absolute;
right: -0.2rem;
bottom: 0;
padding: 0.65rem 0 0.15rem 1rem;
background: var(--color-bg);
color: var(--color-dark-soft);
font-family: var(--font-display);
font-size: 1.05rem;
font-style: italic;
}
.manifesto {
text-align: center;
}
.manifesto h2 {
margin-inline: auto;
}
.manifesto-text {
max-width: 37ch;
margin-right: auto;
margin-bottom: 0;
margin-left: auto;
color: var(--color-dark-soft);
font-family: var(--font-display);
font-size: clamp(1.55rem, 4vw, 2.45rem);
line-height: 1.42;
}
.manifesto-text > :last-child {
margin-bottom: 0;
}
.section-heading {
margin-bottom: clamp(2.5rem, 7vw, 4.5rem);
}
.section-heading h2 {
margin-bottom: 0;
}
.feature-grid {
display: grid;
gap: 1rem;
}
.feature-card {
min-height: 17rem;
padding: 1.75rem;
border: 1px solid var(--color-border);
background: rgb(255 255 255 / 24%);
}
.feature-card:nth-child(2) {
background: color-mix(in srgb, var(--color-sage) 8%, var(--color-bg-light));
}
.card-number {
display: block;
margin-bottom: 3.75rem;
color: var(--color-primary);
font-family: var(--font-display);
font-size: 1.2rem;
}
.feature-card p {
max-width: 31ch;
margin-bottom: 0;
color: var(--color-dark-soft);
}
.split-grid,
.teacher-grid {
display: grid;
gap: 3rem;
}
.editorial-image,
.teacher-photo,
.show-image {
overflow: hidden;
background: var(--color-sand);
}
.editorial-image img {
aspect-ratio: 5 / 4;
object-fit: cover;
}
.split-copy {
align-self: center;
}
.practice-list {
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
margin: 2.25rem 0 0;
padding: 0;
list-style: none;
}
.practice-list li {
padding: 0.5rem 0.85rem;
border: 1px solid var(--color-sand);
border-radius: 99rem;
color: var(--color-sage);
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.teacher-photo img {
aspect-ratio: 4 / 5;
object-fit: cover;
}
.teacher-copy {
align-self: center;
}
.teacher-copy h2 {
margin-bottom: 0.75rem;
}
.teacher-copy h3 {
margin-bottom: 1.5rem;
color: var(--color-primary);
}
blockquote {
position: relative;
margin: 2.5rem 0 0;
padding: 1.5rem 0 0 1.5rem;
border-top: 1px solid rgb(74 67 58 / 20%);
color: var(--color-dark);
font-family: var(--font-display);
font-size: clamp(1.65rem, 3vw, 2.35rem);
font-style: italic;
line-height: 1.25;
}
blockquote::before {
position: absolute;
top: 1.5rem;
left: 0;
color: var(--color-primary);
content: "—";
}
.horizontal-heading > p {
max-width: 33rem;
margin-bottom: 0;
color: var(--color-dark-soft);
}
.lesson-panel {
border-top: 1px solid var(--color-sand);
}
.lesson-details {
margin-bottom: 2rem;
}
.lesson-details > div {
display: grid;
gap: 0.35rem;
padding-block: 1.4rem;
border-bottom: 1px solid var(--color-border);
}
.lesson-details dt {
color: var(--color-primary);
font-size: 0.73rem;
font-weight: 700;
letter-spacing: 0.13em;
text-transform: uppercase;
}
.lesson-details dd {
margin-bottom: 0;
font-size: 1.05rem;
}
.trial-card {
padding: 2rem;
background: var(--color-sage);
color: var(--color-bg-light);
}
.trial-card .eyebrow {
color: var(--color-sand);
}
.trial-card h3 {
font-size: clamp(1.9rem, 4vw, 2.7rem);
line-height: 1.15;
}
.trial-card p:not(.eyebrow) {
color: rgb(255 248 239 / 78%);
}
.trial-card .button {
margin-top: 1rem;
border-color: var(--color-bg-light);
background: var(--color-bg-light);
color: var(--color-sage);
}
.show-grid {
display: grid;
gap: 2.5rem;
}
.show-card {
display: grid;
border-bottom: 1px solid var(--color-border);
background: var(--color-bg-light);
}
.show-image img {
aspect-ratio: 4 / 5;
object-fit: cover;
transition: transform 500ms ease;
}
.show-card:hover .show-image img {
transform: scale(1.02);
}
.show-copy {
padding: 1.5rem 0 2rem;
}
.show-meta {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 1.25rem;
color: var(--color-primary);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.09em;
text-transform: uppercase;
}
.show-copy p:last-child {
margin-bottom: 0;
}
.gallery-grid {
display: grid;
gap: 1.5rem;
}
.gallery-item {
margin-bottom: 0;
}
.gallery-item img {
aspect-ratio: 5 / 4;
object-fit: cover;
}
.gallery-item figcaption {
padding-top: 0.75rem;
color: var(--color-dark-soft);
font-size: 0.88rem;
}
.gallery-item figcaption span {
margin-right: 0.75rem;
color: var(--color-primary);
font-weight: 700;
letter-spacing: 0.07em;
text-transform: uppercase;
}
.contact {
background: var(--color-dark);
color: var(--color-bg-light);
}
.contact-grid {
display: grid;
gap: 3rem;
}
.contact .eyebrow {
color: var(--color-sand);
}
.contact .lead {
color: rgb(255 248 239 / 78%);
}
.contact address {
color: var(--color-sand);
font-style: normal;
}
.contact-actions {
border-top: 1px solid rgb(255 248 239 / 20%);
}
.contact-actions a {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.3rem 1rem;
align-items: center;
padding-block: 1.35rem;
border-bottom: 1px solid rgb(255 248 239 / 20%);
color: var(--color-bg-light);
text-decoration: none;
}
.contact-actions a > span:first-child {
color: var(--color-sand);
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.contact-actions strong {
grid-row: 2;
min-width: 0;
font-family: var(--font-display);
font-size: clamp(1.15rem, 4vw, 1.65rem);
font-weight: 500;
overflow-wrap: anywhere;
}
.contact-actions a > span:last-child {
grid-row: 1 / 3;
grid-column: 2;
font-size: 1.4rem;
transition: transform 160ms ease;
}
.contact-actions a:hover {
color: var(--color-bg-light);
}
.contact-actions a:hover > span:last-child {
transform: translate(0.2rem, -0.2rem);
}
.contact a:focus-visible {
outline-color: var(--color-bg-light);
}
.empty-state {
padding: 2rem;
border: 1px solid var(--color-border);
color: var(--color-dark-soft);
}
.contact-empty {
border-color: rgb(255 248 239 / 20%);
color: var(--color-sand);
}
.site-footer {
padding-block: 3rem 1.5rem;
background: var(--color-bg-light);
}
.footer-main,
.footer-bottom {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.footer-main > div p {
max-width: 35rem;
margin: 1rem 0 0;
color: var(--color-dark-soft);
}
.footer-main nav {
display: flex;
gap: 1.25rem;
}
.footer-bottom {
margin-top: 2.5rem;
padding-top: 1.25rem;
border-top: 1px solid var(--color-border);
color: var(--color-dark-soft);
font-size: 0.8rem;
}
.footer-bottom p {
margin: 0;
}
@media (min-width: 36rem) {
.container {
width: min(100% - 4rem, 76rem);
}
.feature-grid,
.gallery-grid,
.show-grid {
grid-template-columns: repeat(2, 1fr);
}
.gallery-item:nth-child(3n + 1) {
grid-column: span 2;
}
.gallery-item:nth-child(3n + 1) img {
aspect-ratio: 2 / 1;
}
.lesson-details > div {
grid-template-columns: 9rem 1fr;
gap: 1rem;
}
.lesson-details dd {
font-size: 1.1rem;
}
}
@media (min-width: 54rem) {
.site-header {
padding-block: 1.75rem;
}
.desktop-nav,
.desktop-cta {
display: flex;
}
.desktop-nav {
align-items: center;
gap: clamp(1rem, 2.5vw, 2rem);
}
.desktop-nav a {
font-size: 0.79rem;
font-weight: 600;
text-decoration: none;
}
.mobile-menu {
display: none;
}
.hero {
min-height: min(54rem, 100svh);
padding-top: 9.5rem;
}
.hero-grid {
grid-template-columns: minmax(0, 1.05fr) minmax(20rem, 0.7fr);
align-items: center;
gap: clamp(3rem, 7vw, 7rem);
}
.hero-visual {
margin: 0;
}
.hero-visual img {
max-height: none;
aspect-ratio: 6 / 7;
}
.horizontal-heading {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 4rem;
}
.feature-grid {
grid-template-columns: repeat(3, 1fr);
}
.feature-card {
min-height: 20rem;
padding: 2.25rem;
}
.feature-card:nth-child(2) {
transform: translateY(1.75rem);
}
.split-grid {
grid-template-columns: minmax(0, 1.1fr) minmax(20rem, 0.9fr);
gap: clamp(4rem, 8vw, 8rem);
}
.teacher-grid {
grid-template-columns: minmax(18rem, 0.65fr) minmax(25rem, 1fr);
gap: clamp(4rem, 8vw, 8rem);
}
.teacher-photo {
transform: rotate(-1.25deg);
}
.lesson-panel {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(18rem, 0.65fr);
gap: clamp(3rem, 8vw, 7rem);
align-items: start;
padding-top: 2rem;
}
.trial-card {
margin-top: 1rem;
padding: 2.5rem;
}
.show-grid {
gap: 2.5rem;
}
.show-copy {
padding: 1.75rem 0 2.25rem;
}
.gallery-grid {
grid-template-columns: repeat(12, 1fr);
align-items: start;
}
.gallery-item,
.gallery-item:nth-child(3n + 1) {
grid-column: span 6;
}
.gallery-item:nth-child(4n + 2),
.gallery-item:nth-child(4n + 3) {
grid-column: span 5;
}
.gallery-item:nth-child(4n + 3) {
grid-column-start: 8;
margin-top: 4rem;
}
.gallery-item:nth-child(4n + 4) {
grid-column: 3 / span 8;
margin-top: 2rem;
}
.gallery-item img,
.gallery-item:nth-child(3n + 1) img {
aspect-ratio: 5 / 4;
}
.contact-grid {
grid-template-columns: minmax(0, 0.9fr) minmax(26rem, 1fr);
gap: clamp(5rem, 10vw, 10rem);
}
.footer-main,
.footer-bottom {
flex-direction: row;
align-items: flex-end;
justify-content: space-between;
}
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"noUncheckedIndexedAccess": true
}
}
+106
View File
@@ -0,0 +1,106 @@
#!/bin/sh
set -eu
enabled="${LETSENCRYPT_ENABLED:-0}"
domain="${LETSENCRYPT_DOMAIN:-azionelab.org}"
interval="${TLS_RELOAD_INTERVAL_SECONDS:-30}"
certificate="/etc/letsencrypt/live/${domain}/fullchain.pem"
private_key="/etc/letsencrypt/live/${domain}/privkey.pem"
http_config="/etc/nginx/http-enabled/site.conf"
tls_config="/etc/nginx/tls-enabled/site.conf"
tls_template="/etc/nginx/templates/tls.conf.template.source"
base_template="/etc/nginx/templates/default.conf.template.source"
base_config="/etc/nginx/conf.d/default.conf"
proxy_routes="/etc/nginx/snippets/proxy-routes.conf"
case "$domain" in
"" | *[!A-Za-z0-9.-]* | .* | *. | *..* | -* | *- | *.-* | *-.*)
echo "Invalid LETSENCRYPT_DOMAIN: $domain" >&2
exit 1
;;
esac
case "$domain" in
*.*) ;;
*)
echo "LETSENCRYPT_DOMAIN must be a fully qualified domain name." >&2
exit 1
;;
esac
case "$enabled" in
0 | 1) ;;
*)
echo "LETSENCRYPT_ENABLED must be 0 or 1." >&2
exit 1
;;
esac
case "$interval" in
"" | *[!0-9]*)
echo "TLS_RELOAD_INTERVAL_SECONDS must be a positive integer." >&2
exit 1
;;
esac
if [ "$interval" -eq 0 ]; then
echo "TLS_RELOAD_INTERVAL_SECONDS must be a positive integer." >&2
exit 1
fi
certificate_state() {
if [ ! -s "$certificate" ] || [ ! -s "$private_key" ]; then
echo "missing"
return
fi
cksum "$certificate" "$private_key" | cksum | awk '{print $1 ":" $2}'
}
render_http_only() {
printf 'include %s;\n' "$proxy_routes" > "$http_config"
rm -f "$tls_config"
}
render_https() {
sed "s|__DOMAIN__|${domain}|g" "$tls_template" > "${tls_config}.tmp"
mv "${tls_config}.tmp" "$tls_config"
printf 'location ^~ / { return 301 https://$host$request_uri; }\n' > "$http_config"
}
sed "s|__DOMAIN__|${domain}|g" "$base_template" > "${base_config}.tmp"
mv "${base_config}.tmp" "$base_config"
if [ "$enabled" != "1" ]; then
render_http_only
exit 0
fi
state="$(certificate_state)"
if [ "$state" = "missing" ]; then
render_http_only
else
render_https
fi
(
while sleep "$interval"; do
next_state="$(certificate_state)"
if [ "$next_state" = "$state" ]; then
continue
fi
if [ "$next_state" = "missing" ]; then
render_http_only
else
render_https
fi
if nginx -t; then
nginx -s reload
state="$next_state"
else
echo "TLS configuration reload failed; retrying after ${interval}s." >&2
fi
done
) &
+10
View File
@@ -0,0 +1,10 @@
FROM nginx:1.30.0-alpine
COPY default.conf /etc/nginx/templates/default.conf.template.source
COPY proxy-routes.conf /etc/nginx/snippets/proxy-routes.conf
COPY tls.conf.template /etc/nginx/templates/tls.conf.template.source
COPY 40-configure-tls.sh /docker-entrypoint.d/40-configure-tls.sh
RUN chmod 755 /docker-entrypoint.d/40-configure-tls.sh \
&& rm -f /etc/nginx/conf.d/default.conf \
&& mkdir -p /etc/nginx/http-enabled /etc/nginx/tls-enabled /var/www/certbot
+50
View File
@@ -0,0 +1,50 @@
map $http_upgrade $connection_upgrade {
default upgrade;
"" close;
}
map $http_x_forwarded_proto $effective_forwarded_proto {
default $scheme;
https https;
}
server_tokens off;
upstream wagtail_backend {
server backend:8000;
keepalive 16;
}
upstream astro_frontend {
server frontend:4321;
keepalive 16;
}
server {
listen 80 default_server;
server_name _;
location = /nginx-health {
access_log off;
return 200 "ok\n";
}
location / {
return 404;
}
}
server {
listen 80;
server_name __DOMAIN__;
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
default_type text/plain;
try_files $uri =404;
}
include /etc/nginx/http-enabled/site.conf;
}
include /etc/nginx/tls-enabled/*.conf;
+20
View File
@@ -0,0 +1,20 @@
client_max_body_size 10m;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $effective_forwarded_proto;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
location ~ ^/(admin|api|documents|health|media|static)(/|$) {
proxy_pass http://wagtail_backend;
}
location / {
proxy_pass http://astro_frontend;
}
+26
View File
@@ -0,0 +1,26 @@
server {
listen 443 ssl default_server;
http2 on;
server_name _;
ssl_certificate /etc/letsencrypt/live/__DOMAIN__/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/__DOMAIN__/privkey.pem;
ssl_reject_handshake on;
}
server {
listen 443 ssl;
http2 on;
server_name __DOMAIN__;
ssl_certificate /etc/letsencrypt/live/__DOMAIN__/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/__DOMAIN__/privkey.pem;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
add_header Strict-Transport-Security "max-age=15552000" always;
include /etc/nginx/snippets/proxy-routes.conf;
}
+3
View File
@@ -0,0 +1,3 @@
node_modules/
test-results/
playwright-report/
+13
View File
@@ -0,0 +1,13 @@
FROM mcr.microsoft.com/playwright:v1.61.1-noble
WORKDIR /tests
RUN chown pwuser:pwuser /tests
COPY --chown=pwuser:pwuser package.json package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY --chown=pwuser:pwuser . .
USER pwuser
CMD ["npm", "test"]
+78
View File
@@ -0,0 +1,78 @@
{
"name": "azionelab-functional-tests",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "azionelab-functional-tests",
"version": "0.1.0",
"devDependencies": {
"@playwright/test": "1.61.1"
}
},
"node_modules/@playwright/test": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
"integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"name": "azionelab-functional-tests",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"test": "playwright test"
},
"devDependencies": {
"@playwright/test": "1.61.1"
}
}
+22
View File
@@ -0,0 +1,22 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./tests",
timeout: 30_000,
expect: {
timeout: 5_000,
},
fullyParallel: false,
forbidOnly: true,
retries: 0,
workers: 1,
reporter: "line",
outputDir: "test-results",
use: {
baseURL: process.env.BASE_URL ?? "http://azionelab.org",
browserName: "chromium",
screenshot: "only-on-failure",
trace: "retain-on-failure",
video: "off",
},
});
+160
View File
@@ -0,0 +1,160 @@
import { expect, test } from "@playwright/test";
const sectionIds = [
"inizio",
"laboratorio",
"maestro",
"lezioni",
"spettacoli",
"galleria",
"contatti",
];
test("renders the seeded single page in the required order", async ({ page }) => {
await page.goto("/");
await expect(page).toHaveTitle(/Azione!Lab/);
await expect(page.getByRole("heading", { level: 1 })).toHaveText(
"Il teatro diventa presenza.",
);
await expect(page.getByText("Ernesto Estatico")).toBeVisible();
await expect(
page.getByText("Via dell'Epomeo 9999, Napoli", { exact: true }),
).toBeVisible();
await expect(page.locator(".feature-card")).toHaveCount(3);
await expect(page.locator(".show-card")).toHaveCount(2);
await expect(page.locator(".gallery-item")).toHaveCount(4);
await expect(
page.getByRole("link", { name: "Vieni a conoscerci" }).first(),
).toHaveAttribute("href", "#contatti");
await expect(page.getByRole("link", { name: "Guarda la galleria" })).toHaveAttribute(
"href",
"#galleria",
);
const verticalPositions = await page.evaluate(
(ids) =>
ids.map((id) => {
const element = document.getElementById(id);
if (!element) throw new Error(`Missing section #${id}`);
return element.getBoundingClientRect().top + window.scrollY;
}),
sectionIds,
);
expect(verticalPositions).toEqual([...verticalPositions].sort((a, b) => a - b));
});
test("exposes usable contact actions", async ({ page }) => {
await page.goto("/#contatti");
await expect(page.getByRole("link", { name: /Scrivi una mail/ })).toHaveAttribute(
"href",
"mailto:ciao@laboratorioteatrale.it",
);
await expect(page.getByRole("link", { name: /Chiama/ })).toHaveAttribute(
"href",
"tel:+393331234567",
);
await expect(page.getByRole("link", { name: /WhatsApp/ })).toHaveAttribute(
"href",
"https://wa.me/393331234567",
);
});
test("supports mobile navigation without horizontal overflow", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/");
const menu = page.locator("details.mobile-menu");
await menu.locator("summary").click();
await expect(menu).toHaveAttribute("open", "");
await menu.getByRole("link", { name: "Le lezioni" }).click();
await expect(page).toHaveURL(/#lezioni$/);
const dimensions = await page.evaluate(() => ({
clientWidth: document.documentElement.clientWidth,
scrollWidth: document.documentElement.scrollWidth,
}));
expect(dimensions.scrollWidth).toBeLessThanOrEqual(dimensions.clientWidth + 1);
});
test("provides semantic landmarks, image alternatives and keyboard access", async ({
page,
}) => {
await page.goto("/");
await expect(page.getByRole("banner")).toHaveCount(1);
await expect(page.getByRole("main")).toHaveCount(1);
await expect(page.getByRole("contentinfo")).toHaveCount(1);
await expect(page.getByRole("heading", { level: 1 })).toHaveCount(1);
const imageAlternatives = await page.locator("img").evaluateAll((images) =>
images.map((image) => image.getAttribute("alt")?.trim() ?? ""),
);
expect(imageAlternatives.length).toBeGreaterThan(0);
expect(imageAlternatives.every(Boolean)).toBe(true);
const images = page.locator("img");
for (let index = 0; index < await images.count(); index += 1) {
const image = images.nth(index);
await image.scrollIntoViewIfNeeded();
await expect
.poll(() => image.evaluate((element) => element.naturalWidth))
.toBeGreaterThan(0);
}
await page.keyboard.press("Tab");
await expect(page.getByRole("link", { name: "Vai al contenuto" })).toBeFocused();
});
test("returns the aggregate API contract and serves CMS media", async ({ request }) => {
const healthResponse = await request.get("/health/");
expect(healthResponse.ok()).toBe(true);
expect(await healthResponse.json()).toEqual({ status: "ok" });
const response = await request.get("/api/site/home/");
expect(response.ok()).toBe(true);
const payload = await response.json();
expect(Object.keys(payload).sort()).toEqual([
"feature_cards",
"gallery_items",
"homepage",
"lesson_info",
"settings",
"shows",
"teacher",
]);
expect(payload.settings.laboratory_name).toBe("Azione!Lab");
expect(payload.feature_cards).toHaveLength(3);
expect(payload.shows).toHaveLength(2);
expect(payload.gallery_items).toHaveLength(4);
const mediaPath = new URL(payload.homepage.hero_image.url).pathname;
const mediaResponse = await request.get(mediaPath);
expect(mediaResponse.ok()).toBe(true);
expect(mediaResponse.headers()["content-type"]).toMatch(/^image\//);
});
test("routes Wagtail admin and rejects unknown virtual hosts", async ({
page,
playwright,
}) => {
await page.goto("/admin/");
await expect(page).toHaveURL(/\/admin\/login\/\?next=\/admin\/$/);
await expect(page.locator('input[name="username"]')).toBeVisible();
await expect(page.locator('input[name="password"]')).toBeVisible();
const invalidHostContext = await playwright.request.newContext({
baseURL: process.env.INVALID_HOST_URL ?? "http://proxy",
});
const invalidHostResponse = await invalidHostContext.get("/");
expect(invalidHostResponse.status()).toBe(404);
await invalidHostContext.dispose();
const missingChallengeResponse = await page.request.get(
"/.well-known/acme-challenge/not-issued",
);
expect(missingChallengeResponse.status()).toBe(404);
});