diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..f8851d4 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,71 @@ +# AGENTS.md - labhelper + +**Type**: Django 5.2 web app (lab inventory) +**Python**: 3.13 | **DB**: SQLite (dev) | **Env**: `.venv/` + +## Essential Commands + +```bash +# Activate venv first +source .venv/bin/activate + +# Dev server +python manage.py runserver # localhost:8000 +python manage.py runserver 0.0.0.0:8000 # all interfaces + +# Database +python manage.py makemigrations boxes # after model changes +python manage.py migrate +python manage.py showmigrations + +# Testing +python manage.py test # all +python manage.py test boxes # app only +python manage.py test boxes.tests.BoxModelTests.test_method # specific + +# Custom commands +python manage.py create_default_users # admin/admin123, staff/staff123, viewer/viewer123 +python manage.py clean_orphaned_files --dry-run +python manage.py clean_orphaned_images --dry-run +python manage.py collectstatic # after CSS changes +``` + +## Critical Rules + +1. **NEVER commit/push without explicit permission** - wait for user to say "commit and push" +2. Use `get_object_or_404()` not bare `.get()` for model lookups +3. Run `makemigrations` then `migrate` after any model change +4. Default users: `admin/admin123` (superuser), `staff/staff123`, `viewer/viewer123` + +## Data Model + +- **BoxType** → Box (1:N, PROTECT) +- **Box** → Thing (1:N, PROTECT) — Box.pk is CharField(max=10) +- **Facet** → Tag (1:N, CASCADE) — Facet.cardinality: single/multiple +- **Thing** ↔ Tag (M2M) +- **Thing** → ThingFile, ThingLink (1:N, CASCADE) + +## Deployment (when instructed) + +**Full deploy**: bump both versions in `argocd/deployment.yaml` (+0.001), then: +```bash +cp data/db.sqlite3 data-loader/preload.sqlite3 +``` + +**Partial deploy**: bump main container only, skip DB copy. + +## Key Directories + +- `boxes/` — main app (models, views, forms, templates) +- `labhelper/` — project settings, base template +- `argocd/` — Kubernetes manifests for production +- `data-loader/` — init container with preloaded DB + +## Gotchas + +- Template base: `labhelper/templates/base.html` +- App templates: `boxes/templates/boxes/` +- Box ID is CharField (e.g., "A1-001"), not auto-increment +- Views use `conditional_login_required` - bypasses login for IPs in `ALLOWED_CIDR_NETS` env var +- Markdown via `{{ text|render_markdown }}` (sanitized with bleach) +- Third-party: django-mptt, sorl-thumbnail, bleach, markdown diff --git a/README.md b/README.md new file mode 100644 index 0000000..240da86 --- /dev/null +++ b/README.md @@ -0,0 +1,219 @@ +# 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 + +```bash +# 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](#authentication)). Otherwise you need a working Keycloak realm +configured through the environment variables below. + +### Common commands + +```bash +# 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: + +```bash +# 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/-.` after the thing gets + a primary key (see `Thing.save`). +- Deleting a thing also deletes its picture and attached files from disk. diff --git a/argocd/configmap.yaml b/argocd/configmap.yaml index f96e32d..c471244 100644 --- a/argocd/configmap.yaml +++ b/argocd/configmap.yaml @@ -6,7 +6,7 @@ metadata: data: DEBUG: "False" ALLOWED_HOSTS: "labhelper.adebaumann.com,*" - ALLOWED_CIDR_NETS: "10.0.0.0/16" + ALLOWED_CIDR_NETS: "10.0.0.0/16,192.168.0.0/16" LANGUAGE_CODE: "en-us" TIME_ZONE: "UTC" USE_I18N: "True" diff --git a/boxes/decorators.py b/boxes/decorators.py new file mode 100644 index 0000000..753207c --- /dev/null +++ b/boxes/decorators.py @@ -0,0 +1,31 @@ +import functools + +from django.conf import settings + + +def conditional_login_required(view_func): + """Skip login_required if client IP is in ALLOWED_CIDR_NETS.""" + + @functools.wraps(view_func) + def wrapper(request, *args, **kwargs): + # Get client IP + x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR") + if x_forwarded_for: + ip = x_forwarded_for.split(",")[0].strip() + else: + ip = request.META.get("REMOTE_ADDR", "") + + # Check if IP is in allowed networks + from ipaddress import ip_address, ip_network + + client_ip = ip_address(ip) + for net in getattr(settings, "ALLOWED_CIDR_NETS", []): + if client_ip in ip_network(net, strict=False): + return view_func(request, *args, **kwargs) + + # Fall back to login_required + from django.contrib.auth.decorators import login_required + + return login_required(view_func)(request, *args, **kwargs) + + return wrapper diff --git a/boxes/views.py b/boxes/views.py index d793e25..0bbf278 100644 --- a/boxes/views.py +++ b/boxes/views.py @@ -1,7 +1,7 @@ import bleach import markdown -from django.contrib.auth.decorators import login_required +from boxes.decorators import conditional_login_required as login_required from django.db.models import Q, Prefetch from django.http import HttpResponse, JsonResponse from django.shortcuts import get_object_or_404, redirect, render @@ -20,25 +20,25 @@ from .models import Box, BoxType, Facet, Tag, Thing, ThingFile, ThingLink def health_check(request): """Health check endpoint for Kubernetes liveness/readiness probes.""" - return HttpResponse('OK', status=200) + return HttpResponse("OK", status=200) def _strip_markdown(text, max_length=100): """Convert Markdown to plain text and truncate.""" if not text: - return '' + return "" html = markdown.markdown(text) plain_text = bleach.clean(html, tags=[], strip=True) - plain_text = ' '.join(plain_text.split()) + plain_text = " ".join(plain_text.split()) if len(plain_text) > max_length: - return plain_text[:max_length].rsplit(' ', 1)[0] + '...' + return plain_text[:max_length].rsplit(" ", 1)[0] + "..." return plain_text @login_required def index(request): """Home page with search and tags.""" - facets = Facet.objects.all().prefetch_related('tags') + facets = Facet.objects.all().prefetch_related("tags") facet_tag_counts = {} for facet in facets: @@ -49,95 +49,107 @@ def index(request): facet_tag_counts[facet] = [] facet_tag_counts[facet].append((tag, count)) - return render(request, 'boxes/index.html', { - 'facets': facets, - 'facet_tag_counts': facet_tag_counts, - }) + return render( + request, + "boxes/index.html", + { + "facets": facets, + "facet_tag_counts": facet_tag_counts, + }, + ) @login_required def box_detail(request, box_id): """Display contents of a box.""" box = get_object_or_404(Box, pk=box_id) - things = box.things.prefetch_related('tags').all() - return render(request, 'boxes/box_detail.html', { - 'box': box, - 'things': things, - }) + things = box.things.prefetch_related("tags").all() + return render( + request, + "boxes/box_detail.html", + { + "box": box, + "things": things, + }, + ) @login_required def thing_detail(request, thing_id): """Display details of a thing (read-only).""" thing = get_object_or_404( - Thing.objects.select_related('box', 'box__box_type').prefetch_related('files', 'links', 'tags'), - pk=thing_id + Thing.objects.select_related("box", "box__box_type").prefetch_related( + "files", "links", "tags" + ), + pk=thing_id, ) - return render(request, 'boxes/thing_detail.html', {'thing': thing}) + return render(request, "boxes/thing_detail.html", {"thing": thing}) @login_required def edit_thing(request, thing_id): """Edit a thing's details.""" thing = get_object_or_404( - Thing.objects.select_related('box', 'box__box_type').prefetch_related('files', 'links', 'tags'), - pk=thing_id + Thing.objects.select_related("box", "box__box_type").prefetch_related( + "files", "links", "tags" + ), + pk=thing_id, ) - boxes = Box.objects.select_related('box_type').all() - facets = Facet.objects.all().prefetch_related('tags') + boxes = Box.objects.select_related("box_type").all() + facets = Facet.objects.all().prefetch_related("tags") picture_form = ThingPictureForm(instance=thing) file_form = ThingFileForm() link_form = ThingLinkForm() - if request.method == 'POST': - action = request.POST.get('action') + if request.method == "POST": + action = request.POST.get("action") - if action == 'save_details': + if action == "save_details": form = ThingForm(request.POST, request.FILES, instance=thing) if form.is_valid(): form.save() - return redirect('thing_detail', thing_id=thing.id) + return redirect("thing_detail", thing_id=thing.id) - elif action == 'move': - new_box_id = request.POST.get('new_box') + elif action == "move": + new_box_id = request.POST.get("new_box") if new_box_id: new_box = get_object_or_404(Box, pk=new_box_id) thing.box = new_box thing.save() - return redirect('edit_thing', thing_id=thing.id) + return redirect("edit_thing", thing_id=thing.id) - elif action == 'upload_picture': + elif action == "upload_picture": picture_form = ThingPictureForm(request.POST, request.FILES, instance=thing) if picture_form.is_valid(): picture_form.save() - return redirect('edit_thing', thing_id=thing.id) + return redirect("edit_thing", thing_id=thing.id) - elif action == 'delete_picture': + elif action == "delete_picture": if thing.picture: thing.picture.delete() thing.picture = None thing.save() - return redirect('edit_thing', thing_id=thing.id) + return redirect("edit_thing", thing_id=thing.id) - elif action == 'add_file': + elif action == "add_file": file_form = ThingFileForm(request.POST, request.FILES) if file_form.is_valid(): thing_file = file_form.save(commit=False) thing_file.thing = thing thing_file.save() - return redirect('edit_thing', thing_id=thing.id) + return redirect("edit_thing", thing_id=thing.id) - elif action == 'add_link': + elif action == "add_link": link_form = ThingLinkForm(request.POST) if link_form.is_valid(): thing_link = link_form.save(commit=False) thing_link.thing = thing thing_link.save() - return redirect('edit_thing', thing_id=thing.id) + return redirect("edit_thing", thing_id=thing.id) - elif action == 'delete_file': - file_id = request.POST.get('file_id') + elif action == "delete_file": + file_id = request.POST.get("file_id") if file_id: try: thing_file = ThingFile.objects.get(pk=file_id, thing=thing) @@ -145,20 +157,20 @@ def edit_thing(request, thing_id): thing_file.delete() except ThingFile.DoesNotExist: pass - return redirect('edit_thing', thing_id=thing.id) + return redirect("edit_thing", thing_id=thing.id) - elif action == 'delete_link': - link_id = request.POST.get('link_id') + elif action == "delete_link": + link_id = request.POST.get("link_id") if link_id: try: thing_link = ThingLink.objects.get(pk=link_id, thing=thing) thing_link.delete() except ThingLink.DoesNotExist: pass - return redirect('edit_thing', thing_id=thing.id) + return redirect("edit_thing", thing_id=thing.id) - elif action == 'add_tag': - tag_id = request.POST.get('tag_id') + elif action == "add_tag": + tag_id = request.POST.get("tag_id") if tag_id: try: tag = Tag.objects.get(pk=tag_id) @@ -169,114 +181,134 @@ def edit_thing(request, thing_id): thing.tags.add(tag) except Tag.DoesNotExist: pass - return redirect('edit_thing', thing_id=thing.id) + return redirect("edit_thing", thing_id=thing.id) - elif action == 'remove_tag': - tag_id = request.POST.get('tag_id') + elif action == "remove_tag": + tag_id = request.POST.get("tag_id") if tag_id: try: tag = Tag.objects.get(pk=tag_id) thing.tags.remove(tag) except Tag.DoesNotExist: pass - return redirect('edit_thing', thing_id=thing.id) + return redirect("edit_thing", thing_id=thing.id) thing_form = ThingForm(instance=thing) - return render(request, 'boxes/edit_thing.html', { - 'thing': thing, - 'boxes': boxes, - 'facets': facets, - 'picture_form': picture_form, - 'file_form': file_form, - 'link_form': link_form, - 'thing_form': thing_form, - }) + return render( + request, + "boxes/edit_thing.html", + { + "thing": thing, + "boxes": boxes, + "facets": facets, + "picture_form": picture_form, + "file_form": file_form, + "link_form": link_form, + "thing_form": thing_form, + }, + ) @login_required def boxes_list(request): """Boxes list page showing all boxes with contents.""" - boxes = Box.objects.select_related('box_type').prefetch_related('things').all() - return render(request, 'boxes/boxes_list.html', { - 'boxes': boxes, - }) + boxes = Box.objects.select_related("box_type").prefetch_related("things").all() + return render( + request, + "boxes/boxes_list.html", + { + "boxes": boxes, + }, + ) @login_required def search_api(request): """AJAX endpoint for searching things.""" - query = request.GET.get('q', '').strip() + query = request.GET.get("q", "").strip() if len(query) < 2: - return JsonResponse({'results': []}) + return JsonResponse({"results": []}) # Check for "Facet:Word" format - if ':' in query: - parts = query.split(':',1) + if ":" in query: + parts = query.split(":", 1) facet_name = parts[0].strip() tag_name = parts[1].strip() - + # Search for things with specific facet and tag - things = Thing.objects.filter( - Q(tags__facet__name__icontains=facet_name) & - Q(tags__name__icontains=tag_name) - ).prefetch_related('files', 'links', 'tags').select_related('box').distinct()[:50] + things = ( + Thing.objects.filter( + Q(tags__facet__name__icontains=facet_name) + & Q(tags__name__icontains=tag_name) + ) + .prefetch_related("files", "links", "tags") + .select_related("box") + .distinct()[:50] + ) else: # Normal search - things = Thing.objects.filter( - Q(name__icontains=query) | - Q(description__icontains=query) | - Q(files__title__icontains=query) | - Q(files__file__icontains=query) | - Q(links__title__icontains=query) | - Q(links__url__icontains=query) | - Q(tags__name__icontains=query) | - Q(tags__facet__name__icontains=query) - ).prefetch_related('files', 'links', 'tags').select_related('box').distinct()[:50] + things = ( + Thing.objects.filter( + Q(name__icontains=query) + | Q(description__icontains=query) + | Q(files__title__icontains=query) + | Q(files__file__icontains=query) + | Q(links__title__icontains=query) + | Q(links__url__icontains=query) + | Q(tags__name__icontains=query) + | Q(tags__facet__name__icontains=query) + ) + .prefetch_related("files", "links", "tags") + .select_related("box") + .distinct()[:50] + ) results = [ { - 'id': thing.id, - 'name': thing.name, - 'box': thing.box.id, - 'description': _strip_markdown(thing.description), - 'tags': [ + "id": thing.id, + "name": thing.name, + "box": thing.box.id, + "description": _strip_markdown(thing.description), + "tags": [ { - 'name': tag.name, - 'color': tag.facet.color, + "name": tag.name, + "color": tag.facet.color, } for tag in thing.tags.all() ], - 'files': [ + "files": [ { - 'title': f.title, - 'filename': f.filename(), + "title": f.title, + "filename": f.filename(), } for f in thing.files.all() ], - 'links': [ + "links": [ { - 'title': l.title, - 'url': l.url, + "title": l.title, + "url": l.url, } for l in thing.links.all() ], } for thing in things ] - return JsonResponse({'results': results}) + return JsonResponse({"results": results}) @login_required def add_things(request, box_id): """Add multiple things to a box at once.""" box = get_object_or_404(Box, pk=box_id) - + success_message = None - - if request.method == 'POST': - formset = ThingFormSet(request.POST, request.FILES, queryset=Thing.objects.filter(box=box)) - + + if request.method == "POST": + formset = ThingFormSet( + request.POST, request.FILES, queryset=Thing.objects.filter(box=box) + ) + if formset.is_valid(): things = formset.save(commit=False) created_count = 0 @@ -286,173 +318,195 @@ def add_things(request, box_id): thing.save() created_count += 1 if created_count > 0: - success_message = f'Added {created_count} thing{"s" if created_count > 1 else ""} successfully.' + success_message = f"Added {created_count} thing{'s' if created_count > 1 else ''} successfully." formset = ThingFormSet(queryset=Thing.objects.filter(box=box)) else: formset = ThingFormSet(queryset=Thing.objects.filter(box=box)) - - return render(request, 'boxes/add_things.html', { - 'box': box, - 'formset': formset, - 'success_message': success_message, - }) + + return render( + request, + "boxes/add_things.html", + { + "box": box, + "formset": formset, + "success_message": success_message, + }, + ) @login_required def box_management(request): """Main page for managing boxes and box types.""" - box_types = BoxType.objects.all().prefetch_related('boxes') - boxes = Box.objects.select_related('box_type').all().prefetch_related('things') + box_types = BoxType.objects.all().prefetch_related("boxes") + boxes = Box.objects.select_related("box_type").all().prefetch_related("things") box_type_form = BoxTypeForm() box_form = BoxForm() - return render(request, 'boxes/box_management.html', { - 'box_types': box_types, - 'boxes': boxes, - 'box_type_form': box_type_form, - 'box_form': box_form, - }) + return render( + request, + "boxes/box_management.html", + { + "box_types": box_types, + "boxes": boxes, + "box_type_form": box_type_form, + "box_form": box_form, + }, + ) @login_required def add_box_type(request): """Add a new box type.""" - if request.method == 'POST': + if request.method == "POST": form = BoxTypeForm(request.POST) if form.is_valid(): form.save() - return redirect('box_management') + return redirect("box_management") @login_required def edit_box_type(request, type_id): """Edit an existing box type.""" box_type = get_object_or_404(BoxType, pk=type_id) - if request.method == 'POST': + if request.method == "POST": form = BoxTypeForm(request.POST, instance=box_type) if form.is_valid(): form.save() - return redirect('box_management') + return redirect("box_management") @login_required def delete_box_type(request, type_id): """Delete a box type.""" box_type = get_object_or_404(BoxType, pk=type_id) - if request.method == 'POST': + if request.method == "POST": if box_type.boxes.exists(): - return redirect('box_management') + return redirect("box_management") box_type.delete() - return redirect('box_management') + return redirect("box_management") @login_required def add_box(request): """Add a new box.""" - if request.method == 'POST': + if request.method == "POST": form = BoxForm(request.POST) if form.is_valid(): form.save() - return redirect('box_management') + return redirect("box_management") @login_required def edit_box(request, box_id): """Edit an existing box.""" box = get_object_or_404(Box, pk=box_id) - if request.method == 'POST': + if request.method == "POST": form = BoxForm(request.POST, instance=box) if form.is_valid(): form.save() - return redirect('box_management') + return redirect("box_management") @login_required def delete_box(request, box_id): """Delete a box.""" box = get_object_or_404(Box, pk=box_id) - if request.method == 'POST': + if request.method == "POST": if box.things.exists(): - return redirect('box_management') + return redirect("box_management") box.delete() - return redirect('box_management') + return redirect("box_management") @login_required def delete_thing(request, thing_id): """Delete a thing and its associated files.""" thing = get_object_or_404(Thing, pk=thing_id) - if request.method == 'POST': + if request.method == "POST": box_id = thing.box.id if thing.picture: thing.picture.delete(save=False) for thing_file in thing.files.all(): thing_file.file.delete(save=False) thing.delete() - return redirect('box_detail', box_id=box_id) - return redirect('edit_thing', thing_id=thing_id) + return redirect("box_detail", box_id=box_id) + return redirect("edit_thing", thing_id=thing_id) @login_required def resources_list(request): """List all links and files from things that have them.""" - things_with_files = Thing.objects.filter(files__isnull=False).prefetch_related('files').distinct() - things_with_links = Thing.objects.filter(links__isnull=False).prefetch_related('links').distinct() - - all_things = (things_with_files | things_with_links).distinct().order_by('name') - + things_with_files = ( + Thing.objects.filter(files__isnull=False).prefetch_related("files").distinct() + ) + things_with_links = ( + Thing.objects.filter(links__isnull=False).prefetch_related("links").distinct() + ) + + all_things = (things_with_files | things_with_links).distinct().order_by("name") + resources = [] - for thing in all_things.prefetch_related('files', 'links'): + for thing in all_things.prefetch_related("files", "links"): for file in thing.files.all(): - resources.append({ - 'type': 'file', - 'thing_name': thing.name, - 'thing_id': thing.id, - 'title': file.title, - 'url': file.file.url, - }) + resources.append( + { + "type": "file", + "thing_name": thing.name, + "thing_id": thing.id, + "title": file.title, + "url": file.file.url, + } + ) for link in thing.links.all(): - resources.append({ - 'type': 'link', - 'thing_name': thing.name, - 'thing_id': thing.id, - 'title': link.title, - 'url': link.url, - }) - - return render(request, 'boxes/resources_list.html', { - 'resources': resources, - }) + resources.append( + { + "type": "link", + "thing_name": thing.name, + "thing_id": thing.id, + "title": link.title, + "url": link.url, + } + ) + + return render( + request, + "boxes/resources_list.html", + { + "resources": resources, + }, + ) @login_required def fixme(request): """Page to find and fix things missing tags for specific facets.""" - facets = Facet.objects.all().prefetch_related('tags') - + facets = Facet.objects.all().prefetch_related("tags") + selected_facet = None missing_things = [] - - if request.method == 'GET' and 'facet_id' in request.GET: + + if request.method == "GET" and "facet_id" in request.GET: try: - selected_facet = Facet.objects.get(pk=request.GET['facet_id']) + selected_facet = Facet.objects.get(pk=request.GET["facet_id"]) # Find things that don't have any tag from this facet - missing_things = Thing.objects.exclude( - tags__facet=selected_facet - ).select_related('box', 'box__box_type').prefetch_related('tags') + missing_things = ( + Thing.objects.exclude(tags__facet=selected_facet) + .select_related("box", "box__box_type") + .prefetch_related("tags") + ) except Facet.DoesNotExist: selected_facet = None - - elif request.method == 'POST': - facet_id = request.POST.get('facet_id') - tag_ids = request.POST.getlist('tag_ids') - thing_ids = request.POST.getlist('thing_ids') - + + elif request.method == "POST": + facet_id = request.POST.get("facet_id") + tag_ids = request.POST.getlist("tag_ids") + thing_ids = request.POST.getlist("thing_ids") + if facet_id and tag_ids and thing_ids: facet = get_object_or_404(Facet, pk=facet_id) tags = Tag.objects.filter(id__in=tag_ids, facet=facet) things = Thing.objects.filter(id__in=thing_ids) - + for thing in things: if facet.cardinality == Facet.Cardinality.SINGLE: # Remove existing tags from this facet @@ -461,11 +515,15 @@ def fixme(request): for tag in tags: if tag.facet == facet: thing.tags.add(tag) - - return redirect('fixme') - - return render(request, 'boxes/fixme.html', { - 'facets': facets, - 'selected_facet': selected_facet, - 'missing_things': missing_things, - }) + + return redirect("fixme") + + return render( + request, + "boxes/fixme.html", + { + "facets": facets, + "selected_facet": selected_facet, + "missing_things": missing_things, + }, + ) diff --git a/labhelper/settings.py b/labhelper/settings.py index 1eed89f..d2d7562 100644 --- a/labhelper/settings.py +++ b/labhelper/settings.py @@ -22,71 +22,76 @@ BASE_DIR = Path(__file__).resolve().parent.parent # See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY', 'f0arjg8q3ut4iuqrguqfjaruf0eripIZZN3t1kymy8ugqnj$li2knhha0@gc5v8f3bge=$+gbybj2$jt28uqm') +SECRET_KEY = os.environ.get( + "DJANGO_SECRET_KEY", + "f0arjg8q3ut4iuqrguqfjaruf0eripIZZN3t1kymy8ugqnj$li2knhha0@gc5v8f3bge=$+gbybj2$jt28uqm", +) # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = os.environ.get('DEBUG', 'True').lower() == 'true' +DEBUG = os.environ.get("DEBUG", "True").lower() == "true" -ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '*').split(',') -ALLOWED_CIDR_NETS = os.environ.get('ALLOWED_CIDR_NETS', '10.0.0.0/16').split(',') +ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "*").split(",") +ALLOWED_CIDR_NETS = os.environ.get( + "ALLOWED_CIDR_NETS", "10.0.0.0/16,192.168.0.0/16" +).split(",") # Application definition INSTALLED_APPS = [ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'mozilla_django_oidc', - 'mptt', - 'django_mptt_admin', - 'sorl.thumbnail', - 'boxes', + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "mozilla_django_oidc", + "mptt", + "django_mptt_admin", + "sorl.thumbnail", + "boxes", ] MIDDLEWARE = [ - 'django.middleware.security.SecurityMiddleware', - 'whitenoise.middleware.WhiteNoiseMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'mozilla_django_oidc.middleware.SessionRefresh', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', + "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "mozilla_django_oidc.middleware.SessionRefresh", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", ] -ROOT_URLCONF = 'labhelper.urls' +ROOT_URLCONF = "labhelper.urls" TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [BASE_DIR / 'labhelper' / 'templates'], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', - 'labhelper.context_processors.image_tag', + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [BASE_DIR / "labhelper" / "templates"], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + "labhelper.context_processors.image_tag", ], }, }, ] -WSGI_APPLICATION = 'labhelper.wsgi.application' +WSGI_APPLICATION = "labhelper.wsgi.application" # Database # https://docs.djangoproject.com/en/5.2/ref/settings/#databases DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': BASE_DIR / 'data' / 'db.sqlite3', + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "data" / "db.sqlite3", } } @@ -96,16 +101,16 @@ DATABASES = { AUTH_PASSWORD_VALIDATORS = [ { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", }, ] @@ -113,43 +118,45 @@ AUTH_PASSWORD_VALIDATORS = [ # Internationalization # https://docs.djangoproject.com/en/5.2/topics/i18n/ -LANGUAGE_CODE = os.environ.get('LANGUAGE_CODE', 'en-us') +LANGUAGE_CODE = os.environ.get("LANGUAGE_CODE", "en-us") -TIME_ZONE = os.environ.get('TIME_ZONE', 'UTC') +TIME_ZONE = os.environ.get("TIME_ZONE", "UTC") -USE_I18N = os.environ.get('USE_I18N', 'True').lower() == 'true' +USE_I18N = os.environ.get("USE_I18N", "True").lower() == "true" -USE_TZ = os.environ.get('USE_TZ', 'True').lower() == 'true' +USE_TZ = os.environ.get("USE_TZ", "True").lower() == "true" # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/5.2/howto/static-files/ -STATIC_URL = os.environ.get('STATIC_URL', '/static/') -STATIC_ROOT = BASE_DIR / 'staticfiles' +STATIC_URL = os.environ.get("STATIC_URL", "/static/") +STATIC_ROOT = BASE_DIR / "staticfiles" # WhiteNoise static file serving configuration -STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage' +STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" # Media files (user uploads) -MEDIA_URL = os.environ.get('MEDIA_URL', '/media/') -MEDIA_ROOT = BASE_DIR / 'data' / 'media' +MEDIA_URL = os.environ.get("MEDIA_URL", "/media/") +MEDIA_ROOT = BASE_DIR / "data" / "media" # Default primary key field type # https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field -DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" -CSRF_TRUSTED_ORIGINS=os.environ.get('CSRF_TRUSTED_ORIGINS', 'https://labhelper.adebaumann.com,http://127.0.0.1:8000').split(',') +CSRF_TRUSTED_ORIGINS = os.environ.get( + "CSRF_TRUSTED_ORIGINS", "https://labhelper.adebaumann.com,http://127.0.0.1:8000" +).split(",") -LOGIN_URL = os.environ.get('LOGIN_URL', 'oidc_authentication_init') -LOGIN_REDIRECT_URL = os.environ.get('LOGIN_REDIRECT_URL', 'index') -LOGOUT_REDIRECT_URL = os.environ.get('LOGOUT_REDIRECT_URL', 'login') +LOGIN_URL = os.environ.get("LOGIN_URL", "oidc_authentication_init") +LOGIN_REDIRECT_URL = os.environ.get("LOGIN_REDIRECT_URL", "index") +LOGOUT_REDIRECT_URL = os.environ.get("LOGOUT_REDIRECT_URL", "login") AUTHENTICATION_BACKENDS = [ - 'labhelper.auth_backend.KeycloakOIDCBackend', + "labhelper.auth_backend.KeycloakOIDCBackend", # ModelBackend kept as fallback for Django admin emergency access - 'django.contrib.auth.backends.ModelBackend', + "django.contrib.auth.backends.ModelBackend", ] # --------------------------------------------------------------------------- @@ -161,34 +168,40 @@ AUTHENTICATION_BACKENDS = [ # All individual endpoints are derived from OIDC_OP_BASE_URL automatically. # You can override any individual endpoint with its own env var. # --------------------------------------------------------------------------- -_oidc_base = os.environ.get('OIDC_OP_BASE_URL', '').rstrip('/') -_oidc_connect = f'{_oidc_base}/protocol/openid-connect' if _oidc_base else '' +_oidc_base = os.environ.get("OIDC_OP_BASE_URL", "").rstrip("/") +_oidc_connect = f"{_oidc_base}/protocol/openid-connect" if _oidc_base else "" -OIDC_RP_CLIENT_ID = os.environ.get('OIDC_RP_CLIENT_ID', '') -OIDC_RP_CLIENT_SECRET = os.environ.get('OIDC_RP_CLIENT_SECRET', '') -OIDC_RP_SIGN_ALGO = 'RS256' -OIDC_RP_SCOPES = 'openid email profile' +OIDC_RP_CLIENT_ID = os.environ.get("OIDC_RP_CLIENT_ID", "") +OIDC_RP_CLIENT_SECRET = os.environ.get("OIDC_RP_CLIENT_SECRET", "") +OIDC_RP_SIGN_ALGO = "RS256" +OIDC_RP_SCOPES = "openid email profile" OIDC_USE_PKCE = True -OIDC_OP_AUTHORIZATION_ENDPOINT = os.environ.get('OIDC_OP_AUTHORIZATION_ENDPOINT', f'{_oidc_connect}/auth') -OIDC_OP_TOKEN_ENDPOINT = os.environ.get('OIDC_OP_TOKEN_ENDPOINT', f'{_oidc_connect}/token') -OIDC_OP_USER_ENDPOINT = os.environ.get('OIDC_OP_USER_ENDPOINT', f'{_oidc_connect}/userinfo') -OIDC_OP_JWKS_ENDPOINT = os.environ.get('OIDC_OP_JWKS_ENDPOINT', f'{_oidc_connect}/certs') -OIDC_OP_LOGOUT_ENDPOINT = os.environ.get('OIDC_OP_LOGOUT_ENDPOINT', f'{_oidc_connect}/logout') +OIDC_OP_AUTHORIZATION_ENDPOINT = os.environ.get( + "OIDC_OP_AUTHORIZATION_ENDPOINT", f"{_oidc_connect}/auth" +) +OIDC_OP_TOKEN_ENDPOINT = os.environ.get( + "OIDC_OP_TOKEN_ENDPOINT", f"{_oidc_connect}/token" +) +OIDC_OP_USER_ENDPOINT = os.environ.get( + "OIDC_OP_USER_ENDPOINT", f"{_oidc_connect}/userinfo" +) +OIDC_OP_JWKS_ENDPOINT = os.environ.get( + "OIDC_OP_JWKS_ENDPOINT", f"{_oidc_connect}/certs" +) +OIDC_OP_LOGOUT_ENDPOINT = os.environ.get( + "OIDC_OP_LOGOUT_ENDPOINT", f"{_oidc_connect}/logout" +) # Store the ID token in the session so Keycloak logout can use id_token_hint OIDC_STORE_ID_TOKEN = True # Redirect to the static login page on auth failure instead of looping back into OIDC -OIDC_AUTHENTICATION_FAILURE_REDIRECT_URL = os.environ.get('OIDC_AUTHENTICATION_FAILURE_REDIRECT_URL', '/login/') +OIDC_AUTHENTICATION_FAILURE_REDIRECT_URL = os.environ.get( + "OIDC_AUTHENTICATION_FAILURE_REDIRECT_URL", "/login/" +) -# Exempt endpoints and uploaded media from the session-refresh middleware. -# -# Media URLs are already served without a login_required view. If an embedded -# image request is redirected into a silent OIDC refresh, the callback happens -# in a subresource context and browsers can withhold the session cookie, causing -# the OIDC state check to fail with HTTP 400. -OIDC_EXEMPT_URLS = [ - 'search_api', - re.compile(rf'^{re.escape(MEDIA_URL)}'), -] +# Exempt AJAX endpoints and media files from the session-refresh middleware redirect +import re + +OIDC_EXEMPT_URLS = ["search_api", "health_check", re.compile(r"^/media/")]