Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| df392f7b7f | |||
| f4e9cea73b | |||
| c72869383b | |||
| c4946f8e16 | |||
|
a2dbcaf79a
|
|||
|
de4d3fc790
|
|||
| 20c468cca8 | |||
|
88c1dd704f
|
@@ -1,71 +0,0 @@
|
|||||||
# AGENTS.md - labhelper
|
|
||||||
|
|
||||||
**Type**: Django 5.2 web app (lab inventory)
|
|
||||||
**Python**: 3.13 | **DB**: SQLite (dev) | **Env**: `.venv/`
|
|
||||||
|
|
||||||
## Essential Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Activate venv first
|
|
||||||
source .venv/bin/activate
|
|
||||||
|
|
||||||
# Dev server
|
|
||||||
python manage.py runserver # localhost:8000
|
|
||||||
python manage.py runserver 0.0.0.0:8000 # all interfaces
|
|
||||||
|
|
||||||
# Database
|
|
||||||
python manage.py makemigrations boxes # after model changes
|
|
||||||
python manage.py migrate
|
|
||||||
python manage.py showmigrations
|
|
||||||
|
|
||||||
# Testing
|
|
||||||
python manage.py test # all
|
|
||||||
python manage.py test boxes # app only
|
|
||||||
python manage.py test boxes.tests.BoxModelTests.test_method # specific
|
|
||||||
|
|
||||||
# Custom commands
|
|
||||||
python manage.py create_default_users # admin/admin123, staff/staff123, viewer/viewer123
|
|
||||||
python manage.py clean_orphaned_files --dry-run
|
|
||||||
python manage.py clean_orphaned_images --dry-run
|
|
||||||
python manage.py collectstatic # after CSS changes
|
|
||||||
```
|
|
||||||
|
|
||||||
## Critical Rules
|
|
||||||
|
|
||||||
1. **NEVER commit/push without explicit permission** - wait for user to say "commit and push"
|
|
||||||
2. Use `get_object_or_404()` not bare `.get()` for model lookups
|
|
||||||
3. Run `makemigrations` then `migrate` after any model change
|
|
||||||
4. Default users: `admin/admin123` (superuser), `staff/staff123`, `viewer/viewer123`
|
|
||||||
|
|
||||||
## Data Model
|
|
||||||
|
|
||||||
- **BoxType** → Box (1:N, PROTECT)
|
|
||||||
- **Box** → Thing (1:N, PROTECT) — Box.pk is CharField(max=10)
|
|
||||||
- **Facet** → Tag (1:N, CASCADE) — Facet.cardinality: single/multiple
|
|
||||||
- **Thing** ↔ Tag (M2M)
|
|
||||||
- **Thing** → ThingFile, ThingLink (1:N, CASCADE)
|
|
||||||
|
|
||||||
## Deployment (when instructed)
|
|
||||||
|
|
||||||
**Full deploy**: bump both versions in `argocd/deployment.yaml` (+0.001), then:
|
|
||||||
```bash
|
|
||||||
cp data/db.sqlite3 data-loader/preload.sqlite3
|
|
||||||
```
|
|
||||||
|
|
||||||
**Partial deploy**: bump main container only, skip DB copy.
|
|
||||||
|
|
||||||
## Key Directories
|
|
||||||
|
|
||||||
- `boxes/` — main app (models, views, forms, templates)
|
|
||||||
- `labhelper/` — project settings, base template
|
|
||||||
- `argocd/` — Kubernetes manifests for production
|
|
||||||
- `data-loader/` — init container with preloaded DB
|
|
||||||
|
|
||||||
## Gotchas
|
|
||||||
|
|
||||||
- Template base: `labhelper/templates/base.html`
|
|
||||||
- App templates: `boxes/templates/boxes/`
|
|
||||||
- Box ID is CharField (e.g., "A1-001"), not auto-increment
|
|
||||||
- Views use `conditional_login_required` - bypasses login for IPs in `ALLOWED_CIDR_NETS` env var
|
|
||||||
- Markdown via `{{ text|render_markdown }}` (sanitized with bleach)
|
|
||||||
- Third-party: django-mptt, sorl-thumbnail, bleach, markdown
|
|
||||||
@@ -1,272 +0,0 @@
|
|||||||
# AGENTS.md - AI Coding Agent Guidelines
|
|
||||||
|
|
||||||
This document provides guidelines for AI coding agents working in the labhelper repository.
|
|
||||||
|
|
||||||
## Project Overview
|
|
||||||
|
|
||||||
- **Type**: Django web application
|
|
||||||
- **Python**: 3.13.7
|
|
||||||
- **Django**: 5.2.9
|
|
||||||
- **Database**: SQLite (development)
|
|
||||||
- **Virtual Environment**: `.venv/`
|
|
||||||
|
|
||||||
## Build/Run Commands
|
|
||||||
|
|
||||||
### Development Server
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python manage.py runserver # Start dev server on port 8000
|
|
||||||
python manage.py runserver 0.0.0.0:8000 # Bind to all interfaces
|
|
||||||
```
|
|
||||||
|
|
||||||
### Database Operations
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python manage.py makemigrations # Create migration files
|
|
||||||
python manage.py makemigrations boxes # Create migrations for specific app
|
|
||||||
python manage.py migrate # Apply all migrations
|
|
||||||
python manage.py showmigrations # List migration status
|
|
||||||
```
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run all tests
|
|
||||||
python manage.py test
|
|
||||||
|
|
||||||
# Run tests for a specific app
|
|
||||||
python manage.py test boxes
|
|
||||||
|
|
||||||
# Run a specific test class
|
|
||||||
python manage.py test boxes.tests.TestClassName
|
|
||||||
|
|
||||||
# Run a single test method
|
|
||||||
python manage.py test boxes.tests.TestClassName.test_method_name
|
|
||||||
|
|
||||||
# Run tests with verbosity
|
|
||||||
python manage.py test -v 2
|
|
||||||
|
|
||||||
# Run tests with coverage
|
|
||||||
coverage run manage.py test
|
|
||||||
coverage report
|
|
||||||
coverage html # Generate HTML report
|
|
||||||
```
|
|
||||||
|
|
||||||
### Django Shell
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python manage.py shell # Interactive Django shell
|
|
||||||
python manage.py createsuperuser # Create admin user
|
|
||||||
python manage.py collectstatic # Collect static files
|
|
||||||
```
|
|
||||||
|
|
||||||
### Production
|
|
||||||
|
|
||||||
```bash
|
|
||||||
gunicorn labhelper.wsgi:application # Run with Gunicorn
|
|
||||||
```
|
|
||||||
|
|
||||||
## Code Style Guidelines
|
|
||||||
|
|
||||||
### Python Style
|
|
||||||
|
|
||||||
- Follow PEP 8 conventions
|
|
||||||
- Use 4-space indentation (no tabs)
|
|
||||||
- Maximum line length: 79 characters (PEP 8 standard)
|
|
||||||
- Use single quotes for strings: `'string'`
|
|
||||||
- Use double quotes for docstrings: `"""Docstring."""`
|
|
||||||
|
|
||||||
### Import Order
|
|
||||||
|
|
||||||
Organize imports in this order, with blank lines between groups:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# 1. Standard library imports
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# 2. Django imports
|
|
||||||
from django.db import models
|
|
||||||
from django.contrib import admin
|
|
||||||
from django.shortcuts import render, redirect
|
|
||||||
from django.http import HttpResponse, JsonResponse
|
|
||||||
|
|
||||||
# 3. Third-party imports
|
|
||||||
import requests
|
|
||||||
from markdown import markdown
|
|
||||||
|
|
||||||
# 4. Local application imports
|
|
||||||
from .models import MyModel
|
|
||||||
from .forms import MyForm
|
|
||||||
```
|
|
||||||
|
|
||||||
### Naming Conventions
|
|
||||||
|
|
||||||
| Type | Convention | Example |
|
|
||||||
|------|------------|---------|
|
|
||||||
| Modules | lowercase_with_underscores | `user_profile.py` |
|
|
||||||
| Classes | PascalCase | `UserProfile` |
|
|
||||||
| Functions | lowercase_with_underscores | `get_user_data()` |
|
|
||||||
| Constants | UPPERCASE_WITH_UNDERSCORES | `MAX_CONNECTIONS` |
|
|
||||||
| Variables | lowercase_with_underscores | `user_count` |
|
|
||||||
| Django Models | PascalCase (singular) | `Box`, `UserProfile` |
|
|
||||||
| Django Apps | lowercase (short) | `boxes`, `users` |
|
|
||||||
|
|
||||||
### Django-Specific Conventions
|
|
||||||
|
|
||||||
**Models:**
|
|
||||||
```python
|
|
||||||
from django.db import models
|
|
||||||
|
|
||||||
class Box(models.Model):
|
|
||||||
"""A storage box in the lab."""
|
|
||||||
|
|
||||||
name = models.CharField(max_length=255)
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name_plural = 'boxes'
|
|
||||||
ordering = ['-created_at']
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return self.name
|
|
||||||
```
|
|
||||||
|
|
||||||
**Views:**
|
|
||||||
```python
|
|
||||||
from django.shortcuts import render, get_object_or_404
|
|
||||||
from django.http import Http404
|
|
||||||
|
|
||||||
def box_detail(request, box_id):
|
|
||||||
"""Display details for a specific box."""
|
|
||||||
box = get_object_or_404(Box, pk=box_id)
|
|
||||||
return render(request, 'boxes/detail.html', {'box': box})
|
|
||||||
```
|
|
||||||
|
|
||||||
### Error Handling
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Use specific exceptions
|
|
||||||
try:
|
|
||||||
result = some_operation()
|
|
||||||
except SpecificError as exc:
|
|
||||||
raise CustomError('Descriptive message') from exc
|
|
||||||
|
|
||||||
# Django: Use get_object_or_404 for model lookups
|
|
||||||
box = get_object_or_404(Box, pk=box_id)
|
|
||||||
|
|
||||||
# Log errors appropriately
|
|
||||||
import logging
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
logger.error('Error message: %s', error_detail)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Type Hints (Recommended)
|
|
||||||
|
|
||||||
```python
|
|
||||||
from typing import Optional
|
|
||||||
from django.http import HttpRequest, HttpResponse
|
|
||||||
|
|
||||||
def get_box(request: HttpRequest, box_id: int) -> HttpResponse:
|
|
||||||
"""Retrieve a box by ID."""
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
labhelper/
|
|
||||||
├── manage.py # Django CLI entry point
|
|
||||||
├── requirements.txt # Python dependencies
|
|
||||||
├── labhelper/ # Project configuration
|
|
||||||
│ ├── settings.py # Django settings
|
|
||||||
│ ├── urls.py # Root URL routing
|
|
||||||
│ ├── wsgi.py # WSGI application
|
|
||||||
│ └── asgi.py # ASGI application
|
|
||||||
└── boxes/ # Django app
|
|
||||||
├── admin.py # Admin configuration
|
|
||||||
├── apps.py # App configuration
|
|
||||||
├── models.py # Data models
|
|
||||||
├── views.py # View functions
|
|
||||||
├── tests.py # Test cases
|
|
||||||
├── migrations/ # Database migrations
|
|
||||||
└── templates/ # HTML templates
|
|
||||||
```
|
|
||||||
|
|
||||||
## Available Django Extensions
|
|
||||||
|
|
||||||
The project includes these pre-installed packages:
|
|
||||||
|
|
||||||
- **django-mptt**: Tree structures (categories, hierarchies)
|
|
||||||
- **django-mptt-admin**: Admin interface for MPTT models
|
|
||||||
- **django-admin-sortable2**: Drag-and-drop ordering in admin
|
|
||||||
- **django-nested-admin**: Nested inline forms in admin
|
|
||||||
- **django-nested-inline**: Additional nested inline support
|
|
||||||
- **django-revproxy**: Reverse proxy functionality
|
|
||||||
|
|
||||||
## Testing Guidelines
|
|
||||||
|
|
||||||
- Use `django.test.TestCase` for database tests
|
|
||||||
- Use `django.test.SimpleTestCase` for tests without database
|
|
||||||
- Name test files `test_*.py` or `*_tests.py`
|
|
||||||
- Name test methods `test_*`
|
|
||||||
- Use descriptive test method names
|
|
||||||
|
|
||||||
```python
|
|
||||||
from django.test import TestCase
|
|
||||||
from .models import Box
|
|
||||||
|
|
||||||
class BoxModelTests(TestCase):
|
|
||||||
"""Tests for the Box model."""
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
"""Set up test fixtures."""
|
|
||||||
self.box = Box.objects.create(name='Test Box')
|
|
||||||
|
|
||||||
def test_box_str_returns_name(self):
|
|
||||||
"""Box __str__ should return the box name."""
|
|
||||||
self.assertEqual(str(self.box), 'Test Box')
|
|
||||||
```
|
|
||||||
|
|
||||||
## Files to Never Commit
|
|
||||||
|
|
||||||
Per `.gitignore`:
|
|
||||||
- `__pycache__/`, `*.pyc` - Python bytecode
|
|
||||||
- `.venv/` - Virtual environment
|
|
||||||
- `.env` - Environment variables
|
|
||||||
- `data/db.sqlite3` - Database file
|
|
||||||
- `keys/` - Secret keys
|
|
||||||
|
|
||||||
## Common Pitfalls
|
|
||||||
|
|
||||||
1. **Always activate venv**: `source .venv/bin/activate`
|
|
||||||
2. **Run migrations after model changes**: `makemigrations` then `migrate`
|
|
||||||
3. **Add new apps to INSTALLED_APPS** in `settings.py`
|
|
||||||
4. **Use get_object_or_404** instead of bare `.get()` calls
|
|
||||||
5. **Never commit SECRET_KEY** - use environment variables in production
|
|
||||||
|
|
||||||
## Deployment Commands
|
|
||||||
|
|
||||||
### Prepare a Full Deployment
|
|
||||||
|
|
||||||
When instructed to "Prepare a full deployment", perform the following steps:
|
|
||||||
|
|
||||||
1. **Bump container versions**: In `argocd/deployment.yaml`, increment the version numbers by 0.001 for both containers:
|
|
||||||
- `labhelper-data-loader` (initContainer)
|
|
||||||
- `labhelper` (main container)
|
|
||||||
|
|
||||||
2. **Copy database**: Copy the current development database to the data-loader preload location:
|
|
||||||
```bash
|
|
||||||
cp data/db.sqlite3 data-loader/preload.sqlite3
|
|
||||||
```
|
|
||||||
|
|
||||||
### Prepare a Partial Deployment
|
|
||||||
|
|
||||||
When instructed to "Prepare a partial deployment", perform the following step:
|
|
||||||
|
|
||||||
1. **Bump main container version only**: In `argocd/deployment.yaml`, increment the version number by 0.001 for the main container only:
|
|
||||||
- `labhelper` (main container)
|
|
||||||
|
|
||||||
Do NOT bump the data-loader version or copy the database.
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
# LabHelper
|
|
||||||
|
|
||||||
A Django web app for keeping track of what's in the physical storage boxes in a lab
|
|
||||||
or workshop. Boxes hold *things*, things are described, pictured, tagged, and linked
|
|
||||||
to datasheets and resources, and everything is searchable from one box. Authentication
|
|
||||||
is handled by Keycloak (OIDC), and the app is packaged for Kubernetes deployment via
|
|
||||||
ArgoCD.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- **Boxes & box types** — define reusable box types (with physical dimensions) and
|
|
||||||
create boxes of those types. Box IDs are human-chosen strings (e.g. `A1-001`), not
|
|
||||||
auto-increment numbers.
|
|
||||||
- **Things** — items stored in a box, each with a name, Markdown description, picture,
|
|
||||||
file attachments (datasheets, etc.), and hyperlinks.
|
|
||||||
- **Faceted tagging** — organise things by facets (e.g. *Category*, *Priority*). Each
|
|
||||||
facet is single- or multi-valued and has its own colour. Tags belong to a facet.
|
|
||||||
- **Search** — live AJAX search across names, descriptions, tags, files, and links.
|
|
||||||
Supports `Facet:Value` queries (e.g. `Category:Resistors`).
|
|
||||||
- **Resources view** — a flat list of every file and link across all things.
|
|
||||||
- **"Fix me" view** — find things missing a tag for a given facet and bulk-tag them.
|
|
||||||
- **Bulk add** — add many things to a box at once via a formset.
|
|
||||||
- **SSO** — Keycloak/OIDC login with automatic Django group and `is_staff` mapping.
|
|
||||||
|
|
||||||
## Tech stack
|
|
||||||
|
|
||||||
| Area | Choice |
|
|
||||||
|------|--------|
|
|
||||||
| Framework | Django 5.2 (Python 3.13/3.14) |
|
|
||||||
| Database | SQLite (file-backed, persisted on a PVC in production) |
|
|
||||||
| Auth | `mozilla-django-oidc` against Keycloak |
|
|
||||||
| Static files | WhiteNoise (compressed manifest storage) |
|
|
||||||
| Images | Pillow + `sorl-thumbnail` |
|
|
||||||
| Markdown | `markdown` rendered then sanitised with `bleach` |
|
|
||||||
| Tree data | `django-mptt` (facet/type hierarchies) |
|
|
||||||
| WSGI server | Gunicorn (3 workers) |
|
|
||||||
| Deployment | Docker image → Gitea registry → ArgoCD → Kubernetes (Traefik ingress) |
|
|
||||||
|
|
||||||
## Data model
|
|
||||||
|
|
||||||
```
|
|
||||||
BoxType ──1:N──> Box ──1:N──> Thing ──M:N──> Tag <──1:N── Facet
|
|
||||||
│
|
|
||||||
├──1:N──> ThingFile
|
|
||||||
└──1:N──> ThingLink
|
|
||||||
```
|
|
||||||
|
|
||||||
- `BoxType → Box` and `Box → Thing` use `PROTECT` (you can't delete a box type still
|
|
||||||
in use, or a box that still holds things).
|
|
||||||
- `Facet → Tag` and `Thing → ThingFile`/`ThingLink` use `CASCADE`.
|
|
||||||
- `Box.id` is a `CharField(max_length=10)` primary key.
|
|
||||||
- `Facet.cardinality` is `single` (0..1 tag per thing) or `multiple` (0..n).
|
|
||||||
|
|
||||||
## Local development
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Activate the virtualenv
|
|
||||||
source .venv/bin/activate
|
|
||||||
|
|
||||||
# 2. Apply migrations (SQLite DB lives at data/db.sqlite3)
|
|
||||||
python manage.py migrate
|
|
||||||
|
|
||||||
# 3. Run the dev server
|
|
||||||
python manage.py runserver # http://localhost:8000
|
|
||||||
python manage.py runserver 0.0.0.0:8000 # all interfaces
|
|
||||||
```
|
|
||||||
|
|
||||||
For local development you'll typically want `DEBUG=True` (the default) and to reach
|
|
||||||
the app from an IP inside `ALLOWED_CIDR_NETS`, which bypasses the login requirement
|
|
||||||
(see [Authentication](#authentication)). Otherwise you need a working Keycloak realm
|
|
||||||
configured through the environment variables below.
|
|
||||||
|
|
||||||
### Common commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Database
|
|
||||||
python manage.py makemigrations boxes # after editing boxes/models.py
|
|
||||||
python manage.py migrate
|
|
||||||
python manage.py showmigrations
|
|
||||||
|
|
||||||
# Tests
|
|
||||||
python manage.py test # everything
|
|
||||||
python manage.py test boxes # the boxes app only
|
|
||||||
|
|
||||||
# Static files (after CSS changes)
|
|
||||||
python manage.py collectstatic
|
|
||||||
|
|
||||||
# Custom management commands
|
|
||||||
python manage.py list_things # print all things with box IDs
|
|
||||||
python manage.py clean_orphaned_files --dry-run # remove unreferenced files
|
|
||||||
python manage.py clean_orphaned_images --dry-run # remove unreferenced images
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
All configuration is read from environment variables (see `labhelper/settings.py`).
|
|
||||||
Sensible defaults exist for local development; production values come from the
|
|
||||||
Kubernetes ConfigMap (`argocd/configmap.yaml`) and Secret.
|
|
||||||
|
|
||||||
| Variable | Purpose | Default |
|
|
||||||
|----------|---------|---------|
|
|
||||||
| `DJANGO_SECRET_KEY` | Django secret key | dev key (do **not** use in prod) |
|
|
||||||
| `DEBUG` | Debug mode | `True` |
|
|
||||||
| `ALLOWED_HOSTS` | Comma-separated allowed hosts | `*` |
|
|
||||||
| `ALLOWED_CIDR_NETS` | CIDR ranges that skip login | `10.0.0.0/16,192.168.0.0/16` |
|
|
||||||
| `CSRF_TRUSTED_ORIGINS` | Trusted origins for CSRF | prod URL + `127.0.0.1:8000` |
|
|
||||||
| `STATIC_URL` / `MEDIA_URL` | Static/media URL prefixes | `/static/` / `/media/` |
|
|
||||||
| `TIME_ZONE`, `LANGUAGE_CODE`, `USE_I18N`, `USE_TZ` | Localisation | UTC / en-us / True |
|
|
||||||
| `LOGIN_URL` | Where unauthenticated users go | `oidc_authentication_init` |
|
|
||||||
| `LOGIN_REDIRECT_URL` / `LOGOUT_REDIRECT_URL` | Post-auth redirects | `index` / `login` |
|
|
||||||
| `OIDC_OP_BASE_URL` | Keycloak realm URL (endpoints derived from it) | — |
|
|
||||||
| `OIDC_RP_CLIENT_ID` / `OIDC_RP_CLIENT_SECRET` | OIDC client credentials | — |
|
|
||||||
| `GUNICORN_OPTS` | Extra gunicorn flags | — |
|
|
||||||
|
|
||||||
`OIDC_OP_BASE_URL` should be the realm URL, e.g.
|
|
||||||
`https://sso.example.com/realms/homelab`. The individual OIDC endpoints
|
|
||||||
(auth, token, userinfo, JWKS, logout) are derived from it automatically but can each
|
|
||||||
be overridden with their own `OIDC_OP_*_ENDPOINT` variable.
|
|
||||||
|
|
||||||
## Authentication
|
|
||||||
|
|
||||||
Login is handled by Keycloak through `mozilla-django-oidc`. Two access paths exist:
|
|
||||||
|
|
||||||
1. **OIDC login** — the normal path. `KeycloakOIDCBackend`
|
|
||||||
(`labhelper/auth_backend.py`) syncs the user's name, `is_staff` flag, and Django
|
|
||||||
group membership from Keycloak group claims on every login. The mapping:
|
|
||||||
|
|
||||||
| Keycloak group | Django group | `is_staff` |
|
|
||||||
|----------------|--------------|-----------|
|
|
||||||
| `LabHelper Administrators` | `LabHelper Administrators` | yes |
|
|
||||||
| `LabHelper Staff` | `LabHelper Staff` | no |
|
|
||||||
| `LabHelper Viewers` | `LabHelper Viewers` | no |
|
|
||||||
|
|
||||||
2. **Trusted networks** — every view is wrapped in
|
|
||||||
`conditional_login_required` (`boxes/decorators.py`), which **skips** authentication
|
|
||||||
entirely for clients whose IP falls within `ALLOWED_CIDR_NETS`. This is what lets
|
|
||||||
the app run open on a trusted LAN while still requiring SSO from outside.
|
|
||||||
|
|
||||||
The `ModelBackend` is retained as a fallback so the Django admin
|
|
||||||
(`/admin/`) remains reachable for emergency access.
|
|
||||||
|
|
||||||
See `Keycloak-installation.md` for notes on setting up the realm, client, and groups.
|
|
||||||
|
|
||||||
## Deployment
|
|
||||||
|
|
||||||
The app ships as two container images to a Gitea registry
|
|
||||||
(`git.baumann.gr/adebaumann/…`) and is reconciled onto Kubernetes by ArgoCD from the
|
|
||||||
manifests in `argocd/`:
|
|
||||||
|
|
||||||
- **`web`** — the Django app (built from the root `Dockerfile`, served by gunicorn).
|
|
||||||
- **`loader`** — an init container (`data-loader/`) that seeds the SQLite database from
|
|
||||||
a preloaded copy on first boot, then preserves any existing DB on the PVC.
|
|
||||||
|
|
||||||
Images are **not** built on every push. The Gitea Actions workflow
|
|
||||||
(`.gitea/workflows/build-containers-on-demand.yml`) triggers only when
|
|
||||||
`argocd/deployment.yaml`, the `Dockerfile`, or `data-loader/**` change, reads the exact
|
|
||||||
image tag out of the deployment manifest, and builds + pushes only if that tag isn't
|
|
||||||
already in the registry. So **deploying = bumping the image tag** in the manifest.
|
|
||||||
|
|
||||||
Two helper scripts prepare a release:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Full deploy: bump BOTH image versions (+0.001) and snapshot the DB into the loader
|
|
||||||
./scripts/full_deploy.sh
|
|
||||||
|
|
||||||
# Partial deploy: bump only the main web container (no DB snapshot)
|
|
||||||
./scripts/partial_deploy.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
After running a script, commit and push the changed manifests — Gitea Actions builds
|
|
||||||
the images and ArgoCD rolls them out.
|
|
||||||
|
|
||||||
### Kubernetes resources (`argocd/`)
|
|
||||||
|
|
||||||
| File | Resource |
|
|
||||||
|------|----------|
|
|
||||||
| `deployment.yaml` | Deployment (web + loader init container) and Service |
|
|
||||||
| `ingress.yaml` | Traefik Ingress for `labhelper.adebaumann.com` |
|
|
||||||
| `configmap.yaml` | Non-secret environment configuration |
|
|
||||||
| `001_pvc.yaml` | PersistentVolumeClaim for the SQLite DB and media |
|
|
||||||
| `nfs-pv.yaml` | NFS-backed PersistentVolume |
|
|
||||||
|
|
||||||
Secrets (`DJANGO_SECRET_KEY`, `oidc-client-secret`) are supplied via a Kubernetes
|
|
||||||
Secret; see `k8s-templates/secret.yaml` and `scripts/deploy_secret.sh`. Liveness and
|
|
||||||
readiness probes hit the `/health/` endpoint.
|
|
||||||
|
|
||||||
## Project layout
|
|
||||||
|
|
||||||
```
|
|
||||||
labhelper/
|
|
||||||
├── boxes/ # main app: models, views, forms, templates, static, commands
|
|
||||||
│ ├── models.py # BoxType, Box, Facet, Tag, Thing, ThingFile, ThingLink
|
|
||||||
│ ├── views.py # all views (function-based)
|
|
||||||
│ ├── decorators.py # conditional_login_required
|
|
||||||
│ └── management/commands # list_things, clean_orphaned_files, clean_orphaned_images
|
|
||||||
├── labhelper/ # project config
|
|
||||||
│ ├── settings.py # env-driven settings
|
|
||||||
│ ├── urls.py # URL routing
|
|
||||||
│ ├── auth_backend.py # Keycloak OIDC backend + group mapping
|
|
||||||
│ └── templates/ # base.html, login.html
|
|
||||||
├── data/ # SQLite DB + uploaded media (mounted from a PVC in prod)
|
|
||||||
├── data-loader/ # init-container image that seeds the DB
|
|
||||||
├── argocd/ # Kubernetes manifests (ArgoCD-managed)
|
|
||||||
├── k8s-templates/ # secret templates
|
|
||||||
├── scripts/ # deploy helpers
|
|
||||||
├── Dockerfile # web container build
|
|
||||||
├── gunicorn.conf.py # gunicorn configuration
|
|
||||||
└── manage.py
|
|
||||||
```
|
|
||||||
|
|
||||||
## Notes & gotchas
|
|
||||||
|
|
||||||
- Templates live in `labhelper/templates/` (base) and `boxes/templates/boxes/` (app).
|
|
||||||
- Descriptions are Markdown, rendered via a `render_markdown` filter and sanitised with
|
|
||||||
`bleach` — don't trust raw HTML from descriptions.
|
|
||||||
- Media files are served by Django in all environments (WhiteNoise handles static).
|
|
||||||
- Uploaded pictures are renamed to `things/<thing-id>-<slug>.<ext>` after the thing gets
|
|
||||||
a primary key (see `Thing.save`).
|
|
||||||
- Deleting a thing also deletes its picture and attached files from disk.
|
|
||||||
@@ -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,192.168.0.0/16"
|
ALLOWED_CIDR_NETS: "10.0.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.083"
|
IMAGE_TAG: "0.086"
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ spec:
|
|||||||
mountPath: /data
|
mountPath: /data
|
||||||
containers:
|
containers:
|
||||||
- name: web
|
- name: web
|
||||||
image: git.baumann.gr/adebaumann/labhelper:0.083
|
image: git.baumann.gr/adebaumann/labhelper:0.086
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 8000
|
- containerPort: 8000
|
||||||
|
|||||||
@@ -5,6 +5,14 @@ metadata:
|
|||||||
namespace: labhelper
|
namespace: labhelper
|
||||||
annotations:
|
annotations:
|
||||||
argocd.argoproj.io/ignore-healthcheck: "true"
|
argocd.argoproj.io/ignore-healthcheck: "true"
|
||||||
|
gethomepage.dev/enabled: "true"
|
||||||
|
gethomepage.dev/name: "Labhelper"
|
||||||
|
gethomepage.dev/description: "Laboratory inventory system"
|
||||||
|
gethomepage.dev/group: "Kubernetes"
|
||||||
|
gethomepage.dev/icon: "shield.png"
|
||||||
|
gethomepage.dev/href: "https://labhelper.adebaumann.com"
|
||||||
|
gethomepage.dev/ping: "https://labhelper.adebaumann.com/health/"
|
||||||
|
gethomepage.dev/pod-selector: "app=django"
|
||||||
spec:
|
spec:
|
||||||
ingressClassName: traefik
|
ingressClassName: traefik
|
||||||
rules:
|
rules:
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
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
|
|
||||||
+18
-1
@@ -1,14 +1,31 @@
|
|||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
from django.contrib.admin.sites import AdminSite
|
from django.contrib.admin.sites import AdminSite
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
from django.db import IntegrityError
|
from django.db import IntegrityError
|
||||||
from django.test import Client, TestCase
|
from django.test import Client, RequestFactory, SimpleTestCase, TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from mozilla_django_oidc.middleware import SessionRefresh
|
||||||
|
|
||||||
from .admin import BoxAdmin, BoxTypeAdmin, ThingAdmin
|
from .admin import BoxAdmin, BoxTypeAdmin, ThingAdmin
|
||||||
from .models import Box, BoxType, Facet, Tag, Thing, ThingFile, ThingLink
|
from .models import Box, BoxType, Facet, Tag, Thing, ThingFile, ThingLink
|
||||||
|
|
||||||
|
|
||||||
|
class OIDCSessionRefreshTests(SimpleTestCase):
|
||||||
|
"""Tests for OIDC session refresh routing exclusions."""
|
||||||
|
|
||||||
|
def test_media_urls_do_not_trigger_oidc_session_refresh(self):
|
||||||
|
"""Uploaded files should be served directly, not redirected through OIDC."""
|
||||||
|
request = RequestFactory().get('/media/things/files/251/diag_6606.png')
|
||||||
|
request.user = SimpleNamespace(is_authenticated=True)
|
||||||
|
request.session = {}
|
||||||
|
|
||||||
|
middleware = SessionRefresh(lambda request: None)
|
||||||
|
|
||||||
|
self.assertFalse(middleware.is_refreshable_url(request))
|
||||||
|
|
||||||
|
|
||||||
class AuthTestCase(TestCase):
|
class AuthTestCase(TestCase):
|
||||||
"""Base test case that provides authenticated client."""
|
"""Base test case that provides authenticated client."""
|
||||||
|
|
||||||
|
|||||||
+179
-237
@@ -1,7 +1,7 @@
|
|||||||
import bleach
|
import bleach
|
||||||
import markdown
|
import markdown
|
||||||
|
|
||||||
from boxes.decorators import conditional_login_required as login_required
|
from django.contrib.auth.decorators import 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,107 +49,95 @@ 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(
|
return render(request, 'boxes/index.html', {
|
||||||
request,
|
'facets': facets,
|
||||||
"boxes/index.html",
|
'facet_tag_counts': facet_tag_counts,
|
||||||
{
|
})
|
||||||
"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(
|
return render(request, 'boxes/box_detail.html', {
|
||||||
request,
|
'box': box,
|
||||||
"boxes/box_detail.html",
|
'things': things,
|
||||||
{
|
})
|
||||||
"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(
|
Thing.objects.select_related('box', 'box__box_type').prefetch_related('files', 'links', 'tags'),
|
||||||
"files", "links", "tags"
|
pk=thing_id
|
||||||
),
|
|
||||||
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(
|
Thing.objects.select_related('box', 'box__box_type').prefetch_related('files', 'links', 'tags'),
|
||||||
"files", "links", "tags"
|
pk=thing_id
|
||||||
),
|
|
||||||
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)
|
||||||
@@ -157,20 +145,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)
|
||||||
@@ -181,134 +169,114 @@ 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(
|
return render(request, 'boxes/edit_thing.html', {
|
||||||
request,
|
'thing': thing,
|
||||||
"boxes/edit_thing.html",
|
'boxes': boxes,
|
||||||
{
|
'facets': facets,
|
||||||
"thing": thing,
|
'picture_form': picture_form,
|
||||||
"boxes": boxes,
|
'file_form': file_form,
|
||||||
"facets": facets,
|
'link_form': link_form,
|
||||||
"picture_form": picture_form,
|
'thing_form': thing_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(
|
return render(request, 'boxes/boxes_list.html', {
|
||||||
request,
|
'boxes': boxes,
|
||||||
"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 = (
|
things = Thing.objects.filter(
|
||||||
Thing.objects.filter(
|
Q(tags__facet__name__icontains=facet_name) &
|
||||||
Q(tags__facet__name__icontains=facet_name)
|
Q(tags__name__icontains=tag_name)
|
||||||
& Q(tags__name__icontains=tag_name)
|
).prefetch_related('files', 'links', 'tags').select_related('box').distinct()[:50]
|
||||||
)
|
|
||||||
.prefetch_related("files", "links", "tags")
|
|
||||||
.select_related("box")
|
|
||||||
.distinct()[:50]
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
# Normal search
|
# Normal search
|
||||||
things = (
|
things = Thing.objects.filter(
|
||||||
Thing.objects.filter(
|
Q(name__icontains=query) |
|
||||||
Q(name__icontains=query)
|
Q(description__icontains=query) |
|
||||||
| Q(description__icontains=query)
|
Q(files__title__icontains=query) |
|
||||||
| Q(files__title__icontains=query)
|
Q(files__file__icontains=query) |
|
||||||
| Q(files__file__icontains=query)
|
Q(links__title__icontains=query) |
|
||||||
| Q(links__title__icontains=query)
|
Q(links__url__icontains=query) |
|
||||||
| Q(links__url__icontains=query)
|
Q(tags__name__icontains=query) |
|
||||||
| Q(tags__name__icontains=query)
|
Q(tags__facet__name__icontains=query)
|
||||||
| Q(tags__facet__name__icontains=query)
|
).prefetch_related('files', 'links', 'tags').select_related('box').distinct()[:50]
|
||||||
)
|
|
||||||
.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(
|
formset = ThingFormSet(request.POST, request.FILES, queryset=Thing.objects.filter(box=box))
|
||||||
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
|
||||||
@@ -318,195 +286,173 @@ 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(
|
return render(request, 'boxes/add_things.html', {
|
||||||
request,
|
'box': box,
|
||||||
"boxes/add_things.html",
|
'formset': formset,
|
||||||
{
|
'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(
|
return render(request, 'boxes/box_management.html', {
|
||||||
request,
|
'box_types': box_types,
|
||||||
"boxes/box_management.html",
|
'boxes': boxes,
|
||||||
{
|
'box_type_form': box_type_form,
|
||||||
"box_types": box_types,
|
'box_form': box_form,
|
||||||
"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 = (
|
things_with_files = Thing.objects.filter(files__isnull=False).prefetch_related('files').distinct()
|
||||||
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_links = (
|
all_things = (things_with_files | things_with_links).distinct().order_by('name')
|
||||||
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',
|
||||||
"type": "file",
|
'thing_name': thing.name,
|
||||||
"thing_name": thing.name,
|
'thing_id': thing.id,
|
||||||
"thing_id": thing.id,
|
'title': file.title,
|
||||||
"title": file.title,
|
'url': file.file.url,
|
||||||
"url": file.file.url,
|
})
|
||||||
}
|
|
||||||
)
|
|
||||||
for link in thing.links.all():
|
for link in thing.links.all():
|
||||||
resources.append(
|
resources.append({
|
||||||
{
|
'type': 'link',
|
||||||
"type": "link",
|
'thing_name': thing.name,
|
||||||
"thing_name": thing.name,
|
'thing_id': thing.id,
|
||||||
"thing_id": thing.id,
|
'title': link.title,
|
||||||
"title": link.title,
|
'url': link.url,
|
||||||
"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 = (
|
missing_things = Thing.objects.exclude(
|
||||||
Thing.objects.exclude(tags__facet=selected_facet)
|
tags__facet=selected_facet
|
||||||
.select_related("box", "box__box_type")
|
).select_related('box', 'box__box_type').prefetch_related('tags')
|
||||||
.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
|
||||||
@@ -515,15 +461,11 @@ 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(
|
return render(request, 'boxes/fixme.html', {
|
||||||
request,
|
'facets': facets,
|
||||||
"boxes/fixme.html",
|
'selected_facet': selected_facet,
|
||||||
{
|
'missing_things': missing_things,
|
||||||
"facets": facets,
|
})
|
||||||
"selected_facet": selected_facet,
|
|
||||||
"missing_things": missing_things,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|||||||
+81
-93
@@ -11,6 +11,7 @@ https://docs.djangoproject.com/en/5.2/ref/settings/
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
@@ -21,76 +22,71 @@ 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(
|
SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY', 'f0arjg8q3ut4iuqrguqfjaruf0eripIZZN3t1kymy8ugqnj$li2knhha0@gc5v8f3bge=$+gbybj2$jt28uqm')
|
||||||
"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 = os.environ.get('ALLOWED_CIDR_NETS', '10.0.0.0/16').split(',')
|
||||||
"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',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,16 +96,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',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -117,45 +113,43 @@ 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=os.environ.get('CSRF_TRUSTED_ORIGINS', 'https://labhelper.adebaumann.com,http://127.0.0.1:8000').split(',')
|
||||||
"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',
|
||||||
]
|
]
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -167,40 +161,34 @@ 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 = os.environ.get('OIDC_OP_AUTHORIZATION_ENDPOINT', f'{_oidc_connect}/auth')
|
||||||
"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_TOKEN_ENDPOINT = os.environ.get(
|
OIDC_OP_JWKS_ENDPOINT = os.environ.get('OIDC_OP_JWKS_ENDPOINT', f'{_oidc_connect}/certs')
|
||||||
"OIDC_OP_TOKEN_ENDPOINT", f"{_oidc_connect}/token"
|
OIDC_OP_LOGOUT_ENDPOINT = os.environ.get('OIDC_OP_LOGOUT_ENDPOINT', f'{_oidc_connect}/logout')
|
||||||
)
|
|
||||||
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 = os.environ.get('OIDC_AUTHENTICATION_FAILURE_REDIRECT_URL', '/login/')
|
||||||
"OIDC_AUTHENTICATION_FAILURE_REDIRECT_URL", "/login/"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Exempt AJAX endpoints and media files from the session-refresh middleware redirect
|
# Exempt endpoints and uploaded media from the session-refresh middleware.
|
||||||
import re
|
#
|
||||||
|
# Media URLs are already served without a login_required view. If an embedded
|
||||||
OIDC_EXEMPT_URLS = ["search_api", "health_check", re.compile(r"^/media/")]
|
# image request is redirected into a silent OIDC refresh, the callback happens
|
||||||
|
# in a subresource context and browsers can withhold the session cookie, causing
|
||||||
|
# the OIDC state check to fail with HTTP 400.
|
||||||
|
OIDC_EXEMPT_URLS = [
|
||||||
|
'search_api',
|
||||||
|
re.compile(rf'^{re.escape(MEDIA_URL)}'),
|
||||||
|
]
|
||||||
|
|||||||
+12
-1
@@ -2,6 +2,7 @@
|
|||||||
# Full deployment script - bumps both container versions by 0.001 and copies database
|
# Full deployment script - bumps both container versions by 0.001 and copies database
|
||||||
|
|
||||||
DEPLOYMENT_FILE="argocd/deployment.yaml"
|
DEPLOYMENT_FILE="argocd/deployment.yaml"
|
||||||
|
CONFIGMAP_FILE="argocd/configmap.yaml"
|
||||||
DB_SOURCE="data/db.sqlite3"
|
DB_SOURCE="data/db.sqlite3"
|
||||||
DB_DEST="data-loader/preload.sqlite3"
|
DB_DEST="data-loader/preload.sqlite3"
|
||||||
|
|
||||||
@@ -11,6 +12,11 @@ if [ ! -f "$DEPLOYMENT_FILE" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$CONFIGMAP_FILE" ]; then
|
||||||
|
echo "Error: $CONFIGMAP_FILE not found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# Check if source database exists
|
# Check if source database exists
|
||||||
if [ ! -f "$DB_SOURCE" ]; then
|
if [ ! -f "$DB_SOURCE" ]; then
|
||||||
echo "Error: $DB_SOURCE not found"
|
echo "Error: $DB_SOURCE not found"
|
||||||
@@ -37,7 +43,12 @@ sed -i "s|image: git.baumann.gr/adebaumann/labhelper-data-loader:$LOADER_VERSION
|
|||||||
sed -i "s|image: git.baumann.gr/adebaumann/labhelper:$MAIN_VERSION|image: git.baumann.gr/adebaumann/labhelper:$NEW_MAIN_VERSION|" "$DEPLOYMENT_FILE"
|
sed -i "s|image: git.baumann.gr/adebaumann/labhelper:$MAIN_VERSION|image: git.baumann.gr/adebaumann/labhelper:$NEW_MAIN_VERSION|" "$DEPLOYMENT_FILE"
|
||||||
|
|
||||||
# Update ConfigMap with new main container version
|
# Update ConfigMap with new main container version
|
||||||
sed -i "s| IMAGE_TAG: \"$MAIN_VERSION\"| IMAGE_TAG: \"$NEW_MAIN_VERSION\"|" "argocd/configmap.yaml"
|
sed -i -E "s|^([[:space:]]*IMAGE_TAG: \")[^\"]*(\")|\\1$NEW_MAIN_VERSION\\2|" "$CONFIGMAP_FILE"
|
||||||
|
|
||||||
|
if ! grep -q "IMAGE_TAG: \"$NEW_MAIN_VERSION\"" "$CONFIGMAP_FILE"; then
|
||||||
|
echo "Error: Could not update IMAGE_TAG in $CONFIGMAP_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# Copy database
|
# Copy database
|
||||||
cp "$DB_SOURCE" "$DB_DEST"
|
cp "$DB_SOURCE" "$DB_DEST"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
# Partial deployment script - bumps main container version by 0.001
|
# Partial deployment script - bumps main container version by 0.001
|
||||||
|
|
||||||
DEPLOYMENT_FILE="argocd/deployment.yaml"
|
DEPLOYMENT_FILE="argocd/deployment.yaml"
|
||||||
|
CONFIGMAP_FILE="argocd/configmap.yaml"
|
||||||
|
|
||||||
# Check if file exists
|
# Check if file exists
|
||||||
if [ ! -f "$DEPLOYMENT_FILE" ]; then
|
if [ ! -f "$DEPLOYMENT_FILE" ]; then
|
||||||
@@ -9,6 +10,11 @@ if [ ! -f "$DEPLOYMENT_FILE" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$CONFIGMAP_FILE" ]; then
|
||||||
|
echo "Error: $CONFIGMAP_FILE not found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# Extract current version of main container (labhelper, not labhelper-data-loader)
|
# Extract current version of main container (labhelper, not labhelper-data-loader)
|
||||||
CURRENT_VERSION=$(grep -E "image: git.baumann.gr/adebaumann/labhelper:[0-9]" "$DEPLOYMENT_FILE" | grep -v "data-loader" | sed -E 's/.*:([0-9.]+)/\1/')
|
CURRENT_VERSION=$(grep -E "image: git.baumann.gr/adebaumann/labhelper:[0-9]" "$DEPLOYMENT_FILE" | grep -v "data-loader" | sed -E 's/.*:([0-9.]+)/\1/')
|
||||||
|
|
||||||
@@ -24,7 +30,12 @@ NEW_VERSION=$(echo "$CURRENT_VERSION + 0.001" | bc | sed 's/^\./0./')
|
|||||||
sed -i "s|image: git.baumann.gr/adebaumann/labhelper:$CURRENT_VERSION|image: git.baumann.gr/adebaumann/labhelper:$NEW_VERSION|" "$DEPLOYMENT_FILE"
|
sed -i "s|image: git.baumann.gr/adebaumann/labhelper:$CURRENT_VERSION|image: git.baumann.gr/adebaumann/labhelper:$NEW_VERSION|" "$DEPLOYMENT_FILE"
|
||||||
|
|
||||||
# Update ConfigMap with new main container version
|
# Update ConfigMap with new main container version
|
||||||
sed -i "s| IMAGE_TAG: \"$CURRENT_VERSION\"| IMAGE_TAG: \"$NEW_VERSION\"|" "argocd/configmap.yaml"
|
sed -i -E "s|^([[:space:]]*IMAGE_TAG: \")[^\"]*(\")|\\1$NEW_VERSION\\2|" "$CONFIGMAP_FILE"
|
||||||
|
|
||||||
|
if ! grep -q "IMAGE_TAG: \"$NEW_VERSION\"" "$CONFIGMAP_FILE"; then
|
||||||
|
echo "Error: Could not update IMAGE_TAG in $CONFIGMAP_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Partial deployment prepared:"
|
echo "Partial deployment prepared:"
|
||||||
echo " Main container: $CURRENT_VERSION -> $NEW_VERSION"
|
echo " Main container: $CURRENT_VERSION -> $NEW_VERSION"
|
||||||
|
|||||||
Reference in New Issue
Block a user