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:Valuequeries (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_staffmapping.
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 → BoxandBox → ThingusePROTECT(you can't delete a box type still in use, or a box that still holds things).Facet → TagandThing → ThingFile/ThingLinkuseCASCADE.Box.idis aCharField(max_length=10)primary key.Facet.cardinalityissingle(0..1 tag per thing) ormultiple(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:
-
OIDC login — the normal path.
KeycloakOIDCBackend(labhelper/auth_backend.py) syncs the user's name,is_staffflag, and Django group membership from Keycloak group claims on every login. The mapping:Keycloak group Django group is_staffLabHelper AdministratorsLabHelper Administratorsyes LabHelper StaffLabHelper Staffno LabHelper ViewersLabHelper Viewersno -
Trusted networks — every view is wrapped in
conditional_login_required(boxes/decorators.py), which skips authentication entirely for clients whose IP falls withinALLOWED_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 rootDockerfile, 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) andboxes/templates/boxes/(app). - Descriptions are Markdown, rendered via a
render_markdownfilter and sanitised withbleach— 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 (seeThing.save). - Deleting a thing also deletes its picture and attached files from disk.