2 Commits

Author SHA1 Message Date
adebaumann 08a7d1387c Add project README 2026-07-13 10:26:29 +02:00
adebaumann 158af49727 No more login from 192.168.0.0/16
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
7 changed files with 631 additions and 837 deletions
+47 -582
View File
@@ -1,606 +1,71 @@
# AGENTS.md - AI Coding Agent Guidelines # AGENTS.md - labhelper
This document provides guidelines for AI coding agents working in the labhelper repository. **Type**: Django 5.2 web app (lab inventory)
**Python**: 3.13 | **DB**: SQLite (dev) | **Env**: `.venv/`
## Project Overview ## Essential Commands
- **Type**: Django web application (lab inventory management system)
- **Python**: 3.13.7
- **Django**: 5.2.9
- **Database**: SQLite (development)
- **Virtual Environment**: `.venv/`
## Build/Run Commands
### Development Server
```bash ```bash
python manage.py runserver # Start dev server on port 8000 # Activate venv first
python manage.py runserver 0.0.0.0:8000 # Bind to all interfaces source .venv/bin/activate
```
### Database Operations # Dev server
python manage.py runserver # localhost:8000
python manage.py runserver 0.0.0.0:8000 # all interfaces
```bash # Database
python manage.py makemigrations # Create migration files python manage.py makemigrations boxes # after model changes
python manage.py makemigrations boxes # Create migrations for specific app python manage.py migrate
python manage.py migrate # Apply all migrations python manage.py showmigrations
python manage.py showmigrations # List migration status
```
### Testing # Testing
python manage.py test # all
python manage.py test boxes # app only
python manage.py test boxes.tests.BoxModelTests.test_method # specific
```bash # Custom commands
# Run all tests python manage.py create_default_users # admin/admin123, staff/staff123, viewer/viewer123
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
```
### Custom Management Commands
```bash
# Create default users and groups
python manage.py create_default_users
# Clean up orphaned files from deleted things
python manage.py clean_orphaned_files
python manage.py clean_orphaned_files --dry-run python manage.py clean_orphaned_files --dry-run
# Clean up orphaned images and thumbnails
python manage.py clean_orphaned_images
python manage.py clean_orphaned_images --dry-run python manage.py clean_orphaned_images --dry-run
python manage.py collectstatic # after CSS changes
``` ```
## Code Style Guidelines ## Critical Rules
### Python Style 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`
- Follow PEP 8 conventions ## Data Model
- 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 - **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)
Organize imports in this order, with blank lines between groups: ## Deployment (when instructed)
```python **Full deploy**: bump both versions in `argocd/deployment.yaml` (+0.001), then:
# 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/
├── .gitea/
│ └── workflows/
│ └── build-containers-on-demand.yml # CI/CD workflow
├── argocd/ # Kubernetes deployment manifests
│ ├── 001_pvc.yaml # PersistentVolumeClaim
│ ├── deployment.yaml # Deployment + Service
│ ├── ingress.yaml # Traefik ingress
│ ├── nfs-pv.yaml # NFS PersistentVolume
│ ├── nfs-storageclass.yaml # NFS StorageClass
│ └── secret.yaml # Django secret key template
├── boxes/ # Main Django app
│ ├── management/
│ │ └── commands/
│ │ ├── clean_orphaned_files.py # Cleanup orphaned ThingFile attachments
│ │ └── clean_orphaned_images.py # Cleanup orphaned Thing images
│ ├── migrations/ # Database migrations
│ ├── static/
│ │ └── css/
│ │ ├── base.css # Base styles (layout, navbar, buttons, alerts, etc.)
│ │ ├── edit_thing.css # Edit thing form styles
│ │ └── thing_detail.css # Markdown content + lightbox styles
│ ├── templates/
│ │ └── boxes/
│ │ ├── add_things.html # Form to add multiple things
│ │ ├── box_detail.html # Box contents view
│ │ ├── box_management.html # Box/BoxType CRUD management
│ │ ├── boxes_list.html # Boxes list page with tabular view
│ │ ├── edit_thing.html # Edit thing page (name, description, picture, tags, files, links)
│ │ ├── index.html # Home page with search and tags
│ │ ├── resources_list.html # List all links and files from things
│ │ └── thing_detail.html # Read-only thing details view
│ ├── templatetags/
│ │ └── dict_extras.py # Custom template filters: get_item, render_markdown, truncate_markdown
│ ├── admin.py # Admin configuration
│ ├── apps.py # App configuration
│ ├── forms.py # All forms and formsets
│ ├── models.py # Data models
│ ├── tests.py # Test cases
│ └── views.py # View functions
├── data-loader/ # Init container for database preload
│ ├── Dockerfile # Alpine-based init container
│ └── preload.sqlite3 # Preloaded database for deployment
├── labhelper/ # Project configuration
│ ├── management/
│ │ └── commands/
│ │ └── create_default_users.py # Create default users/groups
│ ├── templates/
│ │ ├── base.html # Base template with navigation
│ │ └── login.html # Login page
│ ├── asgi.py # ASGI configuration
│ ├── settings.py # Django settings
│ ├── urls.py # Root URL configuration
│ └── wsgi.py # WSGI configuration
├── scripts/
│ ├── deploy_secret.sh # Generate and deploy Django secret
│ ├── full_deploy.sh # Bump both container versions + copy DB
│ └── partial_deploy.sh # Bump main container version only
├── .gitignore
├── AGENTS.md # This file
├── Dockerfile # Multi-stage build for main container
├── manage.py # Django CLI entry point
└── requirements.txt # Python dependencies
```
## Data Models
### boxes app
| Model | Description | Key Fields |
|-------|-------------|------------|
| **BoxType** | Type of storage box with dimensions | `name`, `width`, `height`, `length` (in mm) |
| **Box** | A storage box in the lab | `id` (CharField PK, max 10), `box_type` (FK) |
| **Facet** | A category of tags (e.g., Priority, Category) | `name`, `slug`, `color`, `cardinality` (single/multiple) |
| **Tag** | A tag value for a specific facet | `facet` (FK), `name` |
| **Thing** | An item stored in a box | `name`, `box` (FK), `description` (Markdown), `picture`, `tags` (M2M) |
| **ThingFile** | File attachment for a Thing | `thing` (FK), `file`, `title`, `uploaded_at` |
| **ThingLink** | Hyperlink for a Thing | `thing` (FK), `url`, `title`, `uploaded_at` |
**Model Relationships:**
- BoxType -> Box (1:N via `boxes` related_name, PROTECT on delete)
- Box -> Thing (1:N via `things` related_name, PROTECT on delete)
- Facet -> Tag (1:N via `tags` related_name, CASCADE on delete)
- Thing <-> Tag (M2M via `tags` related_name on Thing, `things` related_name on Tag)
- Thing -> ThingFile (1:N via `files` related_name, CASCADE on delete)
- Thing -> ThingLink (1:N via `links` related_name, CASCADE on delete)
**Facet Cardinality:**
- `single`: A thing can have at most one tag from this facet (e.g., Priority: High/Medium/Low)
- `multiple`: A thing can have multiple tags from this facet (e.g., Category: Electronics, Tools)
## 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
- **sorl-thumbnail**: Image thumbnailing
- **Pillow**: Image processing
- **gunicorn**: Production WSGI server
- **Markdown**: Markdown processing
- **bleach**: HTML sanitization
- **coverage**: Test coverage
- **Font Awesome**: Icon library (loaded via CDN)
- **jQuery**: JavaScript library (loaded via CDN)
## Frontend/CSS Guidelines
### Base Template
The project uses a base template system at `labhelper/templates/base.html`. All page templates should extend this base:
```django
{% extends "base.html" %}
{% block title %}Page Title - LabHelper{% endblock %}
{% block page_header %}
<!-- Optional page header with breadcrumbs -->
{% endblock %}
{% block content %}
<!-- Main page content -->
{% endblock %}
{% block extra_css %}{% endblock %}
{% block extra_js %}{% endblock %}
```
### Design System
**Color Scheme:**
- Primary gradient: `#667eea` (purple) to `#764ba2` (purple-blue)
- Success: Green gradient
- Error: Red gradient
- Background: Light gray `#f5f5f5` with gradient overlays
- Cards: White with subtle shadows
**Components:**
- **Navigation**: Glassmorphism effect with blur backdrop. Desktop (≥769px) shows horizontal menu with dropdown for authenticated user (contains Box Management, Resources, Admin, Logout)
- **Buttons**: Gradient backgrounds with hover lift effect
- **Cards**: White with rounded corners and box shadows
- **Tables**: Gradient headers with hover row effects
- **Alerts**: Gradient backgrounds with icons
- **Form Inputs**: Focused states with color transitions
**Typography:**
- System fonts: `-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif`
- Headings: Bold, colored
- Body: Regular, dark gray
**Icons:**
- Font Awesome 6.5.1 (CDN)
- Use semantic icons for actions
- Color: Match context or inherit from parent
**Responsive Design:**
- Mobile-first approach
- Grid layouts with `repeat(auto-fill, minmax(250px, 1fr))`
- Flexbox for component layouts
- Breakpoints handled by grid and flex-wrap
- **Navigation**: Responsive navbar with hamburger menu on mobile (≤768px) and horizontal menu with user dropdown on desktop (≥769px). Mobile keeps all items in the dropdown list
### CSS Guidelines
**Static CSS Files:**
- Base styles live in `boxes/static/css/base.css` (loaded by `base.html` via `{% static %}`)
- Page-specific styles live in separate static CSS files (e.g., `thing_detail.css`, `edit_thing.css`)
- Child templates load their CSS via `{% block extra_css %}` with `<link>` tags
- WhiteNoise serves and cache-busts static files via `CompressedManifestStaticFilesStorage`
- Run `python manage.py collectstatic` after adding or modifying static CSS files
**Naming:**
- Use descriptive class names
- BEM pattern encouraged for complex components
- Inline styles allowed for template-specific one-off styling
**Styles:**
- Use base CSS classes when possible
- Page-specific styles in dedicated static CSS files loaded via `{% block extra_css %}`
- JavaScript in `{% block extra_js %}`
- Smooth transitions (0.2s - 0.3s)
- Hover effects with transform and box-shadow
**jQuery Usage:**
- Loaded in base template
- Use for interactive elements (toggles, hovers)
- Event delegation for dynamically added elements
- Focus/blur events for form inputs
### Available Pages/Views
| View Function | URL Pattern | Name | Description |
|---------------|-------------|------|-------------|
| `index` | `/` | `index` | Home page with search and tags overview |
| `boxes_list` | `/search/` | `search`, `boxes_list` | Boxes list page with tabular view |
| `box_management` | `/box-management/` | `box_management` | Manage boxes and box types |
| `add_box_type` | `/box-type/add/` | `add_box_type` | Add new box type |
| `edit_box_type` | `/box-type/<int:type_id>/edit/` | `edit_box_type` | Edit box type |
| `delete_box_type` | `/box-type/<int:type_id>/delete/` | `delete_box_type` | Delete box type |
| `add_box` | `/box/add/` | `add_box` | Add new box |
| `edit_box` | `/box/<str:box_id>/edit/` | `edit_box` | Edit box |
| `delete_box` | `/box/<str:box_id>/delete/` | `delete_box` | Delete box |
| `box_detail` | `/box/<str:box_id>/` | `box_detail` | View box contents |
| `thing_detail` | `/thing/<int:thing_id>/` | `thing_detail` | Read-only view of thing details |
| `edit_thing` | `/thing/<int:thing_id>/edit/` | `edit_thing` | Edit thing (name, description, picture, tags, files, links, move) |
| `add_things` | `/box/<str:box_id>/add/` | `add_things` | Add multiple things to a box |
| `search_api` | `/search/api/` | `search_api` | AJAX search endpoint |
| `resources_list` | `/resources/` | `resources_list` | List all links and files from things (sorted by thing name) |
| `LoginView` | `/login/` | `login` | Django auth login |
| `LogoutView` | `/logout/` | `logout` | Django auth logout |
| `admin.site` | `/admin/` | - | Django admin |
**All views except login require authentication via `@login_required`.**
### Template Best Practices
1. **Always extend base template**
```django
{% extends "base.html" %}
```
2. **Use block system for content injection**
- `title`: Page title tag
- `page_header`: Page header with breadcrumbs
- `content`: Main page content
- `extra_css`: Additional CSS via `<link>` tags to static files
- `extra_head`: Additional head elements
- `extra_js`: Additional JavaScript
3. **Load required template tags**
```django
{% load static %} {# Required when using {% static %} for CSS/asset links #}
{% load mptt_tags %}
{% load thumbnail %}
{% load dict_extras %}
```
4. **Link page-specific CSS from static files**
```django
{% block extra_css %}
<link rel="stylesheet" href="{% static 'css/thing_detail.css' %}">
{% endblock %}
```
5. **Use URL names for links**
```django
<a href="{% url 'box_detail' box.id %}">
```
6. **Use icons with Font Awesome**
```django
<i class="fas fa-box"></i>
```
7. **Add breadcrumbs for navigation**
```django
<p class="breadcrumb">
<a href="/"><i class="fas fa-home"></i> Home</a> /
<a href="/box/{{ box.id }}/"><i class="fas fa-box"></i> Box {{ box.id }}</a>
</p>
```
8. **Icon alignment in lists**: When using icons in list items, use fixed width containers to ensure proper alignment
```django
<a href="{% url 'thing_detail' thing.id %}" style="display: inline-block; width: 20px; text-align: center;">
<i class="fas fa-link"></i>
</a>
```
### Markdown Support
The `Thing.description` field supports Markdown formatting with HTML sanitization for security.
**Available Template Filters:**
- `render_markdown`: Converts Markdown text to sanitized HTML with automatic link handling
- Converts Markdown syntax (headers, lists, bold, italic, links, code, tables, etc.)
- Sanitizes HTML using `bleach` to prevent XSS attacks
- Automatically adds `target="_blank"` and `rel="noopener noreferrer"` to external links
- Use in `thing_detail.html` for full rendered Markdown
- `truncate_markdown`: Converts Markdown to plain text and truncates
- Strips HTML tags after Markdown conversion
- Adds ellipsis (`...`) if text exceeds specified length (default: 100)
- Use in `box_detail.html` or search API previews where space is limited
**Usage Examples:**
```django
<!-- Full Markdown rendering -->
<div class="markdown-content">
{{ thing.description|render_markdown }}
</div>
<!-- Truncated plain text preview -->
{{ thing.description|truncate_markdown:100 }}
```
**Supported Markdown Features:**
- Bold: `**text**` or `__text__`
- Italic: `*text*` or `_text_`
- Headers: `# Header 1`, `## Header 2`, etc.
- Lists: `- item` or `1. item`
- Links: `[text](url)`
- Code: `` `code` `` or ` ```code block```
- Blockquotes: `> quote`
- Tables: `| A | B |\n|---|---|`
**Security:**
- All Markdown is sanitized before rendering
- Dangerous HTML tags (`<script>`, `<iframe>`, etc.) are stripped
- Only safe HTML tags and attributes are allowed
- External links automatically get `target="_blank"` and security attributes
## Forms
| Form | Model | Purpose |
|------|-------|---------|
| `ThingForm` | Thing | Add/edit a thing (name, description, picture) - tags managed separately |
| `ThingPictureForm` | Thing | Upload/change thing picture only |
| `ThingFileForm` | ThingFile | Add file attachment |
| `ThingLinkForm` | ThingLink | Add link |
| `BoxTypeForm` | BoxType | Add/edit box type |
| `BoxForm` | Box | Add/edit box |
| `ThingFormSet` | Thing | Formset for adding multiple things |
## Management Commands
### boxes app
| Command | Description | Options |
|---------|-------------|---------|
| `clean_orphaned_files` | Clean up orphaned files from deleted things | `--dry-run` |
| `clean_orphaned_images` | Clean up orphaned images and thumbnails | `--dry-run` |
### labhelper project
| Command | Description |
|---------|-------------|
| `create_default_users` | Create default users and groups (admin/admin123, staff/staff123, viewer/viewer123) |
## 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. **NEVER commit or push without explicit permission**: Always ask the user before running `git commit` or `git push`. The user will explicitly say "commit and push" when they want you to do this. Do NOT automatically commit/push after making changes unless instructed to do so.
2. **Always activate venv**: `source .venv/bin/activate`
3. **Run migrations after model changes**: `makemigrations` then `migrate`
4. **Add new apps to INSTALLED_APPS** in `settings.py`
5. **Templates in labhelper/templates/**: The base template and shared templates are in `labhelper/templates/`. App-specific templates remain in `app_name/templates/`.
6. **Use get_object_or_404** instead of bare `.get()` calls
7. **Never commit SECRET_KEY** - use environment variables in production
8. **Be careful with process management**: Avoid blanket kills on ports (e.g., `lsof -ti:8000 | xargs kill -9`) as they can kill unintended processes like web browsers. Use specific process kills instead: `pkill -f "process_name"`
## 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 ```bash
cp data/db.sqlite3 data-loader/preload.sqlite3 cp data/db.sqlite3 data-loader/preload.sqlite3
``` ```
### Prepare a Partial Deployment **Partial deploy**: bump main container only, skip DB copy.
When instructed to "Prepare a partial deployment", perform the following step: ## Key Directories
1. **Bump main container version only**: In `argocd/deployment.yaml`, increment the version number by 0.001 for the main container only: - `boxes/` — main app (models, views, forms, templates)
- `labhelper` (main container) - `labhelper/` — project settings, base template
- `argocd/` — Kubernetes manifests for production
- `data-loader/` — init container with preloaded DB
Do NOT bump the data-loader version or copy the database. ## 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.
+2 -2
View File
@@ -6,7 +6,7 @@ metadata:
data: data:
DEBUG: "False" DEBUG: "False"
ALLOWED_HOSTS: "labhelper.adebaumann.com,*" 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" LANGUAGE_CODE: "en-us"
TIME_ZONE: "UTC" TIME_ZONE: "UTC"
USE_I18N: "True" USE_I18N: "True"
@@ -21,4 +21,4 @@ data:
LOGOUT_REDIRECT_URL: "/login/" LOGOUT_REDIRECT_URL: "/login/"
OIDC_AUTHENTICATION_FAILURE_REDIRECT_URL: "/login/" OIDC_AUTHENTICATION_FAILURE_REDIRECT_URL: "/login/"
GUNICORN_OPTS: "--access-logfile -" GUNICORN_OPTS: "--access-logfile -"
IMAGE_TAG: "0.079" IMAGE_TAG: "0.083"
+1 -1
View File
@@ -27,7 +27,7 @@ spec:
mountPath: /data mountPath: /data
containers: containers:
- name: web - name: web
image: git.baumann.gr/adebaumann/labhelper:0.082 image: git.baumann.gr/adebaumann/labhelper:0.083
imagePullPolicy: Always imagePullPolicy: Always
ports: ports:
- containerPort: 8000 - containerPort: 8000
+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
+222 -164
View File
@@ -1,7 +1,7 @@
import bleach import bleach
import markdown 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.db.models import Q, Prefetch
from django.http import HttpResponse, JsonResponse from django.http import HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render 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): def health_check(request):
"""Health check endpoint for Kubernetes liveness/readiness probes.""" """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): def _strip_markdown(text, max_length=100):
"""Convert Markdown to plain text and truncate.""" """Convert Markdown to plain text and truncate."""
if not text: if not text:
return '' return ""
html = markdown.markdown(text) html = markdown.markdown(text)
plain_text = bleach.clean(html, tags=[], strip=True) 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: 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 return plain_text
@login_required @login_required
def index(request): def index(request):
"""Home page with search and tags.""" """Home page with search and tags."""
facets = Facet.objects.all().prefetch_related('tags') facets = Facet.objects.all().prefetch_related("tags")
facet_tag_counts = {} facet_tag_counts = {}
for facet in facets: for facet in facets:
@@ -49,95 +49,107 @@ def index(request):
facet_tag_counts[facet] = [] facet_tag_counts[facet] = []
facet_tag_counts[facet].append((tag, count)) facet_tag_counts[facet].append((tag, count))
return render(request, 'boxes/index.html', { return render(
'facets': facets, request,
'facet_tag_counts': facet_tag_counts, "boxes/index.html",
}) {
"facets": facets,
"facet_tag_counts": facet_tag_counts,
},
)
@login_required @login_required
def box_detail(request, box_id): def box_detail(request, box_id):
"""Display contents of a box.""" """Display contents of a box."""
box = get_object_or_404(Box, pk=box_id) box = get_object_or_404(Box, pk=box_id)
things = box.things.prefetch_related('tags').all() things = box.things.prefetch_related("tags").all()
return render(request, 'boxes/box_detail.html', { return render(
'box': box, request,
'things': things, "boxes/box_detail.html",
}) {
"box": box,
"things": things,
},
)
@login_required @login_required
def thing_detail(request, thing_id): def thing_detail(request, thing_id):
"""Display details of a thing (read-only).""" """Display details of a thing (read-only)."""
thing = get_object_or_404( thing = get_object_or_404(
Thing.objects.select_related('box', 'box__box_type').prefetch_related('files', 'links', 'tags'), Thing.objects.select_related("box", "box__box_type").prefetch_related(
pk=thing_id "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 @login_required
def edit_thing(request, thing_id): def edit_thing(request, thing_id):
"""Edit a thing's details.""" """Edit a thing's details."""
thing = get_object_or_404( thing = get_object_or_404(
Thing.objects.select_related('box', 'box__box_type').prefetch_related('files', 'links', 'tags'), Thing.objects.select_related("box", "box__box_type").prefetch_related(
pk=thing_id "files", "links", "tags"
),
pk=thing_id,
) )
boxes = Box.objects.select_related('box_type').all() boxes = Box.objects.select_related("box_type").all()
facets = Facet.objects.all().prefetch_related('tags') facets = Facet.objects.all().prefetch_related("tags")
picture_form = ThingPictureForm(instance=thing) picture_form = ThingPictureForm(instance=thing)
file_form = ThingFileForm() file_form = ThingFileForm()
link_form = ThingLinkForm() link_form = ThingLinkForm()
if request.method == 'POST': if request.method == "POST":
action = request.POST.get('action') action = request.POST.get("action")
if action == 'save_details': if action == "save_details":
form = ThingForm(request.POST, request.FILES, instance=thing) form = ThingForm(request.POST, request.FILES, instance=thing)
if form.is_valid(): if form.is_valid():
form.save() form.save()
return redirect('thing_detail', thing_id=thing.id) return redirect("thing_detail", thing_id=thing.id)
elif action == 'move': elif action == "move":
new_box_id = request.POST.get('new_box') new_box_id = request.POST.get("new_box")
if new_box_id: if new_box_id:
new_box = get_object_or_404(Box, pk=new_box_id) new_box = get_object_or_404(Box, pk=new_box_id)
thing.box = new_box thing.box = new_box
thing.save() 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) picture_form = ThingPictureForm(request.POST, request.FILES, instance=thing)
if picture_form.is_valid(): if picture_form.is_valid():
picture_form.save() 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: if thing.picture:
thing.picture.delete() thing.picture.delete()
thing.picture = None thing.picture = None
thing.save() 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) file_form = ThingFileForm(request.POST, request.FILES)
if file_form.is_valid(): if file_form.is_valid():
thing_file = file_form.save(commit=False) thing_file = file_form.save(commit=False)
thing_file.thing = thing thing_file.thing = thing
thing_file.save() 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) link_form = ThingLinkForm(request.POST)
if link_form.is_valid(): if link_form.is_valid():
thing_link = link_form.save(commit=False) thing_link = link_form.save(commit=False)
thing_link.thing = thing thing_link.thing = thing
thing_link.save() thing_link.save()
return redirect('edit_thing', thing_id=thing.id) return redirect("edit_thing", thing_id=thing.id)
elif action == 'delete_file': elif action == "delete_file":
file_id = request.POST.get('file_id') file_id = request.POST.get("file_id")
if file_id: if file_id:
try: try:
thing_file = ThingFile.objects.get(pk=file_id, thing=thing) thing_file = ThingFile.objects.get(pk=file_id, thing=thing)
@@ -145,20 +157,20 @@ def edit_thing(request, thing_id):
thing_file.delete() thing_file.delete()
except ThingFile.DoesNotExist: except ThingFile.DoesNotExist:
pass pass
return redirect('edit_thing', thing_id=thing.id) return redirect("edit_thing", thing_id=thing.id)
elif action == 'delete_link': elif action == "delete_link":
link_id = request.POST.get('link_id') link_id = request.POST.get("link_id")
if link_id: if link_id:
try: try:
thing_link = ThingLink.objects.get(pk=link_id, thing=thing) thing_link = ThingLink.objects.get(pk=link_id, thing=thing)
thing_link.delete() thing_link.delete()
except ThingLink.DoesNotExist: except ThingLink.DoesNotExist:
pass pass
return redirect('edit_thing', thing_id=thing.id) return redirect("edit_thing", thing_id=thing.id)
elif action == 'add_tag': elif action == "add_tag":
tag_id = request.POST.get('tag_id') tag_id = request.POST.get("tag_id")
if tag_id: if tag_id:
try: try:
tag = Tag.objects.get(pk=tag_id) tag = Tag.objects.get(pk=tag_id)
@@ -169,102 +181,120 @@ def edit_thing(request, thing_id):
thing.tags.add(tag) thing.tags.add(tag)
except Tag.DoesNotExist: except Tag.DoesNotExist:
pass pass
return redirect('edit_thing', thing_id=thing.id) return redirect("edit_thing", thing_id=thing.id)
elif action == 'remove_tag': elif action == "remove_tag":
tag_id = request.POST.get('tag_id') tag_id = request.POST.get("tag_id")
if tag_id: if tag_id:
try: try:
tag = Tag.objects.get(pk=tag_id) tag = Tag.objects.get(pk=tag_id)
thing.tags.remove(tag) thing.tags.remove(tag)
except Tag.DoesNotExist: except Tag.DoesNotExist:
pass pass
return redirect('edit_thing', thing_id=thing.id) return redirect("edit_thing", thing_id=thing.id)
thing_form = ThingForm(instance=thing) thing_form = ThingForm(instance=thing)
return render(request, 'boxes/edit_thing.html', { return render(
'thing': thing, request,
'boxes': boxes, "boxes/edit_thing.html",
'facets': facets, {
'picture_form': picture_form, "thing": thing,
'file_form': file_form, "boxes": boxes,
'link_form': link_form, "facets": facets,
'thing_form': thing_form, "picture_form": picture_form,
}) "file_form": file_form,
"link_form": link_form,
"thing_form": thing_form,
},
)
@login_required @login_required
def boxes_list(request): def boxes_list(request):
"""Boxes list page showing all boxes with contents.""" """Boxes list page showing all boxes with contents."""
boxes = Box.objects.select_related('box_type').prefetch_related('things').all() boxes = Box.objects.select_related("box_type").prefetch_related("things").all()
return render(request, 'boxes/boxes_list.html', { return render(
'boxes': boxes, request,
}) "boxes/boxes_list.html",
{
"boxes": boxes,
},
)
@login_required @login_required
def search_api(request): def search_api(request):
"""AJAX endpoint for searching things.""" """AJAX endpoint for searching things."""
query = request.GET.get('q', '').strip() query = request.GET.get("q", "").strip()
if len(query) < 2: if len(query) < 2:
return JsonResponse({'results': []}) return JsonResponse({"results": []})
# Check for "Facet:Word" format # Check for "Facet:Word" format
if ':' in query: if ":" in query:
parts = query.split(':',1) parts = query.split(":", 1)
facet_name = parts[0].strip() facet_name = parts[0].strip()
tag_name = parts[1].strip() tag_name = parts[1].strip()
# Search for things with specific facet and tag # Search for things with specific facet and tag
things = Thing.objects.filter( things = (
Q(tags__facet__name__icontains=facet_name) & Thing.objects.filter(
Q(tags__name__icontains=tag_name) Q(tags__facet__name__icontains=facet_name)
).prefetch_related('files', 'links', 'tags').select_related('box').distinct()[:50] & Q(tags__name__icontains=tag_name)
)
.prefetch_related("files", "links", "tags")
.select_related("box")
.distinct()[:50]
)
else: else:
# Normal search # Normal search
things = Thing.objects.filter( things = (
Q(name__icontains=query) | Thing.objects.filter(
Q(description__icontains=query) | Q(name__icontains=query)
Q(files__title__icontains=query) | | Q(description__icontains=query)
Q(files__file__icontains=query) | | Q(files__title__icontains=query)
Q(links__title__icontains=query) | | Q(files__file__icontains=query)
Q(links__url__icontains=query) | | Q(links__title__icontains=query)
Q(tags__name__icontains=query) | | Q(links__url__icontains=query)
Q(tags__facet__name__icontains=query) | Q(tags__name__icontains=query)
).prefetch_related('files', 'links', 'tags').select_related('box').distinct()[:50] | Q(tags__facet__name__icontains=query)
)
.prefetch_related("files", "links", "tags")
.select_related("box")
.distinct()[:50]
)
results = [ results = [
{ {
'id': thing.id, "id": thing.id,
'name': thing.name, "name": thing.name,
'box': thing.box.id, "box": thing.box.id,
'description': _strip_markdown(thing.description), "description": _strip_markdown(thing.description),
'tags': [ "tags": [
{ {
'name': tag.name, "name": tag.name,
'color': tag.facet.color, "color": tag.facet.color,
} }
for tag in thing.tags.all() for tag in thing.tags.all()
], ],
'files': [ "files": [
{ {
'title': f.title, "title": f.title,
'filename': f.filename(), "filename": f.filename(),
} }
for f in thing.files.all() for f in thing.files.all()
], ],
'links': [ "links": [
{ {
'title': l.title, "title": l.title,
'url': l.url, "url": l.url,
} }
for l in thing.links.all() for l in thing.links.all()
], ],
} }
for thing in things for thing in things
] ]
return JsonResponse({'results': results}) return JsonResponse({"results": results})
@login_required @login_required
@@ -274,8 +304,10 @@ def add_things(request, box_id):
success_message = None success_message = None
if request.method == 'POST': if request.method == "POST":
formset = ThingFormSet(request.POST, request.FILES, queryset=Thing.objects.filter(box=box)) formset = ThingFormSet(
request.POST, request.FILES, queryset=Thing.objects.filter(box=box)
)
if formset.is_valid(): if formset.is_valid():
things = formset.save(commit=False) things = formset.save(commit=False)
@@ -286,167 +318,189 @@ def add_things(request, box_id):
thing.save() thing.save()
created_count += 1 created_count += 1
if created_count > 0: 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)) formset = ThingFormSet(queryset=Thing.objects.filter(box=box))
else: else:
formset = ThingFormSet(queryset=Thing.objects.filter(box=box)) formset = ThingFormSet(queryset=Thing.objects.filter(box=box))
return render(request, 'boxes/add_things.html', { return render(
'box': box, request,
'formset': formset, "boxes/add_things.html",
'success_message': success_message, {
}) "box": box,
"formset": formset,
"success_message": success_message,
},
)
@login_required @login_required
def box_management(request): def box_management(request):
"""Main page for managing boxes and box types.""" """Main page for managing boxes and box types."""
box_types = BoxType.objects.all().prefetch_related('boxes') box_types = BoxType.objects.all().prefetch_related("boxes")
boxes = Box.objects.select_related('box_type').all().prefetch_related('things') boxes = Box.objects.select_related("box_type").all().prefetch_related("things")
box_type_form = BoxTypeForm() box_type_form = BoxTypeForm()
box_form = BoxForm() box_form = BoxForm()
return render(request, 'boxes/box_management.html', { return render(
'box_types': box_types, request,
'boxes': boxes, "boxes/box_management.html",
'box_type_form': box_type_form, {
'box_form': box_form, "box_types": box_types,
}) "boxes": boxes,
"box_type_form": box_type_form,
"box_form": box_form,
},
)
@login_required @login_required
def add_box_type(request): def add_box_type(request):
"""Add a new box type.""" """Add a new box type."""
if request.method == 'POST': if request.method == "POST":
form = BoxTypeForm(request.POST) form = BoxTypeForm(request.POST)
if form.is_valid(): if form.is_valid():
form.save() form.save()
return redirect('box_management') return redirect("box_management")
@login_required @login_required
def edit_box_type(request, type_id): def edit_box_type(request, type_id):
"""Edit an existing box type.""" """Edit an existing box type."""
box_type = get_object_or_404(BoxType, pk=type_id) 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) form = BoxTypeForm(request.POST, instance=box_type)
if form.is_valid(): if form.is_valid():
form.save() form.save()
return redirect('box_management') return redirect("box_management")
@login_required @login_required
def delete_box_type(request, type_id): def delete_box_type(request, type_id):
"""Delete a box type.""" """Delete a box type."""
box_type = get_object_or_404(BoxType, pk=type_id) box_type = get_object_or_404(BoxType, pk=type_id)
if request.method == 'POST': if request.method == "POST":
if box_type.boxes.exists(): if box_type.boxes.exists():
return redirect('box_management') return redirect("box_management")
box_type.delete() box_type.delete()
return redirect('box_management') return redirect("box_management")
@login_required @login_required
def add_box(request): def add_box(request):
"""Add a new box.""" """Add a new box."""
if request.method == 'POST': if request.method == "POST":
form = BoxForm(request.POST) form = BoxForm(request.POST)
if form.is_valid(): if form.is_valid():
form.save() form.save()
return redirect('box_management') return redirect("box_management")
@login_required @login_required
def edit_box(request, box_id): def edit_box(request, box_id):
"""Edit an existing box.""" """Edit an existing box."""
box = get_object_or_404(Box, pk=box_id) box = get_object_or_404(Box, pk=box_id)
if request.method == 'POST': if request.method == "POST":
form = BoxForm(request.POST, instance=box) form = BoxForm(request.POST, instance=box)
if form.is_valid(): if form.is_valid():
form.save() form.save()
return redirect('box_management') return redirect("box_management")
@login_required @login_required
def delete_box(request, box_id): def delete_box(request, box_id):
"""Delete a box.""" """Delete a box."""
box = get_object_or_404(Box, pk=box_id) box = get_object_or_404(Box, pk=box_id)
if request.method == 'POST': if request.method == "POST":
if box.things.exists(): if box.things.exists():
return redirect('box_management') return redirect("box_management")
box.delete() box.delete()
return redirect('box_management') return redirect("box_management")
@login_required @login_required
def delete_thing(request, thing_id): def delete_thing(request, thing_id):
"""Delete a thing and its associated files.""" """Delete a thing and its associated files."""
thing = get_object_or_404(Thing, pk=thing_id) thing = get_object_or_404(Thing, pk=thing_id)
if request.method == 'POST': if request.method == "POST":
box_id = thing.box.id box_id = thing.box.id
if thing.picture: if thing.picture:
thing.picture.delete(save=False) thing.picture.delete(save=False)
for thing_file in thing.files.all(): for thing_file in thing.files.all():
thing_file.file.delete(save=False) thing_file.file.delete(save=False)
thing.delete() thing.delete()
return redirect('box_detail', box_id=box_id) return redirect("box_detail", box_id=box_id)
return redirect('edit_thing', thing_id=thing_id) return redirect("edit_thing", thing_id=thing_id)
@login_required @login_required
def resources_list(request): def resources_list(request):
"""List all links and files from things that have them.""" """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_files = (
things_with_links = Thing.objects.filter(links__isnull=False).prefetch_related('links').distinct() 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') all_things = (things_with_files | things_with_links).distinct().order_by("name")
resources = [] 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(): for file in thing.files.all():
resources.append({ resources.append(
'type': 'file', {
'thing_name': thing.name, "type": "file",
'thing_id': thing.id, "thing_name": thing.name,
'title': file.title, "thing_id": thing.id,
'url': file.file.url, "title": file.title,
}) "url": file.file.url,
}
)
for link in thing.links.all(): for link in thing.links.all():
resources.append({ resources.append(
'type': 'link', {
'thing_name': thing.name, "type": "link",
'thing_id': thing.id, "thing_name": thing.name,
'title': link.title, "thing_id": thing.id,
'url': link.url, "title": link.title,
}) "url": link.url,
}
)
return render(request, 'boxes/resources_list.html', { return render(
'resources': resources, request,
}) "boxes/resources_list.html",
{
"resources": resources,
},
)
@login_required @login_required
def fixme(request): def fixme(request):
"""Page to find and fix things missing tags for specific facets.""" """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 selected_facet = None
missing_things = [] missing_things = []
if request.method == 'GET' and 'facet_id' in request.GET: if request.method == "GET" and "facet_id" in request.GET:
try: 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 # Find things that don't have any tag from this facet
missing_things = Thing.objects.exclude( missing_things = (
tags__facet=selected_facet Thing.objects.exclude(tags__facet=selected_facet)
).select_related('box', 'box__box_type').prefetch_related('tags') .select_related("box", "box__box_type")
.prefetch_related("tags")
)
except Facet.DoesNotExist: except Facet.DoesNotExist:
selected_facet = None selected_facet = None
elif request.method == 'POST': elif request.method == "POST":
facet_id = request.POST.get('facet_id') facet_id = request.POST.get("facet_id")
tag_ids = request.POST.getlist('tag_ids') tag_ids = request.POST.getlist("tag_ids")
thing_ids = request.POST.getlist('thing_ids') thing_ids = request.POST.getlist("thing_ids")
if facet_id and tag_ids and thing_ids: if facet_id and tag_ids and thing_ids:
facet = get_object_or_404(Facet, pk=facet_id) facet = get_object_or_404(Facet, pk=facet_id)
@@ -462,10 +516,14 @@ def fixme(request):
if tag.facet == facet: if tag.facet == facet:
thing.tags.add(tag) thing.tags.add(tag)
return redirect('fixme') return redirect("fixme")
return render(request, 'boxes/fixme.html', { return render(
'facets': facets, request,
'selected_facet': selected_facet, "boxes/fixme.html",
'missing_things': missing_things, {
}) "facets": facets,
"selected_facet": selected_facet,
"missing_things": missing_things,
},
)
+93 -72
View File
@@ -21,71 +21,76 @@ BASE_DIR = Path(__file__).resolve().parent.parent
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ # See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret! # 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! # 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_HOSTS = os.environ.get("ALLOWED_HOSTS", "*").split(",")
ALLOWED_CIDR_NETS = os.environ.get('ALLOWED_CIDR_NETS', '10.0.0.0/16').split(',') ALLOWED_CIDR_NETS = os.environ.get(
"ALLOWED_CIDR_NETS", "10.0.0.0/16,192.168.0.0/16"
).split(",")
# Application definition # Application definition
INSTALLED_APPS = [ INSTALLED_APPS = [
'django.contrib.admin', "django.contrib.admin",
'django.contrib.auth', "django.contrib.auth",
'django.contrib.contenttypes', "django.contrib.contenttypes",
'django.contrib.sessions', "django.contrib.sessions",
'django.contrib.messages', "django.contrib.messages",
'django.contrib.staticfiles', "django.contrib.staticfiles",
'mozilla_django_oidc', "mozilla_django_oidc",
'mptt', "mptt",
'django_mptt_admin', "django_mptt_admin",
'sorl.thumbnail', "sorl.thumbnail",
'boxes', "boxes",
] ]
MIDDLEWARE = [ MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware', "django.middleware.security.SecurityMiddleware",
'whitenoise.middleware.WhiteNoiseMiddleware', "whitenoise.middleware.WhiteNoiseMiddleware",
'django.contrib.sessions.middleware.SessionMiddleware', "django.contrib.sessions.middleware.SessionMiddleware",
'django.middleware.common.CommonMiddleware', "django.middleware.common.CommonMiddleware",
'django.middleware.csrf.CsrfViewMiddleware', "django.middleware.csrf.CsrfViewMiddleware",
'django.contrib.auth.middleware.AuthenticationMiddleware', "django.contrib.auth.middleware.AuthenticationMiddleware",
'mozilla_django_oidc.middleware.SessionRefresh', "mozilla_django_oidc.middleware.SessionRefresh",
'django.contrib.messages.middleware.MessageMiddleware', "django.contrib.messages.middleware.MessageMiddleware",
'django.middleware.clickjacking.XFrameOptionsMiddleware', "django.middleware.clickjacking.XFrameOptionsMiddleware",
] ]
ROOT_URLCONF = 'labhelper.urls' ROOT_URLCONF = "labhelper.urls"
TEMPLATES = [ TEMPLATES = [
{ {
'BACKEND': 'django.template.backends.django.DjangoTemplates', "BACKEND": "django.template.backends.django.DjangoTemplates",
'DIRS': [BASE_DIR / 'labhelper' / 'templates'], "DIRS": [BASE_DIR / "labhelper" / "templates"],
'APP_DIRS': True, "APP_DIRS": True,
'OPTIONS': { "OPTIONS": {
'context_processors': [ "context_processors": [
'django.template.context_processors.request', "django.template.context_processors.request",
'django.contrib.auth.context_processors.auth', "django.contrib.auth.context_processors.auth",
'django.contrib.messages.context_processors.messages', "django.contrib.messages.context_processors.messages",
'labhelper.context_processors.image_tag', "labhelper.context_processors.image_tag",
], ],
}, },
}, },
] ]
WSGI_APPLICATION = 'labhelper.wsgi.application' WSGI_APPLICATION = "labhelper.wsgi.application"
# Database # Database
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases # https://docs.djangoproject.com/en/5.2/ref/settings/#databases
DATABASES = { DATABASES = {
'default': { "default": {
'ENGINE': 'django.db.backends.sqlite3', "ENGINE": "django.db.backends.sqlite3",
'NAME': BASE_DIR / 'data' / 'db.sqlite3', "NAME": BASE_DIR / "data" / "db.sqlite3",
} }
} }
@@ -95,16 +100,16 @@ DATABASES = {
AUTH_PASSWORD_VALIDATORS = [ 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 # Internationalization
# https://docs.djangoproject.com/en/5.2/topics/i18n/ # 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) # Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.2/howto/static-files/ # https://docs.djangoproject.com/en/5.2/howto/static-files/
STATIC_URL = os.environ.get('STATIC_URL', '/static/') STATIC_URL = os.environ.get("STATIC_URL", "/static/")
STATIC_ROOT = BASE_DIR / 'staticfiles' STATIC_ROOT = BASE_DIR / "staticfiles"
# WhiteNoise static file serving configuration # WhiteNoise static file serving configuration
STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage' STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
# Media files (user uploads) # Media files (user uploads)
MEDIA_URL = os.environ.get('MEDIA_URL', '/media/') MEDIA_URL = os.environ.get("MEDIA_URL", "/media/")
MEDIA_ROOT = BASE_DIR / 'data' / 'media' MEDIA_ROOT = BASE_DIR / "data" / "media"
# Default primary key field type # Default primary key field type
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field # 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_URL = os.environ.get("LOGIN_URL", "oidc_authentication_init")
LOGIN_REDIRECT_URL = os.environ.get('LOGIN_REDIRECT_URL', 'index') LOGIN_REDIRECT_URL = os.environ.get("LOGIN_REDIRECT_URL", "index")
LOGOUT_REDIRECT_URL = os.environ.get('LOGOUT_REDIRECT_URL', 'login') LOGOUT_REDIRECT_URL = os.environ.get("LOGOUT_REDIRECT_URL", "login")
AUTHENTICATION_BACKENDS = [ AUTHENTICATION_BACKENDS = [
'labhelper.auth_backend.KeycloakOIDCBackend', "labhelper.auth_backend.KeycloakOIDCBackend",
# ModelBackend kept as fallback for Django admin emergency access # 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. # All individual endpoints are derived from OIDC_OP_BASE_URL automatically.
# You can override any individual endpoint with its own env var. # You can override any individual endpoint with its own env var.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_oidc_base = os.environ.get('OIDC_OP_BASE_URL', '').rstrip('/') _oidc_base = os.environ.get("OIDC_OP_BASE_URL", "").rstrip("/")
_oidc_connect = f'{_oidc_base}/protocol/openid-connect' if _oidc_base else '' _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_ID = os.environ.get("OIDC_RP_CLIENT_ID", "")
OIDC_RP_CLIENT_SECRET = os.environ.get('OIDC_RP_CLIENT_SECRET', '') OIDC_RP_CLIENT_SECRET = os.environ.get("OIDC_RP_CLIENT_SECRET", "")
OIDC_RP_SIGN_ALGO = 'RS256' OIDC_RP_SIGN_ALGO = "RS256"
OIDC_RP_SCOPES = 'openid email profile' OIDC_RP_SCOPES = "openid email profile"
OIDC_USE_PKCE = True OIDC_USE_PKCE = True
OIDC_OP_AUTHORIZATION_ENDPOINT = os.environ.get('OIDC_OP_AUTHORIZATION_ENDPOINT', f'{_oidc_connect}/auth') OIDC_OP_AUTHORIZATION_ENDPOINT = os.environ.get(
OIDC_OP_TOKEN_ENDPOINT = os.environ.get('OIDC_OP_TOKEN_ENDPOINT', f'{_oidc_connect}/token') "OIDC_OP_AUTHORIZATION_ENDPOINT", f"{_oidc_connect}/auth"
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_TOKEN_ENDPOINT = os.environ.get(
OIDC_OP_LOGOUT_ENDPOINT = os.environ.get('OIDC_OP_LOGOUT_ENDPOINT', f'{_oidc_connect}/logout') "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 # Store the ID token in the session so Keycloak logout can use id_token_hint
OIDC_STORE_ID_TOKEN = True OIDC_STORE_ID_TOKEN = True
# Redirect to the static login page on auth failure instead of looping back into OIDC # 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 # Exempt AJAX endpoints and media files from the session-refresh middleware redirect
OIDC_EXEMPT_URLS = ['search_api'] import re
OIDC_EXEMPT_URLS = ["search_api", "health_check", re.compile(r"^/media/")]