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
11 changed files with 710 additions and 313 deletions

71
AGENTS.md Normal file
View File

@@ -0,0 +1,71 @@
# AGENTS.md - labhelper
**Type**: Django 5.2 web app (lab inventory)
**Python**: 3.13 | **DB**: SQLite (dev) | **Env**: `.venv/`
## Essential Commands
```bash
# Activate venv first
source .venv/bin/activate
# Dev server
python manage.py runserver # localhost:8000
python manage.py runserver 0.0.0.0:8000 # all interfaces
# Database
python manage.py makemigrations boxes # after model changes
python manage.py migrate
python manage.py showmigrations
# Testing
python manage.py test # all
python manage.py test boxes # app only
python manage.py test boxes.tests.BoxModelTests.test_method # specific
# Custom commands
python manage.py create_default_users # admin/admin123, staff/staff123, viewer/viewer123
python manage.py clean_orphaned_files --dry-run
python manage.py clean_orphaned_images --dry-run
python manage.py collectstatic # after CSS changes
```
## Critical Rules
1. **NEVER commit/push without explicit permission** - wait for user to say "commit and push"
2. Use `get_object_or_404()` not bare `.get()` for model lookups
3. Run `makemigrations` then `migrate` after any model change
4. Default users: `admin/admin123` (superuser), `staff/staff123`, `viewer/viewer123`
## Data Model
- **BoxType** → Box (1:N, PROTECT)
- **Box** → Thing (1:N, PROTECT) — Box.pk is CharField(max=10)
- **Facet** → Tag (1:N, CASCADE) — Facet.cardinality: single/multiple
- **Thing** ↔ Tag (M2M)
- **Thing** → ThingFile, ThingLink (1:N, CASCADE)
## Deployment (when instructed)
**Full deploy**: bump both versions in `argocd/deployment.yaml` (+0.001), then:
```bash
cp data/db.sqlite3 data-loader/preload.sqlite3
```
**Partial deploy**: bump main container only, skip DB copy.
## Key Directories
- `boxes/` — main app (models, views, forms, templates)
- `labhelper/` — project settings, base template
- `argocd/` — Kubernetes manifests for production
- `data-loader/` — init container with preloaded DB
## Gotchas
- Template base: `labhelper/templates/base.html`
- App templates: `boxes/templates/boxes/`
- Box ID is CharField (e.g., "A1-001"), not auto-increment
- Views use `conditional_login_required` - bypasses login for IPs in `ALLOWED_CIDR_NETS` env var
- Markdown via `{{ text|render_markdown }}` (sanitized with bleach)
- Third-party: django-mptt, sorl-thumbnail, bleach, markdown

272
AGENTS.md.backup Normal file
View File

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

View File

@@ -6,7 +6,7 @@ metadata:
data: 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.086" 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.086 image: git.baumann.gr/adebaumann/labhelper:0.083
imagePullPolicy: Always imagePullPolicy: Always
ports: ports:
- containerPort: 8000 - containerPort: 8000

View File

@@ -5,14 +5,6 @@ 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:

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,31 +1,14 @@
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, RequestFactory, SimpleTestCase, TestCase from django.test import Client, 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."""

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

@@ -11,7 +11,6 @@ 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'.
@@ -22,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",
} }
} }
@@ -96,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",
}, },
] ]
@@ -113,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",
] ]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -161,34 +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 endpoints and uploaded media from the session-refresh middleware. # Exempt AJAX endpoints and media files from the session-refresh middleware redirect
# import re
# Media URLs are already served without a login_required view. If an embedded
# image request is redirected into a silent OIDC refresh, the callback happens OIDC_EXEMPT_URLS = ["search_api", "health_check", re.compile(r"^/media/")]
# 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)}'),
]

View File

@@ -2,7 +2,6 @@
# 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"
@@ -12,11 +11,6 @@ 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"
@@ -43,12 +37,7 @@ 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 -E "s|^([[:space:]]*IMAGE_TAG: \")[^\"]*(\")|\\1$NEW_MAIN_VERSION\\2|" "$CONFIGMAP_FILE" sed -i "s| IMAGE_TAG: \"$MAIN_VERSION\"| IMAGE_TAG: \"$NEW_MAIN_VERSION\"|" "argocd/configmap.yaml"
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"

View File

@@ -2,7 +2,6 @@
# 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
@@ -10,11 +9,6 @@ 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/')
@@ -30,12 +24,7 @@ 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 -E "s|^([[:space:]]*IMAGE_TAG: \")[^\"]*(\")|\\1$NEW_VERSION\\2|" "$CONFIGMAP_FILE" sed -i "s| IMAGE_TAG: \"$CURRENT_VERSION\"| IMAGE_TAG: \"$NEW_VERSION\"|" "argocd/configmap.yaml"
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"