From 41b6ff2f303314123755060d33519c862ee57610 Mon Sep 17 00:00:00 2001 From: sdairs Date: Fri, 2 Oct 2026 11:51:53 +0100 Subject: [PATCH 1/4] Add Django equipment checkout board on managed Postgres --- .github/workflows/equipment-checkout.yml | 29 +++ applications/equipment-checkout/.env.example | 12 + applications/equipment-checkout/.gitignore | 6 + applications/equipment-checkout/README.md | 205 +++++++++++++++++ .../equipment-checkout/board/__init__.py | 0 .../equipment-checkout/board/admin.py | 31 +++ .../board/management/__init__.py | 0 .../board/management/commands/__init__.py | 0 .../board/management/commands/seed_demo.py | 30 +++ .../board/migrations/0001_initial.py | 47 ++++ .../board/migrations/__init__.py | 0 .../equipment-checkout/board/models.py | 34 +++ .../equipment-checkout/board/services.py | 41 ++++ .../board/static/board/app.js | 7 + .../board/static/board/style.css | 209 ++++++++++++++++++ .../board/templates/board/_board.html | 8 + .../board/templates/board/base.html | 9 + .../board/templates/board/index.html | 1 + .../board/templates/registration/login.html | 1 + .../equipment-checkout/board/views.py | 57 +++++ .../equipment-checkout/config/__init__.py | 0 .../equipment-checkout/config/settings.py | 50 +++++ .../equipment-checkout/config/urls.py | 13 ++ .../equipment-checkout/config/wsgi.py | 4 + applications/equipment-checkout/manage.py | 6 + .../equipment-checkout/requirements-dev.in | 3 + .../equipment-checkout/requirements-dev.txt | 16 ++ .../equipment-checkout/requirements.in | 3 + .../equipment-checkout/requirements.txt | 8 + .../equipment-checkout/sql/bootstrap.sql | 10 + .../equipment-checkout/sql/cleanup.sql | 5 + .../equipment-checkout/sql/grants.sql | 6 + .../equipment-checkout/tests/browser.py | 31 +++ .../equipment-checkout/tests/integration.py | 184 +++++++++++++++ .../equipment-checkout/tests/persistence.py | 34 +++ 35 files changed, 1100 insertions(+) create mode 100644 .github/workflows/equipment-checkout.yml create mode 100644 applications/equipment-checkout/.env.example create mode 100644 applications/equipment-checkout/.gitignore create mode 100644 applications/equipment-checkout/README.md create mode 100644 applications/equipment-checkout/board/__init__.py create mode 100644 applications/equipment-checkout/board/admin.py create mode 100644 applications/equipment-checkout/board/management/__init__.py create mode 100644 applications/equipment-checkout/board/management/commands/__init__.py create mode 100644 applications/equipment-checkout/board/management/commands/seed_demo.py create mode 100644 applications/equipment-checkout/board/migrations/0001_initial.py create mode 100644 applications/equipment-checkout/board/migrations/__init__.py create mode 100644 applications/equipment-checkout/board/models.py create mode 100644 applications/equipment-checkout/board/services.py create mode 100644 applications/equipment-checkout/board/static/board/app.js create mode 100644 applications/equipment-checkout/board/static/board/style.css create mode 100644 applications/equipment-checkout/board/templates/board/_board.html create mode 100644 applications/equipment-checkout/board/templates/board/base.html create mode 100644 applications/equipment-checkout/board/templates/board/index.html create mode 100644 applications/equipment-checkout/board/templates/registration/login.html create mode 100644 applications/equipment-checkout/board/views.py create mode 100644 applications/equipment-checkout/config/__init__.py create mode 100644 applications/equipment-checkout/config/settings.py create mode 100644 applications/equipment-checkout/config/urls.py create mode 100644 applications/equipment-checkout/config/wsgi.py create mode 100644 applications/equipment-checkout/manage.py create mode 100644 applications/equipment-checkout/requirements-dev.in create mode 100644 applications/equipment-checkout/requirements-dev.txt create mode 100644 applications/equipment-checkout/requirements.in create mode 100644 applications/equipment-checkout/requirements.txt create mode 100644 applications/equipment-checkout/sql/bootstrap.sql create mode 100644 applications/equipment-checkout/sql/cleanup.sql create mode 100644 applications/equipment-checkout/sql/grants.sql create mode 100644 applications/equipment-checkout/tests/browser.py create mode 100644 applications/equipment-checkout/tests/integration.py create mode 100644 applications/equipment-checkout/tests/persistence.py diff --git a/.github/workflows/equipment-checkout.yml b/.github/workflows/equipment-checkout.yml new file mode 100644 index 00000000..5efa1606 --- /dev/null +++ b/.github/workflows/equipment-checkout.yml @@ -0,0 +1,29 @@ +name: Equipment checkout checks +on: + pull_request: + paths: [applications/equipment-checkout/**, .github/workflows/equipment-checkout.yml] + push: + paths: [applications/equipment-checkout/**, .github/workflows/equipment-checkout.yml] +permissions: + contents: read +jobs: + django-check: + runs-on: ubuntu-latest + defaults: + run: + working-directory: applications/equipment-checkout + env: + DJANGO_SECRET_KEY: ci-configuration-check-only + PGHOST: unused.example.invalid + PGUSER: equipment_app + PGPASSWORD: unused + PGSSLROOTCERT: /etc/ssl/certs/ca-certificates.crt + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - run: python -m pip install -r requirements.txt + - run: python manage.py check + - run: python -m compileall -q config board tests +# Cloud integration tests are intentionally manual against a dedicated service. diff --git a/applications/equipment-checkout/.env.example b/applications/equipment-checkout/.env.example new file mode 100644 index 00000000..2f66e0c6 --- /dev/null +++ b/applications/equipment-checkout/.env.example @@ -0,0 +1,12 @@ +# Load explicitly: set -a; source .deployment/app.env; set +a +DJANGO_SECRET_KEY=REPLACE_WITH_A_RANDOM_SECRET +DJANGO_DEBUG=1 +DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1 +# Local HTTP only; use 1 behind HTTPS in hosted operation. +DJANGO_COOKIE_SECURE=0 +PGHOST=YOUR_SERVICE_DIRECT_HOSTNAME +PGPORT=5432 +PGDATABASE=postgres +PGUSER=equipment_app +PGPASSWORD=YOUR_RUNTIME_PASSWORD +PGSSLROOTCERT=/absolute/path/to/.deployment/postgres-ca.pem diff --git a/applications/equipment-checkout/.gitignore b/applications/equipment-checkout/.gitignore new file mode 100644 index 00000000..8ff4edf4 --- /dev/null +++ b/applications/equipment-checkout/.gitignore @@ -0,0 +1,6 @@ +.venv/ +.deployment/ +.env +__pycache__/ +*.pyc +staticfiles/ diff --git a/applications/equipment-checkout/README.md b/applications/equipment-checkout/README.md new file mode 100644 index 00000000..d71385ad --- /dev/null +++ b/applications/equipment-checkout/README.md @@ -0,0 +1,205 @@ +# Equipment Checkout Board + +Borrow shared equipment, see your current loans, and return items with **Django**, **Django ORM**, **psycopg**, and **htmx** on **ClickHouse Managed Postgres (public beta)**. Native Django authentication identifies borrowers. Staff use Django admin to add or disable inventory and can return any current loan. + +Two people can see the same available camera. Only one can check it out: a transaction locks the item, and a Postgres partial unique constraint permits one active loan per item. Returns preserve history and are idempotent. An ordinary member can return only their own loans; staff can return anyone's. Available inventory contains no borrower names. + +[ClickHouse Cloud](https://clickhouse.com/cloud) is the managed data platform. [ClickHouse Managed Postgres](https://clickhouse.com/docs/products/managed-postgres) provides the transactional PostgreSQL service used here. This example needs only Postgres. + +## Structure + +| File | Purpose | +| --- | --- | +| `board/models.py`, `board/migrations/` | Inventory, loan history, database constraints | +| `board/services.py` | Item-first locking, borrower identity, repeatable returns | +| `board/views.py`, `board/templates/` | Authenticated HTML and htmx fragments | +| `board/admin.py` | Staff inventory management; read-only loan history | +| `sql/bootstrap.sql`, `sql/grants.sql` | Separate migration and restricted runtime roles | +| `tests/integration.py`, `tests/browser.py` | Real Cloud service and browser acceptance | + +A loan belongs to an item and a Django user. `returned_at IS NULL` marks an active loan. `one_active_loan_per_item` is a partial unique index; `return_after_checkout` rejects invalid timestamps. Foreign keys protect referenced equipment and users. Admin disables equipment instead of deleting it, and does not edit loan history. + +## Set up in Linux + +Use Python 3.12, Git, OpenSSL, `jq`, `psql`, and a ClickHouse Cloud account with Managed Postgres access. Run application dependencies in a Linux environment. An optional dedicated OrbStack VM keeps installations away from your macOS host: + +```sh +orb create --memory 3G --cpus 2 ubuntu:24.04 equipment-checkout-dev +orb -m equipment-checkout-dev +``` + +Inside Linux, clone onto the VM's native filesystem: + +```sh +sudo apt-get update +sudo apt-get install -y python3-venv git curl jq openssl postgresql-client +git clone https://github.com/ClickHouse/examples.git ~/examples +cd ~/examples/applications/equipment-checkout +python3 -m venv .venv +.venv/bin/python -m pip install -r requirements.txt +umask 077 +mkdir -p .deployment +``` + +`requirements.txt` pins runtime dependencies; `requirements-dev.txt` pins the complete validation environment. htmx 2.0.11 loads from jsDelivr with an integrity hash. CSS uses optional Google Fonts and falls back to local sans-serif fonts. There's no frontend build step. + +### 1. Create a dedicated Cloud service + +Run Cloud provisioning where you manage resources. Install [clickhousectl](https://clickhouse.com/docs/interfaces/cli), and authenticate with an Admin API key. The interactive prompt keeps secrets out of shell history. + +```sh +curl -fsSL https://clickhouse.com/cli | sh +export PATH="$HOME/.local/bin:$PATH" +clickhousectl --version +clickhousectl cloud auth login --interactive +clickhousectl cloud org list +umask 077 +mkdir -p .deployment +``` + +Set your organization ID, supported AWS region, and supported Postgres size in a private `.deployment/resources.env` file. The validation used `c6gd.large` in `us-east-1` with Postgres 18 and no HA; choose from sizes available to your organization. Review [Managed Postgres pricing](https://clickhouse.com/docs/products/managed-postgres/pricing) before provisioning. Compute, storage, backups, and network usage can incur charges. Stopping the app or VM does not delete the Cloud service. + +```dotenv +CH_ORG_ID=YOUR_ORGANIZATION_UUID +DEPLOYMENT_NAME=equipment-checkout-example +CLOUD_REGION=us-east-1 +PG_SIZE=c6gd.large +``` + +Create once and preserve the response, which includes the initial administrator password: + +```sh +source .deployment/resources.env +clickhousectl cloud postgres create \ + --org-id "$CH_ORG_ID" --name "$DEPLOYMENT_NAME" \ + --provider aws --region "$CLOUD_REGION" --size "$PG_SIZE" \ + --pg-version 18 --ha-type none --tag project=equipment-checkout --json \ + > .deployment/postgres-create.json +PG_SERVICE_ID="$(jq -er '.id' .deployment/postgres-create.json)" +printf 'PG_SERVICE_ID=%s\n' "$PG_SERVICE_ID" >> .deployment/resources.env +``` + +Repeat `get` until `state` is `running`. Do not repeat creation while waiting: + +```sh +clickhousectl cloud postgres get "$PG_SERVICE_ID" \ + --org-id "$CH_ORG_ID" --json > .deployment/postgres-status.json +jq '{id, state, size, postgresVersion}' .deployment/postgres-status.json +``` + +If creation was interrupted, reconcile the recorded service name against `clickhousectl cloud postgres list --org-id "$CH_ORG_ID" --json` before creating anything else. Fetch the CA only after the service is running: + +```sh +clickhousectl cloud postgres certs get "$PG_SERVICE_ID" \ + --org-id "$CH_ORG_ID" --output .deployment/postgres-ca.pem +``` + +Use `--output` for a PEM file. Redirecting JSON certificate metadata to `.pem` won't produce a usable CA. If provisioning outside Linux, transfer the private create receipt and CA to the Linux app's `.deployment` directory for setup, without copying Cloud API keys. + +### 2. Bootstrap roles and apply migrations + +The remaining commands run inside Linux from the app directory. Generate these role passwords **once** and preserve the file on retries: + +```sh +umask 077 +cat > .deployment/passwords.env <p:last-child,.login>p { + color:var(--muted); + line-height:1.6} +.intro { + margin-bottom:46px} +.section-heading { + display:flex; + align-items:center; + justify-content:space-between; + margin-bottom:14px} +.section-heading span { + font-size:13px; + color:var(--muted)} +.inventory { + display:grid; + grid-template-columns:repeat(2,1fr); + gap:18px} +.card { + padding:27px; + background:#fff; + border:1px solid var(--line); + border-radius:15px} +.card-top { + display:flex; + align-items:center; + justify-content:space-between} +.asset { + font-size:12px; + letter-spacing:1px; + color:var(--muted)} +.badge { + font-size:11px; + font-weight:700; + padding:6px 10px; + border-radius:20px; + background:#edf4e5; + color:#376138} +.badge.muted { + background:#f0f0ec; + color:#747a70} +.card>p { + font-size:14px; + min-height:28px; + color:var(--muted)} +button { + border:0; + background:var(--green); + color:#fff; + border-radius:8px; + padding:12px 17px; + font:600 14px 'DM Sans',sans-serif; + cursor:pointer} +button span { + margin-left:30px} +button:disabled { + background:#eff0eb; + color:#8e9589; + cursor:default} +.secondary { + background:#e8ede5; + color:var(--green)} +.quiet { + background:none; + color:var(--muted); + padding:0} +.loans { + margin-top:42px} +.loan { + display:flex; + align-items:center; + justify-content:space-between; + border-top:1px solid var(--line); + padding:23px 0} +.loan p { + font-size:13px; + color:var(--muted); + margin:8px 0 0} +.empty { + border:1px dashed #c9d0c3; + border-radius:12px; + padding:28px; + color:var(--muted); + font-size:14px} +.notice { + border-left:3px solid var(--green); + background:#e8ede2; + padding:15px 18px; + border-radius:6px} +.login { + max-width:480px; + margin:30px auto} +.login form { + margin-top:30px} +.login label { + display:block; + font-size:13px; + font-weight:600; + margin-bottom:8px} +.login input { + width:100%; + padding:12px; + border:1px solid #c8cec4; + border-radius:8px; + font:inherit} +.errorlist { + color:#a32d26; + font-size:14px} +footer { + border-top:1px solid var(--line); + padding:26px 6vw; + font-size:12px; + color:var(--muted)} +@media(min-width:1100px) { + .inventory { + grid-template-columns:repeat(4,1fr)} +} +@media(max-width:650px) { + header { + padding:0 22px; + height:auto; + min-height:80px; + flex-wrap:wrap; + gap:16px} +nav { + gap:14px; + padding-bottom:15px} +.inventory { + grid-template-columns:1fr} +main { + padding:35px 22px} +h1 { + font-size:34px} +.loan { + gap:14px} +} + diff --git a/applications/equipment-checkout/board/templates/board/_board.html b/applications/equipment-checkout/board/templates/board/_board.html new file mode 100644 index 00000000..0e079fb6 --- /dev/null +++ b/applications/equipment-checkout/board/templates/board/_board.html @@ -0,0 +1,8 @@ +

THE SHARED KIT

Good tools, ready to go.

Borrow what you need. Return it when you’re done.

+{% if notice %}

{{ notice }}

{% endif %}{% for message in messages %}

{{ message }}

{% endfor %} +

Inventory

{{ items|length }} items
+{% for item in items %}
{{ item.asset_tag }}{% if not item.enabled %}Out of service{% elif item.on_loan %}On loan{% else %}Available{% endif %}
+

{{ item.name }}

{{ item.description }}

+{% if item.enabled and not item.on_loan %}
{% csrf_token %}
{% else %}{% endif %}
{% empty %}

No equipment yet. Staff can add items in the admin.

{% endfor %}
+

{% if user.is_staff %}Current loans{% else %}Your current loans{% endif %}

{{ loans|length }} active
+{% for loan in loans %}
{{ loan.item.name }}

{{ loan.item.asset_tag }} · borrowed {{ loan.checked_out_at|date:'j M, H:i' }}{% if user.is_staff %} by {{ loan.borrower.username }}{% endif %}

{% csrf_token %}
{% empty %}
Nothing checked out. Choose an available item above.
{% endfor %}
diff --git a/applications/equipment-checkout/board/templates/board/base.html b/applications/equipment-checkout/board/templates/board/base.html new file mode 100644 index 00000000..918ffc1b --- /dev/null +++ b/applications/equipment-checkout/board/templates/board/base.html @@ -0,0 +1,9 @@ +{% load static %} + +Equipment Checkout Board + + +
↗ Equipment room +{% if user.is_authenticated %}{% endif %}
+
{% block content %}{% endblock %}
Shared equipment. One clear record.
diff --git a/applications/equipment-checkout/board/templates/board/index.html b/applications/equipment-checkout/board/templates/board/index.html new file mode 100644 index 00000000..21853d72 --- /dev/null +++ b/applications/equipment-checkout/board/templates/board/index.html @@ -0,0 +1 @@ +{% extends 'board/base.html' %}{% block content %}{% include 'board/_board.html' %}{% endblock %} diff --git a/applications/equipment-checkout/board/templates/registration/login.html b/applications/equipment-checkout/board/templates/registration/login.html new file mode 100644 index 00000000..312127e0 --- /dev/null +++ b/applications/equipment-checkout/board/templates/registration/login.html @@ -0,0 +1 @@ +{% extends 'board/base.html' %}{% block content %}{% endblock %} diff --git a/applications/equipment-checkout/board/views.py b/applications/equipment-checkout/board/views.py new file mode 100644 index 00000000..79c8cf3a --- /dev/null +++ b/applications/equipment-checkout/board/views.py @@ -0,0 +1,57 @@ +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.db import DatabaseError +from django.db.models import Exists, OuterRef +from django.shortcuts import redirect, render +from django.http import HttpResponse +from django.views.decorators.http import require_GET, require_POST +from .models import Item, Loan +from .services import borrow_item, complete_return, Unavailable + + +def render_board(request, notice="", status=200): + active = Loan.objects.filter(item=OuterRef("pk"), returned_at__isnull=True) + items = Item.objects.annotate(on_loan=Exists(active)) + loans = Loan.objects.filter(returned_at__isnull=True).select_related("item", "borrower") + if not request.user.is_staff: + loans = loans.filter(borrower=request.user) + template = "board/_board.html" if request.headers.get("HX-Request") == "true" else "board/index.html" + response = render(request, template, {"items": items, "loans": loans, "notice": notice}, status=status) + response["Cache-Control"] = "no-store" + response["Vary"] = "HX-Request, Cookie" + return response + + +@login_required +@require_GET +def board(request): + return render_board(request) + + +def mutation_response(request, notice, status=200): + if request.headers.get("HX-Request") == "true" or status != 200: + return render_board(request, notice, status) + messages.success(request, notice) + return redirect("board") + + +@login_required +@require_POST +def borrow(request, item_id): + try: + loan, created = borrow_item(item_id, request.user) + except Unavailable as error: + return mutation_response(request, str(error), 409) + except DatabaseError: + return HttpResponse('

The database is busy. Refresh and try again.

', status=503) + return mutation_response(request, f"Borrowed {loan.item.name}." if created else "You already have this item.") + + +@login_required +@require_POST +def return_loan(request, loan_id): + try: + loan = complete_return(loan_id, request.user) + except DatabaseError: + return HttpResponse('

The database is busy. Refresh and try again.

', status=503) + return mutation_response(request, f"Returned {loan.item.name}.") diff --git a/applications/equipment-checkout/config/__init__.py b/applications/equipment-checkout/config/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/applications/equipment-checkout/config/settings.py b/applications/equipment-checkout/config/settings.py new file mode 100644 index 00000000..56651310 --- /dev/null +++ b/applications/equipment-checkout/config/settings.py @@ -0,0 +1,50 @@ +import os +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent.parent +SECRET_KEY = os.environ["DJANGO_SECRET_KEY"] +DEBUG = os.environ.get("DJANGO_DEBUG", "0") == "1" +ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1").split(",") +INSTALLED_APPS = ["django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", + "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", "board"] +MIDDLEWARE = ["django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware"] +ROOT_URLCONF = "config.urls" +TEMPLATES = [{"BACKEND": "django.template.backends.django.DjangoTemplates", "APP_DIRS": True, + "OPTIONS": {"context_processors": ["django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages"]}}] +WSGI_APPLICATION = "config.wsgi.application" +DATABASES = {"default": { + "ENGINE": "django.db.backends.postgresql", "NAME": os.environ.get("PGDATABASE", "postgres"), + "USER": os.environ["PGUSER"], "PASSWORD": os.environ["PGPASSWORD"], + "HOST": os.environ["PGHOST"], "PORT": os.environ.get("PGPORT", "5432"), + "CONN_MAX_AGE": 0, + "OPTIONS": {"sslmode": "verify-full", "sslrootcert": os.environ["PGSSLROOTCERT"], + "connect_timeout": 10, "options": "-c search_path=equipment,public -c statement_timeout=10000 -c lock_timeout=5000"}, +}} +AUTH_PASSWORD_VALIDATORS = [ + {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, + {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, + {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, +] +LANGUAGE_CODE = "en-gb" +TIME_ZONE = "UTC" +USE_TZ = True +STATIC_URL = "static/" +STATIC_ROOT = BASE_DIR / "staticfiles" +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" +LOGIN_URL = "login" +LOGIN_REDIRECT_URL = "board" +LOGOUT_REDIRECT_URL = "login" +# Set secure cookies behind HTTPS; local HTTP development must opt out explicitly. +SESSION_COOKIE_SECURE = os.environ.get("DJANGO_COOKIE_SECURE", "1") == "1" +CSRF_COOKIE_SECURE = SESSION_COOKIE_SECURE +SESSION_COOKIE_HTTPONLY = True +SESSION_COOKIE_SAMESITE = "Lax" +CSRF_COOKIE_SAMESITE = "Lax" +SECURE_CONTENT_TYPE_NOSNIFF = True +X_FRAME_OPTIONS = "DENY" +DATA_UPLOAD_MAX_MEMORY_SIZE = 16384 diff --git a/applications/equipment-checkout/config/urls.py b/applications/equipment-checkout/config/urls.py new file mode 100644 index 00000000..608eeb8e --- /dev/null +++ b/applications/equipment-checkout/config/urls.py @@ -0,0 +1,13 @@ +from django.contrib import admin +from django.contrib.auth import views as auth_views +from django.urls import path +from board import views + +urlpatterns = [ + path("admin/", admin.site.urls), + path("accounts/login/", auth_views.LoginView.as_view(), name="login"), + path("accounts/logout/", auth_views.LogoutView.as_view(), name="logout"), + path("", views.board, name="board"), + path("items//borrow/", views.borrow, name="borrow"), + path("loans//return/", views.return_loan, name="return_loan"), +] diff --git a/applications/equipment-checkout/config/wsgi.py b/applications/equipment-checkout/config/wsgi.py new file mode 100644 index 00000000..72126a55 --- /dev/null +++ b/applications/equipment-checkout/config/wsgi.py @@ -0,0 +1,4 @@ +import os +from django.core.wsgi import get_wsgi_application +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") +application = get_wsgi_application() diff --git a/applications/equipment-checkout/manage.py b/applications/equipment-checkout/manage.py new file mode 100644 index 00000000..60bebe87 --- /dev/null +++ b/applications/equipment-checkout/manage.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +import os +import sys +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") +from django.core.management import execute_from_command_line +execute_from_command_line(sys.argv) diff --git a/applications/equipment-checkout/requirements-dev.in b/applications/equipment-checkout/requirements-dev.in new file mode 100644 index 00000000..f66a122f --- /dev/null +++ b/applications/equipment-checkout/requirements-dev.in @@ -0,0 +1,3 @@ +-r requirements.in +requests==2.32.5 +playwright==1.55.0 diff --git a/applications/equipment-checkout/requirements-dev.txt b/applications/equipment-checkout/requirements-dev.txt new file mode 100644 index 00000000..cdd40ece --- /dev/null +++ b/applications/equipment-checkout/requirements-dev.txt @@ -0,0 +1,16 @@ +asgiref==3.12.1 +certifi==2026.7.22 +charset-normalizer==3.5.2 +Django==5.2.17 +greenlet==3.5.6 +gunicorn==23.0.0 +idna==3.20 +packaging==26.3 +playwright==1.55.0 +psycopg==3.3.6 +psycopg-binary==3.3.6 +pyee==13.0.1 +requests==2.32.5 +sqlparse==0.6.0 +typing_extensions==4.16.0 +urllib3==2.8.0 diff --git a/applications/equipment-checkout/requirements.in b/applications/equipment-checkout/requirements.in new file mode 100644 index 00000000..c41be2e1 --- /dev/null +++ b/applications/equipment-checkout/requirements.in @@ -0,0 +1,3 @@ +Django==5.2.17 +psycopg[binary]==3.3.6 +gunicorn==23.0.0 diff --git a/applications/equipment-checkout/requirements.txt b/applications/equipment-checkout/requirements.txt new file mode 100644 index 00000000..32efd96c --- /dev/null +++ b/applications/equipment-checkout/requirements.txt @@ -0,0 +1,8 @@ +asgiref==3.12.1 +Django==5.2.17 +gunicorn==23.0.0 +packaging==26.3 +psycopg==3.3.6 +psycopg-binary==3.3.6 +sqlparse==0.6.0 +typing_extensions==4.16.0 diff --git a/applications/equipment-checkout/sql/bootstrap.sql b/applications/equipment-checkout/sql/bootstrap.sql new file mode 100644 index 00000000..a8601242 --- /dev/null +++ b/applications/equipment-checkout/sql/bootstrap.sql @@ -0,0 +1,10 @@ +\set ON_ERROR_STOP on +\getenv migrator_password EQUIPMENT_MIGRATOR_PASSWORD +\getenv app_password EQUIPMENT_APP_PASSWORD +CREATE ROLE equipment_migrator LOGIN PASSWORD :'migrator_password' NOSUPERUSER NOCREATEDB NOCREATEROLE; +CREATE ROLE equipment_app LOGIN PASSWORD :'app_password' NOSUPERUSER NOCREATEDB NOCREATEROLE; +CREATE SCHEMA equipment AUTHORIZATION equipment_migrator; +REVOKE ALL ON SCHEMA equipment FROM PUBLIC; +GRANT USAGE ON SCHEMA equipment TO equipment_app; +ALTER ROLE equipment_migrator SET search_path = equipment, public; +ALTER ROLE equipment_app SET search_path = equipment, public; diff --git a/applications/equipment-checkout/sql/cleanup.sql b/applications/equipment-checkout/sql/cleanup.sql new file mode 100644 index 00000000..3c3a4fd6 --- /dev/null +++ b/applications/equipment-checkout/sql/cleanup.sql @@ -0,0 +1,5 @@ +\set ON_ERROR_STOP on +-- Destructive: run explicitly as service administrator after stopping the app. +DROP SCHEMA equipment CASCADE; +DROP ROLE equipment_app; +DROP ROLE equipment_migrator; diff --git a/applications/equipment-checkout/sql/grants.sql b/applications/equipment-checkout/sql/grants.sql new file mode 100644 index 00000000..1c04ff1d --- /dev/null +++ b/applications/equipment-checkout/sql/grants.sql @@ -0,0 +1,6 @@ +\set ON_ERROR_STOP on +GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA equipment TO equipment_app; +REVOKE ALL ON equipment.django_migrations FROM equipment_app; +GRANT SELECT ON equipment.django_migrations TO equipment_app; +GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA equipment TO equipment_app; +-- Re-run after every migration that creates tables or sequences. diff --git a/applications/equipment-checkout/tests/browser.py b/applications/equipment-checkout/tests/browser.py new file mode 100644 index 00000000..d1fbfadb --- /dev/null +++ b/applications/equipment-checkout/tests/browser.py @@ -0,0 +1,31 @@ +"""Browser smoke test against the seeded local HTTP app. Run inside Linux.""" +import os +from pathlib import Path +from playwright.sync_api import sync_playwright, expect + +with sync_playwright() as p: + browser = p.chromium.launch() + page = browser.new_page(viewport={"width": 1440, "height": 1100}) + errors = [] + page.on("pageerror", lambda error: errors.append(str(error))) + page.goto("http://127.0.0.1:8000/accounts/login/") + page.get_by_label("Username").fill("alex") + page.get_by_label("Password").fill(os.environ["DEMO_PASSWORD"]) + page.get_by_role("button", name="Sign in").click() + expect(page.get_by_role("heading", name="Good tools, ready to go.")).to_be_visible() + page.wait_for_function("typeof htmx !== 'undefined'") + camera = page.locator("article").filter(has=page.get_by_role("heading", name="Mirrorless camera")) + camera.get_by_role("button", name="Borrow item").click() + expect(page.get_by_role("status")).to_contain_text("Borrowed Mirrorless camera") + expect(page.locator(".loan").filter(has_text="Mirrorless camera")).to_be_visible() + page.locator(".loan").filter(has_text="Mirrorless camera").get_by_role("button", name="Return item").click() + expect(page.get_by_role("status")).to_contain_text("Returned Mirrorless camera") + expect(camera.get_by_role("button", name="Borrow item")).to_be_visible() + Path(".deployment").mkdir(exist_ok=True) + page.screenshot(path=".deployment/board-desktop.png", full_page=True) + page.set_viewport_size({"width": 390, "height": 844}) + page.screenshot(path=".deployment/board-mobile.png", full_page=True) + assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth"), "mobile overflow" + assert not errors, errors + browser.close() + print("Browser login, htmx borrow/return, desktop/mobile checks passed.") diff --git a/applications/equipment-checkout/tests/integration.py b/applications/equipment-checkout/tests/integration.py new file mode 100644 index 00000000..18c77e5b --- /dev/null +++ b/applications/equipment-checkout/tests/integration.py @@ -0,0 +1,184 @@ +"""Destructive fixture tests: use only a dedicated, migrated example service.""" +import concurrent.futures +import os +import re +import threading +import time +import unittest +import uuid +from datetime import timedelta + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") +import django +django.setup() +import psycopg +import requests +from django.contrib.auth import get_user_model +from django.db import IntegrityError, connection, transaction +from django.utils import timezone +from board.models import Item, Loan + +BASE = os.environ.get("TEST_BASE_URL", "http://127.0.0.1:8000") +PASSWORD = "FixturePass-" + uuid.uuid4().hex + + +def login(username): + session = requests.Session() + page = session.get(BASE + "/accounts/login/", timeout=15) + token = re.search(r'name="csrfmiddlewaretoken" value="([^"]+)"', page.text).group(1) + result = session.post(BASE + "/accounts/login/", data={"username": username, "password": PASSWORD, + "csrfmiddlewaretoken": token}, timeout=15, allow_redirects=False) + assert result.status_code == 302, result.status_code + return session + + +def post(session, path, **extra): + return session.post(BASE + path, data={"csrfmiddlewaretoken": session.cookies["csrftoken"], **extra}, + headers={"HX-Request": "true"}, timeout=20, allow_redirects=False) + + +class Acceptance(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.prefix = "test-" + uuid.uuid4().hex[:10] + cls.users = [get_user_model().objects.create_user(cls.prefix + str(i), password=PASSWORD, + is_staff=(i == 2), is_superuser=(i == 2)) for i in range(3)] + cls.sessions = [login(user.username) for user in cls.users] + + @classmethod + def tearDownClass(cls): + Loan.objects.filter(item__asset_tag__startswith=cls.prefix).delete() + Item.objects.filter(asset_tag__startswith=cls.prefix).delete() + for user in cls.users: + user.delete() + connection.close() + + def setUp(self): + self.item = Item.objects.create(asset_tag=self.prefix + uuid.uuid4().hex[:8], name="Fixture camera") + + def borrow(self, who=0): + return post(self.sessions[who], f"/items/{self.item.pk}/borrow/") + + def test_authentication_and_methods(self): + self.assertEqual(requests.get(BASE + "/", allow_redirects=False, timeout=15).status_code, 302) + self.assertEqual(self.sessions[0].get(BASE + f"/items/{self.item.pk}/borrow/", timeout=15).status_code, 405) + + def test_csrf_rejection(self): + result = self.sessions[0].post(BASE + f"/items/{self.item.pk}/borrow/", timeout=15) + self.assertEqual(result.status_code, 403) + self.assertFalse(Loan.objects.filter(item=self.item).exists()) + + def test_identity_is_server_owned(self): + result = post(self.sessions[0], f"/items/{self.item.pk}/borrow/", borrower=self.users[1].pk) + self.assertEqual(result.status_code, 200) + self.assertEqual(Loan.objects.get(item=self.item).borrower_id, self.users[0].pk) + + def test_concurrent_checkout(self): + barrier = threading.Barrier(2) + def checkout(who): + barrier.wait() + return self.borrow(who).status_code + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: + results = list(pool.map(checkout, [0, 1])) + self.assertEqual(sorted(results), [200, 409]) + self.assertEqual(Loan.objects.filter(item=self.item, returned_at__isnull=True).count(), 1) + + def test_inventory_disable_serializes_with_checkout(self): + # ItemAdmin uses this same lock inside its atomic save transaction. + started = threading.Event() + def checkout(): + started.set() + return self.borrow().status_code + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: + with transaction.atomic(): + item = Item.objects.select_for_update().get(pk=self.item.pk) + future = pool.submit(checkout) + self.assertTrue(started.wait(timeout=5)) + time.sleep(0.5) + self.assertFalse(future.done(), "checkout should wait for inventory update") + item.enabled = False + item.save(update_fields=["enabled"]) + self.assertEqual(future.result(timeout=20), 409) + self.assertFalse(Loan.objects.filter(item=self.item).exists()) + + def test_database_constraint_without_application_lock(self): + Loan.objects.create(item=self.item, borrower=self.users[0]) + with self.assertRaises(IntegrityError), transaction.atomic(): + Loan.objects.create(item=self.item, borrower=self.users[1]) + + def test_return_constraint(self): + with self.assertRaises(IntegrityError), transaction.atomic(): + Loan.objects.create(item=self.item, borrower=self.users[0], returned_at=timezone.now() - timedelta(days=1)) + + def test_owner_return_and_idempotence(self): + self.assertEqual(self.borrow().status_code, 200) + self.assertEqual(self.borrow().status_code, 200) + loan = Loan.objects.get(item=self.item) + path = f"/loans/{loan.pk}/return/" + self.assertEqual(post(self.sessions[1], path).status_code, 404) + loan.refresh_from_db() + self.assertIsNone(loan.returned_at) + self.assertEqual(post(self.sessions[0], path).status_code, 200) + loan.refresh_from_db() + returned = loan.returned_at + self.assertEqual(post(self.sessions[0], path).status_code, 200) + loan.refresh_from_db() + self.assertEqual(loan.returned_at, returned) + self.assertEqual(self.borrow(1).status_code, 200) + self.assertEqual(Loan.objects.filter(item=self.item).count(), 2) + + def test_staff_return_and_private_listing(self): + self.borrow() + loan = Loan.objects.get(item=self.item) + self.assertNotIn(f'data-loan="{loan.pk}"', self.sessions[1].get(BASE + "/", timeout=15).text) + self.assertIn(f'data-loan="{loan.pk}"', self.sessions[2].get(BASE + "/", timeout=15).text) + self.assertEqual(post(self.sessions[2], f"/loans/{loan.pk}/return/").status_code, 200) + + def test_disabled_and_missing_items(self): + self.item.enabled = False + self.item.save() + self.assertEqual(self.borrow().status_code, 409) + self.assertEqual(post(self.sessions[0], "/items/999999999/borrow/").status_code, 404) + self.assertEqual(post(self.sessions[0], "/items/nope/borrow/").status_code, 404) + + def test_admin_authorization_and_validation(self): + self.assertEqual(self.sessions[0].get(BASE + "/admin/board/item/add/", allow_redirects=False, timeout=15).status_code, 302) + page = self.sessions[2].get(BASE + "/admin/board/item/add/", timeout=15) + token = re.search(r'name="csrfmiddlewaretoken" value="([^"]+)"', page.text).group(1) + result = self.sessions[2].post(BASE + "/admin/board/item/add/", data={"csrfmiddlewaretoken": token, + "asset_tag": "", "name": "", "_save": "Save"}, timeout=15) + self.assertEqual(result.status_code, 200) + self.assertIn("This field is required", result.text) + result = self.sessions[2].post(BASE + "/admin/board/item/add/", data={"csrfmiddlewaretoken": token, + "asset_tag": self.prefix + "admin", "name": "Staff-added kit", "enabled": "on", "_save": "Save"}, timeout=15, allow_redirects=False) + self.assertEqual(result.status_code, 302) + self.assertTrue(Item.objects.filter(asset_tag=self.prefix + "admin").exists()) + + def test_runtime_role_cannot_change_schema(self): + with self.assertRaises(django.db.ProgrammingError), transaction.atomic(): + with connection.cursor() as cur: + cur.execute("CREATE TABLE equipment.must_not_exist (id integer)") + with self.assertRaises(django.db.ProgrammingError), transaction.atomic(): + with connection.cursor() as cur: + cur.execute("UPDATE django_migrations SET name = name") + + def test_tls_chain_and_hostname_verification(self): + with connection.cursor() as cur: + cur.execute("SELECT ssl FROM pg_stat_ssl WHERE pid = pg_backend_pid()") + self.assertTrue(cur.fetchone()[0]) + params = dict(host=os.environ["PGHOST"], port=os.environ.get("PGPORT", "5432"), + dbname=os.environ.get("PGDATABASE", "postgres"), user=os.environ["PGUSER"], + password=os.environ["PGPASSWORD"], sslmode="verify-full", connect_timeout=10) + with self.assertRaises(psycopg.OperationalError) as wrong_ca: + psycopg.connect(**params, sslrootcert="/etc/ssl/certs/ca-certificates.crt") + self.assertIn("certificate verify failed", str(wrong_ca.exception).lower()) + import socket + params["hostaddr"] = socket.gethostbyname(params["host"]) + params["host"] = "wrong-hostname.example.invalid" + with self.assertRaises(psycopg.OperationalError) as wrong_host: + psycopg.connect(**params, sslrootcert=os.environ["PGSSLROOTCERT"]) + self.assertIn("does not match host name", str(wrong_host.exception).lower()) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/applications/equipment-checkout/tests/persistence.py b/applications/equipment-checkout/tests/persistence.py new file mode 100644 index 00000000..a0835c52 --- /dev/null +++ b/applications/equipment-checkout/tests/persistence.py @@ -0,0 +1,34 @@ +import os,json,re,sys +from pathlib import Path +import requests +os.environ.setdefault('DJANGO_SETTINGS_MODULE','config.settings') +import django +django.setup() +from board.models import Item,Loan +from django.contrib.auth import get_user_model +p=Path('.deployment/persistence.json') +s=requests.Session();base='http://127.0.0.1:8000' +if sys.argv[1]=='before': + page=s.get(base+'/accounts/login/',timeout=20) + token=re.search(r'name="csrfmiddlewaretoken" value="([^"]+)"',page.text).group(1) + response=s.post(base+'/accounts/login/',data={'username':'alex','password':os.environ['DEMO_PASSWORD'],'csrfmiddlewaretoken':token},timeout=20,allow_redirects=False) + assert response.status_code==302 + item=Item.objects.get(asset_tag='MIC-01') + response=s.post(base+f'/items/{item.pk}/borrow/',data={'csrfmiddlewaretoken':s.cookies['csrftoken']},headers={'HX-Request':'true'},timeout=20) + assert response.status_code==200 + loan=Loan.objects.get(item=item,returned_at__isnull=True) + p.write_text(json.dumps({'cookies':s.cookies.get_dict(),'loan':loan.pk,'checked_out_at':loan.checked_out_at.isoformat()}));p.chmod(0o600) + print('Before restart: authenticated session and active microphone loan recorded.') +else: + state=json.loads(p.read_text()) + for name,value in state['cookies'].items(): + s.cookies.set(name,value,domain='127.0.0.1',path='/') + response=s.get(base+'/',timeout=20,allow_redirects=False) + assert response.status_code==200 + assert f'data-loan="{state["loan"]}"' in response.text + loan=Loan.objects.get(pk=state['loan']) + assert loan.checked_out_at.isoformat()==state['checked_out_at'] and loan.returned_at is None + response=s.post(base+f'/loans/{loan.pk}/return/',data={'csrfmiddlewaretoken':s.cookies['csrftoken']},headers={'HX-Request':'true'},timeout=20) + assert response.status_code==200 + p.unlink() + print('After process restart: same session authorized, same loan/timestamp persisted; return succeeded.') From 38e11ded0719c8559f73c2e1a52e4a24f4b8daed Mon Sep 17 00:00:00 2001 From: sdairs Date: Fri, 2 Oct 2026 11:52:48 +0100 Subject: [PATCH 2/4] Format checkout stylesheet without trailing whitespace --- applications/equipment-checkout/board/static/board/style.css | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/applications/equipment-checkout/board/static/board/style.css b/applications/equipment-checkout/board/static/board/style.css index 4233305f..abbc6a05 100644 --- a/applications/equipment-checkout/board/static/board/style.css +++ b/applications/equipment-checkout/board/static/board/style.css @@ -5,7 +5,7 @@ 600; 700; 800&display=swap'); - + :root { --ink:#192b2a; --green:#244d41; @@ -206,4 +206,3 @@ h1 { .loan { gap:14px} } - From 06d673c9b619d7f76aa9a9b87ad684bfd74521cb Mon Sep 17 00:00:00 2001 From: sdairs Date: Fri, 2 Oct 2026 15:26:44 +0100 Subject: [PATCH 3/4] Update browser test helper to Playwright 1.56.0 --- applications/equipment-checkout/requirements-dev.in | 2 +- applications/equipment-checkout/requirements-dev.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/applications/equipment-checkout/requirements-dev.in b/applications/equipment-checkout/requirements-dev.in index f66a122f..af479544 100644 --- a/applications/equipment-checkout/requirements-dev.in +++ b/applications/equipment-checkout/requirements-dev.in @@ -1,3 +1,3 @@ -r requirements.in requests==2.32.5 -playwright==1.55.0 +playwright==1.56.0 diff --git a/applications/equipment-checkout/requirements-dev.txt b/applications/equipment-checkout/requirements-dev.txt index cdd40ece..92b9c849 100644 --- a/applications/equipment-checkout/requirements-dev.txt +++ b/applications/equipment-checkout/requirements-dev.txt @@ -6,7 +6,7 @@ greenlet==3.5.6 gunicorn==23.0.0 idna==3.20 packaging==26.3 -playwright==1.55.0 +playwright==1.56.0 psycopg==3.3.6 psycopg-binary==3.3.6 pyee==13.0.1 From 4a870b90873b8f3ca5928c8684fa756094a51d68 Mon Sep 17 00:00:00 2001 From: sdairs Date: Fri, 2 Oct 2026 21:31:16 +0100 Subject: [PATCH 4/4] docs: remove public beta wording --- applications/equipment-checkout/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/applications/equipment-checkout/README.md b/applications/equipment-checkout/README.md index d71385ad..800513ad 100644 --- a/applications/equipment-checkout/README.md +++ b/applications/equipment-checkout/README.md @@ -1,6 +1,6 @@ # Equipment Checkout Board -Borrow shared equipment, see your current loans, and return items with **Django**, **Django ORM**, **psycopg**, and **htmx** on **ClickHouse Managed Postgres (public beta)**. Native Django authentication identifies borrowers. Staff use Django admin to add or disable inventory and can return any current loan. +Borrow shared equipment, see your current loans, and return items with **Django**, **Django ORM**, **psycopg**, and **htmx** on **ClickHouse Managed Postgres**. Native Django authentication identifies borrowers. Staff use Django admin to add or disable inventory and can return any current loan. Two people can see the same available camera. Only one can check it out: a transaction locks the item, and a Postgres partial unique constraint permits one active loan per item. Returns preserve history and are idempotent. An ordinary member can return only their own loans; staff can return anyone's. Available inventory contains no borrower names.