1 Commits

Author SHA1 Message Date
158af49727 No more login from 192.168.0.0/16
All checks were successful
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/labhelper) (push) Successful in 2m33s
Build containers when image tags change / build-if-image-changed (data-loader, loader, initContainers, init-container, git.baumann.gr/adebaumann/labhelper-data-loader) (push) Successful in 11s
2026-04-06 17:59:50 +02:00
8 changed files with 707 additions and 262 deletions

71
AGENTS.md Normal file
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

272
AGENTS.md.backup Normal file
View File

@@ -0,0 +1,272 @@
# AGENTS.md - AI Coding Agent Guidelines
This document provides guidelines for AI coding agents working in the labhelper repository.
## Project Overview
- **Type**: Django web application
- **Python**: 3.13.7
- **Django**: 5.2.9
- **Database**: SQLite (development)
- **Virtual Environment**: `.venv/`
## Build/Run Commands
### Development Server
```bash
python manage.py runserver # Start dev server on port 8000
python manage.py runserver 0.0.0.0:8000 # Bind to all interfaces
```
### Database Operations
```bash
python manage.py makemigrations # Create migration files
python manage.py makemigrations boxes # Create migrations for specific app
python manage.py migrate # Apply all migrations
python manage.py showmigrations # List migration status
```
### Testing
```bash
# Run all tests
python manage.py test
# Run tests for a specific app
python manage.py test boxes
# Run a specific test class
python manage.py test boxes.tests.TestClassName
# Run a single test method
python manage.py test boxes.tests.TestClassName.test_method_name
# Run tests with verbosity
python manage.py test -v 2
# Run tests with coverage
coverage run manage.py test
coverage report
coverage html # Generate HTML report
```
### Django Shell
```bash
python manage.py shell # Interactive Django shell
python manage.py createsuperuser # Create admin user
python manage.py collectstatic # Collect static files
```
### Production
```bash
gunicorn labhelper.wsgi:application # Run with Gunicorn
```
## Code Style Guidelines
### Python Style
- Follow PEP 8 conventions
- Use 4-space indentation (no tabs)
- Maximum line length: 79 characters (PEP 8 standard)
- Use single quotes for strings: `'string'`
- Use double quotes for docstrings: `"""Docstring."""`
### Import Order
Organize imports in this order, with blank lines between groups:
```python
# 1. Standard library imports
import os
import sys
from pathlib import Path
# 2. Django imports
from django.db import models
from django.contrib import admin
from django.shortcuts import render, redirect
from django.http import HttpResponse, JsonResponse
# 3. Third-party imports
import requests
from markdown import markdown
# 4. Local application imports
from .models import MyModel
from .forms import MyForm
```
### Naming Conventions
| Type | Convention | Example |
|------|------------|---------|
| Modules | lowercase_with_underscores | `user_profile.py` |
| Classes | PascalCase | `UserProfile` |
| Functions | lowercase_with_underscores | `get_user_data()` |
| Constants | UPPERCASE_WITH_UNDERSCORES | `MAX_CONNECTIONS` |
| Variables | lowercase_with_underscores | `user_count` |
| Django Models | PascalCase (singular) | `Box`, `UserProfile` |
| Django Apps | lowercase (short) | `boxes`, `users` |
### Django-Specific Conventions
**Models:**
```python
from django.db import models
class Box(models.Model):
"""A storage box in the lab."""
name = models.CharField(max_length=255)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name_plural = 'boxes'
ordering = ['-created_at']
def __str__(self):
return self.name
```
**Views:**
```python
from django.shortcuts import render, get_object_or_404
from django.http import Http404
def box_detail(request, box_id):
"""Display details for a specific box."""
box = get_object_or_404(Box, pk=box_id)
return render(request, 'boxes/detail.html', {'box': box})
```
### Error Handling
```python
# Use specific exceptions
try:
result = some_operation()
except SpecificError as exc:
raise CustomError('Descriptive message') from exc
# Django: Use get_object_or_404 for model lookups
box = get_object_or_404(Box, pk=box_id)
# Log errors appropriately
import logging
logger = logging.getLogger(__name__)
logger.error('Error message: %s', error_detail)
```
### Type Hints (Recommended)
```python
from typing import Optional
from django.http import HttpRequest, HttpResponse
def get_box(request: HttpRequest, box_id: int) -> HttpResponse:
"""Retrieve a box by ID."""
...
```
## Project Structure
```
labhelper/
├── manage.py # Django CLI entry point
├── requirements.txt # Python dependencies
├── labhelper/ # Project configuration
│ ├── settings.py # Django settings
│ ├── urls.py # Root URL routing
│ ├── wsgi.py # WSGI application
│ └── asgi.py # ASGI application
└── boxes/ # Django app
├── admin.py # Admin configuration
├── apps.py # App configuration
├── models.py # Data models
├── views.py # View functions
├── tests.py # Test cases
├── migrations/ # Database migrations
└── templates/ # HTML templates
```
## Available Django Extensions
The project includes these pre-installed packages:
- **django-mptt**: Tree structures (categories, hierarchies)
- **django-mptt-admin**: Admin interface for MPTT models
- **django-admin-sortable2**: Drag-and-drop ordering in admin
- **django-nested-admin**: Nested inline forms in admin
- **django-nested-inline**: Additional nested inline support
- **django-revproxy**: Reverse proxy functionality
## Testing Guidelines
- Use `django.test.TestCase` for database tests
- Use `django.test.SimpleTestCase` for tests without database
- Name test files `test_*.py` or `*_tests.py`
- Name test methods `test_*`
- Use descriptive test method names
```python
from django.test import TestCase
from .models import Box
class BoxModelTests(TestCase):
"""Tests for the Box model."""
def setUp(self):
"""Set up test fixtures."""
self.box = Box.objects.create(name='Test Box')
def test_box_str_returns_name(self):
"""Box __str__ should return the box name."""
self.assertEqual(str(self.box), 'Test Box')
```
## Files to Never Commit
Per `.gitignore`:
- `__pycache__/`, `*.pyc` - Python bytecode
- `.venv/` - Virtual environment
- `.env` - Environment variables
- `data/db.sqlite3` - Database file
- `keys/` - Secret keys
## Common Pitfalls
1. **Always activate venv**: `source .venv/bin/activate`
2. **Run migrations after model changes**: `makemigrations` then `migrate`
3. **Add new apps to INSTALLED_APPS** in `settings.py`
4. **Use get_object_or_404** instead of bare `.get()` calls
5. **Never commit SECRET_KEY** - use environment variables in production
## Deployment Commands
### Prepare a Full Deployment
When instructed to "Prepare a full deployment", perform the following steps:
1. **Bump container versions**: In `argocd/deployment.yaml`, increment the version numbers by 0.001 for both containers:
- `labhelper-data-loader` (initContainer)
- `labhelper` (main container)
2. **Copy database**: Copy the current development database to the data-loader preload location:
```bash
cp data/db.sqlite3 data-loader/preload.sqlite3
```
### Prepare a Partial Deployment
When instructed to "Prepare a partial deployment", perform the following step:
1. **Bump main container version only**: In `argocd/deployment.yaml`, increment the version number by 0.001 for the main container only:
- `labhelper` (main container)
Do NOT bump the data-loader version or copy the database.

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"
@@ -21,4 +21,4 @@ data:
LOGOUT_REDIRECT_URL: "/login/"
OIDC_AUTHENTICATION_FAILURE_REDIRECT_URL: "/login/"
GUNICORN_OPTS: "--access-logfile -"
IMAGE_TAG: "0.079"
IMAGE_TAG: "0.083"

View File

@@ -27,7 +27,7 @@ spec:
mountPath: /data
containers:
- name: web
image: git.baumann.gr/adebaumann/labhelper:0.082
image: git.baumann.gr/adebaumann/labhelper:0.083
imagePullPolicy: Always
ports:
- containerPort: 8000

View File

@@ -5,14 +5,6 @@ metadata:
namespace: labhelper
annotations:
argocd.argoproj.io/ignore-healthcheck: "true"
gethomepage.dev/enabled: "true"
gethomepage.dev/name: "Labhelper"
gethomepage.dev/description: "Laboratory inventory system"
gethomepage.dev/group: "Kubernetes"
gethomepage.dev/icon: "shield.png"
gethomepage.dev/href: "https://labhelper.adebaumann.com"
gethomepage.dev/ping: "https://labhelper.adebaumann.com/health/"
gethomepage.dev/pod-selector: "app=django"
spec:
ingressClassName: traefik
rules:

31
boxes/decorators.py Normal file
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

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,
},
)

View File

@@ -21,71 +21,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",
}
}
@@ -95,16 +100,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",
},
]
@@ -112,43 +117,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",
]
# ---------------------------------------------------------------------------
@@ -160,26 +167,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 AJAX endpoints from the session-refresh middleware redirect
OIDC_EXEMPT_URLS = ['search_api']
# 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/")]