Files
2026-07-13 10:26:29 +02:00

9.7 KiB

LabHelper

A Django web app for keeping track of what's in the physical storage boxes in a lab or workshop. Boxes hold things, things are described, pictured, tagged, and linked to datasheets and resources, and everything is searchable from one box. Authentication is handled by Keycloak (OIDC), and the app is packaged for Kubernetes deployment via ArgoCD.

Features

  • Boxes & box types — define reusable box types (with physical dimensions) and create boxes of those types. Box IDs are human-chosen strings (e.g. A1-001), not auto-increment numbers.
  • Things — items stored in a box, each with a name, Markdown description, picture, file attachments (datasheets, etc.), and hyperlinks.
  • Faceted tagging — organise things by facets (e.g. Category, Priority). Each facet is single- or multi-valued and has its own colour. Tags belong to a facet.
  • Search — live AJAX search across names, descriptions, tags, files, and links. Supports Facet:Value queries (e.g. Category:Resistors).
  • Resources view — a flat list of every file and link across all things.
  • "Fix me" view — find things missing a tag for a given facet and bulk-tag them.
  • Bulk add — add many things to a box at once via a formset.
  • SSO — Keycloak/OIDC login with automatic Django group and is_staff mapping.

Tech stack

Area Choice
Framework Django 5.2 (Python 3.13/3.14)
Database SQLite (file-backed, persisted on a PVC in production)
Auth mozilla-django-oidc against Keycloak
Static files WhiteNoise (compressed manifest storage)
Images Pillow + sorl-thumbnail
Markdown markdown rendered then sanitised with bleach
Tree data django-mptt (facet/type hierarchies)
WSGI server Gunicorn (3 workers)
Deployment Docker image → Gitea registry → ArgoCD → Kubernetes (Traefik ingress)

Data model

BoxType ──1:N──> Box ──1:N──> Thing ──M:N──> Tag <──1:N── Facet
                                 │
                                 ├──1:N──> ThingFile
                                 └──1:N──> ThingLink
  • BoxType → Box and Box → Thing use PROTECT (you can't delete a box type still in use, or a box that still holds things).
  • Facet → Tag and Thing → ThingFile/ThingLink use CASCADE.
  • Box.id is a CharField(max_length=10) primary key.
  • Facet.cardinality is single (0..1 tag per thing) or multiple (0..n).

Local development

# 1. Activate the virtualenv
source .venv/bin/activate

# 2. Apply migrations (SQLite DB lives at data/db.sqlite3)
python manage.py migrate

# 3. Run the dev server
python manage.py runserver              # http://localhost:8000
python manage.py runserver 0.0.0.0:8000 # all interfaces

For local development you'll typically want DEBUG=True (the default) and to reach the app from an IP inside ALLOWED_CIDR_NETS, which bypasses the login requirement (see Authentication). Otherwise you need a working Keycloak realm configured through the environment variables below.

Common commands

# Database
python manage.py makemigrations boxes   # after editing boxes/models.py
python manage.py migrate
python manage.py showmigrations

# Tests
python manage.py test                   # everything
python manage.py test boxes             # the boxes app only

# Static files (after CSS changes)
python manage.py collectstatic

# Custom management commands
python manage.py list_things            # print all things with box IDs
python manage.py clean_orphaned_files --dry-run    # remove unreferenced files
python manage.py clean_orphaned_images --dry-run   # remove unreferenced images

Configuration

All configuration is read from environment variables (see labhelper/settings.py). Sensible defaults exist for local development; production values come from the Kubernetes ConfigMap (argocd/configmap.yaml) and Secret.

Variable Purpose Default
DJANGO_SECRET_KEY Django secret key dev key (do not use in prod)
DEBUG Debug mode True
ALLOWED_HOSTS Comma-separated allowed hosts *
ALLOWED_CIDR_NETS CIDR ranges that skip login 10.0.0.0/16,192.168.0.0/16
CSRF_TRUSTED_ORIGINS Trusted origins for CSRF prod URL + 127.0.0.1:8000
STATIC_URL / MEDIA_URL Static/media URL prefixes /static/ / /media/
TIME_ZONE, LANGUAGE_CODE, USE_I18N, USE_TZ Localisation UTC / en-us / True
LOGIN_URL Where unauthenticated users go oidc_authentication_init
LOGIN_REDIRECT_URL / LOGOUT_REDIRECT_URL Post-auth redirects index / login
OIDC_OP_BASE_URL Keycloak realm URL (endpoints derived from it)
OIDC_RP_CLIENT_ID / OIDC_RP_CLIENT_SECRET OIDC client credentials
GUNICORN_OPTS Extra gunicorn flags

OIDC_OP_BASE_URL should be the realm URL, e.g. https://sso.example.com/realms/homelab. The individual OIDC endpoints (auth, token, userinfo, JWKS, logout) are derived from it automatically but can each be overridden with their own OIDC_OP_*_ENDPOINT variable.

Authentication

Login is handled by Keycloak through mozilla-django-oidc. Two access paths exist:

  1. OIDC login — the normal path. KeycloakOIDCBackend (labhelper/auth_backend.py) syncs the user's name, is_staff flag, and Django group membership from Keycloak group claims on every login. The mapping:

    Keycloak group Django group is_staff
    LabHelper Administrators LabHelper Administrators yes
    LabHelper Staff LabHelper Staff no
    LabHelper Viewers LabHelper Viewers no
  2. Trusted networks — every view is wrapped in conditional_login_required (boxes/decorators.py), which skips authentication entirely for clients whose IP falls within ALLOWED_CIDR_NETS. This is what lets the app run open on a trusted LAN while still requiring SSO from outside.

The ModelBackend is retained as a fallback so the Django admin (/admin/) remains reachable for emergency access.

See Keycloak-installation.md for notes on setting up the realm, client, and groups.

Deployment

The app ships as two container images to a Gitea registry (git.baumann.gr/adebaumann/…) and is reconciled onto Kubernetes by ArgoCD from the manifests in argocd/:

  • web — the Django app (built from the root Dockerfile, served by gunicorn).
  • loader — an init container (data-loader/) that seeds the SQLite database from a preloaded copy on first boot, then preserves any existing DB on the PVC.

Images are not built on every push. The Gitea Actions workflow (.gitea/workflows/build-containers-on-demand.yml) triggers only when argocd/deployment.yaml, the Dockerfile, or data-loader/** change, reads the exact image tag out of the deployment manifest, and builds + pushes only if that tag isn't already in the registry. So deploying = bumping the image tag in the manifest.

Two helper scripts prepare a release:

# Full deploy: bump BOTH image versions (+0.001) and snapshot the DB into the loader
./scripts/full_deploy.sh

# Partial deploy: bump only the main web container (no DB snapshot)
./scripts/partial_deploy.sh

After running a script, commit and push the changed manifests — Gitea Actions builds the images and ArgoCD rolls them out.

Kubernetes resources (argocd/)

File Resource
deployment.yaml Deployment (web + loader init container) and Service
ingress.yaml Traefik Ingress for labhelper.adebaumann.com
configmap.yaml Non-secret environment configuration
001_pvc.yaml PersistentVolumeClaim for the SQLite DB and media
nfs-pv.yaml NFS-backed PersistentVolume

Secrets (DJANGO_SECRET_KEY, oidc-client-secret) are supplied via a Kubernetes Secret; see k8s-templates/secret.yaml and scripts/deploy_secret.sh. Liveness and readiness probes hit the /health/ endpoint.

Project layout

labhelper/
├── boxes/                  # main app: models, views, forms, templates, static, commands
│   ├── models.py           # BoxType, Box, Facet, Tag, Thing, ThingFile, ThingLink
│   ├── views.py            # all views (function-based)
│   ├── decorators.py       # conditional_login_required
│   └── management/commands # list_things, clean_orphaned_files, clean_orphaned_images
├── labhelper/              # project config
│   ├── settings.py         # env-driven settings
│   ├── urls.py             # URL routing
│   ├── auth_backend.py     # Keycloak OIDC backend + group mapping
│   └── templates/          # base.html, login.html
├── data/                   # SQLite DB + uploaded media (mounted from a PVC in prod)
├── data-loader/            # init-container image that seeds the DB
├── argocd/                 # Kubernetes manifests (ArgoCD-managed)
├── k8s-templates/          # secret templates
├── scripts/                # deploy helpers
├── Dockerfile              # web container build
├── gunicorn.conf.py        # gunicorn configuration
└── manage.py

Notes & gotchas

  • Templates live in labhelper/templates/ (base) and boxes/templates/boxes/ (app).
  • Descriptions are Markdown, rendered via a render_markdown filter and sanitised with bleach — don't trust raw HTML from descriptions.
  • Media files are served by Django in all environments (WhiteNoise handles static).
  • Uploaded pictures are renamed to things/<thing-id>-<slug>.<ext> after the thing gets a primary key (see Thing.save).
  • Deleting a thing also deletes its picture and attached files from disk.