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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sk72AxLwQnYttF6VFSxQLt
This commit is contained in:
Ebbe Baß
2026-10-15 13:44:07 +02:00
co-authored by Claude Sonnet 5.5
parent 24de5878ed
commit f8b0584687
31 changed files with 1134 additions and 2 deletions
+18
View File
@@ -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
+24
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
*.sh text eol=lf
Dockerfile text eol=lf
+13
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
3.13
+30
View File
@@ -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"]
+42 -2
View File
@@ -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
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: <first ALLOWED_HOSTS entry>" 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`).
@@ -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'
---
<frozen-after-approval reason="human-owned intent — do not modify unless human renegotiates">
## 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 |
</frozen-after-approval>
## 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 `<WSGIRequest: GET '/missing?token=supersecret'>`. 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.
+21
View File
@@ -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:
+12
View File
@@ -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 -
+16
View File
@@ -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)
+16
View File
@@ -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()
View File
View File
View File
+118
View File
@@ -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,
)
+23
View File
@@ -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"})
+49
View File
@@ -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},
}
+70
View File
@@ -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
+109
View File
@@ -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)
+7
View File
@@ -0,0 +1,7 @@
from django.urls import path
from .health import healthz
urlpatterns = [
path("healthz", healthz, name="healthz"),
]
+7
View File
@@ -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()
View File
View File
+10
View File
@@ -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),
}
+72
View File
@@ -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
+39
View File
@@ -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"]
+20
View File
@@ -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
+38
View File
@@ -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"
+43
View File
@@ -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"]
Generated
+217
View File
@@ -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" },
]