From f8b058468739dbb34531ff4a2dd3dc9879f14664 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ebbe=20Ba=C3=9F?= Date: Thu, 15 Oct 2026 13:44:07 +0200 Subject: [PATCH] feat: add Django project scaffold, configuration and container Environment-variable configuration that fails fast, database-only health check, JSON request logs with a request id, security headers with a strict CSP, and a multi-stage non-root Docker image with compose file. Ticket 1.1. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Sk72AxLwQnYttF6VFSxQLt --- .dockerignore | 18 ++ .env.example | 24 ++ .gitattributes | 2 + .gitignore | 13 ++ .python-version | 1 + Dockerfile | 30 +++ README.md | 44 +++- ...affold-configuration-and-container-plan.md | 117 ++++++++++ compose.yaml | 21 ++ docker/entrypoint.sh | 12 + docker/healthcheck.py | 16 ++ manage.py | 16 ++ portal/__init__.py | 0 portal/adapters/__init__.py | 0 portal/config/__init__.py | 0 portal/config/env.py | 118 ++++++++++ portal/config/health.py | 23 ++ portal/config/logging.py | 49 ++++ portal/config/middleware.py | 70 ++++++ portal/config/settings.py | 109 +++++++++ portal/config/urls.py | 7 + portal/config/wsgi.py | 7 + portal/static/.gitkeep | 0 portal/tests/__init__.py | 0 portal/tests/conftest.py | 10 + portal/tests/test_config.py | 72 ++++++ portal/tests/test_headers.py | 39 ++++ portal/tests/test_health.py | 20 ++ portal/tests/test_logging.py | 38 +++ pyproject.toml | 43 ++++ uv.lock | 217 ++++++++++++++++++ 31 files changed, 1134 insertions(+), 2 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitattributes create mode 100644 .python-version create mode 100644 Dockerfile create mode 100644 _bmad-output/initiative-ping-mee-portal/epic-platform-and-sign-in/story-project-scaffold-configuration-and-container-plan.md create mode 100644 compose.yaml create mode 100644 docker/entrypoint.sh create mode 100644 docker/healthcheck.py create mode 100644 manage.py create mode 100644 portal/__init__.py create mode 100644 portal/adapters/__init__.py create mode 100644 portal/config/__init__.py create mode 100644 portal/config/env.py create mode 100644 portal/config/health.py create mode 100644 portal/config/logging.py create mode 100644 portal/config/middleware.py create mode 100644 portal/config/settings.py create mode 100644 portal/config/urls.py create mode 100644 portal/config/wsgi.py create mode 100644 portal/static/.gitkeep create mode 100644 portal/tests/__init__.py create mode 100644 portal/tests/conftest.py create mode 100644 portal/tests/test_config.py create mode 100644 portal/tests/test_headers.py create mode 100644 portal/tests/test_health.py create mode 100644 portal/tests/test_logging.py create mode 100644 pyproject.toml create mode 100644 uv.lock diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..cd33877 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +.git +.gitignore +.venv +__pycache__ +*.pyc +.pytest_cache +.ruff_cache +.env +.env.* +!.env.example +_bmad +_bmad-output +.agents +.vscode +context-resources +staticfiles +*.sqlite3* +skills-lock.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..674adbf --- /dev/null +++ b/.env.example @@ -0,0 +1,24 @@ +# Copy to .env and edit. Never commit .env. + +# Required. At least 32 random characters. +# Generate: python -c "import secrets; print(secrets.token_urlsafe(64))" +SECRET_KEY= + +# Required. Comma-separated host names the portal answers to. +ALLOWED_HOSTS=portal.example.test + +# Optional. Directory for the database. Default /data (the container volume). +# DATA_DIR=/data + +# Optional. true or false. Default false. Never true in production. +# DEBUG=false + +# Optional. Comma-separated IP addresses or networks of reverse proxies. Default empty. +# Reserved for the client-IP rule in a later entry; currently has no effect. +# TRUSTED_PROXIES= + +# Optional. DEBUG, INFO, WARNING, ERROR or CRITICAL. Default INFO. +# LOG_LEVEL=INFO + +# Optional. IANA time zone used for display. Default Europe/Berlin. +# DEFAULT_TIMEZONE=Europe/Berlin diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f3f6bec --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +*.sh text eol=lf +Dockerfile text eol=lf diff --git a/.gitignore b/.gitignore index a6b1e16..3d07d98 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,16 @@ # BMAD generated render cache _bmad/render/ + +# Python / project +.venv/ +__pycache__/ +*.pyc +.pytest_cache/ +.ruff_cache/ +staticfiles/ +*.sqlite3 +*.sqlite3-* +.env +.env.* +!.env.example diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0f258e5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +# syntax=docker/dockerfile:1 + +FROM python:3.13-slim AS builder +COPY --from=ghcr.io/astral-sh/uv:0.13.1 /uv /usr/local/bin/uv +ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy UV_PYTHON_DOWNLOADS=never +WORKDIR /app +COPY pyproject.toml uv.lock .python-version README.md ./ +RUN uv sync --frozen --no-dev --no-install-project +COPY manage.py ./ +COPY portal ./portal +# Build-time only values so collectstatic can load settings; not used at runtime. +RUN SECRET_KEY=build-time-only-value-not-used-at-runtime-000000 \ + ALLOWED_HOSTS=localhost DATA_DIR=/tmp \ + /app/.venv/bin/python manage.py collectstatic --noinput + +FROM python:3.13-slim +ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 PATH="/app/.venv/bin:$PATH" +RUN useradd --system --uid 10001 --no-create-home --shell /usr/sbin/nologin app \ + && mkdir /data /backups \ + && chown app:app /data /backups +WORKDIR /app +COPY --from=builder /app /app +COPY --chmod=0755 docker ./docker +ENV DATA_DIR=/data +USER app +VOLUME ["/data", "/backups"] +EXPOSE 8000 +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD ["python", "/app/docker/healthcheck.py"] +ENTRYPOINT ["/app/docker/entrypoint.sh"] diff --git a/README.md b/README.md index 2e5415f..a9c76d3 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,43 @@ -# ping-mee-self-service-platform +# Self-Service Portal -The ping-mee self-servicing platform for all users that use the ping-mee network and its resources \ No newline at end of file +A Django 5.2 LTS container for a user self-service portal. This repository holds the project scaffold: configuration, health check, logging, security headers and the container definition. + +## Configuration + +All configuration comes from environment variables, validated at startup. If any variable is missing or invalid, the process prints one message that lists every problem (never the values) and exits with a non-zero code. Copy `.env.example` to `.env` and edit it. + +| Variable | Required | Default | Meaning | +| --- | --- | --- | --- | +| `SECRET_KEY` | yes | | Django secret, at least 32 characters | +| `ALLOWED_HOSTS` | yes | | Comma-separated host names | +| `DATA_DIR` | no | `/data` | Existing directory for the SQLite database | +| `DEBUG` | no | `false` | `true` or `false` | +| `TRUSTED_PROXIES` | no | empty | Comma-separated IP addresses or networks. Reserved for the client-IP rule in a later entry; currently has no effect | +| `LOG_LEVEL` | no | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL` | +| `DEFAULT_TIMEZONE` | no | `Europe/Berlin` | IANA zone used for display; storage is UTC | + +## Run in a container + +The image is built and run on a Linux host from a checkout of this repository. + +```sh +cp .env.example .env # then set SECRET_KEY and ALLOWED_HOSTS +docker compose up --build +curl -H "Host: " http://localhost:8000/healthz # {"status":"ok"} +``` + +The container runs as a non-root user with a read-only root file system. Only the `/data` and `/backups` volumes are writable. It starts one gunicorn worker with threads after applying migrations. Session and CSRF cookies are marked secure, so use the portal through an HTTPS reverse proxy that sets `X-Forwarded-Proto`. + +`/healthz` checks the database only. It returns 200 `{"status":"ok"}` or 503 `{"status":"unavailable"}`. + +## Develop + +Requires Python 3.13 and [uv](https://docs.astral.sh/uv/). + +```sh +uv sync +uv run pytest +uv run ruff check . +``` + +`pytest` sets its own test environment (see `pyproject.toml`). diff --git a/_bmad-output/initiative-ping-mee-portal/epic-platform-and-sign-in/story-project-scaffold-configuration-and-container-plan.md b/_bmad-output/initiative-ping-mee-portal/epic-platform-and-sign-in/story-project-scaffold-configuration-and-container-plan.md new file mode 100644 index 0000000..b463cb0 --- /dev/null +++ b/_bmad-output/initiative-ping-mee-portal/epic-platform-and-sign-in/story-project-scaffold-configuration-and-container-plan.md @@ -0,0 +1,117 @@ +--- +title: 'Project scaffold, configuration and container' +type: 'feature' +ticket: '1' +created: '2026-10-15' +status: 'built' +baseline_revision: '24de5878ed7f8d297e10aa3cf1e4f772fa5b0fa0' +route: 'full' +route_source: 'auto' +risk: 'medium' +review: 'quick' +review_source: 'pinned' +lenses_ran: ['quick'] +review_loop_iteration: 0 +context: + - '{project-root}/_bmad-output/architecture/architecture-ping-mee-self-service-platform-2026-10-15/ARCHITECTURE-SPINE.md' +--- + + + +## Intent + +**Problem:** The repository holds only planning documents. Every later story needs a runnable, secure and configurable Django container to build on. + +**Approach:** Create the Django 5.2 LTS project with environment-variable configuration that fails fast, a database-only health check, JSON logs with a request id, a strict security-header layer, a multi-stage Docker image with compose file, and a test setup. + +## Boundaries & Constraints + +**Always:** All configuration comes from environment variables, validated at startup; one error message lists every invalid or missing variable and the process exits non-zero. Nothing in the repository names ping-mee, a domain, an OU, a group, an IP or a path. Secrets never appear in logs. One gunicorn worker with threads. The container runs as a non-root user; only `/data` and `/backups` are writable. `/healthz` checks the database only. Python 3.13, Django 5.2 LTS. Spine rules AD-13 and AD-18 apply. Decision: the image is built and run on the Linux host from a checkout of this repository (Docker is not installed on the development machine); no registry or pipeline in this story. + +**Never:** No AD, OIDC, mail, share or feature-app code (later entries). No python-ldap dependency yet. No inline script or external asset anywhere. No Django admin. No commit of secrets, and no real `.env`. + +## I/O & Edge-Case Matrix + +| Scenario | Input / State | Expected Output / Behavior | Error Handling | +|----------|--------------|---------------------------|----------------| +| Valid start | All required variables valid | Migrations run, server answers | No error expected | +| Invalid config | A required variable missing or malformed | Process exits non-zero, message names every bad variable | No traceback, no secret value printed | +| Health ok | GET /healthz, database reachable | 200 `{"status":"ok"}` | No error expected | +| Health down | Database unreachable | 503 `{"status":"unavailable"}` | Logged without secrets | +| Headers | Any response | CSP, nosniff, frame deny, referrer policy, `X-Request-ID` present | No error expected | +| Request log | Any request | One JSON log line with request id, method, path, status | Query strings and bodies not logged | + + + +## Code Map + +Greenfield: only planning documents exist. New files, using the spine's source tree. + +- `pyproject.toml`, `uv.lock`, `.python-version` -- dependencies (Django, gunicorn, whitenoise; dev: pytest, pytest-django, ruff) +- `manage.py`, `portal/config/` -- `settings.py`, `env.py` (typed parsing, collects all errors), `urls.py`, `wsgi.py`, `logging.py` (JSON formatter), `middleware.py` (request id, security headers), `health.py` +- `portal/adapters/__init__.py` -- empty package for later adapters and fakes +- `portal/tests/` -- `conftest.py`, config, health, header and logging tests +- `Dockerfile`, `compose.yaml`, `.dockerignore`, `docker/entrypoint.sh` -- multi-stage image, non-root, migrate then gunicorn +- `.env.example`, `README.md` -- documented variables and run steps +- Reuse: spine AD-13, AD-18. Do not change: anything under `_bmad/`, `_bmad-output/`, `.agents/`, `context-resources/`. + +## Tasks & Acceptance + +**Execution:** +- [ ] `pyproject.toml`, `.python-version` -- project metadata and pinned dependencies -- repeatable installs +- [ ] `portal/config/env.py` -- typed variables (`SECRET_KEY`, `ALLOWED_HOSTS`, `DATA_DIR`, `DEBUG`, `TRUSTED_PROXIES`, `LOG_LEVEL`, `DEFAULT_TIMEZONE`) with defaults only for safe values -- fail fast +- [ ] `portal/config/settings.py`, `urls.py`, `wsgi.py` -- SQLite in WAL mode under `DATA_DIR`, secure cookies, CSRF, whitenoise, UTC storage, translation on -- spine baseline +- [ ] `portal/config/middleware.py`, `logging.py`, `health.py` -- request id, headers, JSON logs, `/healthz` +- [ ] `Dockerfile`, `docker/entrypoint.sh`, `compose.yaml`, `.dockerignore` -- image and run definition +- [ ] `portal/tests/` -- unit-test the matrix rows +- [ ] `.env.example`, `README.md` -- document every variable and the run steps + +**Acceptance Criteria:** +- Given valid variables, when the app starts, then migrations apply and `/healthz` returns 200. +- Given a missing `SECRET_KEY` and a malformed `LOG_LEVEL`, when the app starts, then it exits non-zero naming both. +- Given the database file is unreadable, when `/healthz` is called, then it returns 503. +- Given any request, when the response is sent, then the CSP forbids inline scripts and external sources and the log line has the request id. +- Given the repository, when searched, then it contains no ping-mee name, domain, IP or path. + +## Implementation Notes + +- Implemented by a coding subagent from this plan; the diff was read and `uv run pytest` (12 passed, none skipped) and `uv run ruff check .` were re-run by the orchestrator. +- Beyond the plan: `docker/healthcheck.py` (the container probe needs a Host header that matches `ALLOWED_HOSTS`, otherwise it gets a 400); `.gitattributes` (keeps LF in `.sh` and `Dockerfile` on Windows checkouts); extra headers (Permissions-Policy, COOP); HSTS of one year when `DEBUG=false`, which needs the proxy to send `X-Forwarded-Proto`. +- `README.md` was rewritten and no longer carries the original project name line. +- Ruff excludes `_bmad`, `_bmad-output`, `.agents` and `context-resources`. +- The 503 criterion is tested with a mocked unreachable database, not an unreadable file. +- Not verified: the Docker build, the read-only root file system with gunicorn's worker temp dir, the `uv` image tag and the container health check. They need `docker compose up --build` on the Linux host. +- `compose.yaml` publishes port 8000 on all host interfaces. Bind it to a specific address if the reverse proxy runs on the same host. +- Resolved versions in `uv.lock`: Django 5.2.18, gunicorn 26.2.0, whitenoise 6.12.0. + +## Plan Change Log + +## Review Triage Log + +Pass 1 (quick lens). Counts: high 1, medium 3, low 3, false 1, maybe-false 0. + +| # | Finding | Verdict | Route | Evidence | +|---|---------|---------|-------|----------| +| 1 | `docker/entrypoint.sh` not executable | high | patch | Added as mode 100644 and `COPY docker ./docker` has no chmod, so `ENTRYPOINT` on the Linux host fails with permission denied; "Valid start" would not hold. | +| 2 | Security headers missing on responses from outer middleware | medium | patch | `SecurityHeadersMiddleware` is innermost; a bad Host 400 from `CommonMiddleware`, whitenoise static files and `SecurityMiddleware` redirects never pass through it. The AC says any request. | +| 3 | Query string and `request` repr in Django's own error log lines, and a second line per 4xx | high | patch | Reproduced: a 404 for `/missing?token=supersecret` writes a `django.request` line containing ``. Breaks "query strings not logged" and "one line per request". | +| 4 | `TRUSTED_PROXIES` parsed but unused; `SECURE_PROXY_SSL_HEADER` trusts any client | medium | patch | Nothing reads it until the sign-in entry (client IP rule); docs say it restricts proxies. The fix here is wording; wiring is entry 5. The proxy-header trust has no demonstrated harm yet (low, rejected). | +| 5 | `/tmp` tmpfs is a third writable location | low | rejected | Ephemeral and needed for gunicorn workers; the rule's intent (persistent writable paths) holds; changing it adds complexity. | +| 6 | Gunicorn error log is plain text on stderr | low | rejected | Server start and error lines only; app logs are JSON. A JSON gunicorn log config is more than a direct correction. | +| 7 | "No ping-mee name" not satisfiable because of the plan file | false | rejected | The criterion covers code and image; `.dockerignore` excludes `_bmad-output` and a search outside protected folders found nothing. | +| 8 | 503 criterion tested with a mock, not an unreadable file | low | rejected | Disclosed in Implementation Notes; the view returns 503 on any exception from `cursor()`, so a real failure cannot return 200. A real-file test needs database wrapper plumbing. | + +## Design Notes + +CSP is a constant in `middleware.py` (`default-src 'self'`, `script-src 'self'`, `frame-ancestors 'none'`) so story 6 can adjust the style rules for the Fluent components without weakening script rules. Dependencies were checked on the web: Django 5.2 supports Python 3.10 to 3.14 and is LTS to April 2028; gunicorn 26.x and whitenoise 6.12 support it. Python 3.13 is pinned because this machine's default 3.15 is not supported by Django 5.2. + +## Verification + +**Commands:** +- `uv run pytest` -- expected: all tests pass +- `uv run ruff check .` -- expected: no findings +- `uv run python manage.py check --deploy` with a valid test environment -- expected: no errors +- `uv run python manage.py check` with missing `SECRET_KEY` -- expected: exit non-zero listing the variable + +**Manual checks (if no CLI):** +- Docker is not installed on this machine. On the Linux host: `docker compose up --build`, then `curl http://localhost:8000/healthz` returns `{"status":"ok"}`, and starting with an invalid variable shows the error and exits. diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..9edbee2 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,21 @@ +services: + portal: + build: . + env_file: .env + ports: + - "8000:8000" + volumes: + - data:/data + - backups:/backups + read_only: true + tmpfs: + - /tmp + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + restart: unless-stopped + +volumes: + data: + backups: diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..54553ab --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,12 @@ +#!/bin/sh +set -eu + +python manage.py migrate --noinput + +exec gunicorn portal.config.wsgi:application \ + --bind 0.0.0.0:8000 \ + --workers 1 \ + --threads 4 \ + --preload \ + --worker-tmp-dir /tmp \ + --error-logfile - diff --git a/docker/healthcheck.py b/docker/healthcheck.py new file mode 100644 index 0000000..0637bdb --- /dev/null +++ b/docker/healthcheck.py @@ -0,0 +1,16 @@ +"""Container health probe: GET /healthz with a host name the app accepts.""" + +import os +import sys +import urllib.request + +hosts = [h.strip() for h in os.environ.get("ALLOWED_HOSTS", "").split(",") if h.strip()] +host = hosts[0].lstrip(".") if hosts and hosts[0] != "*" else "localhost" +request = urllib.request.Request( # noqa: S310 + "http://127.0.0.1:8000/healthz", headers={"Host": host} +) +try: + with urllib.request.urlopen(request, timeout=3) as response: # noqa: S310 + sys.exit(0 if response.status == 200 else 1) +except Exception: # noqa: BLE001 + sys.exit(1) diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..1d22354 --- /dev/null +++ b/manage.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python +"""Django command-line entry point.""" + +import os +import sys + + +def main() -> None: + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "portal.config.settings") + from django.core.management import execute_from_command_line + + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/portal/__init__.py b/portal/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/portal/adapters/__init__.py b/portal/adapters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/portal/config/__init__.py b/portal/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/portal/config/env.py b/portal/config/env.py new file mode 100644 index 0000000..d9de5d9 --- /dev/null +++ b/portal/config/env.py @@ -0,0 +1,118 @@ +"""Typed environment configuration that reports every problem at once (AD-13).""" + +from __future__ import annotations + +import ipaddress +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL") +MIN_SECRET_KEY_LENGTH = 32 +_TRUE = {"1", "true", "yes", "on"} +_FALSE = {"0", "false", "no", "off"} + + +class ConfigError(Exception): + """Raised with one message that lists every invalid or missing variable. + + Messages name variables and the rule they break, never their values. + """ + + def __init__(self, problems: list[str]) -> None: + self.problems = problems + super().__init__(self.render(problems)) + + @staticmethod + def render(problems: list[str]) -> str: + lines = ["Invalid configuration. Fix these environment variables:"] + lines.extend(f" - {p}" for p in problems) + return "\n".join(lines) + + +@dataclass(frozen=True) +class Config: + secret_key: str + allowed_hosts: list[str] + data_dir: Path + debug: bool + trusted_proxies: list[str] + log_level: str + default_timezone: str + + +def load_config(environ: Mapping[str, str]) -> Config: + problems: list[str] = [] + + def raw(name: str) -> str | None: + value = environ.get(name) + if value is None or value.strip() == "": + return None + return value.strip() + + secret_key = raw("SECRET_KEY") + if secret_key is None: + problems.append("SECRET_KEY: required") + elif len(secret_key) < MIN_SECRET_KEY_LENGTH: + problems.append(f"SECRET_KEY: must be at least {MIN_SECRET_KEY_LENGTH} characters") + + hosts_raw = raw("ALLOWED_HOSTS") + allowed_hosts: list[str] = [] + if hosts_raw is None: + problems.append("ALLOWED_HOSTS: required, comma-separated host names") + else: + allowed_hosts = [h.strip() for h in hosts_raw.split(",") if h.strip()] + if not allowed_hosts: + problems.append("ALLOWED_HOSTS: must contain at least one host name") + + data_dir = Path(raw("DATA_DIR") or "/data") + if not data_dir.is_dir(): + problems.append("DATA_DIR: must be an existing directory") + else: + data_dir = data_dir.resolve() + + debug = False + debug_raw = raw("DEBUG") + if debug_raw is not None: + if debug_raw.lower() in _TRUE: + debug = True + elif debug_raw.lower() not in _FALSE: + problems.append("DEBUG: must be true or false") + + proxies: list[str] = [] + proxies_raw = raw("TRUSTED_PROXIES") + if proxies_raw is not None: + for item in proxies_raw.split(","): + item = item.strip() + if not item: + continue + try: + ipaddress.ip_network(item, strict=False) + except ValueError: + problems.append("TRUSTED_PROXIES: must be comma-separated IP addresses or networks") + break + proxies.append(item) + + log_level = (raw("LOG_LEVEL") or "INFO").upper() + if log_level not in LOG_LEVELS: + problems.append(f"LOG_LEVEL: must be one of {', '.join(LOG_LEVELS)}") + + timezone = raw("DEFAULT_TIMEZONE") or "Europe/Berlin" + try: + ZoneInfo(timezone) + except (ZoneInfoNotFoundError, ValueError, OSError): + problems.append("DEFAULT_TIMEZONE: must be a valid IANA time zone name") + + if problems: + raise ConfigError(problems) + + return Config( + secret_key=secret_key or "", + allowed_hosts=allowed_hosts, + data_dir=data_dir, + debug=debug, + trusted_proxies=proxies, + log_level=log_level, + default_timezone=timezone, + ) diff --git a/portal/config/health.py b/portal/config/health.py new file mode 100644 index 0000000..008f2a6 --- /dev/null +++ b/portal/config/health.py @@ -0,0 +1,23 @@ +"""Liveness check. Database only; never AD, SMTP or the share (AD-18).""" + +import logging + +from django.db import connection +from django.http import JsonResponse +from django.views.decorators.cache import never_cache +from django.views.decorators.http import require_GET + +logger = logging.getLogger(__name__) + + +@require_GET +@never_cache +def healthz(request): + try: + with connection.cursor() as cursor: + cursor.execute("SELECT 1") + cursor.fetchone() + except Exception as exc: # noqa: BLE001 - any failure means unavailable + logger.error("health check failed", extra={"error_type": type(exc).__name__}) + return JsonResponse({"status": "unavailable"}, status=503) + return JsonResponse({"status": "ok"}) diff --git a/portal/config/logging.py b/portal/config/logging.py new file mode 100644 index 0000000..4543840 --- /dev/null +++ b/portal/config/logging.py @@ -0,0 +1,49 @@ +"""JSON logs on stdout with a request id (AD-18).""" + +from __future__ import annotations + +import json +import logging +from contextvars import ContextVar +from datetime import UTC, datetime + +request_id_var: ContextVar[str] = ContextVar("request_id", default="-") + +# Attributes set by logging itself; everything else passed via `extra` is emitted. +_STANDARD = set(logging.LogRecord("", 0, "", 0, "", (), None).__dict__) | {"message", "asctime"} + + +class JsonFormatter(logging.Formatter): + def format(self, record: logging.LogRecord) -> str: + payload: dict[str, object] = { + "ts": datetime.fromtimestamp(record.created, UTC).isoformat(timespec="milliseconds"), + "level": record.levelname, + "logger": record.name, + "message": record.getMessage(), + "request_id": getattr(record, "request_id", None) or request_id_var.get(), + } + for key, value in record.__dict__.items(): + if key in _STANDARD or key in ("request_id", "request"): + continue + if value is None or isinstance(value, str | int | float | bool): + payload[key] = value + if record.exc_info: + payload["exception"] = self.formatException(record.exc_info) + return json.dumps(payload, default=str) + + +def build_logging(level: str) -> dict: + return { + "version": 1, + "disable_existing_loggers": False, + "formatters": {"json": {"()": "portal.config.logging.JsonFormatter"}}, + "handlers": { + "stdout": { + "class": "logging.StreamHandler", + "stream": "ext://sys.stdout", + "formatter": "json", + } + }, + "loggers": {"django.request": {"level": "ERROR"}}, + "root": {"handlers": ["stdout"], "level": level}, + } diff --git a/portal/config/middleware.py b/portal/config/middleware.py new file mode 100644 index 0000000..9f0de87 --- /dev/null +++ b/portal/config/middleware.py @@ -0,0 +1,70 @@ +"""Request id, request logging and security headers (AD-16, AD-18).""" + +from __future__ import annotations + +import logging +import re +import time +import uuid + +from .logging import request_id_var + +logger = logging.getLogger("portal.request") + +_VALID_REQUEST_ID = re.compile(r"^[A-Za-z0-9._-]{8,64}$") + +CSP = ( + "default-src 'self'; " + "script-src 'self'; " + "frame-ancestors 'none'; " + "base-uri 'self'; " + "form-action 'self'" +) +PERMISSIONS_POLICY = "camera=(), microphone=(), geolocation=(), payment=()" + + +class RequestIdMiddleware: + """Assign a request id, set the response header and write one log line.""" + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + incoming = request.META.get("HTTP_X_REQUEST_ID", "") + request_id = incoming if _VALID_REQUEST_ID.match(incoming) else uuid.uuid4().hex + request.request_id = request_id + token = request_id_var.set(request_id) + started = time.monotonic() + status = 500 + try: + response = self.get_response(request) + status = response.status_code + response["X-Request-ID"] = request_id + return response + finally: + # Path only: query strings and bodies are never logged. + logger.info( + "request", + extra={ + "request_id": request_id, + "method": request.method, + "path": request.path, + "status": status, + "duration_ms": round((time.monotonic() - started) * 1000, 1), + }, + ) + request_id_var.reset(token) + + +class SecurityHeadersMiddleware: + """Add the Content-Security-Policy and related headers to every response.""" + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + response = self.get_response(request) + response.headers.setdefault("Content-Security-Policy", CSP) + response.headers.setdefault("Permissions-Policy", PERMISSIONS_POLICY) + response.headers.setdefault("Cross-Origin-Opener-Policy", "same-origin") + return response diff --git a/portal/config/settings.py b/portal/config/settings.py new file mode 100644 index 0000000..74800ff --- /dev/null +++ b/portal/config/settings.py @@ -0,0 +1,109 @@ +"""Django settings. All deployment values come from environment variables (AD-13).""" + +import os +import sys +from pathlib import Path + +from .env import ConfigError, load_config +from .logging import build_logging + +try: + _config = load_config(os.environ) +except ConfigError as exc: + # One clear message, no traceback, no values. + print(str(exc), file=sys.stderr) + sys.exit(1) + +BASE_DIR = Path(__file__).resolve().parent.parent.parent + +SECRET_KEY = _config.secret_key +DEBUG = _config.debug +ALLOWED_HOSTS = _config.allowed_hosts +# Reserved for the client-IP rule in a later entry; currently has no effect. +TRUSTED_PROXIES = _config.trusted_proxies +DATA_DIR = _config.data_dir +DISPLAY_TIME_ZONE = _config.default_timezone + +INSTALLED_APPS = [ + "django.contrib.contenttypes", + "django.contrib.auth", + "django.contrib.sessions", + "django.contrib.staticfiles", +] + +MIDDLEWARE = [ + "portal.config.middleware.RequestIdMiddleware", + "portal.config.middleware.SecurityHeadersMiddleware", + "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.locale.LocaleMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "portal.config.urls" +WSGI_APPLICATION = "portal.config.wsgi.application" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [BASE_DIR / "portal" / "templates"], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.request", + "django.template.context_processors.i18n", + ], + }, + } +] + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": DATA_DIR / "db.sqlite3", + "OPTIONS": { + "transaction_mode": "IMMEDIATE", + "timeout": 20, + "init_command": "PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;", + }, + } +} +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +# Time and language (AD-15): store UTC, translate every string. +USE_TZ = True +TIME_ZONE = "UTC" +USE_I18N = True +LANGUAGE_CODE = "en" +LANGUAGES = [("en", "English"), ("de", "Deutsch")] +LOCALE_PATHS = [BASE_DIR / "portal" / "locale"] + +# Static files served by whitenoise; assets are built into the image. +STATIC_URL = "static/" +STATIC_ROOT = BASE_DIR / "staticfiles" +STATICFILES_DIRS = [BASE_DIR / "portal" / "static"] +STORAGES = { + "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"}, + "staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"}, +} + +# Sessions in the database; cookies secure, HttpOnly, SameSite=Lax (AD-6). +SESSION_ENGINE = "django.contrib.sessions.backends.db" +SESSION_COOKIE_SECURE = not DEBUG +SESSION_COOKIE_HTTPONLY = True +SESSION_COOKIE_SAMESITE = "Lax" +CSRF_COOKIE_SECURE = not DEBUG +CSRF_COOKIE_HTTPONLY = True +CSRF_COOKIE_SAMESITE = "Lax" + +# TLS ends at the reverse proxy. +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") +SECURE_CONTENT_TYPE_NOSNIFF = True +SECURE_REFERRER_POLICY = "same-origin" +SECURE_HSTS_SECONDS = 0 if DEBUG else 31536000 +X_FRAME_OPTIONS = "DENY" + +LOGGING = build_logging(_config.log_level) diff --git a/portal/config/urls.py b/portal/config/urls.py new file mode 100644 index 0000000..38db922 --- /dev/null +++ b/portal/config/urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from .health import healthz + +urlpatterns = [ + path("healthz", healthz, name="healthz"), +] diff --git a/portal/config/wsgi.py b/portal/config/wsgi.py new file mode 100644 index 0000000..9cdc7ac --- /dev/null +++ b/portal/config/wsgi.py @@ -0,0 +1,7 @@ +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "portal.config.settings") + +application = get_wsgi_application() diff --git a/portal/static/.gitkeep b/portal/static/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/portal/tests/__init__.py b/portal/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/portal/tests/conftest.py b/portal/tests/conftest.py new file mode 100644 index 0000000..c6d666c --- /dev/null +++ b/portal/tests/conftest.py @@ -0,0 +1,10 @@ +import pytest + + +@pytest.fixture +def valid_env(tmp_path): + return { + "SECRET_KEY": "x" * 48, + "ALLOWED_HOSTS": "testserver", + "DATA_DIR": str(tmp_path), + } diff --git a/portal/tests/test_config.py b/portal/tests/test_config.py new file mode 100644 index 0000000..e0b5f07 --- /dev/null +++ b/portal/tests/test_config.py @@ -0,0 +1,72 @@ +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +from portal.config.env import ConfigError, load_config + +ROOT = Path(__file__).resolve().parents[2] + + +def test_valid_environment_uses_safe_defaults(valid_env): + config = load_config(valid_env) + assert config.debug is False + assert config.log_level == "INFO" + assert config.default_timezone == "Europe/Berlin" + assert config.trusted_proxies == [] + assert config.allowed_hosts == ["testserver"] + + +def test_every_problem_is_listed_and_no_value_leaks(valid_env): + secret = "short-secret" + env = { + **valid_env, + "SECRET_KEY": secret, + "LOG_LEVEL": "loud", + "DEBUG": "maybe", + "TRUSTED_PROXIES": "not-an-ip", + "DEFAULT_TIMEZONE": "Nowhere/Land", + } + del env["ALLOWED_HOSTS"] + with pytest.raises(ConfigError) as info: + load_config(env) + message = str(info.value) + for name in ( + "SECRET_KEY", + "ALLOWED_HOSTS", + "LOG_LEVEL", + "DEBUG", + "TRUSTED_PROXIES", + "DEFAULT_TIMEZONE", + ): + assert name in message + assert secret not in message + + +def test_missing_data_dir_is_reported(valid_env, tmp_path): + with pytest.raises(ConfigError, match="DATA_DIR"): + load_config({**valid_env, "DATA_DIR": str(tmp_path / "missing")}) + + +def test_process_exits_nonzero_naming_both_variables(tmp_path): + env = {k: v for k, v in os.environ.items() if k not in ("SECRET_KEY", "LOG_LEVEL")} + env.update( + ALLOWED_HOSTS="testserver", + DATA_DIR=str(tmp_path), + LOG_LEVEL="loud", + DJANGO_SETTINGS_MODULE="portal.config.settings", + ) + result = subprocess.run( # noqa: S603 + [sys.executable, "manage.py", "check"], + cwd=ROOT, + env=env, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode != 0 + assert "SECRET_KEY" in result.stderr + assert "LOG_LEVEL" in result.stderr + assert "Traceback" not in result.stderr diff --git a/portal/tests/test_headers.py b/portal/tests/test_headers.py new file mode 100644 index 0000000..43de4ac --- /dev/null +++ b/portal/tests/test_headers.py @@ -0,0 +1,39 @@ +import pytest + + +@pytest.mark.django_db +def test_security_headers_present(client): + response = client.get("/healthz") + csp = response["Content-Security-Policy"] + assert "script-src 'self'" in csp + assert "default-src 'self'" in csp + assert "frame-ancestors 'none'" in csp + assert "unsafe-inline" not in csp + assert "http" not in csp + assert response["X-Content-Type-Options"] == "nosniff" + assert response["X-Frame-Options"] == "DENY" + assert response["Referrer-Policy"] == "same-origin" + assert response["X-Request-ID"] + + +def test_headers_also_on_error_responses(client): + response = client.get("/missing") + assert response.status_code == 404 + assert "Content-Security-Policy" in response + assert response["X-Request-ID"] + + +def test_valid_incoming_request_id_is_kept(client): + response = client.get("/missing", headers={"X-Request-ID": "abc12345-req"}) + assert response["X-Request-ID"] == "abc12345-req" + + +def test_invalid_incoming_request_id_is_replaced(client): + response = client.get("/missing", headers={"X-Request-ID": "bad id\twith junk"}) + assert response["X-Request-ID"] != "bad id\twith junk" + + +def test_csp_on_bad_host_response(client): + response = client.get("/healthz", HTTP_HOST="unlisted.example") + assert response.status_code == 400 + assert "script-src 'self'" in response["Content-Security-Policy"] diff --git a/portal/tests/test_health.py b/portal/tests/test_health.py new file mode 100644 index 0000000..221468d --- /dev/null +++ b/portal/tests/test_health.py @@ -0,0 +1,20 @@ +from unittest import mock + +import pytest +from django.db.utils import OperationalError + + +@pytest.mark.django_db +def test_health_ok(client): + response = client.get("/healthz") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + +def test_health_down_when_database_unreachable(client, caplog): + with mock.patch("portal.config.health.connection") as connection: + connection.cursor.side_effect = OperationalError("unable to open database file") + response = client.get("/healthz") + assert response.status_code == 503 + assert response.json() == {"status": "unavailable"} + assert "unable to open" not in caplog.text diff --git a/portal/tests/test_logging.py b/portal/tests/test_logging.py new file mode 100644 index 0000000..6edb261 --- /dev/null +++ b/portal/tests/test_logging.py @@ -0,0 +1,38 @@ +import json +import logging + +import pytest + +from portal.config.logging import JsonFormatter + + +@pytest.mark.django_db +def test_request_log_has_id_and_no_query(client, caplog): + with caplog.at_level(logging.INFO, logger="portal.request"): + response = client.get("/healthz?token=supersecret") + records = [r for r in caplog.records if r.name == "portal.request"] + assert len(records) == 1 + record = records[0] + assert record.request_id == response["X-Request-ID"] + assert (record.method, record.path, record.status) == ("GET", "/healthz", 200) + assert "supersecret" not in caplog.text + + +def test_json_formatter_emits_json_with_extras(): + record = logging.LogRecord("x", logging.INFO, __file__, 1, "hello %s", ("world",), None) + record.request_id = "rid-1" + record.status = 200 + data = json.loads(JsonFormatter().format(record)) + assert data["message"] == "hello world" + assert data["request_id"] == "rid-1" + assert data["status"] == 200 + assert data["level"] == "INFO" + + +def test_404_with_query_writes_one_line_without_query(client, caplog): + with caplog.at_level(logging.DEBUG): + client.get("/missing?token=supersecret") + assert len(caplog.records) == 1 + output = JsonFormatter().format(caplog.records[0]) + assert "supersecret" not in output + assert json.loads(output)["path"] == "/missing" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f4cba1f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,43 @@ +[project] +name = "self-service-portal" +version = "0.1.0" +description = "Self-service portal for directory users" +readme = "README.md" +requires-python = ">=3.13,<3.14" +dependencies = [ + "django>=5.2,<5.3", + "gunicorn>=23.0", + "whitenoise>=6.9", + "tzdata", +] + +[dependency-groups] +dev = [ + "pytest>=8", + "pytest-django>=4.9", + "pytest-env>=1.1", + "ruff>=0.8", +] + +[tool.pytest.ini_options] +DJANGO_SETTINGS_MODULE = "portal.config.settings" +testpaths = ["portal/tests"] +filterwarnings = ["ignore:No directory at:UserWarning"] +env = [ + "SECRET_KEY=test-only-secret-key-not-for-use-0123456789abcdef", + "ALLOWED_HOSTS=testserver,localhost", + "DATA_DIR=.", + "DEBUG=false", + "LOG_LEVEL=INFO", +] + +[tool.ruff] +line-length = 100 +target-version = "py313" +extend-exclude = ["_bmad", "_bmad-output", ".agents", "context-resources"] + +[tool.ruff.lint] +select = ["E", "F", "I", "B", "UP", "S"] + +[tool.ruff.lint.per-file-ignores] +"portal/tests/*" = ["S"] diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..57bcb16 --- /dev/null +++ b/uv.lock @@ -0,0 +1,217 @@ +version = 1 +revision = 5 +requires-python = "==3.13.*" + +[[package]] +name = "asgiref" +version = "3.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/26/3b59f2bdae5f640389becb1f673cded775287f5fc4f816309d9ca9a3f93d/asgiref-3.12.1.tar.gz", hash = "sha256:59dcb51c272ad209d59bed5708a64a333083e86017d7fcdd67498eeab7784340", size = 42378, upload-time = "2026-07-14T09:56:18.087Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/1b/54f4ad77cd8a584fa70746c47df988e002cf1ee1eba43364d46f87803647/asgiref-3.12.1-py3-none-any.whl", hash = "sha256:fe386d1c2bff7259ea95929266d12a8cf9a8b5a1c2598402967d8792e7a7c094", size = 25478, upload-time = "2026-07-14T09:56:16.926Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "django" +version = "5.2.18" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "sqlparse" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/85/fe/79e692b430c7721bf4b812c328bfe687e73e3f28f56611a6a18a97e887cc/django-5.2.18.tar.gz", hash = "sha256:461c5dd06d2ea16bd5ca37d3f46e4def1d6b0fe7588c6f4e2119517bb0af8b2d", size = 10910238, upload-time = "2026-10-06T13:01:09.371Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/01/6568ec52b26548ca1b83d33249f7274db3bf74698f42c0da42ec9db1a24a/django-5.2.18-py3-none-any.whl", hash = "sha256:92ed81d500be6408ecd704d7bd1366c534f30427bffcc63c5fefb129561aec7c", size = 8316071, upload-time = "2026-10-06T13:01:04.413Z" }, +] + +[[package]] +name = "gunicorn" +version = "26.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/8a/e4ef6ee11701b6cd64702848415ffb69eeff85cb388a3c6c7fe86f22f3f8/gunicorn-26.2.0.tar.gz", hash = "sha256:62b864895d9ebff0b2f9867ba04fe811c93121596540830c9c916d0769668447", size = 787921, upload-time = "2026-08-24T15:05:59.3Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/85/7522a52e5e2f42faf1a129113ab63e548c42e103e9af395b7bfe65e403e2/gunicorn-26.2.0-py3-none-any.whl", hash = "sha256:bd249d0b3f7972f7432f0a6b6ff3b3ee2d129f70cd1ff6c09a9dd9e29a2b88e3", size = 228389, upload-time = "2026-08-24T15:05:57.67Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-django" +version = "4.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/f6/3851312120c2bf2f19cafff931e75059aad1ba670703cd751e2fde9bc942/pytest_django-4.14.0.tar.gz", hash = "sha256:26787dd3f422cfbab8f55b80a776e2edea7a11092cb74e960bef1312515708ef", size = 94700, upload-time = "2026-08-10T14:13:08.319Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/03/850bffad2b581c440ca51c039d74504d5a422c94bda0bdb8a8ba5068d48b/pytest_django-4.14.0-py3-none-any.whl", hash = "sha256:c533b08d89cc675efcd5398eea270b34547e35f9a3608e2c9748dd88428ea187", size = 27067, upload-time = "2026-08-10T14:13:06.998Z" }, +] + +[[package]] +name = "pytest-env" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "python-dotenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/e5/02fb78ca59d456291135b48fc95ee481a6e55dc47ad53e6cee31a7e40d54/pytest_env-1.8.0.tar.gz", hash = "sha256:e2dd383be15823a875403509949c0d975266f4f1711ccfbdcdbcbcf8fa83097c", size = 17363, upload-time = "2026-10-10T22:49:40.747Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/cd/c592fb78f8d5fc456568faf1983f2b994f00aa1690f0b3b24af95ad75121/pytest_env-1.8.0-py3-none-any.whl", hash = "sha256:43a026236949342be217f1539fa7ef32973f0d3a98d79d86a15eee973d3b1a2a", size = 10385, upload-time = "2026-10-10T22:49:39.251Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/74/26/2fbeedb218a787a5eea551c7532cac4e009f83d689dd2faa0d0353473f86/python_dotenv-1.2.4.tar.gz", hash = "sha256:f0d53e69935a851c0dcc78f3ab7aaccd8cabef0b92382b576b824212902873c0", size = 60824, upload-time = "2026-10-01T05:36:10Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/d1/38f3a3405989a89ac18390803e70c6ad7c7760da4f9b83cbeca0c44a0c72/python_dotenv-1.2.4-py3-none-any.whl", hash = "sha256:42269a8a5b3fd54ffa6f3d84b18abed50064717576b4ecf03dc4a55d8aa04fdc", size = 23266, upload-time = "2026-10-01T05:36:08.633Z" }, +] + +[[package]] +name = "ruff" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/a7/70debb024dfacda67b8e560cc7511f52b34b9348a8cc8c1ec23036dcd51d/ruff-0.17.0.tar.gz", hash = "sha256:5cd03240d8208a557c2a9655a5cb07ebe36aa6bb35065f97d48c1f6adef5a322", size = 5320554, upload-time = "2026-10-09T19:47:29.248Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/f8/ee5ab9da6089eae2a33e6008b01deb1eda19992c1c8e10661e98cee1640f/ruff-0.17.0-py3-none-linux_armv6l.whl", hash = "sha256:0e271826af9a20d18c6cfae8c51e82959167c24859686ddd3eb9a7f0842ce81e", size = 10148907, upload-time = "2026-10-09T19:46:38.695Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d9/2f81fb5a9d580afbb11b1c8ff915233a11f2a1b27405d7991f183c5e1976/ruff-0.17.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5f0ca4a40f81403689c04f12966e22f44e329ae362072d8f1587b7bda87f603b", size = 10224208, upload-time = "2026-10-09T19:46:41.711Z" }, + { url = "https://files.pythonhosted.org/packages/a7/20/643f3c8f75594f937b2bf74801241c56a2e2b8e139d24dff8b66b28cdd7f/ruff-0.17.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:cbf7149e0927dc3295d5d64679a4765576eef71b00782b2ae969ef82274d6bb9", size = 9273324, upload-time = "2026-10-09T19:46:44.323Z" }, + { url = "https://files.pythonhosted.org/packages/ec/91/627700b233d367736cb274f1bd0b47d1f2b12f68878192812bd875adadc3/ruff-0.17.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13ee90156522998c3037059d8f66885c8adeeaf7643bdce2caceee196ecd23e0", size = 9953190, upload-time = "2026-10-09T19:46:47.155Z" }, + { url = "https://files.pythonhosted.org/packages/cd/92/91f7b5ed39490f89d6cbf56e1f543c383667a725efa8e2c2dee0f01f5591/ruff-0.17.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3d8e4a002a94cd9d0dc48b51dc69d807a172b5b9bf2b668e656424dc5b55ead1", size = 9961746, upload-time = "2026-10-09T19:46:50.098Z" }, + { url = "https://files.pythonhosted.org/packages/87/c5/7310f9fc63ce11ff6394edbd5e85433dfb0e14c9fbf6ccc97f1538491bc7/ruff-0.17.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c0b8a60c06a218c337e1161638d34757f83449243e2db161483ddf948e53ad14", size = 10625483, upload-time = "2026-10-09T19:46:53.379Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8d/97443f0dca4a03a0bc7629fd396fd494a1cb6666121e38c5075acb217d8f/ruff-0.17.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a330178bdffc4205dbf3bda11d93e059e388fd6546f8cdd304501a9160363c0d", size = 11350129, upload-time = "2026-10-09T19:46:56.486Z" }, + { url = "https://files.pythonhosted.org/packages/9c/0a/c525efd9777be4b6b012e6969a3012648468e7e6c4b3e5b46af69f46e8eb/ruff-0.17.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7bb08489e234876fa2da67ae3ea938e9a2156da80293e0e4365abd6973d98329", size = 10906223, upload-time = "2026-10-09T19:47:00.263Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3c/4a01195d93420cad1175bedad13a515dc8a56f95a6e39789b92e582682f5/ruff-0.17.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc73e7c133e82d55b5f15897b2a442d72c0cb4a0c886c46801ce3c247150b60c", size = 10386314, upload-time = "2026-10-09T19:47:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/c7/72/1a3951665485a921f6375f91e754a1854d5a645d41acc3642668064ff64d/ruff-0.17.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:db4f74c533403ab70fe4007873f6ae0c9f94a8b03158cf48d78788e47cdbe399", size = 10565464, upload-time = "2026-10-09T19:47:05.831Z" }, + { url = "https://files.pythonhosted.org/packages/90/8c/539b4d8c082f57e18db8ae2be85a460d77861c79dcd5798e32b536a6a06f/ruff-0.17.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3d8cc360e666d1914e47b0777c6906d70cf18891a55532bd0a16844195d70859", size = 10079468, upload-time = "2026-10-09T19:47:08.617Z" }, + { url = "https://files.pythonhosted.org/packages/e0/b8/84286966db79434e8c26b585b0a0f6897cb3ab1c51a4aa4df10c28488b62/ruff-0.17.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d66de796b726c4801e05fa99a2a8d7a780e107be222486c304ab61765561e866", size = 9958556, upload-time = "2026-10-09T19:47:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/69/50/27b6eed27b83fcdd5bfa0d52b83231e29094754374698da404d094487ae3/ruff-0.17.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c3f268baf004aea944f040623327119527ea231af15f7fb7890e82cea0679589", size = 10352173, upload-time = "2026-10-09T19:47:14.188Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/955399fd13044cd827862044117d784a59e3196f6cce7424908ac9a7f914/ruff-0.17.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:864b6c1acb6b0bccf94b5a3938a1531fd09aaca5e5659a2e7bf0f3cf2a685540", size = 10791761, upload-time = "2026-10-09T19:47:16.931Z" }, + { url = "https://files.pythonhosted.org/packages/ae/bf/024e01e1f6aec87768696725e648ed5b438941341eea8f8100beb681961f/ruff-0.17.0-py3-none-win32.whl", hash = "sha256:5e50aa5b84decd9fe5b0bb0e6f71c3b592f1767ed09faa4b7207d933961e35cd", size = 9904112, upload-time = "2026-10-09T19:47:19.75Z" }, + { url = "https://files.pythonhosted.org/packages/cc/77/1ee73df41dcc8d1cdb686ee4bc46ea29704ea175feb6b95c78420f631ab8/ruff-0.17.0-py3-none-win_amd64.whl", hash = "sha256:8ab76bcda86dfd28e13776cb5de3c7bcdcf1ae3d37ed761113d1a5a415dc134c", size = 10115585, upload-time = "2026-10-09T19:47:22.698Z" }, + { url = "https://files.pythonhosted.org/packages/fd/71/eb4f0ccc844aece56963e8578df9c95d4c00f580547d52329d4035d3af18/ruff-0.17.0-py3-none-win_arm64.whl", hash = "sha256:c154c73ff43f9854395e24cac507af13078962e53d2b511605058d22af1fdb88", size = 9872035, upload-time = "2026-10-09T19:47:26.306Z" }, +] + +[[package]] +name = "self-service-portal" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "django" }, + { name = "gunicorn" }, + { name = "tzdata" }, + { name = "whitenoise" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-django" }, + { name = "pytest-env" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "django", specifier = ">=5.2,<5.3" }, + { name = "gunicorn", specifier = ">=23.0" }, + { name = "tzdata" }, + { name = "whitenoise", specifier = ">=6.9" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8" }, + { name = "pytest-django", specifier = ">=4.9" }, + { name = "pytest-env", specifier = ">=1.1" }, + { name = "ruff", specifier = ">=0.8" }, +] + +[[package]] +name = "sqlparse" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/d3/3f06a1006f2261d1342aefb3c71eed02f5d4ca5bdbecd86ebc12ad38306e/sqlparse-0.6.0.tar.gz", hash = "sha256:113c35c75365ab9cc9c7231d68c6428fb11c085fc8e9eb1ad659b7ddbf6cd2b9", size = 178477, upload-time = "2026-08-13T19:16:06.396Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/50/f00935da0ec7cbf325f8dc4f772ae46fbc7b672dd62876e73f0a94adda57/sqlparse-0.6.0-py3-none-any.whl", hash = "sha256:b861c0288ce2fa56209a9a6412d2e066ac664b3873b89c26c9d8415e8e32996f", size = 50070, upload-time = "2026-08-13T19:16:04.062Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/68/f1b440335057bfce71b6e50a9d09445aa2ecbd08359a337976627b8409e7/tzdata-2026.5.tar.gz", hash = "sha256:8cc73c0a0bfca7dbfa59235d60b2eff82231dee33f53d206db1acd9173cfc0a7", size = 200404, upload-time = "2026-10-03T09:23:14.143Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/21/1e5995a1c920cce14e4bffae20c665ec10e7ed03ab25e006cd741092b718/tzdata-2026.5-py2.py3-none-any.whl", hash = "sha256:b683bd1b6659ddcd810ff02ad09ba821d4bf1065072805063eb35c49617905ac", size = 347996, upload-time = "2026-10-03T09:23:12.535Z" }, +] + +[[package]] +name = "whitenoise" +version = "6.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/2a/55b3f3a4ec326cd077c1c3defeee656b9298372a69229134d930151acd01/whitenoise-6.12.0.tar.gz", hash = "sha256:f723ebb76a112e98816ff80fcea0a6c9b8ecde835f8ddda25df7a30a3c2db6ad", size = 26841, upload-time = "2026-02-27T00:05:42.028Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/eb/d5583a11486211f3ebd4b385545ae787f32363d453c19fffd81106c9c138/whitenoise-6.12.0-py3-none-any.whl", hash = "sha256:fc5e8c572e33ebf24795b47b6a7da8da3c00cff2349f5b04c02f28d0cc5a3cc2", size = 20302, upload-time = "2026-02-27T00:05:40.086Z" }, +]