15 Commits

51 changed files with 2717 additions and 90 deletions
+146 -52
View File
@@ -1,31 +1,115 @@
# Project configuration for Codex # AzioneLab project configuration for Codex
Edit this file for each repository.
## Project identity ## Project identity
Project name: `CHANGE_ME` Project name: `AzioneLab`
Project description: `CHANGE_ME`
Primary language/runtime: `CHANGE_ME` Project description: public website for a theatre company with a simple booking system for performances, email confirmation, QR code generation, and staff entrance check-in.
Primary language/runtime: Python with Django 5.2 LTS.
## Project mode ## Project mode
Choose one:
```text ```text
project_mode: personal project_mode: personal
project_mode: work
``` ```
Rules: Docker base image policy is neutral, but images must use explicit tags and must not use `latest`.
- `personal`: Docker base image policy is neutral. ## Tech stack
- `work`: prefer Red Hat UBI minimal images when possible.
- Backend: Python, Django 5.2 LTS, Django REST Framework.
- Backend runtime: gunicorn.
- Frontend: Angular with Angular Material.
- Database: PostgreSQL.
- Reverse proxy: nginx.
- Deployment: Docker Compose.
- QR codes: a small Python QR code library such as `qrcode` or `segno`.
- Email: Django email backend configured through environment variables.
Do not add Celery, Redis, message brokers, background workers, or extra services unless a future ADR explicitly changes this decision.
## Architecture
AzioneLab uses a pragmatic monolith-oriented architecture:
- a Django backend owns APIs, admin, booking, confirmation, QR generation, check-in validation, and transactional capacity rules;
- an Angular frontend renders public pages, show listings, booking forms, confirmation states, and staff check-in UI;
- PostgreSQL is the system of record;
- nginx is the public reverse proxy and serves frontend assets;
- Docker Compose defines the initial deployment topology.
Only nginx should be exposed publicly in production. Backend, frontend, and PostgreSQL services should remain on the internal Compose network.
## Key features
- Public descriptive pages for the theatre company.
- Public show list and show detail pages.
- Public booking form for a specific performance.
- Administration through Django admin.
- Configurable shows, venues, performance dates, room capacity, manually occupied seats, and optional additional seats.
- Email confirmation before a reservation becomes confirmed.
- QR code generation after reservation confirmation.
- QR codes usable on smartphones or printed copies.
- Staff/admin check-in flow using a mobile-friendly page to scan or enter QR tokens.
- QR verification preview and check-in confirmation endpoints.
- Duplicate check-in prevention.
## Domain and business constraints
- A `Performance` belongs to one `Show` and one `Venue`.
- Available seats are calculated server-side as:
```text
room capacity + additional seats - manually occupied seats - confirmed reservations
```
- Reservations start as `pending`.
- Reservations become `confirmed` only through a valid confirmation link.
- Confirmed reservations receive a QR code.
- QR codes must contain only an opaque verification token or URL, never personal data.
- Staff/admin users perform check-in.
- A reservation must be confirmed before check-in.
- A reservation cannot be checked in twice.
- Capacity validation must happen server-side and must avoid overbooking.
- Store reservation status explicitly.
- Public booking endpoints must validate input strictly.
- Admin functionality must require authentication.
## Security constraints
- Do not commit secrets, credentials, private keys, raw tokens, or real personal data.
- Use opaque, random, non-guessable tokens for confirmation and QR verification.
- Do not expose personal data in QR codes.
- Do not log raw tokens, credentials, session cookies, authorization headers, or full booking payloads.
- CORS must allow only configured Angular frontend origins through `CORS_ALLOWED_ORIGINS`.
- Keep `.env` files out of version control; `.env.example` may contain example values only.
- Prefer least privilege for containers, users, networks, and filesystem access.
## Development rules
Codex MUST:
- work only on the current feature branch;
- not merge into `develop`;
- keep changes minimal, focused, and easy to review;
- preserve existing architecture decisions unless the user explicitly requests a change;
- update documentation when behavior, deployment, operation, security, or architecture changes;
- create or update ADRs for architectural decisions;
- use Conventional Commits;
- run the configured Docker-based checks before committing;
- report tests/checks, residual risks, and rollback notes.
Codex MUST NOT:
- add Celery or Redis;
- add unnecessary services or dependencies;
- implement unrelated features while handling a focused task;
- introduce broad rewrites or formatting-only churn;
- disable authentication, authorization, CSRF, CORS restrictions, TLS verification, input validation, or security checks without an explicit ADR.
## Enabled profiles ## Enabled profiles
Enable only the profiles that apply to this repository:
```text ```text
enabled_profiles: enabled_profiles:
- docker - docker
@@ -35,11 +119,10 @@ enabled_profiles:
## Branching model ## Branching model
Codex MUST: Work must happen on the current feature branch for the task.
Allowed branch prefixes when a new branch is explicitly needed:
- start from `develop`;
- create one branch per task;
- use one of these prefixes:
- `feature/` - `feature/`
- `fix/` - `fix/`
- `hotfix/` - `hotfix/`
@@ -47,61 +130,72 @@ Codex MUST:
- `docs/` - `docs/`
- `refactor/` - `refactor/`
Examples: Do not merge task branches into `develop`. Leave integration to the repository owner or a separate explicit request.
```text
feature/add-healthcheck
fix/selinux-authorized-keys
hotfix/restore-container-startup
```
## Commit style ## Commit style
Codex MUST use Conventional Commits. Use Conventional Commits.
Examples: Examples:
```text ```text
feat: add Docker healthcheck feat: add health endpoint
fix: correct Ansible SELinux handling fix: correct backend Docker workdir
docs: add ADR for deployment strategy docs: update deployment notes
test: add regression tests for parser test: add reservation capacity checks
refactor: simplify container startup logic refactor: simplify booking token validation
chore: update Codex project metadata chore: update Codex project configuration
``` ```
## Test command ## Testing approach
All tests MUST be executed inside Docker containers. All checks must run inside Docker containers when applicable.
Configure the canonical test command for this repository: Canonical check command:
```bash ```bash
CHANGE_ME docker compose --env-file .env.example -f infra/docker/compose.yml config
docker compose --env-file .env.example -f infra/docker/compose.yml run --rm --build backend python manage.py test
``` ```
Examples: Current coverage:
```bash - Docker Compose configuration validation;
docker compose run --rm app pytest - Django backend tests, including the health endpoint test.
```
```bash Run formatting or linting only when a project configuration for those tools exists.
docker compose run --rm app ruff check .
docker compose run --rm app pytest
```
```bash ## Deployment approach
docker compose run --rm ansible ansible-playbook --syntax-check playbook.yml
```
If no test command is configured, Codex MUST: Deployment uses `infra/docker/compose.yml` with explicit services:
1. report that tests are not configured; - `nginx`: public reverse proxy;
2. suggest the appropriate Docker-based test command; - `frontend`: Angular frontend build/static service served by nginx;
3. avoid claiming that the task is fully verified. - `backend`: Django application served by gunicorn;
- `postgres`: PostgreSQL with named volume persistence.
## Documentation language Configuration comes from `.env`. `.env.example` documents the required environment variables with example values. PostgreSQL data is persisted in the `postgres_data` named volume.
## Documentation and ADRs
Primary documentation:
- `docs/architecture.md`
- `docs/domain-model.md`
- `docs/api-contract.md`
- `docs/booking-flow.md`
- `docs/security-notes.md`
- `docs/deployment.md`
- `docs/testing.md`
Accepted ADRs currently define:
- Django monolith backend;
- no async task queue at this stage;
- opaque QR token strategy;
- email confirmation flow;
- staff check-in with token validation;
- Docker Compose deployment.
Documentation language: English. Documentation language: English.
Code comments language: English. Code comments language: English.
+8
View File
@@ -0,0 +1,8 @@
.git
.env
.env.*
!.env.example
__pycache__/
*.py[cod]
.pytest_cache/
.ruff_cache/
+26
View File
@@ -0,0 +1,26 @@
# AzioneLab Docker Compose example environment.
# Copy this file to .env and replace placeholder values before deployment.
COMPOSE_PROJECT_NAME=azionelab
NGINX_HTTP_PORT=8080
BACKEND_HOST=backend
BACKEND_PORT=8000
FRONTEND_HOST=frontend
FRONTEND_PORT=8080
DJANGO_SECRET_KEY=change-me
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1
DJANGO_CSRF_TRUSTED_ORIGINS=http://localhost:8080
DJANGO_DEBUG=false
CORS_ALLOWED_ORIGINS=http://localhost:4200,http://localhost:8080
TIME_ZONE=Europe/Rome
POSTGRES_DB=azionelab
POSTGRES_USER=azionelab
POSTGRES_PASSWORD=change-me
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
DATABASE_URL=postgres://azionelab:change-me@postgres:5432/azionelab
+3
View File
@@ -0,0 +1,3 @@
.env
.env.*
!.env.example
+1
View File
@@ -0,0 +1 @@
+8
View File
@@ -0,0 +1,8 @@
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "azionelab.settings")
application = get_asgi_application()
+114
View File
@@ -0,0 +1,114 @@
import os
from pathlib import Path
import dj_database_url
from django.core.exceptions import ImproperlyConfigured
BASE_DIR = Path(__file__).resolve().parent.parent
DEBUG = os.environ.get("DJANGO_DEBUG", "false").lower() == "true"
SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY")
if not SECRET_KEY:
if DEBUG:
SECRET_KEY = "insecure-development-key"
else:
raise ImproperlyConfigured("DJANGO_SECRET_KEY must be set when DJANGO_DEBUG is false.")
def csv_env(name, default=""):
return [item.strip() for item in os.environ.get(name, default).split(",") if item.strip()]
ALLOWED_HOSTS = csv_env("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1")
CSRF_TRUSTED_ORIGINS = csv_env("DJANGO_CSRF_TRUSTED_ORIGINS")
CORS_ALLOWED_ORIGINS = csv_env("CORS_ALLOWED_ORIGINS")
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"rest_framework",
"corsheaders",
"shows",
"bookings",
"checkins",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"corsheaders.middleware.CorsMiddleware",
"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",
]
ROOT_URLCONF = "azionelab.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"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 = "azionelab.wsgi.application"
DATABASES = {
"default": dj_database_url.config(
default=(
f"postgres://{os.environ.get('POSTGRES_USER', 'azionelab')}:"
f"{os.environ.get('POSTGRES_PASSWORD', 'azionelab')}"
f"@{os.environ.get('POSTGRES_HOST', 'postgres')}:"
f"{os.environ.get('POSTGRES_PORT', '5432')}/"
f"{os.environ.get('POSTGRES_DB', 'azionelab')}"
),
conn_max_age=60,
)
}
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 = "en-us"
TIME_ZONE = os.environ.get("TIME_ZONE", "Europe/Rome")
USE_I18N = True
USE_TZ = True
STATIC_URL = "static/"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
REST_FRAMEWORK = {
"DEFAULT_RENDERER_CLASSES": [
"rest_framework.renderers.JSONRenderer",
],
"DEFAULT_PARSER_CLASSES": [
"rest_framework.parsers.JSONParser",
],
}
+10
View File
@@ -0,0 +1,10 @@
from django.test import SimpleTestCase
from django.urls import reverse
class HealthEndpointTests(SimpleTestCase):
def test_health_endpoint_returns_ok(self):
response = self.client.get(reverse("health"))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"status": "ok"})
+15
View File
@@ -0,0 +1,15 @@
from django.contrib import admin
from django.urls import path
from rest_framework.decorators import api_view
from rest_framework.response import Response
@api_view(["GET"])
def health(request):
return Response({"status": "ok"})
urlpatterns = [
path("admin/", admin.site.urls),
path("api/health/", health, name="health"),
]
+8
View File
@@ -0,0 +1,8 @@
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "azionelab.settings")
application = get_wsgi_application()
+1
View File
@@ -0,0 +1 @@
+34
View File
@@ -0,0 +1,34 @@
from django.contrib import admin
from .models import Reservation, ReservationToken
class ReservationTokenInline(admin.TabularInline):
model = ReservationToken
extra = 0
readonly_fields = ("token_hash", "used_at", "created_at")
fields = ("purpose", "token_hash", "expires_at", "used_at", "created_at")
@admin.register(Reservation)
class ReservationAdmin(admin.ModelAdmin):
list_display = (
"name",
"email",
"performance",
"party_size",
"status",
"confirmed_at",
"created_at",
)
list_filter = ("status", "performance", "created_at", "confirmed_at")
search_fields = ("name", "email", "phone", "performance__show__title")
inlines = (ReservationTokenInline,)
@admin.register(ReservationToken)
class ReservationTokenAdmin(admin.ModelAdmin):
list_display = ("reservation", "purpose", "expires_at", "used_at", "created_at")
list_filter = ("purpose", "expires_at", "used_at", "created_at")
search_fields = ("reservation__name", "reservation__email", "token_hash")
readonly_fields = ("token_hash", "created_at", "used_at")
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class BookingsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "bookings"
@@ -0,0 +1,95 @@
# Generated by Django 5.2.3 on 2026-04-28
import django.core.validators
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("shows", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="Reservation",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"status",
models.CharField(
choices=[
("pending", "Pending"),
("confirmed", "Confirmed"),
("cancelled", "Cancelled"),
("expired", "Expired"),
],
db_index=True,
default="pending",
max_length=20,
),
),
("name", models.CharField(max_length=200)),
("email", models.EmailField(db_index=True, max_length=254)),
("phone", models.CharField(blank=True, max_length=40)),
("party_size", models.PositiveIntegerField(validators=[django.core.validators.MinValueValidator(1)])),
("notes", models.TextField(blank=True)),
("confirmed_at", models.DateTimeField(blank=True, null=True)),
("qr_code_generated_at", models.DateTimeField(blank=True, null=True)),
(
"performance",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="reservations",
to="shows.performance",
),
),
],
options={
"ordering": ["-created_at"],
"indexes": [
models.Index(fields=["performance", "status"], name="bookings_re_perform_730504_idx"),
models.Index(fields=["email"], name="bookings_re_email_924b70_idx"),
models.Index(fields=["created_at"], name="bookings_re_created_823f26_idx"),
],
},
),
migrations.CreateModel(
name="ReservationToken",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
(
"purpose",
models.CharField(
choices=[("confirmation", "Confirmation"), ("check_in", "Check-in")],
db_index=True,
max_length=20,
),
),
("token_hash", models.CharField(max_length=64, unique=True)),
("expires_at", models.DateTimeField()),
("used_at", models.DateTimeField(blank=True, null=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"reservation",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="tokens",
to="bookings.reservation",
),
),
],
options={
"ordering": ["-created_at"],
"indexes": [
models.Index(fields=["reservation", "purpose"], name="bookings_re_reserva_017db4_idx"),
models.Index(fields=["purpose", "expires_at"], name="bookings_re_purpose_36fca5_idx"),
models.Index(fields=["used_at"], name="bookings_re_used_at_ae1c4d_idx"),
],
},
),
]
+1
View File
@@ -0,0 +1 @@
+138
View File
@@ -0,0 +1,138 @@
import hashlib
import secrets
from django.core.validators import MinValueValidator
from django.core.exceptions import ValidationError
from django.db import models
from django.utils import timezone
from shows.models import Performance, TimeStampedModel
class Reservation(TimeStampedModel):
class Status(models.TextChoices):
PENDING = "pending", "Pending"
CONFIRMED = "confirmed", "Confirmed"
CANCELLED = "cancelled", "Cancelled"
EXPIRED = "expired", "Expired"
performance = models.ForeignKey(
Performance,
on_delete=models.PROTECT,
related_name="reservations",
)
status = models.CharField(
max_length=20,
choices=Status.choices,
default=Status.PENDING,
db_index=True,
)
name = models.CharField(max_length=200)
email = models.EmailField(db_index=True)
phone = models.CharField(max_length=40, blank=True)
party_size = models.PositiveIntegerField(validators=[MinValueValidator(1)])
notes = models.TextField(blank=True)
confirmed_at = models.DateTimeField(blank=True, null=True)
qr_code_generated_at = models.DateTimeField(blank=True, null=True)
class Meta:
ordering = ["-created_at"]
indexes = [
models.Index(fields=["performance", "status"]),
models.Index(fields=["email"]),
models.Index(fields=["created_at"]),
]
def __str__(self):
return f"{self.name} ({self.party_size}) for {self.performance}"
@property
def is_confirmed(self):
return self.status == self.Status.CONFIRMED
def confirm(self):
if self.status != self.Status.PENDING:
raise ValidationError("Only pending reservations can be confirmed.")
if not self.confirmed_at:
self.confirmed_at = timezone.now()
self.status = self.Status.CONFIRMED
self.save(update_fields=["status", "confirmed_at", "updated_at"])
def confirm_with_token(self, raw_token):
token = self.tokens.get_valid_token(raw_token, ReservationToken.Purpose.CONFIRMATION)
self.confirm()
token.mark_used()
return token
class ReservationTokenQuerySet(models.QuerySet):
def get_valid_token(self, raw_token, purpose):
token_hash = ReservationToken.hash_token(raw_token)
now = timezone.now()
return self.get(
token_hash=token_hash,
purpose=purpose,
used_at__isnull=True,
expires_at__gt=now,
)
class ReservationToken(models.Model):
class Purpose(models.TextChoices):
CONFIRMATION = "confirmation", "Confirmation"
CHECK_IN = "check_in", "Check-in"
reservation = models.ForeignKey(
Reservation,
on_delete=models.CASCADE,
related_name="tokens",
)
purpose = models.CharField(max_length=20, choices=Purpose.choices, db_index=True)
token_hash = models.CharField(max_length=64, unique=True)
expires_at = models.DateTimeField()
used_at = models.DateTimeField(blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
objects = ReservationTokenQuerySet.as_manager()
class Meta:
ordering = ["-created_at"]
indexes = [
models.Index(fields=["reservation", "purpose"]),
models.Index(fields=["purpose", "expires_at"]),
models.Index(fields=["used_at"]),
]
def __str__(self):
return f"{self.get_purpose_display()} token for reservation {self.reservation_id}"
@staticmethod
def generate_raw_token():
return secrets.token_urlsafe(32)
@staticmethod
def hash_token(raw_token):
return hashlib.sha256(raw_token.encode("utf-8")).hexdigest()
@classmethod
def create_token(cls, reservation, purpose, expires_at):
raw_token = cls.generate_raw_token()
token = cls.objects.create(
reservation=reservation,
purpose=purpose,
token_hash=cls.hash_token(raw_token),
expires_at=expires_at,
)
return token, raw_token
@property
def is_expired(self):
return timezone.now() >= self.expires_at
@property
def is_used(self):
return self.used_at is not None
def mark_used(self):
self.used_at = timezone.now()
self.save(update_fields=["used_at"])
+119
View File
@@ -0,0 +1,119 @@
from datetime import timedelta
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.db import IntegrityError
from django.test import TestCase
from django.utils import timezone
from bookings.models import Reservation, ReservationToken
from checkins.models import CheckIn
from shows.models import Performance, Show, Venue
class DomainModelTests(TestCase):
def setUp(self):
self.show = Show.objects.create(
title="Open Stage",
slug="open-stage",
is_published=True,
)
self.venue = Venue.objects.create(
name="AzioneLab Theatre",
slug="azionelab-theatre",
address="Via Example 1",
city="Rome",
)
self.performance = Performance.objects.create(
show=self.show,
venue=self.venue,
starts_at=timezone.now() + timedelta(days=7),
room_capacity=10,
manually_occupied_seats=2,
additional_seats=3,
)
def create_reservation(self, **overrides):
data = {
"performance": self.performance,
"name": "Maria Rossi",
"email": "maria@example.com",
"party_size": 2,
}
data.update(overrides)
return Reservation.objects.create(**data)
def test_available_seats_count_only_confirmed_reservations(self):
self.create_reservation(party_size=4)
self.create_reservation(
party_size=3,
status=Reservation.Status.CONFIRMED,
confirmed_at=timezone.now(),
)
self.assertEqual(self.performance.configured_capacity, 11)
self.assertEqual(self.performance.confirmed_seats(), 3)
self.assertEqual(self.performance.available_seats(), 8)
def test_reservation_lifecycle_pending_to_confirmed_with_token(self):
reservation = self.create_reservation()
token, raw_token = ReservationToken.create_token(
reservation=reservation,
purpose=ReservationToken.Purpose.CONFIRMATION,
expires_at=timezone.now() + timedelta(hours=2),
)
reservation.confirm_with_token(raw_token)
reservation.refresh_from_db()
token.refresh_from_db()
self.assertEqual(reservation.status, Reservation.Status.CONFIRMED)
self.assertIsNotNone(reservation.confirmed_at)
self.assertIsNotNone(token.used_at)
def test_only_pending_reservations_can_be_confirmed(self):
reservation = self.create_reservation(status=Reservation.Status.CANCELLED)
token, raw_token = ReservationToken.create_token(
reservation=reservation,
purpose=ReservationToken.Purpose.CONFIRMATION,
expires_at=timezone.now() + timedelta(hours=2),
)
with self.assertRaises(ValidationError):
reservation.confirm_with_token(raw_token)
token.refresh_from_db()
self.assertIsNone(token.used_at)
def test_capacity_configuration_rejects_impossible_manual_occupancy(self):
performance = Performance(
show=self.show,
venue=self.venue,
starts_at=timezone.now() + timedelta(days=8),
room_capacity=5,
additional_seats=1,
manually_occupied_seats=7,
)
with self.assertRaises(ValidationError):
performance.full_clean()
def test_check_in_requires_confirmed_reservation(self):
reservation = self.create_reservation()
user = get_user_model().objects.create_user(username="staff", password="test")
check_in = CheckIn(reservation=reservation, checked_in_by=user)
with self.assertRaises(ValidationError):
check_in.full_clean()
def test_reservation_cannot_be_checked_in_twice(self):
reservation = self.create_reservation(
status=Reservation.Status.CONFIRMED,
confirmed_at=timezone.now(),
)
user = get_user_model().objects.create_user(username="staff", password="test")
CheckIn.objects.create(reservation=reservation, checked_in_by=user)
with self.assertRaises((IntegrityError, ValidationError)):
CheckIn.objects.create(reservation=reservation, checked_in_by=user)
+1
View File
@@ -0,0 +1 @@
+17
View File
@@ -0,0 +1,17 @@
from django.contrib import admin
from .models import CheckIn
@admin.register(CheckIn)
class CheckInAdmin(admin.ModelAdmin):
list_display = ("reservation", "checked_in_at", "checked_in_by", "source", "created_at")
list_filter = ("source", "checked_in_at", "created_at")
search_fields = (
"reservation__name",
"reservation__email",
"reservation__performance__show__title",
"checked_in_by__username",
"checked_in_by__email",
)
readonly_fields = ("created_at", "updated_at")
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class CheckinsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "checkins"
@@ -0,0 +1,54 @@
# Generated by Django 5.2.3 on 2026-04-28
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("bookings", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="CheckIn",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("checked_in_at", models.DateTimeField(default=django.utils.timezone.now)),
(
"source",
models.CharField(choices=[("qr_scan", "QR scan"), ("manual", "Manual")], default="qr_scan", max_length=20),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"checked_in_by",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="checkins",
to=settings.AUTH_USER_MODEL,
),
),
(
"reservation",
models.OneToOneField(
on_delete=django.db.models.deletion.PROTECT,
related_name="check_in",
to="bookings.reservation",
),
),
],
options={
"ordering": ["-checked_in_at"],
"indexes": [
models.Index(fields=["checked_in_at"], name="checkins_ch_checked_761e33_idx"),
models.Index(fields=["checked_in_by"], name="checkins_ch_checked_becaae_idx"),
],
},
),
]
+1
View File
@@ -0,0 +1 @@
+46
View File
@@ -0,0 +1,46 @@
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from django.utils import timezone
from bookings.models import Reservation
class CheckIn(models.Model):
class Source(models.TextChoices):
QR_SCAN = "qr_scan", "QR scan"
MANUAL = "manual", "Manual"
reservation = models.OneToOneField(
Reservation,
on_delete=models.PROTECT,
related_name="check_in",
)
checked_in_at = models.DateTimeField(default=timezone.now)
checked_in_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name="checkins",
)
source = models.CharField(max_length=20, choices=Source.choices, default=Source.QR_SCAN)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["-checked_in_at"]
indexes = [
models.Index(fields=["checked_in_at"]),
models.Index(fields=["checked_in_by"]),
]
def __str__(self):
return f"Check-in for reservation {self.reservation_id}"
def clean(self):
super().clean()
if self.reservation_id and not self.reservation.is_confirmed:
raise ValidationError({"reservation": "Only confirmed reservations can be checked in."})
def save(self, *args, **kwargs):
self.full_clean()
return super().save(*args, **kwargs)
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env python
import os
import sys
def main():
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "azionelab.settings")
from django.core.management import execute_from_command_line
execute_from_command_line(sys.argv)
if __name__ == "__main__":
main()
+1
View File
@@ -0,0 +1 @@
+34
View File
@@ -0,0 +1,34 @@
from django.contrib import admin
from .models import Performance, Show, Venue
@admin.register(Show)
class ShowAdmin(admin.ModelAdmin):
list_display = ("title", "slug", "is_published", "created_at", "updated_at")
list_filter = ("is_published",)
search_fields = ("title", "slug", "summary", "description")
prepopulated_fields = {"slug": ("title",)}
@admin.register(Venue)
class VenueAdmin(admin.ModelAdmin):
list_display = ("name", "slug", "city", "address", "created_at", "updated_at")
list_filter = ("city",)
search_fields = ("name", "slug", "address", "city", "notes")
prepopulated_fields = {"slug": ("name",)}
@admin.register(Performance)
class PerformanceAdmin(admin.ModelAdmin):
list_display = (
"show",
"venue",
"starts_at",
"room_capacity",
"additional_seats",
"manually_occupied_seats",
"is_booking_enabled",
)
list_filter = ("is_booking_enabled", "starts_at", "show", "venue")
search_fields = ("show__title", "venue__name", "venue__city")
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ShowsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "shows"
+98
View File
@@ -0,0 +1,98 @@
# Generated by Django 5.2.3 on 2026-04-28
import django.db.models.deletion
from django.db import migrations, models
from django.db.models import F, Q
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="Show",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("title", models.CharField(max_length=200)),
("slug", models.SlugField(max_length=220, unique=True)),
("summary", models.TextField(blank=True)),
("description", models.TextField(blank=True)),
("poster_image", models.URLField(blank=True)),
("is_published", models.BooleanField(db_index=True, default=False)),
],
options={
"ordering": ["title"],
"indexes": [
models.Index(fields=["slug"], name="shows_show_slug_83daa9_idx"),
models.Index(fields=["is_published"], name="shows_show_is_publ_63247e_idx"),
],
},
),
migrations.CreateModel(
name="Venue",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("name", models.CharField(max_length=200)),
("slug", models.SlugField(max_length=220, unique=True)),
("address", models.CharField(max_length=255)),
("city", models.CharField(max_length=120)),
("notes", models.TextField(blank=True)),
],
options={
"ordering": ["name"],
"indexes": [
models.Index(fields=["slug"], name="shows_venue_slug_0717a3_idx"),
models.Index(fields=["city"], name="shows_venue_city_acfb26_idx"),
],
},
),
migrations.CreateModel(
name="Performance",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("starts_at", models.DateTimeField(db_index=True)),
("room_capacity", models.PositiveIntegerField()),
("manually_occupied_seats", models.PositiveIntegerField(default=0)),
("additional_seats", models.PositiveIntegerField(default=0)),
("is_booking_enabled", models.BooleanField(db_index=True, default=True)),
(
"show",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="performances",
to="shows.show",
),
),
(
"venue",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="performances",
to="shows.venue",
),
),
],
options={
"ordering": ["starts_at"],
"indexes": [
models.Index(fields=["show", "starts_at"], name="shows_perfo_show_id_bae2ea_idx"),
models.Index(fields=["venue", "starts_at"], name="shows_perfo_venue_i_fcdf27_idx"),
models.Index(fields=["is_booking_enabled", "starts_at"], name="shows_perfo_is_book_9371e4_idx"),
],
"constraints": [
models.CheckConstraint(
condition=Q(("manually_occupied_seats__lte", F("room_capacity") + F("additional_seats"))),
name="performance_manual_seats_within_capacity",
),
],
},
),
]
+1
View File
@@ -0,0 +1 @@
+85
View File
@@ -0,0 +1,85 @@
from django.db import models
from django.db.models import F, Q, Sum
class TimeStampedModel(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
abstract = True
class Show(TimeStampedModel):
title = models.CharField(max_length=200)
slug = models.SlugField(max_length=220, unique=True)
summary = models.TextField(blank=True)
description = models.TextField(blank=True)
poster_image = models.URLField(blank=True)
is_published = models.BooleanField(default=False, db_index=True)
class Meta:
ordering = ["title"]
indexes = [
models.Index(fields=["slug"]),
models.Index(fields=["is_published"]),
]
def __str__(self):
return self.title
class Venue(TimeStampedModel):
name = models.CharField(max_length=200)
slug = models.SlugField(max_length=220, unique=True)
address = models.CharField(max_length=255)
city = models.CharField(max_length=120)
notes = models.TextField(blank=True)
class Meta:
ordering = ["name"]
indexes = [
models.Index(fields=["slug"]),
models.Index(fields=["city"]),
]
def __str__(self):
return self.name
class Performance(TimeStampedModel):
show = models.ForeignKey(Show, on_delete=models.PROTECT, related_name="performances")
venue = models.ForeignKey(Venue, on_delete=models.PROTECT, related_name="performances")
starts_at = models.DateTimeField(db_index=True)
room_capacity = models.PositiveIntegerField()
manually_occupied_seats = models.PositiveIntegerField(default=0)
additional_seats = models.PositiveIntegerField(default=0)
is_booking_enabled = models.BooleanField(default=True, db_index=True)
class Meta:
ordering = ["starts_at"]
indexes = [
models.Index(fields=["show", "starts_at"]),
models.Index(fields=["venue", "starts_at"]),
models.Index(fields=["is_booking_enabled", "starts_at"]),
]
constraints = [
models.CheckConstraint(
condition=Q(manually_occupied_seats__lte=F("room_capacity") + F("additional_seats")),
name="performance_manual_seats_within_capacity",
),
]
def __str__(self):
return f"{self.show} at {self.starts_at:%Y-%m-%d %H:%M}"
@property
def configured_capacity(self):
return self.room_capacity + self.additional_seats - self.manually_occupied_seats
def confirmed_seats(self):
result = self.reservations.filter(status="confirmed").aggregate(total=Sum("party_size"))
return result["total"] or 0
def available_seats(self):
return self.configured_capacity - self.confirmed_seats()
+35
View File
@@ -0,0 +1,35 @@
# ADR-0001: Use Django Monolith
Date: 2026-04-28
## Status
Accepted
## Context
AzioneLab needs a public theatre company website, a simple booking system, administration tools, email confirmation, QR code generation, and entrance check-in.
The project is small enough that separate backend services would add operational cost without clear benefit. The selected backend stack is Python, Django 5.2 LTS, Django REST Framework, PostgreSQL, and gunicorn.
## Decision
Use a single Django monolith for the backend.
The Django application will own:
- public REST APIs;
- booking and confirmation logic;
- QR code generation;
- check-in validation;
- Django admin for internal management;
- database transactions for capacity enforcement.
The Angular frontend remains a separate client application served through nginx.
## Consequences
- The system stays simple to develop, deploy, and operate.
- Django admin can cover the initial administration needs without building custom admin screens immediately.
- Booking and capacity rules can be enforced close to the data model.
- The monolith may need to be split later if traffic, team size, or operational needs grow.
+26
View File
@@ -0,0 +1,26 @@
# ADR-0002: Do Not Add an Async Task Queue Yet
Date: 2026-04-28
## Status
Accepted
## Context
The initial booking flow requires email delivery and QR code generation. These operations are important, but the project does not yet require high-volume background processing, scheduled jobs, retries across many task types, or distributed workers.
Adding Celery, Redis, or another queue would increase deployment and operational complexity for a small theatre company website.
## Decision
Do not use Celery, Redis, or any asynchronous task queue at this stage.
The backend will perform initial email sending and QR code generation synchronously within the Django application, with clear error handling and logging that avoids sensitive data.
## Consequences
- The deployment remains small: Django, PostgreSQL, Angular static assets, and nginx.
- Local development and production operations are easier to understand.
- Email provider latency or outages can affect booking and confirmation responses.
- If email volume, retry needs, or long-running work become a real problem, the project can introduce a task queue later with a new ADR.
+26
View File
@@ -0,0 +1,26 @@
# ADR-0003: Use Opaque Tokens in QR Codes
Date: 2026-04-28
## Status
Accepted
## Context
Confirmed reservations need QR codes that visitors can show on a smartphone or printed page. Staff must be able to scan the QR code at the entrance and verify the reservation.
QR codes are easy to copy or share, so they must not expose personal data or encode reservation details directly.
## Decision
QR codes will contain only an opaque, random, non-guessable token or a verification URL containing that token.
The backend will resolve the token server-side, validate that the reservation is confirmed, and reject duplicate check-ins. Tokens must not contain names, email addresses, phone numbers, notes, or other personal data.
## Consequences
- QR codes remain privacy-preserving even if printed, forwarded, or photographed.
- Token validation stays centralized in the backend.
- Check-in requires backend availability at the venue.
- Duplicate check-in prevention depends on reliable server-side state and database constraints.
+27
View File
@@ -0,0 +1,27 @@
# ADR-0004: Use Email Confirmation for Reservations
Date: 2026-04-28
## Status
Accepted
## Context
Public visitors can request seats for a specific performance. The system needs a simple way to verify that the requester controls the provided email address before issuing a confirmed reservation and QR code.
The system also needs to avoid overbooking when multiple pending reservations exist for the same performance.
## Decision
Create reservations as `pending` first, then send an email confirmation link containing an opaque confirmation token.
A reservation becomes `confirmed` only when the confirmation link is opened and the backend validates the token. Final capacity validation happens during confirmation inside a database transaction. A QR code is generated only after successful confirmation.
## Consequences
- Visitors receive a clear confirmation step before the reservation is finalized.
- Mistyped or unreachable email addresses are less likely to consume capacity.
- Pending reservations do not guarantee seats.
- A visitor may lose availability if other reservations are confirmed before they open the confirmation link.
- The booking flow depends on email delivery working reliably enough for the audience.
@@ -0,0 +1,29 @@
# ADR-0006: Staff Check-In with Token Validation
Date: 2026-04-28
## Status
Accepted
## Context
Confirmed reservations need a simple entrance check-in process. Staff should be able to use a mobile-friendly web page to scan a visitor's QR code or enter the token manually.
QR codes may be shown on smartphones, printed, forwarded, or photographed, so they must not expose personal data. Check-in must be restricted to authenticated staff or admin users and must prevent duplicate entrance.
## Decision
Use staff-only Django REST Framework endpoints for QR verification preview and check-in confirmation.
The QR code will contain only an opaque verification token or URL. The backend will validate the token server-side, require a confirmed reservation, reject duplicate check-ins, and create or update a `CheckIn` record with timestamp and staff user on successful confirmation.
No Celery, Redis, or separate check-in service is required.
## Consequences
- Staff can use a simple mobile web page for scanning or manual token entry.
- QR codes remain privacy-preserving because they do not contain personal data.
- Check-in decisions stay centralized in the Django backend and PostgreSQL state.
- The entrance workflow depends on backend availability at the venue.
- Duplicate check-ins can be blocked with application checks and database constraints.
@@ -0,0 +1,27 @@
# ADR-0007: Use Docker Compose for Deployment
Date: 2026-04-28
## Status
Accepted
## Context
AzioneLab needs a simple production-oriented deployment for a small theatre company website. The initial runtime services are Django with gunicorn, an Angular frontend served by nginx, PostgreSQL, and an nginx reverse proxy.
The project does not need Celery, Redis, a container orchestrator, or a more complex platform at this stage.
## Decision
Use Docker Compose as the initial deployment mechanism.
The Compose setup will define explicit `backend`, `frontend`, `postgres`, and `nginx` services under `infra/docker/compose.yml`. Configuration is provided through `.env`, PostgreSQL data is stored in a named volume, and only the reverse proxy publishes a host port.
## Consequences
- The deployment remains easy to understand, run, and review.
- The same topology can support local infrastructure checks and small production deployments.
- PostgreSQL persistence is explicit through a named volume.
- The setup can be replaced later if hosting or scaling needs outgrow Docker Compose.
- Operators must manage `.env`, backups, TLS, and image updates carefully.
+381
View File
@@ -0,0 +1,381 @@
# API Contract
This document proposes the initial REST API for AzioneLab. Endpoint names are intentionally explicit and small-project friendly.
All examples use JSON unless noted otherwise.
## Public Content
### List Shows
```http
GET /api/shows/
```
Returns published shows.
Response `200 OK`:
```json
{
"results": [
{
"id": 1,
"title": "The Open Stage",
"slug": "the-open-stage",
"summary": "A contemporary theatre performance.",
"poster_image": "https://example.org/media/shows/open-stage.jpg"
}
]
}
```
### Show Detail
```http
GET /api/shows/{slug}/
```
Response `200 OK`:
```json
{
"id": 1,
"title": "The Open Stage",
"slug": "the-open-stage",
"summary": "A contemporary theatre performance.",
"description": "Full public show description.",
"poster_image": "https://example.org/media/shows/open-stage.jpg",
"performances": [
{
"id": 10,
"starts_at": "2026-05-15T20:30:00+02:00",
"venue": {
"name": "AzioneLab Theatre",
"city": "Rome"
},
"booking_enabled": true,
"available_seats": 24
}
]
}
```
Status codes:
- `200 OK`: show found;
- `404 Not Found`: show does not exist or is not published.
### List Performances
```http
GET /api/performances/
```
Optional filters:
- `show`: show slug;
- `from`: start date/time lower bound.
Response `200 OK`:
```json
{
"results": [
{
"id": 10,
"show": {
"title": "The Open Stage",
"slug": "the-open-stage"
},
"venue": {
"name": "AzioneLab Theatre",
"city": "Rome"
},
"starts_at": "2026-05-15T20:30:00+02:00",
"booking_enabled": true,
"available_seats": 24
}
]
}
```
### Performance Detail
```http
GET /api/performances/{id}/
```
Response `200 OK`:
```json
{
"id": 10,
"show": {
"title": "The Open Stage",
"slug": "the-open-stage",
"summary": "A contemporary theatre performance."
},
"venue": {
"name": "AzioneLab Theatre",
"address": "Via Example 10",
"city": "Rome"
},
"starts_at": "2026-05-15T20:30:00+02:00",
"booking_enabled": true,
"available_seats": 24
}
```
Status codes:
- `200 OK`: performance found;
- `404 Not Found`: performance does not exist or is not public.
## Booking
### Create Reservation
```http
POST /api/performances/{id}/reservations/
```
Creates a pending reservation and sends a confirmation email.
Request:
```json
{
"name": "Maria Rossi",
"email": "maria.rossi@example.org",
"phone": "+390600000000",
"party_size": 2,
"notes": "We will arrive a few minutes early."
}
```
Response `201 Created`:
```json
{
"id": 123,
"status": "pending",
"performance": 10,
"party_size": 2,
"message": "Reservation created. Please check your email to confirm it."
}
```
Status codes:
- `201 Created`: pending reservation created;
- `400 Bad Request`: invalid input;
- `404 Not Found`: performance does not exist or is not public;
- `409 Conflict`: booking is closed or capacity is already unavailable.
Validation rules:
- `name` is required;
- `email` must be a valid email address;
- `party_size` must be a positive integer;
- public clients must not set reservation status;
- the backend must validate booking availability server-side.
### Confirm Reservation
```http
POST /api/reservations/confirm/
```
Confirms a pending reservation using the token from the email link.
Request:
```json
{
"token": "opaque-confirmation-token"
}
```
Response `200 OK`:
```json
{
"reservation_id": 123,
"status": "confirmed",
"party_size": 2,
"qr_code_url": "https://example.org/api/reservations/123/qr-code/"
}
```
Status codes:
- `200 OK`: reservation confirmed;
- `400 Bad Request`: token is missing or malformed;
- `404 Not Found`: token is unknown;
- `409 Conflict`: token already used, reservation already handled, or no capacity remains;
- `410 Gone`: token expired.
### Retrieve QR Code
```http
GET /api/reservations/{id}/qr-code/
```
Returns the generated QR code for a confirmed reservation. Access must be protected by a valid QR token, signed URL, or equivalent control so that reservation IDs are not enough to retrieve QR codes.
Response `200 OK`:
```json
{
"reservation_id": 123,
"qr_code_image": "data:image/png;base64,...",
"printable": true
}
```
Status codes:
- `200 OK`: QR code available;
- `403 Forbidden`: caller is not allowed to access the QR code;
- `404 Not Found`: reservation not found;
- `409 Conflict`: reservation is not confirmed.
## Check-In
Check-in endpoints are for authenticated staff or admin users. Staff use a mobile-friendly Angular page to scan the QR code with a device camera or enter the token manually.
The QR code must contain only an opaque verification token or a verification URL containing that token. The backend resolves and validates the token server-side.
### QR Verification Preview
```http
POST /api/check-ins/preview/
```
Validates a QR token and returns a preview before staff confirms entrance. This endpoint must not create a successful check-in.
Request:
```json
{
"token": "opaque-check-in-token"
}
```
Response `200 OK`:
```json
{
"status": "valid",
"reservation_id": 123,
"performance_id": 10,
"show_title": "The Open Stage",
"starts_at": "2026-05-15T20:30:00+02:00",
"party_size": 2
}
```
Status codes:
- `200 OK`: token is valid and reservation can be checked in;
- `400 Bad Request`: token is missing or malformed;
- `401 Unauthorized`: staff authentication is missing;
- `403 Forbidden`: authenticated user cannot preview check-in;
- `404 Not Found`: token is unknown;
- `409 Conflict`: reservation is not confirmed or was already checked in;
- `410 Gone`: token is expired.
The preview response should include only the minimum information staff need to validate the party. It must not expose unnecessary reservation personal data.
### Check-In Confirmation
```http
POST /api/check-ins/confirm/
```
Validates the token again and records successful entrance.
Request:
```json
{
"token": "opaque-check-in-token"
}
```
Response `200 OK`:
```json
{
"status": "checked_in",
"reservation_id": 123,
"performance_id": 10,
"party_size": 2,
"checked_in_at": "2026-05-15T19:55:00+02:00",
"checked_in_by": 7
}
```
Status codes:
- `200 OK`: reservation checked in;
- `400 Bad Request`: token is missing or malformed;
- `401 Unauthorized`: staff authentication is missing;
- `403 Forbidden`: authenticated user cannot confirm check-in;
- `404 Not Found`: token is unknown;
- `409 Conflict`: reservation is not confirmed or was already checked in;
- `410 Gone`: token is expired.
Successful confirmation creates a `CheckIn` record, or updates an existing incomplete check-in record for the reservation. A reservation cannot have two successful check-ins.
Error responses should use clear machine-readable states so the staff interface can show simple messages.
Example `409 Conflict` for duplicate check-in:
```json
{
"status": "already_checked_in",
"detail": "This reservation has already been checked in."
}
```
Example `409 Conflict` for unconfirmed reservation:
```json
{
"status": "reservation_not_confirmed",
"detail": "This reservation is not confirmed."
}
```
## Administration
The initial administration API is Django admin.
Admin paths:
```http
GET /admin/
```
Admin users can manage shows, venues, performances, reservations, reservation tokens, and check-ins according to staff permissions.
Status codes:
- `302 Found`: unauthenticated browser redirected to admin login;
- `200 OK`: authenticated admin page;
- `403 Forbidden`: authenticated user lacks the required permission.
## Error Format
Use Django REST Framework's standard validation error format unless a project-specific envelope is introduced later.
Example `400 Bad Request`:
```json
{
"email": ["Enter a valid email address."],
"party_size": ["Ensure this value is greater than or equal to 1."]
}
```
+160 -9
View File
@@ -1,13 +1,164 @@
# Architecture # Architecture
Describe the project architecture here. AzioneLab is a public website for a theatre company with a small booking system for performances.
Include: The architecture is intentionally simple: one Django backend, one Angular frontend, one PostgreSQL database, and nginx as the public reverse proxy. There is no Celery, Redis, message broker, or separate worker service at this stage.
- main components; ## Components
- runtime dependencies;
- data flow; ### Public frontend
- persistence;
- external integrations; The public frontend is an Angular application using Angular Material.
- deployment topology;
- relevant ADRs. Responsibilities:
- render descriptive pages for the theatre company;
- render the public list of shows;
- render public show and performance detail pages;
- provide the booking form for a selected performance;
- show reservation submission, confirmation, and check-in feedback states;
- call the backend through REST APIs.
The frontend must not calculate authoritative availability. It may display availability returned by the backend, but the backend remains responsible for final capacity validation.
### Backend API
The backend is a Django 5.2 LTS application using Django REST Framework.
The initial backend skeleton lives under `backend/` and includes the Django project, Django admin, the `shows`, `bookings`, and `checkins` apps, CORS configuration for the Angular frontend, PostgreSQL configuration through environment variables, and a health endpoint at `/api/health/`.
Responsibilities:
- expose public read APIs for shows, venues, and performances;
- expose public booking and reservation confirmation APIs;
- expose an authenticated check-in verification API;
- provide Django admin or an equivalent authenticated administration area;
- store reservations, tokens, and check-in state;
- calculate performance availability server-side;
- send reservation confirmation emails;
- generate QR codes after reservation confirmation.
The backend runs with gunicorn in production.
### Administration area
The first administration area should use Django admin unless a custom Angular admin becomes necessary later.
Administrators can manage:
- shows;
- venues;
- performances;
- total room capacity;
- manually occupied seats;
- optional additional seats available during booking;
- reservation status and check-in records when operationally necessary.
Admin functionality must require authenticated staff access.
### Database
PostgreSQL is the system of record.
It stores:
- show and venue content;
- performance scheduling and capacity configuration;
- reservations and explicit reservation status;
- reservation tokens used for confirmation and QR verification;
- check-in records.
Capacity checks must happen inside database transactions to avoid overbooking when multiple users book at the same time.
### Email
The backend sends transactional emails for:
- reservation confirmation link after booking submission;
- optional confirmation success email containing the QR code or QR verification link.
Email delivery can use Django's email backend configuration. No asynchronous email worker is required initially; failures should be logged without exposing tokens or personal data.
### QR code generation
The backend generates QR codes using a small Python library such as `qrcode` or `segno`.
QR codes must contain only an opaque token or verification URL. They must not contain names, email addresses, phone numbers, notes, or other personal data.
### nginx
nginx is the public entry point.
Responsibilities:
- terminate HTTP traffic, and TLS when configured;
- serve the built Angular static assets;
- reverse proxy API and admin requests to gunicorn;
- serve static and media files according to the deployment configuration.
## Runtime Dependencies
Required runtime dependencies:
- Python;
- Django 5.2 LTS;
- Django REST Framework;
- gunicorn;
- PostgreSQL;
- Angular;
- Angular Material;
- nginx;
- Docker Compose;
- a Python QR code library such as `qrcode` or `segno`;
- an SMTP-compatible email provider or relay.
Not included at this stage:
- Celery;
- Redis;
- background workers;
- separate search, cache, or queue services.
## Data Flow
1. A visitor opens the website through nginx.
2. nginx serves the Angular frontend.
3. The frontend calls public backend API endpoints for shows and performances.
4. The visitor submits a booking form for a specific performance.
5. The backend validates input, checks capacity server-side, creates a pending reservation, creates a confirmation token, and sends a confirmation email.
6. The visitor opens the confirmation link.
7. The backend validates the token, confirms the reservation if capacity is still available, and generates a QR code token or QR code image.
8. The visitor presents the QR code at the venue using a smartphone or printed copy.
9. Staff scans the QR code.
10. The backend validates the token and records a check-in if the reservation is confirmed and not already checked in.
## Deployment Topology
The initial deployment uses Docker Compose with these services:
- `nginx`: public reverse proxy and static frontend server;
- `frontend`: Angular build stage or static asset build source;
- `backend`: Django application served by gunicorn;
- `postgres`: PostgreSQL database.
Only nginx should be publicly exposed. The backend and database should be reachable only on the internal Compose network.
The initial Compose files live under `infra/docker/`. The backend and frontend images are placeholders until the Django and Angular applications are implemented.
## Architectural Constraints
- Keep the booking workflow synchronous and explicit.
- Keep all capacity validation on the backend.
- Store reservation status explicitly.
- Use opaque, random, non-guessable tokens.
- Do not place personal data in QR codes.
- Avoid optional infrastructure until the project needs it.
- Prefer Django admin for internal management before building custom admin UI.
## Relevant ADRs
- [ADR-0001: Use Django Monolith](adr/0001-use-django-monolith.md)
- [ADR-0002: Do Not Add an Async Task Queue Yet](adr/0002-no-async-task-queue.md)
- [ADR-0003: Use Opaque Tokens in QR Codes](adr/0003-qr-code-token-strategy.md)
- [ADR-0004: Use Email Confirmation for Reservations](adr/0004-email-confirmation-flow.md)
- [ADR-0007: Use Docker Compose for Deployment](adr/0007-use-docker-compose-for-deployment.md)
+159
View File
@@ -0,0 +1,159 @@
# Booking Flow
This document describes the full public booking, confirmation, QR generation, and entrance check-in flow.
## 1. Public Discovery
1. A visitor opens the public website.
2. The Angular frontend requests published shows and upcoming performances from the backend.
3. The visitor opens a show detail page.
4. The frontend displays upcoming public performances and availability returned by the backend.
Availability shown to visitors is informational. The backend recalculates availability when reservation state changes.
## 2. Booking Submission
1. The visitor selects a performance.
2. The frontend displays the booking form for that performance.
3. The visitor enters contact details and party size.
4. The frontend submits the booking request to the backend.
5. The backend validates:
- required fields;
- email format;
- positive party size;
- performance exists and booking is open;
- requested seats do not exceed currently available seats.
6. The backend creates a `pending` reservation.
7. The backend creates a random opaque confirmation token.
8. The backend sends an email with a confirmation link.
9. The frontend tells the visitor to check their email.
The reservation is not confirmed at this stage.
## 3. Email Confirmation
1. The visitor opens the confirmation link from the email.
2. The frontend or backend submits the confirmation token to the confirmation endpoint.
3. The backend validates that the token:
- exists;
- has the `confirmation` purpose;
- belongs to a pending reservation;
- has not expired;
- has not already been used.
4. The backend starts a database transaction.
5. The backend locks the related performance row.
6. The backend recalculates confirmed reservations for the performance.
7. The backend confirms the reservation only if enough seats remain.
8. The backend marks the confirmation token as used.
9. The backend creates a QR verification token.
10. The backend generates a QR code containing the opaque QR token or a verification URL.
11. The backend returns or sends the QR code to the visitor.
If there is no longer enough capacity, the backend must not confirm the reservation.
## 4. QR Code Delivery
After confirmation, the visitor receives access to a QR code.
The QR code may be delivered as:
- an image displayed on the confirmation page;
- an image or link in a confirmation email;
- a printable page linked from the confirmation result.
The QR code must be usable from a smartphone screen or printed copy.
The QR code must contain:
- an opaque check-in token; or
- a verification URL containing an opaque token.
The QR code must not contain:
- visitor name;
- email address;
- phone number;
- notes;
- party size if avoidable;
- any other personal data.
## 5. Entrance Check-In
1. Staff signs in with an account that has check-in permission.
2. Staff opens a mobile-friendly web page for entrance check-in.
3. Staff scans the visitor's QR code with the device camera or enters the QR token manually.
4. The QR code provides only an opaque check-in token or verification URL.
5. The frontend submits the token to the backend for a verification preview.
6. The backend validates:
- staff authentication and permission;
- token exists and has the `check_in` purpose;
- reservation exists;
- reservation is confirmed;
- token is valid for the performance check-in window if such a window is configured;
- reservation has not already been checked in.
7. The backend returns a preview with only the minimum information needed for admission, such as performance, party size, and check-in state.
8. Staff confirms check-in in the mobile web page.
9. The backend validates the token again server-side.
10. The backend creates a `CheckIn` record, or updates an existing incomplete check-in record for the same reservation.
11. The backend stores the check-in timestamp and authenticated staff user.
12. The backend returns a successful check-in response.
13. Staff admits the visitor party.
The token remains opaque throughout the flow. The QR code must not expose visitor name, email address, phone number, notes, or other personal data.
## Duplicate Check-In
If the same QR code is scanned again:
1. The backend detects an existing check-in for the reservation.
2. The backend returns `409 Conflict`.
3. The backend does not create a second check-in.
The response should be clear enough for staff to understand that the reservation was already used.
## Check-In Failure States
Failed validation must return clear error states without creating a successful check-in.
Expected check-in failures include:
- missing or malformed token;
- unknown token;
- expired token;
- staff user is not authenticated;
- staff user does not have check-in permission;
- reservation is not confirmed;
- reservation was already checked in;
- token is not valid for the selected performance or check-in window.
## Capacity Handling
Capacity is calculated as:
```text
room capacity + additional seats - manually occupied seats - confirmed reservations
```
Only confirmed reservations consume capacity. Pending reservations represent interest, not a guaranteed seat.
To avoid overbooking, final confirmation must be transactional:
1. lock the performance;
2. recalculate confirmed seats;
3. compare availability with requested party size;
4. confirm only when seats are available.
## Failure States
Expected failure states include:
- invalid booking input;
- booking disabled for the performance;
- no capacity remaining;
- confirmation token expired;
- confirmation token already used;
- QR token invalid;
- reservation not confirmed;
- reservation already checked in.
Each failure should return a clear status code and a concise user-facing message.
+179 -11
View File
@@ -1,15 +1,183 @@
# Deployment # Deployment
Describe how this project is deployed. AzioneLab should deploy with a simple Docker Compose topology:
Include: - `nginx`: public reverse proxy and static frontend server;
- `frontend`: Angular build source or build stage for static assets;
- `backend`: Django 5.2 LTS application served by gunicorn;
- `postgres`: PostgreSQL database.
- environments; Only nginx should expose public ports. The backend and database should stay on the internal Compose network.
- Docker/Compose usage;
- required configuration; The initial Compose setup is located at `infra/docker/compose.yml`.
- secrets handling;
- exposed ports; ## Services
- volumes;
- networks; ### nginx
- deployment commands;
- rollback procedure. Responsibilities:
- listen on public HTTP and HTTPS ports;
- serve built Angular files;
- proxy `/api/` and `/admin/` requests to the backend;
- serve static and media files according to the selected storage layout;
- apply request size and timeout limits appropriate for booking and admin usage.
Public ports:
- `80` for HTTP;
- `443` for HTTPS in production.
### frontend
The frontend is an Angular application using Angular Material.
Deployment options:
- build the Angular app in a Docker build stage and copy static files into the nginx image;
- or run a one-shot build container that writes static files to a shared volume consumed by nginx.
The first option is preferred for a simple production deployment because nginx can serve immutable built assets without a long-running Node process.
At the infrastructure placeholder stage, the `frontend` service serves a static placeholder page with nginx. The Angular build will replace this placeholder later.
### backend
The backend is a Django application served by gunicorn.
Responsibilities:
- REST API;
- Django admin;
- booking, confirmation, QR generation, and check-in logic;
- transactional capacity validation;
- email sending.
The backend should run database migrations before or during deployment through an explicit operational command, not as hidden startup magic unless that choice is documented later.
The `backend` service runs gunicorn against the Django WSGI application. The current backend is an initial skeleton with Django admin, Django REST Framework, CORS configuration, the `shows`, `bookings`, and `checkins` apps, and a health endpoint.
### postgres
PostgreSQL is the only database service.
Responsibilities:
- persistent application data;
- reservation and check-in state;
- transactional capacity enforcement.
Use a named Docker volume for database data.
## Networks
Recommended Compose networks:
- `public`: nginx-facing network when needed;
- `internal`: private network for nginx, backend, and db communication.
The database should not be published to the host in production.
## Volumes
Recommended volumes:
- `postgres_data`: PostgreSQL data directory;
- `media`: uploaded media and generated QR assets if stored on disk;
- `static`: collected Django static files if served by nginx from a shared volume.
Generated QR codes may also be generated on demand instead of stored as files. If stored, they must not reveal personal data and access must remain controlled.
## Configuration
Copy `.env.example` to `.env` and replace all placeholder values before running or deploying the stack.
Required backend configuration:
- `DJANGO_SECRET_KEY`;
- `DJANGO_ALLOWED_HOSTS`;
- `DJANGO_CSRF_TRUSTED_ORIGINS`;
- `CORS_ALLOWED_ORIGINS`;
- `TIME_ZONE`;
- `DATABASE_URL` or equivalent database settings;
- email host, port, username, password, TLS settings, and sender address;
- public site URL used to build confirmation and QR verification links.
Required database configuration:
- database name;
- database user;
- database password;
- data volume path.
Required nginx configuration:
- upstream backend service name and port;
- static frontend root;
- proxy rules for `/api/` and `/admin/`;
- TLS certificate paths for production.
Secrets must be provided through deployment-managed environment variables, Docker secrets, or another secret manager. Do not commit real secret values.
## Example Request Routing
```text
Visitor browser
-> nginx
-> Angular static files
-> /api/ requests proxied to backend:gunicorn
-> /admin/ requests proxied to backend:gunicorn
Backend
-> PostgreSQL
-> SMTP provider
```
## Deployment Commands
The exact commands will be finalized when application code and Compose files are added.
Expected production-style flow:
```bash
docker compose --env-file .env -f infra/docker/compose.yml build
docker compose --env-file .env -f infra/docker/compose.yml run --rm backend python manage.py migrate
docker compose --env-file .env -f infra/docker/compose.yml run --rm backend python manage.py collectstatic --noinput
docker compose --env-file .env -f infra/docker/compose.yml up -d
```
Expected validation commands:
```bash
docker compose --env-file .env.example -f infra/docker/compose.yml config
docker compose --env-file .env.example -f infra/docker/compose.yml run --rm --build backend python manage.py test
docker compose --env-file .env -f infra/docker/compose.yml run --rm backend python manage.py check --deploy
docker compose --env-file .env -f infra/docker/compose.yml run --rm backend python manage.py test
```
The canonical repository check for the current stage is:
```bash
docker compose --env-file .env.example -f infra/docker/compose.yml config
docker compose --env-file .env.example -f infra/docker/compose.yml run --rm --build backend python manage.py test
```
## Rollback
Rollback should be designed around immutable images and database backups.
Basic rollback steps:
1. identify the previous known-good image tags or Git commit;
2. stop the current Compose stack;
3. deploy the previous image tags or commit;
4. restore the database from backup only if a migration or data change requires it;
5. run smoke checks for public pages, booking creation, confirmation, and check-in.
Database rollback needs special care once migrations exist. Down migrations or backup restore procedures should be documented before production use.
## Operational Notes
- Configure database backups before accepting real bookings.
- Monitor backend errors, email delivery failures, and check-in failures.
- Keep container images explicitly versioned; do not use `latest` tags.
- Keep the system small until operational needs justify additional services.
+193
View File
@@ -0,0 +1,193 @@
# Domain Model
This document describes the core domain concepts for AzioneLab's theatre website and booking system.
## Show
A show is a theatrical production presented by the company.
Suggested fields:
- `id`: internal identifier;
- `title`: public show title;
- `slug`: stable public URL identifier;
- `summary`: short public description;
- `description`: full public description;
- `poster_image`: optional public image;
- `is_published`: controls public visibility;
- `created_at`: creation timestamp;
- `updated_at`: last update timestamp.
Relationships:
- one show can have many performances.
Rules:
- unpublished shows are not listed publicly;
- a public show detail page may include only published upcoming performances.
## Venue
A venue is the place where a performance happens.
Suggested fields:
- `id`: internal identifier;
- `name`: public venue name;
- `slug`: stable public URL identifier;
- `address`: public address;
- `city`: venue city;
- `notes`: optional public or internal venue notes;
- `created_at`: creation timestamp;
- `updated_at`: last update timestamp.
Relationships:
- one venue can host many performances.
## Performance
A performance is a scheduled presentation of one show at one venue.
Suggested fields:
- `id`: internal identifier;
- `show`: required reference to `Show`;
- `venue`: required reference to `Venue`;
- `starts_at`: performance date and time;
- `room_capacity`: configured total room capacity;
- `manually_occupied_seats`: seats unavailable because they are reserved outside the public booking system;
- `additional_seats`: optional extra seats made available during booking;
- `is_booking_enabled`: controls whether public booking is open;
- `created_at`: creation timestamp;
- `updated_at`: last update timestamp.
Relationships:
- each performance belongs to one show;
- each performance belongs to one venue;
- one performance can have many reservations.
Availability formula:
```text
available seats =
room capacity
+ additional seats
- manually occupied seats
- confirmed reservations
```
Rules:
- capacity values must not be negative;
- `manually_occupied_seats` must not exceed `room_capacity + additional_seats`;
- only confirmed reservations reduce public availability;
- pending reservations do not guarantee a seat until confirmation;
- final capacity validation must happen server-side when confirming a reservation;
- changes to capacity configuration must preserve existing confirmed reservations.
## Reservation
A reservation is a booking request for a specific performance.
Suggested fields:
- `id`: internal identifier;
- `performance`: required reference to `Performance`;
- `status`: explicit status such as `pending`, `confirmed`, `cancelled`, or `expired`;
- `name`: reservation contact name;
- `email`: reservation contact email;
- `phone`: optional reservation contact phone;
- `party_size`: number of requested seats;
- `notes`: optional visitor note;
- `confirmed_at`: timestamp set when the reservation is confirmed;
- `qr_code_generated_at`: timestamp set when the QR code is generated;
- `created_at`: creation timestamp;
- `updated_at`: last update timestamp.
Relationships:
- each reservation belongs to one performance;
- one reservation can have one or more reservation tokens for different purposes;
- one confirmed reservation can have at most one successful check-in.
Rules:
- a new reservation starts as `pending`;
- a reservation becomes `confirmed` only through a valid confirmation token;
- a confirmed reservation receives a QR code;
- `party_size` must be positive;
- the backend must reject confirmation if the requested seats would exceed availability;
- personal data must never be stored in QR codes.
## ReservationToken
A reservation token is an opaque token used for confirmation or QR verification.
Suggested fields:
- `id`: internal identifier;
- `reservation`: required reference to `Reservation`;
- `purpose`: token purpose, such as `confirmation` or `check_in`;
- `token_hash`: server-side hash of the opaque token;
- `expires_at`: optional expiration timestamp;
- `used_at`: timestamp set when a one-time token is consumed;
- `created_at`: creation timestamp.
Relationships:
- each token belongs to one reservation.
Rules:
- raw tokens must be random, non-guessable, and generated with a cryptographically secure generator;
- store only a hash of the token when practical;
- confirmation tokens should be single use;
- QR tokens may remain valid until the performance check-in window closes;
- tokens must not encode personal data.
## CheckIn
A check-in records entrance validation for a confirmed reservation.
Suggested fields:
- `id`: internal identifier;
- `reservation`: required unique reference to `Reservation`;
- `checked_in_at`: timestamp of successful check-in;
- `checked_in_by`: required authenticated staff user reference for successful check-in;
- `source`: optional source such as `qr_scan` or `manual`;
- `created_at`: creation timestamp;
- `updated_at`: last update timestamp.
Relationships:
- each check-in belongs to one reservation;
- a reservation can have at most one successful check-in.
Rules:
- check-in is performed only by authenticated staff or admin users;
- staff use a mobile-friendly web page to scan the QR code or enter the token manually;
- the QR code contains only an opaque verification token or URL;
- the backend validates the token server-side;
- only confirmed reservations can be checked in;
- a reservation cannot be checked in twice;
- successful check-in creates a `CheckIn` record, or updates an existing incomplete check-in record for the same reservation;
- successful check-in records must include `checked_in_at` and `checked_in_by`;
- failed check-in attempts should return a clear status without changing successful check-in state;
- check-in must not expose unnecessary personal data to scanning clients.
## Anti-Overbooking Rule
The backend must enforce capacity inside a transaction when confirming reservations.
Recommended approach:
- lock the relevant `Performance` row during confirmation;
- count confirmed seats for that performance;
- compare requested seats with available seats;
- confirm only if enough seats remain;
- otherwise leave the reservation pending or mark it as expired/rejected according to the future product decision.
+171
View File
@@ -0,0 +1,171 @@
# Security Notes
This document records security assumptions and controls for AzioneLab's initial architecture.
## Assumptions
- The public website is readable by anonymous visitors.
- Booking endpoints are public but must validate input strictly.
- Administration and check-in functions require authenticated staff users.
- HTTPS is expected in production.
- PostgreSQL is reachable only from trusted application containers.
- Email is sent through a configured SMTP provider or relay.
## Personal Data
Reservations contain personal data such as name, email address, optional phone number, and optional notes.
Controls:
- collect only data required to manage the reservation;
- do not expose reservation personal data through public APIs;
- do not include personal data in QR codes;
- keep check-in preview and confirmation responses limited to operational admission data;
- avoid logging request bodies from booking and confirmation endpoints;
- avoid logging raw tokens;
- restrict admin access to staff users who need it.
## Token Handling
Reservation tokens are used for email confirmation and QR verification.
Rules:
- generate tokens with a cryptographically secure random generator;
- make tokens opaque and non-guessable;
- do not encode personal data in tokens;
- store token hashes when practical;
- treat raw tokens as secrets;
- mark one-time confirmation tokens as used after successful confirmation;
- expire confirmation tokens after a reasonable period;
- keep QR tokens valid only for the intended performance and check-in period where practical.
## QR Codes
QR codes must contain only:
- an opaque verification token; or
- a verification URL containing an opaque token.
QR codes must not contain:
- name;
- email;
- phone;
- notes;
- reservation metadata that identifies the visitor.
The check-in endpoint resolves the token server-side and returns only the minimum information staff need.
## Check-In Security
Check-in is performed by authenticated staff or admin users through a mobile-friendly web page.
Controls:
- require staff authentication for QR verification preview and check-in confirmation;
- allow QR scanning and manual token entry in the staff interface;
- validate every token server-side;
- require the reservation to be confirmed before check-in;
- reject duplicate check-in attempts;
- store successful check-in timestamp and staff user;
- return clear validation states without exposing unnecessary personal data;
- do not log raw QR tokens.
## Authentication and Authorization
Required controls:
- Django admin requires authenticated staff accounts;
- check-in verification preview and confirmation require authenticated staff or admin users;
- staff permissions should separate content management from operational check-in when practical;
- public APIs must not allow clients to set protected fields such as reservation status, token values, or check-in state.
- CORS must allow only configured Angular frontend origins through `CORS_ALLOWED_ORIGINS`.
## Input Validation
Public booking endpoints must validate:
- required fields;
- email format;
- maximum field lengths;
- positive party size;
- booking status for the selected performance;
- capacity availability;
- unexpected fields.
Validation must happen server-side even when the frontend also validates input.
## Anti-Overbooking
The backend must enforce capacity server-side.
Capacity calculation:
```text
available seats =
room capacity
+ additional seats
- manually occupied seats
- confirmed reservations
```
Controls:
- use database transactions for confirmation;
- lock the performance row or use an equivalent consistency control;
- recalculate availability inside the transaction;
- confirm the reservation only when enough seats remain;
- ensure duplicate confirmation requests are idempotent or rejected safely;
- do not rely on frontend availability values.
## Secrets
Secrets must not be committed to the repository.
Expected secret configuration:
- Django `SECRET_KEY`;
- database password;
- SMTP credentials;
- TLS private keys or certificate automation credentials, if used.
Use environment variables, Docker secrets, or deployment-managed secret injection. Documentation and example configuration should use placeholders only.
For the Docker Compose setup, copy `.env.example` to `.env` and replace placeholder values outside version control. The repository ignores `.env` and `.env.*` files except `.env.example`.
## Deployment Security
Deployment should follow least privilege:
- expose only nginx publicly;
- keep backend and database on an internal Docker network;
- do not publish backend, frontend, or PostgreSQL ports to the host in production;
- avoid privileged containers;
- use explicit image tags rather than `latest`;
- persist PostgreSQL data in a named volume;
- configure TLS for production;
- serve static and media files without exposing private files.
## Logging
Logs should help diagnose operational issues without exposing sensitive data.
Do not log:
- raw confirmation tokens;
- raw QR tokens;
- full booking payloads;
- passwords;
- session cookies;
- authorization headers;
- SMTP credentials.
## Residual Risks
Initial residual risks:
- synchronous email can make booking responses depend on SMTP availability;
- QR codes can be copied, so duplicate check-in prevention must be reliable;
- staff account compromise would expose admin and check-in functionality;
- retention and deletion rules for personal data still need a project policy.
+9 -12
View File
@@ -1,16 +1,13 @@
# Security # Security
Describe security assumptions and controls. AzioneLab security assumptions and controls are documented in [security-notes.md](security-notes.md).
Include: The initial security model covers:
- authentication; - public website access;
- authorization; - authenticated administration and check-in;
- network exposure; - reservation privacy;
- TLS/certificates; - opaque token handling;
- secrets management; - QR code privacy;
- logging of sensitive data; - server-side capacity validation;
- container privileges; - deployment and logging assumptions.
- filesystem permissions;
- dependency management;
- relevant ADRs.
+7 -1
View File
@@ -7,7 +7,8 @@ All tests should run inside Docker containers.
## Canonical test command ## Canonical test command
```bash ```bash
CHANGE_ME docker compose --env-file .env.example -f infra/docker/compose.yml config
docker compose --env-file .env.example -f infra/docker/compose.yml run --rm --build backend python manage.py test
``` ```
## Test categories ## Test categories
@@ -21,3 +22,8 @@ Describe applicable categories:
- Ansible syntax checks; - Ansible syntax checks;
- Docker/Compose validation; - Docker/Compose validation;
- smoke tests. - smoke tests.
## Current coverage
- Docker Compose configuration validation;
- Django backend unit tests, including the initial health endpoint test.
+19
View File
@@ -0,0 +1,19 @@
FROM python:3.13.4-slim
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
WORKDIR /app
RUN useradd --create-home --shell /usr/sbin/nologin appuser
COPY requirements/backend.txt /app/requirements/backend.txt
RUN pip install --no-cache-dir -r /app/requirements/backend.txt
COPY backend/ /app/
USER appuser
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "azionelab.wsgi:application"]
+80
View File
@@ -0,0 +1,80 @@
services:
backend:
build:
context: ../..
dockerfile: infra/docker/backend/Dockerfile
image: azionelab-backend:local
environment:
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY}
DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS}
DJANGO_CSRF_TRUSTED_ORIGINS: ${DJANGO_CSRF_TRUSTED_ORIGINS}
DJANGO_DEBUG: ${DJANGO_DEBUG:-false}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
TIME_ZONE: ${TIME_ZONE:-Europe/Rome}
DATABASE_URL: ${DATABASE_URL}
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_HOST: ${POSTGRES_HOST:-postgres}
POSTGRES_PORT: ${POSTGRES_PORT:-5432}
expose:
- "${BACKEND_PORT:-8000}"
depends_on:
postgres:
condition: service_healthy
networks:
- internal
restart: unless-stopped
frontend:
build:
context: ./frontend
image: azionelab-frontend:local
expose:
- "${FRONTEND_PORT:-8080}"
networks:
- internal
restart: unless-stopped
postgres:
image: postgres:16.3-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""]
interval: 10s
timeout: 5s
retries: 5
networks:
- internal
restart: unless-stopped
nginx:
image: nginx:1.27.0-alpine
ports:
- "${NGINX_HTTP_PORT:-8080}:80"
environment:
BACKEND_HOST: ${BACKEND_HOST:-backend}
BACKEND_PORT: ${BACKEND_PORT:-8000}
FRONTEND_HOST: ${FRONTEND_HOST:-frontend}
FRONTEND_PORT: ${FRONTEND_PORT:-8080}
NGINX_ENVSUBST_FILTER: "^(BACKEND_HOST|BACKEND_PORT|FRONTEND_HOST|FRONTEND_PORT)$"
volumes:
- ./nginx/templates:/etc/nginx/templates:ro
depends_on:
- backend
- frontend
networks:
- internal
restart: unless-stopped
volumes:
postgres_data:
networks:
internal:
driver: bridge
+6
View File
@@ -0,0 +1,6 @@
FROM nginx:1.27.0-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY html/ /usr/share/nginx/html/
EXPOSE 8080
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AzioneLab</title>
</head>
<body>
<main>
<h1>AzioneLab frontend placeholder</h1>
<p>The Angular application build will replace this page.</p>
</main>
</body>
</html>
+11
View File
@@ -0,0 +1,11 @@
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}
@@ -0,0 +1,50 @@
upstream azionelab_backend {
server ${BACKEND_HOST}:${BACKEND_PORT};
}
upstream azionelab_frontend {
server ${FRONTEND_HOST}:${FRONTEND_PORT};
}
server {
listen 80;
server_name _;
client_max_body_size 10m;
location /api/ {
proxy_pass http://azionelab_backend;
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 $scheme;
}
location /admin/ {
proxy_pass http://azionelab_backend;
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 $scheme;
}
location /static/ {
proxy_pass http://azionelab_backend;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /media/ {
proxy_pass http://azionelab_backend;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
proxy_pass http://azionelab_frontend;
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 $scheme;
}
}
+6
View File
@@ -0,0 +1,6 @@
Django==5.2.3
djangorestframework==3.16.0
django-cors-headers==4.7.0
dj-database-url==2.3.0
gunicorn==23.0.0
psycopg[binary]==3.2.9