Merged sso into master

This commit is contained in:
2026-07-13 10:32:19 +02:00
6 changed files with 652 additions and 260 deletions
+71
View File
@@ -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
+219
View File
@@ -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/<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.
+1 -1
View File
@@ -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"
+31
View File
@@ -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
+237 -179
View File
@@ -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,
},
)
+93 -80
View File
@@ -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/")]