1 Commits

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

631
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 ## 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 ```bash
import os cp data/db.sqlite3 data-loader/preload.sqlite3
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 **Partial deploy**: bump main container only, skip DB copy.
| Type | Convention | Example | ## Key Directories
|------|------------|---------|
| 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 - `boxes/` — main app (models, views, forms, templates)
- `labhelper/` — project settings, base template
- `argocd/` — Kubernetes manifests for production
- `data-loader/` — init container with preloaded DB
**Models:** ## Gotchas
```python
from django.db import models
class Box(models.Model): - Template base: `labhelper/templates/base.html`
"""A storage box in the lab.""" - App templates: `boxes/templates/boxes/`
- Box ID is CharField (e.g., "A1-001"), not auto-increment
name = models.CharField(max_length=255) - Views use `conditional_login_required` - bypasses login for IPs in `ALLOWED_CIDR_NETS` env var
created_at = models.DateTimeField(auto_now_add=True) - Markdown via `{{ text|render_markdown }}` (sanitized with bleach)
updated_at = models.DateTimeField(auto_now=True) - Third-party: django-mptt, sorl-thumbnail, bleach, markdown
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
cp data/db.sqlite3 data-loader/preload.sqlite3
```
### Prepare a Partial Deployment
When instructed to "Prepare a partial deployment", perform the following step:
1. **Bump main container version only**: In `argocd/deployment.yaml`, increment the version number by 0.001 for the main container only:
- `labhelper` (main container)
Do NOT bump the data-loader version or copy the database.

View File

@@ -6,7 +6,7 @@ metadata:
data: 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"

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
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 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,114 +181,134 @@ 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
def add_things(request, box_id): def add_things(request, box_id):
"""Add multiple things to a box at once.""" """Add multiple things to a box at once."""
box = get_object_or_404(Box, pk=box_id) box = get_object_or_404(Box, pk=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)
created_count = 0 created_count = 0
@@ -286,173 +318,195 @@ 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()
)
all_things = (things_with_files | things_with_links).distinct().order_by('name') things_with_links = (
Thing.objects.filter(links__isnull=False).prefetch_related("links").distinct()
)
all_things = (things_with_files | things_with_links).distinct().order_by("name")
resources = [] 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', { )
'resources': resources,
}) return render(
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)
tags = Tag.objects.filter(id__in=tag_ids, facet=facet) tags = Tag.objects.filter(id__in=tag_ids, facet=facet)
things = Thing.objects.filter(id__in=thing_ids) things = Thing.objects.filter(id__in=thing_ids)
for thing in things: for thing in things:
if facet.cardinality == Facet.Cardinality.SINGLE: if facet.cardinality == Facet.Cardinality.SINGLE:
# Remove existing tags from this facet # Remove existing tags from this facet
@@ -461,11 +515,15 @@ def fixme(request):
for tag in tags: for tag in tags:
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,
},
)

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/")]