36 Commits

Author SHA1 Message Date
4569fec82c Image links -> lightbox instead of download
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 2m22s
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 9s
2026-03-13 21:22:14 +01:00
b507f961cb New health check added
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 16s
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 3s
2026-03-02 13:05:51 +01:00
41ec7bdc08 Removed some more unnecessary files from container
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 1m23s
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 5s
2026-03-02 12:36:54 +01:00
da6a73e357 Delete things implemented (can't believe I forgot that...)
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 14s
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 3s
2026-02-25 22:39:53 +01:00
4ad03403aa Keycloak shenanigans
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 17s
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 3s
2026-02-25 22:24:44 +01:00
88ff6ddae5 Testing with real Keycloak
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 1m3s
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 6s
2026-02-25 21:31:52 +01:00
450ff488ea Hardcoded bits removed, Documentation updated. All hail the muse of epic commits! 2026-02-25 01:43:23 +01:00
97ce26fb51 Testing with local keycloak 2026-02-25 01:33:20 +01:00
ed47530c3c NFS adapted to use share "Kubernetesdata" 2026-02-10 14:58:59 +01:00
e537ec2ac0 Deployment 0.076
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 27s
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 7s
2026-01-28 13:51:22 +01:00
ec102dd1cc Menu structure cleaned up 2026-01-28 13:29:32 +01:00
7bae0d12de CSS extracted into separate file
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 26s
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 8s
2026-01-28 10:15:14 +01:00
dbfb38bb8a Revert - actual ip information not available in headers
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 27s
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 7s
2026-01-28 09:55:00 +01:00
20239242ce Not logging correct IP yet
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 28s
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 7s
2026-01-28 09:46:42 +01:00
60e13822ee Not logging correct IP yet
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 26s
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 7s
2026-01-28 09:40:45 +01:00
4d492ded4e Not logging correct IP yet
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 26s
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 7s
2026-01-28 09:33:42 +01:00
3b53967c40 Not logging correct IP yet 2026-01-28 09:33:15 +01:00
65868c043e Not logging yet
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 27s
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 7s
2026-01-28 09:06:39 +01:00
5c9b45715b Deploy last commit
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 2m40s
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 7s
2026-01-28 08:58:07 +01:00
bbed20813a Forward proxying and suppression of health checks in access logs
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 11s
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 8s
2026-01-28 08:55:19 +01:00
22f5b87a20 Search menu entry changed to inventory
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 33s
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 7s
2026-01-20 01:03:04 +01:00
1dede761e3 ...and deploy
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 28s
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 6s
2026-01-20 00:18:39 +01:00
860e80a552 Box sorting works 2026-01-20 00:18:05 +01:00
a1bc7967c5 Box sorting field added and put into effect
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 1m41s
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 7s
2026-01-19 23:56:26 +01:00
2a825646a3 Kubernetes PVC changed to shared NFS 2026-01-19 22:16:48 +01:00
7c990998a4 Revert 2026-01-19 09:04:24 +01:00
bf62ddcbd7 NFS test 2026-01-19 08:58:14 +01:00
985460ff84 Fixed management command to remove orphan files
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 2m13s
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 8s
2026-01-17 14:46:45 +01:00
2705f6c16e Statif media files now served in non-DEBUG-context
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 15s
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 3s
2026-01-16 13:41:52 +01:00
887247028a Static file serving out of DEBUG mode addressed
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 55s
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 4s
2026-01-16 13:15:40 +01:00
4cbd3e2f87 Gunicorn options in config map; Image tag displays on page
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 1m3s
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 4s
2026-01-16 12:22:50 +01:00
935392d27d Variables now in config map
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 6s
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 4s
2026-01-16 11:35:01 +01:00
30657be6c2 Secret now properly deployable 2026-01-16 11:28:03 +01:00
4bca3ae403 Stupid mistake in argocd secret... 2026-01-16 11:24:34 +01:00
db80ddf069 Main image now also "Lightboxes"
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 15s
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 3s
2026-01-06 15:14:44 +01:00
0602347539 Images on detail page now show up as thumbnails with lightbox functionality 2026-01-06 14:54:56 +01:00
32 changed files with 1467 additions and 659 deletions

View File

@@ -209,6 +209,11 @@ labhelper/
│ │ ├── clean_orphaned_files.py # Cleanup orphaned ThingFile attachments
│ │ └── clean_orphaned_images.py # Cleanup orphaned Thing images
│ ├── migrations/ # Database migrations
│ ├── static/
│ │ └── css/
│ │ ├── base.css # Base styles (layout, navbar, buttons, alerts, etc.)
│ │ ├── edit_thing.css # Edit thing form styles
│ │ └── thing_detail.css # Markdown content + lightbox styles
│ ├── templates/
│ │ └── boxes/
│ │ ├── add_things.html # Form to add multiple things
@@ -356,14 +361,21 @@ The project uses a base template system at `labhelper/templates/base.html`. All
### CSS Guidelines
**Static CSS Files:**
- Base styles live in `boxes/static/css/base.css` (loaded by `base.html` via `{% static %}`)
- Page-specific styles live in separate static CSS files (e.g., `thing_detail.css`, `edit_thing.css`)
- Child templates load their CSS via `{% block extra_css %}` with `<link>` tags
- WhiteNoise serves and cache-busts static files via `CompressedManifestStaticFilesStorage`
- Run `python manage.py collectstatic` after adding or modifying static CSS files
**Naming:**
- Use descriptive class names
- BEM pattern encouraged for complex components
- Inline styles allowed for template-specific styling
- Inline styles allowed for template-specific one-off styling
**Styles:**
- Use base template styles when possible
- Template-specific styles in `{% block extra_css %}`
- Use base CSS classes when possible
- Page-specific styles in dedicated static CSS files loaded via `{% block extra_css %}`
- JavaScript in `{% block extra_js %}`
- Smooth transitions (0.2s - 0.3s)
- Hover effects with transform and box-shadow
@@ -410,36 +422,44 @@ The project uses a base template system at `labhelper/templates/base.html`. All
- `title`: Page title tag
- `page_header`: Page header with breadcrumbs
- `content`: Main page content
- `extra_css`: Additional styles
- `extra_css`: Additional CSS via `<link>` tags to static files
- `extra_head`: Additional head elements
- `extra_js`: Additional JavaScript
3. **Load required template tags**
```django
{% load static %}
{% load static %} {# Required when using {% static %} for CSS/asset links #}
{% load mptt_tags %}
{% load thumbnail %}
{% load dict_extras %}
```
4. **Use URL names for links**
4. **Link page-specific CSS from static files**
```django
{% block extra_css %}
<link rel="stylesheet" href="{% static 'css/thing_detail.css' %}">
{% endblock %}
```
5. **Use URL names for links**
```django
<a href="{% url 'box_detail' box.id %}">
```
5. **Use icons with Font Awesome**
6. **Use icons with Font Awesome**
```django
<i class="fas fa-box"></i>
```
6. **Add breadcrumbs for navigation**
7. **Add breadcrumbs for navigation**
```django
<p class="breadcrumb">
<a href="/"><i class="fas fa-home"></i> Home</a> /
<a href="/"><i class="fas fa-home"></i> Home</a> /
<a href="/box/{{ box.id }}/"><i class="fas fa-box"></i> Box {{ box.id }}</a>
</p>
```
7. **Icon alignment in lists**: When using icons in list items, use fixed width containers to ensure proper alignment
8. **Icon alignment in lists**: When using icons in list items, use fixed width containers to ensure proper alignment
```django
<a href="{% url 'thing_detail' thing.id %}" style="display: inline-block; width: 20px; text-align: center;">
<i class="fas fa-link"></i>

View File

@@ -34,6 +34,7 @@ WORKDIR /app
COPY --chown=appuser:appuser . .
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV IMAGE_TAG=build
USER appuser
EXPOSE 8000
RUN rm -rvf /app/Dockerfile* \
@@ -45,7 +46,10 @@ RUN rm -rvf /app/Dockerfile* \
/app/requirements.txt \
/app/node_modules \
/app/*.json \
/app/AGENTS* \
/app/*.md \
/app/k8s-templates \
/app/test_*.py && \
python3 /app/manage.py collectstatic --noinput
CMD ["sh", "-c", "python manage.py thumbnail clear && gunicorn --bind 0.0.0.0:8000 --workers 3 labhelper.wsgi:application"]
CMD ["sh", "-c", "python manage.py thumbnail clear && gunicorn --bind 0.0.0.0:8000 --workers 3 $GUNICORN_OPTS labhelper.wsgi:application"]

207
Keycloak-installation.md Normal file
View File

@@ -0,0 +1,207 @@
# Keycloak SSO Integration
This document describes how Keycloak SSO was integrated into labhelper, replacing the built-in Django username/password authentication.
## Overview
Authentication is handled via OpenID Connect (OIDC) using the `mozilla-django-oidc` library. When a user visits any protected page, they are redirected to Keycloak to authenticate. On return, Keycloak group memberships are synced to Django groups, controlling what the user can do in the app.
---
## Keycloak Setup
### 1. Create a client
In the Keycloak admin console, go to **Clients → Create client**.
| Field | Value | Why |
|---|---|---|
| Client type | OpenID Connect | The protocol mozilla-django-oidc speaks |
| Client ID | `labhelper` | Must match `OIDC_RP_CLIENT_ID` in the app |
| Client authentication | On (confidential) | Server-side apps use a client secret; this is more secure than a public client |
| Authentication flow | Standard flow only | labhelper uses the standard authorisation code flow |
After saving, go to the **Credentials** tab and copy the **Client secret** — this is `OIDC_RP_CLIENT_SECRET`.
### 2. Set the redirect URI
In the client **Settings** tab:
| Field | Value | Why |
|---|---|---|
| Valid redirect URIs | `https://your-app/oidc/callback/` | Keycloak will only redirect back to whitelisted URIs after authentication. The trailing slash is required — without it Keycloak rejects the request. For local dev also add `http://127.0.0.1:8000/oidc/callback/` |
### 3. Set the PKCE code challenge method
In the client **Advanced** tab, find **"Proof Key for Code Exchange Code Challenge Method"** and set it to **S256**.
Why: Keycloak 26 configures new clients with PKCE enforced. `mozilla-django-oidc` sends `S256` as the challenge method (the more secure option). If Keycloak is set to `plain`, the two sides don't agree and authentication fails with `code challenge method is not matching the configured one`.
### 4. Add a Groups mapper
This makes Keycloak include the user's group memberships in the token so the app can sync them to Django groups.
Go to **Clients → labhelper → Client scopes** tab → click the dedicated scope (named `labhelper-dedicated`) → **Add mapper → By configuration → Group Membership**.
| Field | Value | Why |
|---|---|---|
| Name | `groups` | Label for this mapper |
| Token Claim Name | `groups` | The claim name the app reads from the token |
| Full group path | Off | Sends `LabHelper Administrators` instead of `/LabHelper Administrators`. The app strips leading slashes anyway, but this is cleaner |
| Add to ID token | On | |
| Add to access token | On | |
| Add to userinfo | On | The app fetches userinfo after the token exchange |
### 5. Create groups
Go to **Groups** (left sidebar) and create these three groups with exactly these names — they map to the existing Django groups:
- `LabHelper Administrators` — gets `is_staff=True` in Django (admin access)
- `LabHelper Staff`
- `LabHelper Viewers`
### 6. Ensure users have an email address
`mozilla-django-oidc` requires the `email` claim to be present in the token. Every Keycloak user who will log into labhelper must have:
- An **Email** address set (Users → select user → Details tab)
- **Email verified** ticked
Without an email, authentication fails silently with `Claims verification failed` in the Django logs.
---
## App Configuration
The app is configured entirely via environment variables.
### Required variables
```bash
OIDC_OP_BASE_URL=https://keycloak.example.com/realms/your-realm
OIDC_RP_CLIENT_ID=labhelper
OIDC_RP_CLIENT_SECRET=<client-secret-from-keycloak-credentials-tab>
```
`OIDC_OP_BASE_URL` is the realm URL. All OIDC endpoints (authorisation, token, userinfo, JWKS, logout) are derived from it automatically in `settings.py`.
### Other relevant variables
```bash
ALLOWED_HOSTS=your-app-hostname
CSRF_TRUSTED_ORIGINS=https://your-app
```
`CSRF_TRUSTED_ORIGINS` must include the app's origin. The OIDC callback goes through Django's CSRF middleware, and requests from untrusted origins are rejected.
---
## How the App Side Works
### Library
`mozilla-django-oidc` handles the full OIDC flow: redirecting to Keycloak, validating the returned token (RS256 signature verified against Keycloak's JWKS endpoint), exchanging the authorisation code, and fetching userinfo.
### Key settings
| Setting | Value | Why |
|---|---|---|
| `OIDC_RP_SIGN_ALGO` | `RS256` | Keycloak signs tokens with RS256 by default |
| `OIDC_RP_SCOPES` | `openid email profile` | `profile` is needed to get `preferred_username`, `given_name`, `family_name` from Keycloak |
| `OIDC_USE_PKCE` | `True` | Required because Keycloak enforces PKCE on this client |
| `OIDC_STORE_ID_TOKEN` | `True` | The ID token is stored in the session and passed as `id_token_hint` when logging out, so Keycloak also ends its own session |
| `OIDC_EXEMPT_URLS` | `['search_api']` | The search endpoint is called via AJAX. The `SessionRefresh` middleware would return a redirect instead of JSON for unauthenticated AJAX calls, breaking the UI |
| `LOGIN_URL` | `oidc_authentication_init` | When `@login_required` intercepts an unauthenticated request, it redirects directly to the OIDC flow rather than a local login form |
### Authentication backend (`labhelper/auth_backend.py`)
Overrides `OIDCAuthenticationBackend` to:
- Use `preferred_username` from Keycloak as the Django username
- Set `first_name` and `last_name` from `given_name` / `family_name` claims
- Sync group memberships on every login — if a user is added to or removed from a Keycloak group, it takes effect at their next login
- Set `is_staff=True` for members of `LabHelper Administrators` (grants Django admin access)
`django.contrib.auth.backends.ModelBackend` is kept as a fallback so the Django admin login form still works with a local username/password (useful for emergency superuser access without Keycloak).
### Session refresh middleware
`mozilla_django_oidc.middleware.SessionRefresh` is added after `AuthenticationMiddleware`. It periodically checks whether the user's OIDC session is still valid and forces re-authentication if the token has expired, rather than keeping a stale Django session alive indefinitely.
### URLs
All OIDC routes are mounted under `/oidc/`:
| URL | Purpose |
|---|---|
| `/oidc/authenticate/` | Initiates the OIDC flow, redirects to Keycloak |
| `/oidc/callback/` | Keycloak redirects here after authentication |
| `/oidc/logout/` | Logs out of Django and ends the Keycloak session |
`/login/` is kept as a static landing page with a "Login with SSO" button, used when users navigate to it manually or are redirected there after logout.
---
## Auth Flow
```
User visits protected page
@login_required → redirect to /oidc/authenticate/?next=/original/url/
Redirect to Keycloak (with code_challenge for PKCE)
User authenticates in Keycloak
Keycloak redirects to /oidc/callback/?code=...
App exchanges code for tokens, verifies RS256 signature
App fetches userinfo, syncs groups and attributes
User redirected to original URL
```
---
## Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| `Invalid parameter: redirect_uri` | Redirect URI not in Keycloak whitelist, or trailing slash missing | Add exact URI including trailing slash to Keycloak client Valid Redirect URIs |
| `Missing parameter: code_challenge_method` | Keycloak requires PKCE but app wasn't sending it | Set `OIDC_USE_PKCE = True` in settings |
| `code challenge method is not matching the configured one` | Keycloak client set to `plain`, app sends `S256` | Set PKCE method to `S256` in Keycloak client Advanced settings |
| `Claims verification failed` | User has no email set in Keycloak | Set email address and tick Email Verified on the Keycloak user |
| `NoReverseMatch` for `OIDC_EXEMPT_URLS` | Regex pattern used instead of URL name | Use the Django URL name (`'search_api'`), not a regex |
| Login loops without showing Keycloak | Existing Keycloak session auto-authenticates | Expected behaviour — Keycloak reuses its session. Log out of Keycloak admin console to test a clean login |
---
## Kubernetes Deployment
Split the configuration across a ConfigMap and a Secret. The client secret must not go in a ConfigMap as the contents are visible in plain text to anyone with cluster access.
**ConfigMap**
```yaml
data:
OIDC_OP_BASE_URL: https://keycloak.example.com/realms/your-realm
OIDC_RP_CLIENT_ID: labhelper
CSRF_TRUSTED_ORIGINS: https://labhelper.adebaumann.com
ALLOWED_HOSTS: labhelper.adebaumann.com
```
**Secret**
```yaml
stringData:
OIDC_RP_CLIENT_SECRET: <client-secret-from-keycloak-credentials-tab>
DJANGO_SECRET_KEY: <random-secret-key>
```
Reference both in the deployment:
```yaml
envFrom:
- configMapRef:
name: labhelper-config
- secretRef:
name: labhelper-secret
```

View File

@@ -6,7 +6,7 @@ metadata:
spec:
accessModes:
- ReadWriteMany
storageClassName: nfs-labhelper
storageClassName: nfs
resources:
requests:
storage: 2Gi

24
argocd/configmap.yaml Normal file
View File

@@ -0,0 +1,24 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: django-config
namespace: labhelper
data:
DEBUG: "False"
ALLOWED_HOSTS: "labhelper.adebaumann.com,*"
ALLOWED_CIDR_NETS: "10.0.0.0/16"
LANGUAGE_CODE: "en-us"
TIME_ZONE: "UTC"
USE_I18N: "True"
USE_TZ: "True"
STATIC_URL: "/static/"
MEDIA_URL: "/media/"
CSRF_TRUSTED_ORIGINS: "https://labhelper.adebaumann.com"
LOGIN_URL: "oidc_authentication_init"
OIDC_OP_BASE_URL: "https://sso.baumann.gr/realms/homelab"
OIDC_RP_CLIENT_ID: "labhelper"
LOGIN_REDIRECT_URL: "index"
LOGOUT_REDIRECT_URL: "/login/"
OIDC_AUTHENTICATION_FAILURE_REDIRECT_URL: "/login/"
GUNICORN_OPTS: "--access-logfile -"
IMAGE_TAG: "0.079"

View File

@@ -27,7 +27,7 @@ spec:
mountPath: /data
containers:
- name: web
image: git.baumann.gr/adebaumann/labhelper:0.058
image: git.baumann.gr/adebaumann/labhelper:0.082
imagePullPolicy: Always
ports:
- containerPort: 8000
@@ -37,12 +37,107 @@ spec:
secretKeyRef:
name: django-secret
key: secret-key
- name: DEBUG
valueFrom:
configMapKeyRef:
name: django-config
key: DEBUG
- name: ALLOWED_HOSTS
valueFrom:
configMapKeyRef:
name: django-config
key: ALLOWED_HOSTS
- name: ALLOWED_CIDR_NETS
valueFrom:
configMapKeyRef:
name: django-config
key: ALLOWED_CIDR_NETS
- name: LANGUAGE_CODE
valueFrom:
configMapKeyRef:
name: django-config
key: LANGUAGE_CODE
- name: TIME_ZONE
valueFrom:
configMapKeyRef:
name: django-config
key: TIME_ZONE
- name: USE_I18N
valueFrom:
configMapKeyRef:
name: django-config
key: USE_I18N
- name: USE_TZ
valueFrom:
configMapKeyRef:
name: django-config
key: USE_TZ
- name: STATIC_URL
valueFrom:
configMapKeyRef:
name: django-config
key: STATIC_URL
- name: MEDIA_URL
valueFrom:
configMapKeyRef:
name: django-config
key: MEDIA_URL
- name: CSRF_TRUSTED_ORIGINS
valueFrom:
configMapKeyRef:
name: django-config
key: CSRF_TRUSTED_ORIGINS
- name: LOGIN_URL
valueFrom:
configMapKeyRef:
name: django-config
key: LOGIN_URL
- name: OIDC_OP_BASE_URL
valueFrom:
configMapKeyRef:
name: django-config
key: OIDC_OP_BASE_URL
- name: OIDC_RP_CLIENT_ID
valueFrom:
configMapKeyRef:
name: django-config
key: OIDC_RP_CLIENT_ID
- name: OIDC_RP_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: django-secret
key: oidc-client-secret
- name: LOGIN_REDIRECT_URL
valueFrom:
configMapKeyRef:
name: django-config
key: LOGIN_REDIRECT_URL
- name: LOGOUT_REDIRECT_URL
valueFrom:
configMapKeyRef:
name: django-config
key: LOGOUT_REDIRECT_URL
- name: OIDC_AUTHENTICATION_FAILURE_REDIRECT_URL
valueFrom:
configMapKeyRef:
name: django-config
key: OIDC_AUTHENTICATION_FAILURE_REDIRECT_URL
- name: GUNICORN_OPTS
valueFrom:
configMapKeyRef:
name: django-config
key: GUNICORN_OPTS
- name: IMAGE_TAG
valueFrom:
configMapKeyRef:
name: django-config
key: IMAGE_TAG
volumeMounts:
- name: data
mountPath: /app/data
readinessProbe:
httpGet:
path: /
path: /health/
port: 8000
initialDelaySeconds: 5
periodSeconds: 10
@@ -50,7 +145,7 @@ spec:
failureThreshold: 6
livenessProbe:
httpGet:
path: /
path: /health/
port: 8000
initialDelaySeconds: 20
periodSeconds: 20

View File

@@ -4,12 +4,15 @@ metadata:
name: labhelper-data-pv
namespace: labhelper
spec:
claimRef:
name: labhelper-data-pvc
namespace: labhelper
capacity:
storage: 2Gi
accessModes:
- ReadWriteMany
persistentVolumeReclaimPolicy: Retain
storageClassName: nfs-labhelper
storageClassName: nfs
nfs:
server: 192.168.17.199
path: /mnt/user/labhelper
path: /mnt/user/kubernetesdata/labhelper

View File

@@ -1,8 +0,0 @@
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: nfs-labhelper
provisioner: kubernetes.io/no-provisioner
allowVolumeExpansion: true
reclaimPolicy: Retain
volumeBindingMode: Immediate

View File

@@ -1,7 +1,12 @@
import json
from django import forms
from django.contrib import admin
from django.contrib.admin import SimpleListFilter
from django.http import JsonResponse
from django.urls import path
from django.utils.html import format_html
from django.views.decorators.http import require_POST
from .models import Box, BoxType, Facet, Tag, Thing, ThingFile, ThingLink
@@ -13,7 +18,7 @@ class BoxFilter(SimpleListFilter):
parameter_name = 'box__pk'
def lookups(self, request, model_admin):
boxes = Box.objects.select_related('box_type').order_by('id')
boxes = Box.objects.select_related('box_type').order_by('sort_order')
return [(box.pk, str(box)) for box in boxes]
def queryset(self, request, queryset):
@@ -50,9 +55,34 @@ class BoxTypeAdmin(admin.ModelAdmin):
class BoxAdmin(admin.ModelAdmin):
"""Admin configuration for Box model."""
list_display = ('id', 'box_type')
ordering = ['sort_order']
list_display = ('id', 'box_type', 'sort_order')
list_filter = ('box_type',)
search_fields = ('id',)
change_list_template = 'admin/boxes/box/change_list.html'
def get_urls(self):
urls = super().get_urls()
custom_urls = [
path('reorder/', self.admin_site.admin_view(self.reorder_view), name='boxes_box_reorder'),
]
return custom_urls + urls
def reorder_view(self, request):
"""Handle AJAX reorder requests."""
if request.method != 'POST':
return JsonResponse({'error': 'POST required'}, status=405)
try:
data = json.loads(request.body)
order = data.get('order', [])
for index, pk in enumerate(order):
Box.objects.filter(pk=pk).update(sort_order=index)
return JsonResponse({'status': 'ok'})
except (json.JSONDecodeError, KeyError) as e:
return JsonResponse({'error': str(e)}, status=400)
class ThingFileInline(admin.TabularInline):

View File

@@ -4,144 +4,196 @@ from django.conf import settings
from django.core.management.base import BaseCommand
from django.db.models import F
from sorl.thumbnail.models import KVStore
from boxes.models import Thing
from boxes.models import Thing, ThingFile
class Command(BaseCommand):
help = 'Clean up orphaned images and thumbnails from deleted things'
help = "Clean up orphaned images, files, and thumbnails from deleted things"
def add_arguments(self, parser):
parser.add_argument(
'--dry-run',
action='store_true',
dest='dry_run',
help='Show what would be deleted without actually deleting',
"--dry-run",
action="store_true",
dest="dry_run",
help="Show what would be deleted without actually deleting",
)
def handle(self, *args, **options):
dry_run = options.get('dry_run', False)
dry_run = options.get("dry_run", False)
if dry_run:
self.stdout.write(self.style.WARNING('DRY RUN - No files will be deleted'))
self.stdout.write('Finding orphaned images and thumbnails...')
self.stdout.write(self.style.WARNING("DRY RUN - No files will be deleted"))
self.stdout.write("Finding orphaned images and thumbnails...")
media_root = settings.MEDIA_ROOT
cache_root = os.path.join(media_root, 'cache')
things_root = os.path.join(media_root, 'things')
cache_root = os.path.join(media_root, "cache")
things_root = os.path.join(media_root, "things")
if not os.path.exists(things_root):
self.stdout.write(self.style.WARNING('No things directory found'))
self.stdout.write(self.style.WARNING("No things directory found"))
return
valid_paths = set()
for thing in Thing.objects.exclude(picture__exact='').exclude(picture__isnull=True):
for thing in Thing.objects.exclude(picture__exact="").exclude(
picture__isnull=True
):
if thing.picture:
valid_paths.add(os.path.basename(thing.picture.name))
self.stdout.write(f'Found {len(valid_paths)} valid images in database')
for thing_file in ThingFile.objects.all():
if thing_file.file:
if thing_file.file.name.startswith("things/"):
relative_path = thing_file.file.name[7:]
valid_paths.add(relative_path)
self.stdout.write(
f"Found {len(valid_paths)} valid images and files in database"
)
orphaned_thumbnail_paths = set()
db_cache_paths = set()
for kvstore in KVStore.objects.filter(key__startswith='sorl-thumbnail||image||'):
for kvstore in KVStore.objects.filter(
key__startswith="sorl-thumbnail||image||"
):
try:
data = json.loads(kvstore.value)
name = data.get('name', '')
if name.startswith('things/'):
name = data.get("name", "")
if name.startswith("things/"):
filename = os.path.basename(name)
if filename not in valid_paths:
image_hash = kvstore.key.split('||')[-1]
thumbnail_kvstore = KVStore.objects.filter(key=f'sorl-thumbnail||thumbnails||{image_hash}').first()
image_hash = kvstore.key.split("||")[-1]
thumbnail_kvstore = KVStore.objects.filter(
key=f"sorl-thumbnail||thumbnails||{image_hash}"
).first()
if thumbnail_kvstore:
thumbnail_list = json.loads(thumbnail_kvstore.value)
for thumbnail_hash in thumbnail_list:
thumbnail_image_kvstore = KVStore.objects.filter(key=f'sorl-thumbnail||image||{thumbnail_hash}').first()
thumbnail_image_kvstore = KVStore.objects.filter(
key=f"sorl-thumbnail||image||{thumbnail_hash}"
).first()
if thumbnail_image_kvstore:
thumbnail_data = json.loads(thumbnail_image_kvstore.value)
thumbnail_path = thumbnail_data.get('name', '')
if thumbnail_path.startswith('cache/'):
thumbnail_data = json.loads(
thumbnail_image_kvstore.value
)
thumbnail_path = thumbnail_data.get("name", "")
if thumbnail_path.startswith("cache/"):
orphaned_thumbnail_paths.add(thumbnail_path)
elif name.startswith('cache/'):
elif name.startswith("cache/"):
db_cache_paths.add(name)
except (json.JSONDecodeError, KeyError, AttributeError):
pass
deleted_count = 0
thumbnail_deleted_count = 0
empty_dirs_removed = 0
for root, dirs, files in os.walk(things_root, topdown=False):
for filename in files:
file_path = os.path.join(root, filename)
relative_path = os.path.relpath(file_path, things_root)
if relative_path not in valid_paths:
deleted_count += 1
if dry_run:
self.stdout.write(f'Would delete: {file_path}')
self.stdout.write(f"Would delete: {file_path}")
else:
try:
os.remove(file_path)
self.stdout.write(f'Deleted: {file_path}')
self.stdout.write(f"Deleted: {file_path}")
except OSError as e:
self.stdout.write(self.style.ERROR(f'Failed to delete {file_path}: {e}'))
self.stdout.write(
self.style.ERROR(f"Failed to delete {file_path}: {e}")
)
for dirname in dirs:
dir_path = os.path.join(root, dirname)
if not os.listdir(dir_path):
if dry_run:
self.stdout.write(f'Would remove empty directory: {dir_path}')
self.stdout.write(f"Would remove empty directory: {dir_path}")
else:
try:
os.rmdir(dir_path)
self.stdout.write(f'Removed empty directory: {dir_path}')
self.stdout.write(f"Removed empty directory: {dir_path}")
empty_dirs_removed += 1
except OSError as e:
self.stdout.write(self.style.ERROR(f'Failed to remove {dir_path}: {e}'))
self.stdout.write(
self.style.ERROR(f"Failed to remove {dir_path}: {e}")
)
if os.path.exists(cache_root):
for root, dirs, files in os.walk(cache_root, topdown=False):
for filename in files:
file_path = os.path.join(root, filename)
relative_path = os.path.relpath(file_path, media_root)
if relative_path in orphaned_thumbnail_paths:
thumbnail_deleted_count += 1
if dry_run:
self.stdout.write(f'Would delete thumbnail (orphaned image): {file_path}')
self.stdout.write(
f"Would delete thumbnail (orphaned image): {file_path}"
)
else:
try:
os.remove(file_path)
self.stdout.write(f'Deleted thumbnail (orphaned image): {file_path}')
self.stdout.write(
f"Deleted thumbnail (orphaned image): {file_path}"
)
except OSError as e:
self.stdout.write(self.style.ERROR(f'Failed to delete {file_path}: {e}'))
self.stdout.write(
self.style.ERROR(
f"Failed to delete {file_path}: {e}"
)
)
elif relative_path not in db_cache_paths:
thumbnail_deleted_count += 1
if dry_run:
self.stdout.write(f'Would delete thumbnail (no db entry): {file_path}')
self.stdout.write(
f"Would delete thumbnail (no db entry): {file_path}"
)
else:
try:
os.remove(file_path)
self.stdout.write(f'Deleted thumbnail (no db entry): {file_path}')
self.stdout.write(
f"Deleted thumbnail (no db entry): {file_path}"
)
except OSError as e:
self.stdout.write(self.style.ERROR(f'Failed to delete {file_path}: {e}'))
self.stdout.write(
self.style.ERROR(
f"Failed to delete {file_path}: {e}"
)
)
for dirname in dirs:
dir_path = os.path.join(root, dirname)
if not os.listdir(dir_path):
if dry_run:
self.stdout.write(f'Would remove empty cache directory: {dir_path}')
self.stdout.write(
f"Would remove empty cache directory: {dir_path}"
)
else:
try:
os.rmdir(dir_path)
empty_dirs_removed += 1
except OSError as e:
self.stdout.write(self.style.ERROR(f'Failed to remove {dir_path}: {e}'))
self.stdout.write(
self.style.ERROR(
f"Failed to remove {dir_path}: {e}"
)
)
if dry_run:
self.stdout.write(self.style.WARNING(f'\nDry run complete. Would delete {deleted_count} images and {thumbnail_deleted_count} thumbnails'))
self.stdout.write(f'Would remove {empty_dirs_removed} empty directories')
self.stdout.write(
self.style.WARNING(
f"\nDry run complete. Would delete {deleted_count} files and {thumbnail_deleted_count} thumbnails"
)
)
self.stdout.write(f"Would remove {empty_dirs_removed} empty directories")
else:
self.stdout.write(self.style.SUCCESS(f'\nCleanup complete! Deleted {deleted_count} images and {thumbnail_deleted_count} thumbnails'))
self.stdout.write(f'Removed {empty_dirs_removed} empty directories')
self.stdout.write(
self.style.SUCCESS(
f"\nCleanup complete! Deleted {deleted_count} files and {thumbnail_deleted_count} thumbnails"
)
)
self.stdout.write(f"Removed {empty_dirs_removed} empty directories")

View File

@@ -0,0 +1,22 @@
# Generated by Django 5.2.9 on 2026-01-19 23:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('boxes', '0010_remove_thingtype'),
]
operations = [
migrations.AlterModelOptions(
name='box',
options={'ordering': ['sort_order'], 'verbose_name_plural': 'boxes'},
),
migrations.AddField(
model_name='box',
name='sort_order',
field=models.PositiveIntegerField(db_index=True, default=0, help_text='Order in which boxes are displayed'),
),
]

View File

@@ -41,9 +41,15 @@ class Box(models.Model):
on_delete=models.PROTECT,
related_name='boxes'
)
sort_order = models.PositiveIntegerField(
default=0,
db_index=True,
help_text='Order in which boxes are displayed'
)
class Meta:
verbose_name_plural = 'boxes'
ordering = ['sort_order']
def __str__(self):
return self.id

383
boxes/static/css/base.css Normal file
View File

@@ -0,0 +1,383 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 0 20px;
}
.navbar {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 15px;
padding: 15px 30px;
margin: 20px auto;
max-width: 1200px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 15px;
}
.navbar-brand {
font-size: 28px;
font-weight: 700;
color: #667eea;
text-decoration: none;
display: flex;
align-items: center;
gap: 10px;
}
.navbar-brand i {
font-size: 24px;
}
.navbar-toggle {
display: none;
background: none;
border: none;
color: #555;
font-size: 24px;
cursor: pointer;
padding: 10px;
border-radius: 8px;
transition: all 0.3s ease;
}
.navbar-toggle:hover {
background: #667eea;
color: white;
}
.navbar-nav {
display: flex;
gap: 20px;
align-items: center;
}
.navbar-nav a,
.navbar-nav form {
color: #555;
text-decoration: none;
font-weight: 500;
font-size: 15px;
padding: 8px 16px;
border-radius: 8px;
transition: all 0.3s ease;
display: flex;
align-items: center;
gap: 8px;
}
.navbar-nav a:hover,
.navbar-nav button:hover {
background: #667eea;
color: white;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.navbar-nav a i {
font-size: 14px;
}
.navbar-nav button {
background: none;
border: none;
color: #555;
font: inherit;
cursor: pointer;
padding: 8px 16px;
border-radius: 8px;
transition: all 0.3s ease;
display: inline-flex;
align-items: center;
gap: 8px;
}
@media (max-width: 768px) {
.navbar {
padding: 15px 20px;
}
.navbar-brand {
font-size: 24px;
}
.navbar-toggle {
display: block;
}
.navbar-nav {
display: none;
width: 100%;
flex-direction: column;
gap: 0;
padding-top: 10px;
}
.navbar-nav.active {
display: flex;
}
.navbar-nav a,
.navbar-nav form {
width: 100%;
padding: 12px 16px;
border-radius: 0;
}
.navbar-nav a:first-child,
.navbar-nav form:first-child {
border-radius: 8px 8px 0 0;
}
.navbar-nav > a:last-child {
border-radius: 0 0 8px 8px;
}
.dropdown-content a:last-child {
border-radius: 0 0 8px 8px;
}
.navbar-nav button {
width: 100%;
justify-content: flex-start;
}
}
.container {
max-width: 1200px;
margin: 20px auto;
}
.page-header {
background: white;
padding: 30px;
border-radius: 15px;
margin-bottom: 30px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
}
.page-header h1 {
color: #333;
font-size: 32px;
font-weight: 700;
margin-bottom: 10px;
}
.page-header .breadcrumb {
color: #888;
font-size: 14px;
}
.page-header .breadcrumb a {
color: #667eea;
text-decoration: none;
}
.page-header .breadcrumb a:hover {
text-decoration: underline;
}
.section {
background: white;
padding: 30px;
border-radius: 15px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
margin-bottom: 30px;
}
.section h2 {
color: #667eea;
font-size: 24px;
font-weight: 700;
margin-top: 0;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 3px solid #667eea;
display: flex;
align-items: center;
gap: 10px;
}
.section h2 i {
font-size: 20px;
}
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 12px 24px;
border: none;
border-radius: 10px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
text-decoration: none;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.6);
}
.btn:active {
transform: translateY(0);
}
.btn-secondary {
background: linear-gradient(135deg, #7f8c8d 0%, #95a5a6 100%);
box-shadow: 0 4px 15px rgba(127, 140, 141, 0.4);
}
.btn-secondary:hover {
box-shadow: 0 6px 20px rgba(127, 140, 141, 0.6);
}
.btn-sm {
padding: 8px 16px;
font-size: 14px;
}
.alert {
padding: 15px 20px;
border-radius: 10px;
margin-bottom: 20px;
font-weight: 500;
}
.alert-success {
background: linear-gradient(135deg, #00b894 0%, #00cec9 100%);
color: white;
box-shadow: 0 4px 15px rgba(0, 184, 148, 0.3);
}
.alert-error {
background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%);
color: white;
box-shadow: 0 4px 15px rgba(231, 76, 60, 0.3);
}
.footer {
text-align: center;
color: white;
padding: 30px;
margin-top: 30px;
}
.footer a {
color: white;
text-decoration: none;
font-weight: 500;
}
.footer a:hover {
text-decoration: underline;
}
.dropdown {
position: relative;
display: inline-block;
}
.dropdown-content {
display: none;
position: absolute;
right: 0;
background: white;
min-width: 200px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
border-radius: 10px;
padding: 10px 0;
z-index: 1000;
top: 100%;
margin-top: 10px;
}
.dropdown-content a {
display: block;
padding: 12px 20px;
color: #555;
text-decoration: none;
transition: all 0.2s ease;
}
.dropdown-content a:hover {
background: #667eea;
color: white;
transform: none;
box-shadow: none;
}
.dropdown-content a:first-child {
border-radius: 10px 10px 0 0;
}
.dropdown-content a:last-child {
border-radius: 0 0 10px 10px;
}
#logout-form {
display: none;
}
.dropdown:hover .dropdown-content,
.dropdown:focus-within .dropdown-content {
display: block;
}
.dropdown-btn {
background: none;
border: none;
color: #667eea;
font-weight: 600;
font-size: 15px;
cursor: pointer;
padding: 8px 16px;
border-radius: 8px;
transition: all 0.3s ease;
display: inline-flex;
align-items: center;
gap: 8px;
}
.dropdown-btn:hover {
background: #667eea;
color: white;
}
@media (max-width: 768px) {
.dropdown {
display: contents;
}
.dropdown-btn {
display: none;
}
.dropdown-content,
.dropdown:hover .dropdown-content,
.dropdown:focus-within .dropdown-content {
display: contents;
}
.dropdown-content a {
width: 100%;
padding: 12px 16px;
border-radius: 0;
}
}

View File

@@ -0,0 +1,21 @@
#id_name, #id_description {
width: 100%;
padding: 10px 15px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 15px;
background: white;
transition: all 0.3s;
}
#id_name:focus, #id_description:focus {
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
outline: none;
}
#id_description {
min-height: 120px;
font-family: inherit;
}
.detail-row {
margin-bottom: 25px;
}

View File

@@ -0,0 +1,115 @@
.markdown-content p {
margin: 0 0 1em 0;
}
.markdown-content p:last-child {
margin-bottom: 0;
}
.markdown-content h1, .markdown-content h2, .markdown-content h3,
.markdown-content h4, .markdown-content h5, .markdown-content h6 {
margin: 1.5em 0 0.5em 0;
color: #333;
font-weight: 600;
}
.markdown-content h1:first-child, .markdown-content h2:first-child,
.markdown-content h3:first-child {
margin-top: 0;
}
.markdown-content ul, .markdown-content ol {
margin: 0.5em 0;
padding-left: 2em;
}
.markdown-content li {
margin: 0.25em 0;
}
.markdown-content code {
background: #f4f4f4;
padding: 2px 6px;
border-radius: 4px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.9em;
}
.markdown-content pre {
background: #f4f4f4;
padding: 15px;
border-radius: 8px;
overflow-x: auto;
margin: 1em 0;
}
.markdown-content pre code {
background: none;
padding: 0;
}
.markdown-content blockquote {
border-left: 4px solid #667eea;
margin: 1em 0;
padding: 0.5em 1em;
background: #f8f9fa;
color: #666;
}
.markdown-content a {
color: #667eea;
text-decoration: none;
}
.markdown-content a:hover {
text-decoration: underline;
}
.markdown-content table {
border-collapse: collapse;
width: 100%;
margin: 1em 0;
}
.markdown-content th, .markdown-content td {
border: 1px solid #e0e0e0;
padding: 8px 12px;
text-align: left;
}
.markdown-content th {
background: #f8f9fa;
font-weight: 600;
}
.markdown-content hr {
border: none;
border-top: 2px solid #e0e0e0;
margin: 1.5em 0;
}
.lightbox {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.9);
z-index: 9999;
justify-content: center;
align-items: center;
cursor: zoom-out;
}
.lightbox.active {
display: flex;
}
.lightbox img {
max-width: 90%;
max-height: 90%;
object-fit: contain;
border-radius: 8px;
box-shadow: 0 0 30px rgba(0, 0, 0, 0.5);
}
.lightbox-close {
position: absolute;
top: 20px;
right: 30px;
color: white;
font-size: 40px;
cursor: pointer;
z-index: 10000;
transition: opacity 0.2s;
}
.lightbox-close:hover {
opacity: 0.7;
}

View File

@@ -0,0 +1,126 @@
{% extends "admin/change_list.html" %}
{% block extrahead %}
{{ block.super }}
<style>
#result_list tbody tr {
cursor: move;
}
#result_list tbody tr.dragging {
opacity: 0.5;
background: #ffffd0;
}
#result_list tbody tr.drag-over {
border-top: 2px solid #417690;
}
</style>
{% endblock %}
{% block result_list %}
{{ block.super }}
<script>
document.addEventListener('DOMContentLoaded', function() {
const tbody = document.querySelector('#result_list tbody');
if (!tbody) return;
let draggedRow = null;
tbody.querySelectorAll('tr').forEach(row => {
row.draggable = true;
row.addEventListener('dragstart', function(e) {
draggedRow = this;
this.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
});
row.addEventListener('dragend', function() {
this.classList.remove('dragging');
tbody.querySelectorAll('tr').forEach(r => r.classList.remove('drag-over'));
draggedRow = null;
});
row.addEventListener('dragover', function(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (this !== draggedRow) {
this.classList.add('drag-over');
}
});
row.addEventListener('dragleave', function() {
this.classList.remove('drag-over');
});
row.addEventListener('drop', function(e) {
e.preventDefault();
this.classList.remove('drag-over');
if (draggedRow && this !== draggedRow) {
const allRows = Array.from(tbody.querySelectorAll('tr'));
const draggedIndex = allRows.indexOf(draggedRow);
const targetIndex = allRows.indexOf(this);
if (draggedIndex < targetIndex) {
this.parentNode.insertBefore(draggedRow, this.nextSibling);
} else {
this.parentNode.insertBefore(draggedRow, this);
}
saveOrder();
}
});
});
function saveOrder() {
const rows = tbody.querySelectorAll('tr');
const order = [];
rows.forEach(row => {
// Use the action checkbox which contains the PK
const checkbox = row.querySelector('input[name="_selected_action"]');
if (checkbox) {
order.push(checkbox.value);
}
});
fetch('{% url "admin:boxes_box_reorder" %}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]')?.value || getCookie('csrftoken')
},
body: JSON.stringify({ order: order })
})
.then(response => response.json())
.then(data => {
if (data.status === 'ok') {
window.location.reload();
} else {
console.error('Reorder failed:', data.error);
alert('Failed to save order');
}
})
.catch(error => {
console.error('Reorder error:', error);
alert('Failed to save order');
});
}
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
});
</script>
{% endblock %}

View File

@@ -1,4 +1,5 @@
{% extends "base.html" %}
{% load static %}
{% load thumbnail %}
{% load dict_extras %}
@@ -283,32 +284,20 @@
</div>
</div>
<div class="section">
<form method="post" action="{% url 'delete_thing' thing.id %}"
onsubmit="return confirm('Are you sure you want to delete {{ thing.name }}? This cannot be undone.');">
{% csrf_token %}
<button type="submit" class="btn btn-danger">
<i class="fas fa-trash"></i> Delete Thing
</button>
</form>
</div>
{% endblock %}
{% block extra_css %}
<style>
#id_name, #id_description {
width: 100%;
padding: 10px 15px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 15px;
background: white;
transition: all 0.3s;
}
#id_name:focus, #id_description:focus {
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
outline: none;
}
#id_description {
min-height: 120px;
font-family: inherit;
}
.detail-row {
margin-bottom: 25px;
}
</style>
<link rel="stylesheet" href="{% static 'css/edit_thing.css' %}">
{% endblock %}
{% block extra_js %}

View File

@@ -1,4 +1,5 @@
{% extends "base.html" %}
{% load static %}
{% load thumbnail %}
{% load dict_extras %}
@@ -26,7 +27,7 @@
<div class="thing-image" style="flex-shrink: 0; width: 100%; max-width: 400px;">
{% if thing.picture %}
{% thumbnail thing.picture "400x400" crop="center" as thumb %}
<img src="{{ thumb.url }}" alt="{{ thing.name }}" style="width: 100%; height: auto; max-height: 400px; object-fit: cover; border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.15);">
<img class="lightbox-trigger" data-url="{{ thing.picture.url }}" src="{{ thumb.url }}" alt="{{ thing.name }}" style="width: 100%; height: auto; max-height: 400px; object-fit: cover; border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.15); cursor: pointer; transition: transform 0.2s;" onmouseover="this.style.transform='scale(1.02)'" onmouseout="this.style.transform='scale(1)'">
{% endthumbnail %}
{% else %}
<div style="width: 100%; aspect-ratio: 1; max-width: 400px; max-height: 400px; background: linear-gradient(135deg, #e0e0e0 0%, #f0f0f0 100%); display: flex; align-items: center; justify-content: center; color: #999; border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.15);">
@@ -92,11 +93,24 @@
</div>
<div style="display: flex; flex-direction: column; gap: 10px;">
{% for file in thing.files.all %}
{% if file.filename|lower|slice:"-4:" == '.jpg' or file.filename|lower|slice:"-5:" == '.jpeg' or file.filename|lower|slice:"-4:" == '.png' or file.filename|lower|slice:"-5:" == '.webp' or file.filename|lower|slice:"-4:" == '.gif' or file.filename|lower|slice:"-4:" == '.svg' or file.filename|lower|slice:"-4:" == '.bmp' or file.filename|lower|slice:"-5:" == '.tiff' or file.filename|lower|slice:"-4:" == '.ico' %}
{% thumbnail file.file "200x200" crop="center" as thumb %}
<div style="display: flex; align-items: center; gap: 10px; padding: 12px 15px; background: #f8f9fa; border-radius: 8px; border:1px solid #e9ecef;">
<img class="lightbox-trigger" data-url="{{ file.file.url }}" src="{{ thumb.url }}" alt="{{ file.title }}" style="width: 60px; height: 60px; object-fit: cover; border-radius: 6px; cursor: pointer; box-shadow: 0 2px 8px rgba(0,0,0,0.1); transition: transform 0.2s;" onmouseover="this.style.transform='scale(1.05)'" onmouseout="this.style.transform='scale(1)'">
<div style="flex-grow: 1;">
<a href="#" class="lightbox-trigger" data-url="{{ file.file.url }}" style="color: #667eea; text-decoration: none; font-weight: 500; font-size: 15px;">{{ file.title }}</a>
<span style="color: #999; font-size: 12px;">({{ file.filename }})</span>
</div>
<i class="fas fa-expand lightbox-trigger" data-url="{{ file.file.url }}" style="color: #999; font-size: 14px; cursor: pointer;"></i>
</div>
{% endthumbnail %}
{% else %}
<div style="display: flex; align-items: center; gap: 10px; padding: 12px 15px; background: #f8f9fa; border-radius: 8px; border: 1px solid #e9ecef;">
<i class="fas fa-paperclip" style="color: #667eea; font-size: 16px;"></i>
<a href="{{ file.file.url }}" target="_blank" style="color: #667eea; text-decoration: none; font-weight: 500; font-size: 15px;">{{ file.title }}</a>
<span style="color: #999; font-size: 12px;">({{ file.filename }})</span>
</div>
{% endif %}
{% endfor %}
</div>
</div>
@@ -120,83 +134,41 @@
</div>
</div>
</div>
<div class="lightbox" id="lightbox">
<span class="lightbox-close">&times;</span>
<img id="lightbox-image" src="" alt="">
</div>
{% endblock %}
{% block extra_js %}
<script>
$(document).ready(function() {
$('.lightbox-trigger').on('click', function(e) {
e.preventDefault();
const imageUrl = $(this).data('url');
$('#lightbox-image').attr('src', imageUrl);
$('#lightbox').addClass('active');
$('body').css('overflow', 'hidden');
});
$('#lightbox').on('click', function(e) {
if (e.target === this || e.target.classList.contains('lightbox-close')) {
$('#lightbox').removeClass('active');
$('body').css('overflow', 'auto');
}
});
$(document).on('keydown', function(e) {
if (e.key === 'Escape') {
$('#lightbox').removeClass('active');
$('body').css('overflow', 'auto');
}
});
});
</script>
{% endblock %}
{% block extra_css %}
<style>
.markdown-content p {
margin: 0 0 1em 0;
}
.markdown-content p:last-child {
margin-bottom: 0;
}
.markdown-content h1, .markdown-content h2, .markdown-content h3,
.markdown-content h4, .markdown-content h5, .markdown-content h6 {
margin: 1.5em 0 0.5em 0;
color: #333;
font-weight: 600;
}
.markdown-content h1:first-child, .markdown-content h2:first-child,
.markdown-content h3:first-child {
margin-top: 0;
}
.markdown-content ul, .markdown-content ol {
margin: 0.5em 0;
padding-left: 2em;
}
.markdown-content li {
margin: 0.25em 0;
}
.markdown-content code {
background: #f4f4f4;
padding: 2px 6px;
border-radius: 4px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.9em;
}
.markdown-content pre {
background: #f4f4f4;
padding: 15px;
border-radius: 8px;
overflow-x: auto;
margin: 1em 0;
}
.markdown-content pre code {
background: none;
padding: 0;
}
.markdown-content blockquote {
border-left: 4px solid #667eea;
margin: 1em 0;
padding: 0.5em 1em;
background: #f8f9fa;
color: #666;
}
.markdown-content a {
color: #667eea;
text-decoration: none;
}
.markdown-content a:hover {
text-decoration: underline;
}
.markdown-content table {
border-collapse: collapse;
width: 100%;
margin: 1em 0;
}
.markdown-content th, .markdown-content td {
border: 1px solid #e0e0e0;
padding: 8px 12px;
text-align: left;
}
.markdown-content th {
background: #f8f9fa;
font-weight: 600;
}
.markdown-content hr {
border: none;
border-top: 2px solid #e0e0e0;
margin: 1.5em 0;
}
</style>
<link rel="stylesheet" href="{% static 'css/thing_detail.css' %}">
{% endblock %}

View File

@@ -18,6 +18,11 @@ from .forms import (
from .models import Box, BoxType, Facet, Tag, Thing, ThingFile, ThingLink
def health_check(request):
"""Health check endpoint for Kubernetes liveness/readiness probes."""
return HttpResponse('OK', status=200)
def _strip_markdown(text, max_length=100):
"""Convert Markdown to plain text and truncate."""
if not text:
@@ -79,7 +84,7 @@ def edit_thing(request, thing_id):
pk=thing_id
)
boxes = Box.objects.select_related('box_type').all().order_by('id')
boxes = Box.objects.select_related('box_type').all()
facets = Facet.objects.all().prefetch_related('tags')
picture_form = ThingPictureForm(instance=thing)
file_form = ThingFileForm()
@@ -192,7 +197,7 @@ def edit_thing(request, thing_id):
@login_required
def boxes_list(request):
"""Boxes list page showing all boxes with contents."""
boxes = Box.objects.select_related('box_type').prefetch_related('things').all().order_by('id')
boxes = Box.objects.select_related('box_type').prefetch_related('things').all()
return render(request, 'boxes/boxes_list.html', {
'boxes': boxes,
})
@@ -373,6 +378,21 @@ def delete_box(request, box_id):
return redirect('box_management')
@login_required
def delete_thing(request, thing_id):
"""Delete a thing and its associated files."""
thing = get_object_or_404(Thing, pk=thing_id)
if request.method == 'POST':
box_id = thing.box.id
if thing.picture:
thing.picture.delete(save=False)
for thing_file in thing.files.all():
thing_file.file.delete(save=False)
thing.delete()
return redirect('box_detail', box_id=box_id)
return redirect('edit_thing', thing_id=thing_id)
@login_required
def resources_list(request):
"""List all links and files from things that have them."""

18
gunicorn.conf.py Normal file
View File

@@ -0,0 +1,18 @@
import logging
from gunicorn.glogging import Logger
class HealthCheckFilter(logging.Filter):
def filter(self, record):
message = record.getMessage()
return "kube-probe" not in message
class CustomLogger(Logger):
def setup(self, cfg):
super().setup(cfg)
self.access_log.addFilter(HealthCheckFilter())
logger_class = CustomLogger

54
labhelper/auth_backend.py Normal file
View File

@@ -0,0 +1,54 @@
from mozilla_django_oidc.auth import OIDCAuthenticationBackend
from django.contrib.auth.models import Group
# Keycloak group name → Django group name mapping.
# Keycloak may send group paths with a leading slash (e.g. "/LabHelper Administrators");
# these are stripped before comparison.
KEYCLOAK_GROUP_MAP = {
'LabHelper Administrators': 'LabHelper Administrators',
'LabHelper Staff': 'LabHelper Staff',
'LabHelper Viewers': 'LabHelper Viewers',
}
# Members of these groups receive is_staff=True (Django admin access)
STAFF_GROUPS = {'LabHelper Administrators'}
class KeycloakOIDCBackend(OIDCAuthenticationBackend):
"""OIDC backend that maps Keycloak groups to Django groups on every login."""
def get_username(self, claims):
return claims.get('preferred_username') or super().get_username(claims)
def create_user(self, claims):
user = super().create_user(claims)
self._sync_from_claims(user, claims)
return user
def update_user(self, user, claims):
user = super().update_user(user, claims)
self._sync_from_claims(user, claims)
return user
def _sync_from_claims(self, user, claims):
"""Sync user attributes and group memberships from Keycloak token claims."""
user.first_name = claims.get('given_name', user.first_name)
user.last_name = claims.get('family_name', user.last_name)
# Keycloak sends group paths like "/LabHelper Administrators"; normalise them.
raw_groups = claims.get('groups', [])
keycloak_groups = {g.lstrip('/') for g in raw_groups}
user.is_staff = bool(keycloak_groups & STAFF_GROUPS)
user.save()
# Add/remove the user from each managed Django group to match Keycloak.
for kc_group, django_group_name in KEYCLOAK_GROUP_MAP.items():
try:
group = Group.objects.get(name=django_group_name)
except Group.DoesNotExist:
continue
if kc_group in keycloak_groups:
user.groups.add(group)
else:
user.groups.remove(group)

View File

@@ -0,0 +1,8 @@
import os
def image_tag(request):
"""Add the image tag to all template contexts."""
return {
'image_tag': os.environ.get('IMAGE_TAG', 'dev')
}

View File

@@ -9,9 +9,9 @@ class Command(BaseCommand):
self.stdout.write('Creating default users and groups...')
groups = {
'Lab Administrators': 'Full access to all lab functions',
'Lab Staff': 'Can view and search items, add things to boxes',
'Lab Viewers': 'Read-only access to view and search',
'LabHelper Administrators': 'Full access to all lab functions',
'LabHelper Staff': 'Can view and search items, add things to boxes',
'LabHelper Viewers': 'Read-only access to view and search',
}
for group_name, description in groups.items():
@@ -22,9 +22,9 @@ class Command(BaseCommand):
self.stdout.write(f'Group already exists: {group_name}')
users = {
'admin': ('Lab Administrators', True),
'staff': ('Lab Staff', False),
'viewer': ('Lab Viewers', False),
'admin': ('LabHelper Administrators', True),
'staff': ('LabHelper Staff', False),
'viewer': ('LabHelper Viewers', False),
}
for username, (group_name, is_superuser) in users.items():

View File

@@ -24,10 +24,10 @@ BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY', 'f0arjg8q3ut4iuqrguqfjaruf0eripIZZN3t1kymy8ugqnj$li2knhha0@gc5v8f3bge=$+gbybj2$jt28uqm')
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
DEBUG = os.environ.get('DEBUG', 'True').lower() == 'true'
ALLOWED_HOSTS = ["*","labhelper.adebaumann.com"]
ALLOWED_CIDR_NETS = ['10.0.0.0/16']
ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '*').split(',')
ALLOWED_CIDR_NETS = os.environ.get('ALLOWED_CIDR_NETS', '10.0.0.0/16').split(',')
# Application definition
@@ -39,6 +39,7 @@ INSTALLED_APPS = [
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'mozilla_django_oidc',
'mptt',
'django_mptt_admin',
'sorl.thumbnail',
@@ -47,10 +48,12 @@ INSTALLED_APPS = [
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'mozilla_django_oidc.middleware.SessionRefresh',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
@@ -67,6 +70,7 @@ TEMPLATES = [
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
'labhelper.context_processors.image_tag',
],
},
},
@@ -108,23 +112,26 @@ AUTH_PASSWORD_VALIDATORS = [
# Internationalization
# https://docs.djangoproject.com/en/5.2/topics/i18n/
LANGUAGE_CODE = 'en-us'
LANGUAGE_CODE = os.environ.get('LANGUAGE_CODE', 'en-us')
TIME_ZONE = 'UTC'
TIME_ZONE = os.environ.get('TIME_ZONE', 'UTC')
USE_I18N = True
USE_I18N = os.environ.get('USE_I18N', 'True').lower() == 'true'
USE_TZ = True
USE_TZ = os.environ.get('USE_TZ', 'True').lower() == 'true'
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.2/howto/static-files/
STATIC_URL = '/static/'
STATIC_URL = os.environ.get('STATIC_URL', '/static/')
STATIC_ROOT = BASE_DIR / 'staticfiles'
# WhiteNoise static file serving configuration
STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'
# Media files (user uploads)
MEDIA_URL = '/media/'
MEDIA_URL = os.environ.get('MEDIA_URL', '/media/')
MEDIA_ROOT = BASE_DIR / 'data' / 'media'
# Default primary key field type
@@ -132,8 +139,47 @@ MEDIA_ROOT = BASE_DIR / 'data' / 'media'
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
CSRF_TRUSTED_ORIGINS=["https://labhelper.adebaumann.com"]
CSRF_TRUSTED_ORIGINS=os.environ.get('CSRF_TRUSTED_ORIGINS', 'https://labhelper.adebaumann.com,http://127.0.0.1:8000').split(',')
LOGIN_URL = 'login'
LOGIN_REDIRECT_URL = 'index'
LOGOUT_REDIRECT_URL = 'login'
LOGIN_URL = os.environ.get('LOGIN_URL', 'oidc_authentication_init')
LOGIN_REDIRECT_URL = os.environ.get('LOGIN_REDIRECT_URL', 'index')
LOGOUT_REDIRECT_URL = os.environ.get('LOGOUT_REDIRECT_URL', 'login')
AUTHENTICATION_BACKENDS = [
'labhelper.auth_backend.KeycloakOIDCBackend',
# ModelBackend kept as fallback for Django admin emergency access
'django.contrib.auth.backends.ModelBackend',
]
# ---------------------------------------------------------------------------
# Keycloak / OIDC configuration
#
# Set OIDC_OP_BASE_URL to your realm URL, e.g.:
# https://keycloak.example.com/realms/myrealm
#
# All individual endpoints are derived from OIDC_OP_BASE_URL automatically.
# You can override any individual endpoint with its own env var.
# ---------------------------------------------------------------------------
_oidc_base = os.environ.get('OIDC_OP_BASE_URL', '').rstrip('/')
_oidc_connect = f'{_oidc_base}/protocol/openid-connect' if _oidc_base else ''
OIDC_RP_CLIENT_ID = os.environ.get('OIDC_RP_CLIENT_ID', '')
OIDC_RP_CLIENT_SECRET = os.environ.get('OIDC_RP_CLIENT_SECRET', '')
OIDC_RP_SIGN_ALGO = 'RS256'
OIDC_RP_SCOPES = 'openid email profile'
OIDC_USE_PKCE = True
OIDC_OP_AUTHORIZATION_ENDPOINT = os.environ.get('OIDC_OP_AUTHORIZATION_ENDPOINT', f'{_oidc_connect}/auth')
OIDC_OP_TOKEN_ENDPOINT = os.environ.get('OIDC_OP_TOKEN_ENDPOINT', f'{_oidc_connect}/token')
OIDC_OP_USER_ENDPOINT = os.environ.get('OIDC_OP_USER_ENDPOINT', f'{_oidc_connect}/userinfo')
OIDC_OP_JWKS_ENDPOINT = os.environ.get('OIDC_OP_JWKS_ENDPOINT', f'{_oidc_connect}/certs')
OIDC_OP_LOGOUT_ENDPOINT = os.environ.get('OIDC_OP_LOGOUT_ENDPOINT', f'{_oidc_connect}/logout')
# Store the ID token in the session so Keycloak logout can use id_token_hint
OIDC_STORE_ID_TOKEN = True
# Redirect to the static login page on auth failure instead of looping back into OIDC
OIDC_AUTHENTICATION_FAILURE_REDIRECT_URL = os.environ.get('OIDC_AUTHENTICATION_FAILURE_REDIRECT_URL', '/login/')
# Exempt AJAX endpoints from the session-refresh middleware redirect
OIDC_EXEMPT_URLS = ['search_api']

View File

@@ -7,388 +7,8 @@
<title>{% block title %}LabHelper{% endblock %}</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 0 20px;
}
.navbar {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 15px;
padding: 15px 30px;
margin: 20px auto;
max-width: 1200px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 15px;
}
.navbar-brand {
font-size: 28px;
font-weight: 700;
color: #667eea;
text-decoration: none;
display: flex;
align-items: center;
gap: 10px;
}
.navbar-brand i {
font-size: 24px;
}
.navbar-toggle {
display: none;
background: none;
border: none;
color: #555;
font-size: 24px;
cursor: pointer;
padding: 10px;
border-radius: 8px;
transition: all 0.3s ease;
}
.navbar-toggle:hover {
background: #667eea;
color: white;
}
.navbar-nav {
display: flex;
gap: 20px;
align-items: center;
}
.navbar-nav a,
.navbar-nav form {
color: #555;
text-decoration: none;
font-weight: 500;
font-size: 15px;
padding: 8px 16px;
border-radius: 8px;
transition: all 0.3s ease;
display: flex;
align-items: center;
gap: 8px;
}
.navbar-nav a:hover,
.navbar-nav button:hover {
background: #667eea;
color: white;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.navbar-nav a i {
font-size: 14px;
}
.navbar-nav button {
background: none;
border: none;
color: #555;
font: inherit;
cursor: pointer;
padding: 8px 16px;
border-radius: 8px;
transition: all 0.3s ease;
display: inline-flex;
align-items: center;
gap: 8px;
}
@media (max-width: 768px) {
.navbar {
padding: 15px 20px;
}
.navbar-brand {
font-size: 24px;
}
.navbar-toggle {
display: block;
}
.navbar-nav {
display: none;
width: 100%;
flex-direction: column;
gap: 0;
padding-top: 10px;
}
.navbar-nav.active {
display: flex;
}
.navbar-nav a,
.navbar-nav form {
width: 100%;
padding: 12px 16px;
border-radius: 0;
}
.navbar-nav a:first-child,
.navbar-nav form:first-child {
border-radius: 8px 8px 0 0;
}
.navbar-nav a:last-child,
.navbar-nav form:last-child {
border-radius: 0 0 8px 8px;
}
.navbar-nav button {
width: 100%;
justify-content: flex-start;
}
}
.container {
max-width: 1200px;
margin: 20px auto;
}
.page-header {
background: white;
padding: 30px;
border-radius: 15px;
margin-bottom: 30px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
}
.page-header h1 {
color: #333;
font-size: 32px;
font-weight: 700;
margin-bottom: 10px;
}
.page-header .breadcrumb {
color: #888;
font-size: 14px;
}
.page-header .breadcrumb a {
color: #667eea;
text-decoration: none;
}
.page-header .breadcrumb a:hover {
text-decoration: underline;
}
.section {
background: white;
padding: 30px;
border-radius: 15px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
margin-bottom: 30px;
}
.section h2 {
color: #667eea;
font-size: 24px;
font-weight: 700;
margin-top: 0;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 3px solid #667eea;
display: flex;
align-items: center;
gap: 10px;
}
.section h2 i {
font-size: 20px;
}
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 12px 24px;
border: none;
border-radius: 10px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
text-decoration: none;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.6);
}
.btn:active {
transform: translateY(0);
}
.btn-secondary {
background: linear-gradient(135deg, #7f8c8d 0%, #95a5a6 100%);
box-shadow: 0 4px 15px rgba(127, 140, 141, 0.4);
}
.btn-secondary:hover {
box-shadow: 0 6px 20px rgba(127, 140, 141, 0.6);
}
.btn-sm {
padding: 8px 16px;
font-size: 14px;
}
.alert {
padding: 15px 20px;
border-radius: 10px;
margin-bottom: 20px;
font-weight: 500;
}
.alert-success {
background: linear-gradient(135deg, #00b894 0%, #00cec9 100%);
color: white;
box-shadow: 0 4px 15px rgba(0, 184, 148, 0.3);
}
.alert-error {
background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%);
color: white;
box-shadow: 0 4px 15px rgba(231, 76, 60, 0.3);
}
.footer {
text-align: center;
color: white;
padding: 30px;
margin-top: 30px;
}
.footer a {
color: white;
text-decoration: none;
font-weight: 500;
}
.footer a:hover {
text-decoration: underline;
}
.dropdown {
position: relative;
display: inline-block;
}
.dropdown-content {
display: none;
position: absolute;
right: 0;
background: white;
min-width: 200px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
border-radius: 10px;
padding: 10px 0;
z-index: 1000;
top: 100%;
margin-top: 10px;
}
.dropdown-content a {
display: block;
padding: 12px 20px;
color: #555;
text-decoration: none;
transition: all 0.2s ease;
}
.dropdown-content a:hover {
background: #667eea;
color: white;
}
.dropdown-content a:first-child {
border-radius: 10px 10px 0 0;
}
.dropdown-content a:last-child {
border-radius: 0 0 10px 10px;
}
.dropdown-content button:hover {
background: #667eea;
color: white;
}
.dropdown:hover .dropdown-content,
.dropdown:focus-within .dropdown-content {
display: block;
}
.dropdown-btn {
background: none;
border: none;
color: #667eea;
font-weight: 600;
font-size: 15px;
cursor: pointer;
padding: 8px 16px;
border-radius: 8px;
transition: all 0.3s ease;
display: inline-flex;
align-items: center;
gap: 8px;
}
.dropdown-btn:hover {
background: #667eea;
color: white;
}
@media (max-width: 768px) {
.dropdown-content {
position: static;
box-shadow: none;
border-radius: 0;
margin-top: 0;
padding: 0;
}
.dropdown-content a {
width: 100%;
padding: 12px 16px;
border-radius: 0;
}
.dropdown-btn {
width: 100%;
justify-content: flex-start;
}
}
{% block extra_css %}{% endblock %}
</style>
<link rel="stylesheet" href="{% static 'css/base.css' %}">
{% block extra_css %}{% endblock %}
{% block extra_head %}{% endblock %}
</head>
<body>
@@ -402,7 +22,7 @@
</button>
<div class="navbar-nav" id="navbar-nav">
<a href="/"><i class="fas fa-home"></i> Home</a>
<a href="/search/"><i class="fas fa-search"></i> Search</a>
<a href="{% url 'inventory' %}"><i class="fas fa-boxes-stacked"></i> Inventory</a>
{% if user.is_authenticated %}
<div class="dropdown">
<button class="dropdown-btn">
@@ -413,12 +33,12 @@
<a href="/resources/"><i class="fas fa-folder-open"></i> Resources</a>
<a href="/fixme/"><i class="fas fa-exclamation-triangle"></i> Fixme</a>
<a href="/admin/"><i class="fas fa-cog"></i> Admin</a>
<form method="post" action="{% url 'logout' %}" style="padding: 0; margin: 0; background: none; border: none;">
<form method="post" action="{% url 'oidc_logout' %}" id="logout-form">
{% csrf_token %}
<button type="submit" style="width: 100%; text-align: left; padding: 12px 20px; background: none; border: none; color: #555; font: inherit; cursor: pointer; display: block; transition: all 0.2s ease;">
<i class="fas fa-sign-out-alt"></i> Logout
</button>
</form>
<a href="#" onclick="document.getElementById('logout-form').submit(); return false;">
<i class="fas fa-sign-out-alt"></i> Logout
</a>
</div>
</div>
{% else %}
@@ -435,6 +55,7 @@
<footer class="footer">
<p>&copy; 2025 LabHelper. Built with <i class="fas fa-heart"></i> for science.</p>
<p style="font-size: 12px; opacity: 0.7; margin-top: 10px;">Image: {{ image_tag }}</p>
</footer>
{% block extra_js %}{% endblock %}

View File

@@ -9,70 +9,24 @@
{% endblock %}
{% block content %}
<div class="section" style="max-width: 500px; margin: 0 auto;">
{% if form.errors %}
<div class="alert alert-error">
<i class="fas fa-exclamation-circle"></i> Your username and password didn't match. Please try again.
<div class="section" style="max-width: 500px; margin: 0 auto; text-align: center;">
{% if request.GET.next and user.is_authenticated %}
<div class="alert alert-error" style="margin-bottom: 24px;">
<i class="fas fa-info-circle"></i> Your account doesn't have access to this page.
</div>
{% endif %}
{% if next %}
{% if user.is_authenticated %}
<div class="alert alert-error">
<i class="fas fa-info-circle"></i> Your account doesn't have access to this page. To proceed,
please login with an account that has access.
</div>
{% else %}
<div class="alert alert-error">
{% elif request.GET.next %}
<div class="alert alert-error" style="margin-bottom: 24px;">
<i class="fas fa-info-circle"></i> Please login to see this page.
</div>
{% endif %}
{% endif %}
<form method="post" action="{% url 'login' %}" style="display: flex; flex-direction: column; gap: 20px;">
{% csrf_token %}
<div>
<label for="{{ form.username.id_for_label }}" style="display: block; font-weight: 600; margin-bottom: 8px; color: #555;">
<i class="fas fa-user"></i> Username
</label>
<input type="{{ form.username.field.widget.input_type }}"
name="{{ form.username.name }}"
id="{{ form.username.id_for_label }}"
{% if form.username.value %}value="{{ form.username.value }}"{% endif %}
required
autofocus
style="width: 100%; padding: 12px 16px; border: 2px solid #e0e0e0; border-radius: 10px; font-size: 15px; transition: all 0.3s;">
</div>
<p style="color: #555; margin-bottom: 28px;">
Authentication is handled via Single Sign-On. Click below to continue to the login page.
</p>
<div>
<label for="{{ form.password.id_for_label }}" style="display: block; font-weight: 600; margin-bottom: 8px; color: #555;">
<i class="fas fa-lock"></i> Password
</label>
<input type="password"
name="{{ form.password.name }}"
id="{{ form.password.id_for_label }}"
required
style="width: 100%; padding: 12px 16px; border: 2px solid #e0e0e0; border-radius: 10px; font-size: 15px; transition: all 0.3s;">
</div>
<input type="hidden" name="next" value="{{ next }}">
<button type="submit" class="btn" style="justify-content: center; margin-top: 10px;">
<i class="fas fa-sign-in-alt"></i> Login
</button>
</form>
<a href="{% url 'oidc_authentication_init' %}{% if request.GET.next %}?next={{ request.GET.next }}{% endif %}"
class="btn" style="justify-content: center; display: inline-flex;">
<i class="fas fa-sign-in-alt"></i> Login with SSO
</a>
</div>
{% endblock %}
{% block extra_js %}
<script>
$('input[type="text"], input[type="password"]').on('focus', function() {
$(this).css('border-color', '#667eea');
$(this).css('box-shadow', '0 0 0 3px rgba(102, 126, 234, 0.1)');
}).on('blur', function() {
$(this).css('border-color', '#e0e0e0');
$(this).css('box-shadow', 'none');
});
</script>
{% endblock %}

View File

@@ -17,8 +17,8 @@ Including another URLconf
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.urls import path
from django.contrib.auth import views as auth_views
from django.urls import include, path
from django.views.generic import TemplateView
from boxes.views import (
add_box,
@@ -29,10 +29,12 @@ from boxes.views import (
boxes_list,
delete_box,
delete_box_type,
delete_thing,
edit_box,
edit_box_type,
edit_thing,
fixme,
health_check,
index,
resources_list,
search_api,
@@ -40,8 +42,9 @@ from boxes.views import (
)
urlpatterns = [
path('login/', auth_views.LoginView.as_view(template_name='login.html'), name='login'),
path('logout/', auth_views.LogoutView.as_view(), name='logout'),
path('oidc/', include('mozilla_django_oidc.urls')),
path('login/', TemplateView.as_view(template_name='login.html'), name='login'),
path('health/', health_check, name='health_check'),
path('', index, name='index'),
path('box-management/', box_management, name='box_management'),
path('box-type/add/', add_box_type, name='add_box_type'),
@@ -53,14 +56,27 @@ urlpatterns = [
path('box/<str:box_id>/', box_detail, name='box_detail'),
path('thing/<int:thing_id>/', thing_detail, name='thing_detail'),
path('thing/<int:thing_id>/edit/', edit_thing, name='edit_thing'),
path('thing/<int:thing_id>/delete/', delete_thing, name='delete_thing'),
path('box/<str:box_id>/add/', add_things, name='add_things'),
path('boxes/', boxes_list, name='boxes_list'),
path('search/', boxes_list, name='search'),
path('inventory/', boxes_list, name='inventory'),
path('search/api/', search_api, name='search_api'),
path('resources/', resources_list, name='resources_list'),
path('fixme/', fixme, name='fixme'),
path('admin/', admin.site.urls),
]
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
# Static files are served by WhiteNoise middleware in production
# Media files need to be served in all environments
from django.views.static import serve
from django.urls import re_path
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
# Add explicit media serving for production
if not settings.DEBUG:
urlpatterns += [
re_path(r'^media/(?P<path>.*)$', serve, {
'document_root': settings.MEDIA_ROOT,
}),
]

View File

@@ -36,3 +36,5 @@ Pillow==11.1.0
sorl-thumbnail==12.11.0
bleach==6.1.0
coverage==7.6.1
whitenoise==6.8.2
mozilla-django-oidc==4.0.1

View File

@@ -3,7 +3,7 @@
NAMESPACE="labhelper"
SECRET_NAME="django-secret"
SECRET_FILE="argocd/secret.yaml"
SECRET_FILE="k8s-templates/secret.yaml"
# Check if secret file exists
if [ ! -f "$SECRET_FILE" ]; then

View File

@@ -36,10 +36,14 @@ NEW_MAIN_VERSION=$(echo "$MAIN_VERSION + 0.001" | bc | sed 's/^\./0./')
sed -i "s|image: git.baumann.gr/adebaumann/labhelper-data-loader:$LOADER_VERSION|image: git.baumann.gr/adebaumann/labhelper-data-loader:$NEW_LOADER_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
sed -i "s| IMAGE_TAG: \"$MAIN_VERSION\"| IMAGE_TAG: \"$NEW_MAIN_VERSION\"|" "argocd/configmap.yaml"
# Copy database
cp "$DB_SOURCE" "$DB_DEST"
echo "Full deployment prepared:"
echo " Data loader: $LOADER_VERSION -> $NEW_LOADER_VERSION"
echo " Main container: $MAIN_VERSION -> $NEW_MAIN_VERSION"
echo " ConfigMap IMAGE_TAG: $MAIN_VERSION -> $NEW_MAIN_VERSION"
echo " Database copied to $DB_DEST"

View File

@@ -23,5 +23,9 @@ NEW_VERSION=$(echo "$CURRENT_VERSION + 0.001" | bc | sed 's/^\./0./')
# Update the deployment file (only the main container, not the data-loader)
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
sed -i "s| IMAGE_TAG: \"$CURRENT_VERSION\"| IMAGE_TAG: \"$NEW_VERSION\"|" "argocd/configmap.yaml"
echo "Partial deployment prepared:"
echo " Main container: $CURRENT_VERSION -> $NEW_VERSION"
echo " ConfigMap IMAGE_TAG: $CURRENT_VERSION -> $NEW_VERSION"