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

This commit is contained in:
2026-04-06 17:59:50 +02:00
parent 4569fec82c
commit 158af49727
6 changed files with 412 additions and 837 deletions

629
AGENTS.md
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
- **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
## Essential Commands
```bash
python manage.py runserver # Start dev server on port 8000
python manage.py runserver 0.0.0.0:8000 # Bind to all interfaces
```
# Activate venv first
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
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
```
# Database
python manage.py makemigrations boxes # after model changes
python manage.py migrate
python manage.py showmigrations
### 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
# 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
```
### 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
# Custom commands
python manage.py create_default_users # admin/admin123, staff/staff123, viewer/viewer123
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 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
- 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."""`
## Data Model
### 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
# 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:
**Full deploy**: bump both versions in `argocd/deployment.yaml` (+0.001), then:
```bash
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:
- `labhelper` (main container)
- `boxes/` — main app (models, views, forms, templates)
- `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

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

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,102 +181,120 @@ 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
@@ -274,8 +304,10 @@ def add_things(request, 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)
@@ -286,167 +318,189 @@ 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()
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')
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,
})
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,
})
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)
@@ -462,10 +516,14 @@ def fixme(request):
if tag.facet == facet:
thing.tags.add(tag)
return redirect('fixme')
return redirect("fixme")
return render(request, 'boxes/fixme.html', {
'facets': facets,
'selected_facet': selected_facet,
'missing_things': missing_things,
})
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/")]