generated from bisco/codex-bootstrap
feat: build headless theatre laboratory website
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.venv/
|
||||
db.sqlite3
|
||||
media/
|
||||
static/
|
||||
@@ -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", "--access-logfile", "-"]
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
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()
|
||||
]
|
||||
|
||||
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 = "Laboratorio Teatrale"
|
||||
WAGTAILADMIN_BASE_URL = os.getenv("WAGTAILADMIN_BASE_URL", "http://localhost:8000")
|
||||
WAGTAIL_I18N_ENABLED = False
|
||||
|
||||
DATA_UPLOAD_MAX_MEMORY_SIZE = 10 * 1024 * 1024
|
||||
FILE_UPLOAD_MAX_MEMORY_SIZE = 10 * 1024 * 1024
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
attempt=1
|
||||
until python manage.py migrate --noinput; 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
|
||||
exec "$@"
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class HomeConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "home"
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
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="Laboratorio Teatrale",
|
||||
root_page=homepage,
|
||||
is_default_site=True,
|
||||
)
|
||||
elif site.root_page_id != homepage.id:
|
||||
site.root_page = homepage
|
||||
site.site_name = "Laboratorio Teatrale"
|
||||
site.save(update_fields=["root_page", "site_name"])
|
||||
|
||||
settings, _ = SiteSettings.objects.get_or_create(site=site)
|
||||
settings.laboratory_name = "Laboratorio Teatrale"
|
||||
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 del Teatro 12, Roma"
|
||||
settings.footer_text = "Uno spazio di ricerca, ascolto e presenza."
|
||||
settings.save()
|
||||
|
||||
TeacherProfile.objects.update_or_create(
|
||||
name="Andrea Rossi",
|
||||
defaults={
|
||||
"photo": teacher_image,
|
||||
"short_bio": (
|
||||
"Attore, regista e formatore. Accompagna gruppi e persone "
|
||||
"nella ricerca di una presenza scenica sincera."
|
||||
),
|
||||
"full_bio": (
|
||||
"<p>Da oltre quindici anni lavora tra formazione, creazione "
|
||||
"collettiva e spettacolo dal vivo.</p>"
|
||||
),
|
||||
"quote": "Il teatro non è fingere: è imparare a essere presenti.",
|
||||
},
|
||||
)
|
||||
LessonInfo.objects.update_or_create(
|
||||
day_time="Ogni mercoledì, 19:30–22:00",
|
||||
defaults={
|
||||
"location": "Spazio Teatro, Via del Teatro 12, Roma",
|
||||
"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."))
|
||||
@@ -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 @@
|
||||
|
||||
@@ -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="Laboratorio Teatrale")
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import tempfile
|
||||
|
||||
from django.core.management import call_command
|
||||
from django.test import TestCase, override_settings
|
||||
|
||||
from home.models import GalleryItem, HomePage, Show
|
||||
|
||||
|
||||
@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(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)
|
||||
|
||||
|
||||
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"})
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user