diff --git a/.codex/project.md b/.codex/project.md index 4aca4ee..ee16d0c 100644 --- a/.codex/project.md +++ b/.codex/project.md @@ -78,6 +78,7 @@ Configure the canonical test command for this repository: ```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 ``` Examples: diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..bb9865b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.git +.env +.env.* +!.env.example +__pycache__/ +*.py[cod] +.pytest_cache/ +.ruff_cache/ diff --git a/.env.example b/.env.example index 0ff479d..4fadffe 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,8 @@ 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 diff --git a/backend/azionelab/__init__.py b/backend/azionelab/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/azionelab/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/azionelab/asgi.py b/backend/azionelab/asgi.py new file mode 100644 index 0000000..2cc60a1 --- /dev/null +++ b/backend/azionelab/asgi.py @@ -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() diff --git a/backend/azionelab/settings.py b/backend/azionelab/settings.py new file mode 100644 index 0000000..cf89f8f --- /dev/null +++ b/backend/azionelab/settings.py @@ -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", + ], +} diff --git a/backend/azionelab/tests.py b/backend/azionelab/tests.py new file mode 100644 index 0000000..506096b --- /dev/null +++ b/backend/azionelab/tests.py @@ -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"}) diff --git a/backend/azionelab/urls.py b/backend/azionelab/urls.py new file mode 100644 index 0000000..46dde6e --- /dev/null +++ b/backend/azionelab/urls.py @@ -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"), +] diff --git a/backend/azionelab/wsgi.py b/backend/azionelab/wsgi.py new file mode 100644 index 0000000..44ae889 --- /dev/null +++ b/backend/azionelab/wsgi.py @@ -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() diff --git a/backend/bookings/__init__.py b/backend/bookings/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/bookings/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/bookings/apps.py b/backend/bookings/apps.py new file mode 100644 index 0000000..dda2c97 --- /dev/null +++ b/backend/bookings/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class BookingsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "bookings" diff --git a/backend/bookings/models.py b/backend/bookings/models.py new file mode 100644 index 0000000..53b6a17 --- /dev/null +++ b/backend/bookings/models.py @@ -0,0 +1 @@ +# Domain models will be added when booking behavior is implemented. diff --git a/backend/checkins/__init__.py b/backend/checkins/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/checkins/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/checkins/apps.py b/backend/checkins/apps.py new file mode 100644 index 0000000..fbded07 --- /dev/null +++ b/backend/checkins/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CheckinsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "checkins" diff --git a/backend/checkins/models.py b/backend/checkins/models.py new file mode 100644 index 0000000..d433f56 --- /dev/null +++ b/backend/checkins/models.py @@ -0,0 +1 @@ +# Domain models will be added when check-in behavior is implemented. diff --git a/backend/manage.py b/backend/manage.py new file mode 100644 index 0000000..75aebf7 --- /dev/null +++ b/backend/manage.py @@ -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() diff --git a/backend/shows/__init__.py b/backend/shows/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/shows/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/shows/apps.py b/backend/shows/apps.py new file mode 100644 index 0000000..07ab785 --- /dev/null +++ b/backend/shows/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ShowsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "shows" diff --git a/backend/shows/models.py b/backend/shows/models.py new file mode 100644 index 0000000..193bdae --- /dev/null +++ b/backend/shows/models.py @@ -0,0 +1 @@ +# Domain models will be added when show management is implemented. diff --git a/docs/architecture.md b/docs/architecture.md index 30c0ada..e98d428 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -25,6 +25,8 @@ The frontend must not calculate authoritative availability. It may display avail 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; diff --git a/docs/deployment.md b/docs/deployment.md index 54f82c1..0fecfb0 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -55,7 +55,7 @@ Responsibilities: 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. -At the infrastructure placeholder stage, the `backend` service runs gunicorn against a minimal placeholder WSGI application. The real Django application will replace it 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 @@ -97,6 +97,8 @@ 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. @@ -147,14 +149,16 @@ 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 infrastructure stage is: +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 diff --git a/docs/security-notes.md b/docs/security-notes.md index 172a00e..d9a66c8 100644 --- a/docs/security-notes.md +++ b/docs/security-notes.md @@ -80,6 +80,7 @@ Required controls: - 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 diff --git a/docs/testing.md b/docs/testing.md index f23e36d..16fe067 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -8,6 +8,7 @@ All tests should run inside Docker containers. ```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 ``` ## Test categories @@ -21,3 +22,8 @@ Describe applicable categories: - Ansible syntax checks; - Docker/Compose validation; - smoke tests. + +## Current coverage + +- Docker Compose configuration validation; +- Django backend unit tests, including the initial health endpoint test. diff --git a/infra/docker/backend/Dockerfile b/infra/docker/backend/Dockerfile index 763831f..c506056 100644 --- a/infra/docker/backend/Dockerfile +++ b/infra/docker/backend/Dockerfile @@ -5,18 +5,17 @@ ENV PYTHONUNBUFFERED=1 WORKDIR /app -RUN pip install --no-cache-dir \ - "Django==5.2.3" \ - "djangorestframework==3.16.0" \ - "gunicorn==23.0.0" \ - "psycopg[binary]==3.2.9" - RUN useradd --create-home --shell /usr/sbin/nologin appuser -COPY placeholder_wsgi.py /app/placeholder_wsgi.py +COPY requirements/backend.txt /app/requirements/backend.txt +RUN pip install --no-cache-dir -r /app/requirements/backend.txt + +COPY backend/ /app/backend/ + +WORKDIR /app/backend USER appuser EXPOSE 8000 -CMD ["gunicorn", "--bind", "0.0.0.0:8000", "placeholder_wsgi:application"] +CMD ["gunicorn", "--bind", "0.0.0.0:8000", "azionelab.wsgi:application"] diff --git a/infra/docker/backend/placeholder_wsgi.py b/infra/docker/backend/placeholder_wsgi.py deleted file mode 100644 index ac8f182..0000000 --- a/infra/docker/backend/placeholder_wsgi.py +++ /dev/null @@ -1,10 +0,0 @@ -def application(environ, start_response): - body = b"AzioneLab backend placeholder\n" - start_response( - "503 Service Unavailable", - [ - ("Content-Type", "text/plain; charset=utf-8"), - ("Content-Length", str(len(body))), - ], - ) - return [body] diff --git a/infra/docker/compose.yml b/infra/docker/compose.yml index 87e31a5..390d10c 100644 --- a/infra/docker/compose.yml +++ b/infra/docker/compose.yml @@ -1,13 +1,16 @@ services: backend: build: - context: ./backend + 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} diff --git a/requirements/backend.txt b/requirements/backend.txt new file mode 100644 index 0000000..2c08bbf --- /dev/null +++ b/requirements/backend.txt @@ -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