Compare commits
26 Commits
a1bc7967c5
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
de4d3fc790
|
|||
| 20c468cca8 | |||
|
88c1dd704f
|
|||
| 4569fec82c | |||
|
b507f961cb
|
|||
|
41ec7bdc08
|
|||
|
da6a73e357
|
|||
|
4ad03403aa
|
|||
|
88ff6ddae5
|
|||
| 450ff488ea | |||
| 97ce26fb51 | |||
|
ed47530c3c
|
|||
|
e537ec2ac0
|
|||
|
ec102dd1cc
|
|||
|
7bae0d12de
|
|||
|
dbfb38bb8a
|
|||
|
20239242ce
|
|||
|
60e13822ee
|
|||
|
4d492ded4e
|
|||
|
3b53967c40
|
|||
|
65868c043e
|
|||
|
5c9b45715b
|
|||
|
bbed20813a
|
|||
| 22f5b87a20 | |||
| 1dede761e3 | |||
| 860e80a552 |
586
AGENTS.md
586
AGENTS.md
@@ -1,586 +0,0 @@
|
|||||||
# AGENTS.md - AI Coding Agent Guidelines
|
|
||||||
|
|
||||||
This document provides guidelines for AI coding agents working in the labhelper repository.
|
|
||||||
|
|
||||||
## Project Overview
|
|
||||||
|
|
||||||
- **Type**: Django web application (lab inventory management system)
|
|
||||||
- **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
|
|
||||||
```
|
|
||||||
|
|
||||||
### Custom Management Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Create default users and groups
|
|
||||||
python manage.py create_default_users
|
|
||||||
|
|
||||||
# Clean up orphaned files from deleted things
|
|
||||||
python manage.py clean_orphaned_files
|
|
||||||
python manage.py clean_orphaned_files --dry-run
|
|
||||||
|
|
||||||
# Clean up orphaned images and thumbnails
|
|
||||||
python manage.py clean_orphaned_images
|
|
||||||
python manage.py clean_orphaned_images --dry-run
|
|
||||||
```
|
|
||||||
|
|
||||||
## 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/
|
|
||||||
├── .gitea/
|
|
||||||
│ └── workflows/
|
|
||||||
│ └── build-containers-on-demand.yml # CI/CD workflow
|
|
||||||
├── argocd/ # Kubernetes deployment manifests
|
|
||||||
│ ├── 001_pvc.yaml # PersistentVolumeClaim
|
|
||||||
│ ├── deployment.yaml # Deployment + Service
|
|
||||||
│ ├── ingress.yaml # Traefik ingress
|
|
||||||
│ ├── nfs-pv.yaml # NFS PersistentVolume
|
|
||||||
│ ├── nfs-storageclass.yaml # NFS StorageClass
|
|
||||||
│ └── secret.yaml # Django secret key template
|
|
||||||
├── boxes/ # Main Django app
|
|
||||||
│ ├── management/
|
|
||||||
│ │ └── commands/
|
|
||||||
│ │ ├── clean_orphaned_files.py # Cleanup orphaned ThingFile attachments
|
|
||||||
│ │ └── clean_orphaned_images.py # Cleanup orphaned Thing images
|
|
||||||
│ ├── migrations/ # Database migrations
|
|
||||||
│ ├── templates/
|
|
||||||
│ │ └── boxes/
|
|
||||||
│ │ ├── add_things.html # Form to add multiple things
|
|
||||||
│ │ ├── box_detail.html # Box contents view
|
|
||||||
│ │ ├── box_management.html # Box/BoxType CRUD management
|
|
||||||
│ │ ├── boxes_list.html # Boxes list page with tabular view
|
|
||||||
│ │ ├── edit_thing.html # Edit thing page (name, description, picture, tags, files, links)
|
|
||||||
│ │ ├── index.html # Home page with search and tags
|
|
||||||
│ │ ├── resources_list.html # List all links and files from things
|
|
||||||
│ │ └── thing_detail.html # Read-only thing details view
|
|
||||||
│ ├── templatetags/
|
|
||||||
│ │ └── dict_extras.py # Custom template filters: get_item, render_markdown, truncate_markdown
|
|
||||||
│ ├── admin.py # Admin configuration
|
|
||||||
│ ├── apps.py # App configuration
|
|
||||||
│ ├── forms.py # All forms and formsets
|
|
||||||
│ ├── models.py # Data models
|
|
||||||
│ ├── tests.py # Test cases
|
|
||||||
│ └── views.py # View functions
|
|
||||||
├── data-loader/ # Init container for database preload
|
|
||||||
│ ├── Dockerfile # Alpine-based init container
|
|
||||||
│ └── preload.sqlite3 # Preloaded database for deployment
|
|
||||||
├── labhelper/ # Project configuration
|
|
||||||
│ ├── management/
|
|
||||||
│ │ └── commands/
|
|
||||||
│ │ └── create_default_users.py # Create default users/groups
|
|
||||||
│ ├── templates/
|
|
||||||
│ │ ├── base.html # Base template with navigation
|
|
||||||
│ │ └── login.html # Login page
|
|
||||||
│ ├── asgi.py # ASGI configuration
|
|
||||||
│ ├── settings.py # Django settings
|
|
||||||
│ ├── urls.py # Root URL configuration
|
|
||||||
│ └── wsgi.py # WSGI configuration
|
|
||||||
├── scripts/
|
|
||||||
│ ├── deploy_secret.sh # Generate and deploy Django secret
|
|
||||||
│ ├── full_deploy.sh # Bump both container versions + copy DB
|
|
||||||
│ └── partial_deploy.sh # Bump main container version only
|
|
||||||
├── .gitignore
|
|
||||||
├── AGENTS.md # This file
|
|
||||||
├── Dockerfile # Multi-stage build for main container
|
|
||||||
├── manage.py # Django CLI entry point
|
|
||||||
└── requirements.txt # Python dependencies
|
|
||||||
```
|
|
||||||
|
|
||||||
## Data Models
|
|
||||||
|
|
||||||
### boxes app
|
|
||||||
|
|
||||||
| Model | Description | Key Fields |
|
|
||||||
|-------|-------------|------------|
|
|
||||||
| **BoxType** | Type of storage box with dimensions | `name`, `width`, `height`, `length` (in mm) |
|
|
||||||
| **Box** | A storage box in the lab | `id` (CharField PK, max 10), `box_type` (FK) |
|
|
||||||
| **Facet** | A category of tags (e.g., Priority, Category) | `name`, `slug`, `color`, `cardinality` (single/multiple) |
|
|
||||||
| **Tag** | A tag value for a specific facet | `facet` (FK), `name` |
|
|
||||||
| **Thing** | An item stored in a box | `name`, `box` (FK), `description` (Markdown), `picture`, `tags` (M2M) |
|
|
||||||
| **ThingFile** | File attachment for a Thing | `thing` (FK), `file`, `title`, `uploaded_at` |
|
|
||||||
| **ThingLink** | Hyperlink for a Thing | `thing` (FK), `url`, `title`, `uploaded_at` |
|
|
||||||
|
|
||||||
**Model Relationships:**
|
|
||||||
- BoxType -> Box (1:N via `boxes` related_name, PROTECT on delete)
|
|
||||||
- Box -> Thing (1:N via `things` related_name, PROTECT on delete)
|
|
||||||
- Facet -> Tag (1:N via `tags` related_name, CASCADE on delete)
|
|
||||||
- Thing <-> Tag (M2M via `tags` related_name on Thing, `things` related_name on Tag)
|
|
||||||
- Thing -> ThingFile (1:N via `files` related_name, CASCADE on delete)
|
|
||||||
- Thing -> ThingLink (1:N via `links` related_name, CASCADE on delete)
|
|
||||||
|
|
||||||
**Facet Cardinality:**
|
|
||||||
- `single`: A thing can have at most one tag from this facet (e.g., Priority: High/Medium/Low)
|
|
||||||
- `multiple`: A thing can have multiple tags from this facet (e.g., Category: Electronics, Tools)
|
|
||||||
|
|
||||||
## Available Django Extensions
|
|
||||||
|
|
||||||
The project includes these pre-installed packages:
|
|
||||||
|
|
||||||
- **django-mptt**: Tree structures (categories, hierarchies)
|
|
||||||
- **django-mptt-admin**: Admin interface for MPTT models
|
|
||||||
- **django-admin-sortable2**: Drag-and-drop ordering in admin
|
|
||||||
- **django-nested-admin**: Nested inline forms in admin
|
|
||||||
- **django-nested-inline**: Additional nested inline support
|
|
||||||
- **django-revproxy**: Reverse proxy functionality
|
|
||||||
- **sorl-thumbnail**: Image thumbnailing
|
|
||||||
- **Pillow**: Image processing
|
|
||||||
- **gunicorn**: Production WSGI server
|
|
||||||
- **Markdown**: Markdown processing
|
|
||||||
- **bleach**: HTML sanitization
|
|
||||||
- **coverage**: Test coverage
|
|
||||||
- **Font Awesome**: Icon library (loaded via CDN)
|
|
||||||
- **jQuery**: JavaScript library (loaded via CDN)
|
|
||||||
|
|
||||||
## Frontend/CSS Guidelines
|
|
||||||
|
|
||||||
### Base Template
|
|
||||||
|
|
||||||
The project uses a base template system at `labhelper/templates/base.html`. All page templates should extend this base:
|
|
||||||
|
|
||||||
```django
|
|
||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Page Title - LabHelper{% endblock %}
|
|
||||||
|
|
||||||
{% block page_header %}
|
|
||||||
<!-- Optional page header with breadcrumbs -->
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<!-- Main page content -->
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_css %}{% endblock %}
|
|
||||||
{% block extra_js %}{% endblock %}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Design System
|
|
||||||
|
|
||||||
**Color Scheme:**
|
|
||||||
- Primary gradient: `#667eea` (purple) to `#764ba2` (purple-blue)
|
|
||||||
- Success: Green gradient
|
|
||||||
- Error: Red gradient
|
|
||||||
- Background: Light gray `#f5f5f5` with gradient overlays
|
|
||||||
- Cards: White with subtle shadows
|
|
||||||
|
|
||||||
**Components:**
|
|
||||||
- **Navigation**: Glassmorphism effect with blur backdrop. Desktop (≥769px) shows horizontal menu with dropdown for authenticated user (contains Box Management, Resources, Admin, Logout)
|
|
||||||
- **Buttons**: Gradient backgrounds with hover lift effect
|
|
||||||
- **Cards**: White with rounded corners and box shadows
|
|
||||||
- **Tables**: Gradient headers with hover row effects
|
|
||||||
- **Alerts**: Gradient backgrounds with icons
|
|
||||||
- **Form Inputs**: Focused states with color transitions
|
|
||||||
|
|
||||||
**Typography:**
|
|
||||||
- System fonts: `-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif`
|
|
||||||
- Headings: Bold, colored
|
|
||||||
- Body: Regular, dark gray
|
|
||||||
|
|
||||||
**Icons:**
|
|
||||||
- Font Awesome 6.5.1 (CDN)
|
|
||||||
- Use semantic icons for actions
|
|
||||||
- Color: Match context or inherit from parent
|
|
||||||
|
|
||||||
**Responsive Design:**
|
|
||||||
- Mobile-first approach
|
|
||||||
- Grid layouts with `repeat(auto-fill, minmax(250px, 1fr))`
|
|
||||||
- Flexbox for component layouts
|
|
||||||
- Breakpoints handled by grid and flex-wrap
|
|
||||||
- **Navigation**: Responsive navbar with hamburger menu on mobile (≤768px) and horizontal menu with user dropdown on desktop (≥769px). Mobile keeps all items in the dropdown list
|
|
||||||
|
|
||||||
### CSS Guidelines
|
|
||||||
|
|
||||||
**Naming:**
|
|
||||||
- Use descriptive class names
|
|
||||||
- BEM pattern encouraged for complex components
|
|
||||||
- Inline styles allowed for template-specific styling
|
|
||||||
|
|
||||||
**Styles:**
|
|
||||||
- Use base template styles when possible
|
|
||||||
- Template-specific styles in `{% block extra_css %}`
|
|
||||||
- JavaScript in `{% block extra_js %}`
|
|
||||||
- Smooth transitions (0.2s - 0.3s)
|
|
||||||
- Hover effects with transform and box-shadow
|
|
||||||
|
|
||||||
**jQuery Usage:**
|
|
||||||
- Loaded in base template
|
|
||||||
- Use for interactive elements (toggles, hovers)
|
|
||||||
- Event delegation for dynamically added elements
|
|
||||||
- Focus/blur events for form inputs
|
|
||||||
|
|
||||||
### Available Pages/Views
|
|
||||||
|
|
||||||
| View Function | URL Pattern | Name | Description |
|
|
||||||
|---------------|-------------|------|-------------|
|
|
||||||
| `index` | `/` | `index` | Home page with search and tags overview |
|
|
||||||
| `boxes_list` | `/search/` | `search`, `boxes_list` | Boxes list page with tabular view |
|
|
||||||
| `box_management` | `/box-management/` | `box_management` | Manage boxes and box types |
|
|
||||||
| `add_box_type` | `/box-type/add/` | `add_box_type` | Add new box type |
|
|
||||||
| `edit_box_type` | `/box-type/<int:type_id>/edit/` | `edit_box_type` | Edit box type |
|
|
||||||
| `delete_box_type` | `/box-type/<int:type_id>/delete/` | `delete_box_type` | Delete box type |
|
|
||||||
| `add_box` | `/box/add/` | `add_box` | Add new box |
|
|
||||||
| `edit_box` | `/box/<str:box_id>/edit/` | `edit_box` | Edit box |
|
|
||||||
| `delete_box` | `/box/<str:box_id>/delete/` | `delete_box` | Delete box |
|
|
||||||
| `box_detail` | `/box/<str:box_id>/` | `box_detail` | View box contents |
|
|
||||||
| `thing_detail` | `/thing/<int:thing_id>/` | `thing_detail` | Read-only view of thing details |
|
|
||||||
| `edit_thing` | `/thing/<int:thing_id>/edit/` | `edit_thing` | Edit thing (name, description, picture, tags, files, links, move) |
|
|
||||||
| `add_things` | `/box/<str:box_id>/add/` | `add_things` | Add multiple things to a box |
|
|
||||||
| `search_api` | `/search/api/` | `search_api` | AJAX search endpoint |
|
|
||||||
| `resources_list` | `/resources/` | `resources_list` | List all links and files from things (sorted by thing name) |
|
|
||||||
| `LoginView` | `/login/` | `login` | Django auth login |
|
|
||||||
| `LogoutView` | `/logout/` | `logout` | Django auth logout |
|
|
||||||
| `admin.site` | `/admin/` | - | Django admin |
|
|
||||||
|
|
||||||
**All views except login require authentication via `@login_required`.**
|
|
||||||
|
|
||||||
### Template Best Practices
|
|
||||||
|
|
||||||
1. **Always extend base template**
|
|
||||||
```django
|
|
||||||
{% extends "base.html" %}
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Use block system for content injection**
|
|
||||||
- `title`: Page title tag
|
|
||||||
- `page_header`: Page header with breadcrumbs
|
|
||||||
- `content`: Main page content
|
|
||||||
- `extra_css`: Additional styles
|
|
||||||
- `extra_js`: Additional JavaScript
|
|
||||||
|
|
||||||
3. **Load required template tags**
|
|
||||||
```django
|
|
||||||
{% load static %}
|
|
||||||
{% load mptt_tags %}
|
|
||||||
{% load thumbnail %}
|
|
||||||
{% load dict_extras %}
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Use URL names for links**
|
|
||||||
```django
|
|
||||||
<a href="{% url 'box_detail' box.id %}">
|
|
||||||
```
|
|
||||||
|
|
||||||
5. **Use icons with Font Awesome**
|
|
||||||
```django
|
|
||||||
<i class="fas fa-box"></i>
|
|
||||||
```
|
|
||||||
|
|
||||||
6. **Add breadcrumbs for navigation**
|
|
||||||
```django
|
|
||||||
<p class="breadcrumb">
|
|
||||||
<a href="/"><i class="fas fa-home"></i> Home</a> /
|
|
||||||
<a href="/box/{{ box.id }}/"><i class="fas fa-box"></i> Box {{ box.id }}</a>
|
|
||||||
</p>
|
|
||||||
```
|
|
||||||
|
|
||||||
7. **Icon alignment in lists**: When using icons in list items, use fixed width containers to ensure proper alignment
|
|
||||||
```django
|
|
||||||
<a href="{% url 'thing_detail' thing.id %}" style="display: inline-block; width: 20px; text-align: center;">
|
|
||||||
<i class="fas fa-link"></i>
|
|
||||||
</a>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Markdown Support
|
|
||||||
|
|
||||||
The `Thing.description` field supports Markdown formatting with HTML sanitization for security.
|
|
||||||
|
|
||||||
**Available Template Filters:**
|
|
||||||
|
|
||||||
- `render_markdown`: Converts Markdown text to sanitized HTML with automatic link handling
|
|
||||||
- Converts Markdown syntax (headers, lists, bold, italic, links, code, tables, etc.)
|
|
||||||
- Sanitizes HTML using `bleach` to prevent XSS attacks
|
|
||||||
- Automatically adds `target="_blank"` and `rel="noopener noreferrer"` to external links
|
|
||||||
- Use in `thing_detail.html` for full rendered Markdown
|
|
||||||
|
|
||||||
- `truncate_markdown`: Converts Markdown to plain text and truncates
|
|
||||||
- Strips HTML tags after Markdown conversion
|
|
||||||
- Adds ellipsis (`...`) if text exceeds specified length (default: 100)
|
|
||||||
- Use in `box_detail.html` or search API previews where space is limited
|
|
||||||
|
|
||||||
**Usage Examples:**
|
|
||||||
```django
|
|
||||||
<!-- Full Markdown rendering -->
|
|
||||||
<div class="markdown-content">
|
|
||||||
{{ thing.description|render_markdown }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Truncated plain text preview -->
|
|
||||||
{{ thing.description|truncate_markdown:100 }}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Supported Markdown Features:**
|
|
||||||
- Bold: `**text**` or `__text__`
|
|
||||||
- Italic: `*text*` or `_text_`
|
|
||||||
- Headers: `# Header 1`, `## Header 2`, etc.
|
|
||||||
- Lists: `- item` or `1. item`
|
|
||||||
- Links: `[text](url)`
|
|
||||||
- Code: `` `code` `` or ` ```code block```
|
|
||||||
- Blockquotes: `> quote`
|
|
||||||
- Tables: `| A | B |\n|---|---|`
|
|
||||||
|
|
||||||
**Security:**
|
|
||||||
- All Markdown is sanitized before rendering
|
|
||||||
- Dangerous HTML tags (`<script>`, `<iframe>`, etc.) are stripped
|
|
||||||
- Only safe HTML tags and attributes are allowed
|
|
||||||
- External links automatically get `target="_blank"` and security attributes
|
|
||||||
|
|
||||||
## Forms
|
|
||||||
|
|
||||||
| Form | Model | Purpose |
|
|
||||||
|------|-------|---------|
|
|
||||||
| `ThingForm` | Thing | Add/edit a thing (name, description, picture) - tags managed separately |
|
|
||||||
| `ThingPictureForm` | Thing | Upload/change thing picture only |
|
|
||||||
| `ThingFileForm` | ThingFile | Add file attachment |
|
|
||||||
| `ThingLinkForm` | ThingLink | Add link |
|
|
||||||
| `BoxTypeForm` | BoxType | Add/edit box type |
|
|
||||||
| `BoxForm` | Box | Add/edit box |
|
|
||||||
| `ThingFormSet` | Thing | Formset for adding multiple things |
|
|
||||||
|
|
||||||
## Management Commands
|
|
||||||
|
|
||||||
### boxes app
|
|
||||||
|
|
||||||
| Command | Description | Options |
|
|
||||||
|---------|-------------|---------|
|
|
||||||
| `clean_orphaned_files` | Clean up orphaned files from deleted things | `--dry-run` |
|
|
||||||
| `clean_orphaned_images` | Clean up orphaned images and thumbnails | `--dry-run` |
|
|
||||||
|
|
||||||
### labhelper project
|
|
||||||
|
|
||||||
| Command | Description |
|
|
||||||
|---------|-------------|
|
|
||||||
| `create_default_users` | Create default users and groups (admin/admin123, staff/staff123, viewer/viewer123) |
|
|
||||||
|
|
||||||
## Testing Guidelines
|
|
||||||
|
|
||||||
- Use `django.test.TestCase` for database tests
|
|
||||||
- Use `django.test.SimpleTestCase` for tests without database
|
|
||||||
- Name test files `test_*.py` or `*_tests.py`
|
|
||||||
- Name test methods `test_*`
|
|
||||||
- Use descriptive test method names
|
|
||||||
|
|
||||||
```python
|
|
||||||
from django.test import TestCase
|
|
||||||
from .models import Box
|
|
||||||
|
|
||||||
class BoxModelTests(TestCase):
|
|
||||||
"""Tests for the Box model."""
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
"""Set up test fixtures."""
|
|
||||||
self.box = Box.objects.create(name='Test Box')
|
|
||||||
|
|
||||||
def test_box_str_returns_name(self):
|
|
||||||
"""Box __str__ should return the box name."""
|
|
||||||
self.assertEqual(str(self.box), 'Test Box')
|
|
||||||
```
|
|
||||||
|
|
||||||
## Files to Never Commit
|
|
||||||
|
|
||||||
Per `.gitignore`:
|
|
||||||
- `__pycache__/`, `*.pyc` - Python bytecode
|
|
||||||
- `.venv/` - Virtual environment
|
|
||||||
- `.env` - Environment variables
|
|
||||||
- `data/db.sqlite3` - Database file
|
|
||||||
- `keys/` - Secret keys
|
|
||||||
|
|
||||||
## Common Pitfalls
|
|
||||||
|
|
||||||
1. **NEVER commit or push without explicit permission**: Always ask the user before running `git commit` or `git push`. The user will explicitly say "commit and push" when they want you to do this. Do NOT automatically commit/push after making changes unless instructed to do so.
|
|
||||||
2. **Always activate venv**: `source .venv/bin/activate`
|
|
||||||
3. **Run migrations after model changes**: `makemigrations` then `migrate`
|
|
||||||
4. **Add new apps to INSTALLED_APPS** in `settings.py`
|
|
||||||
5. **Templates in labhelper/templates/**: The base template and shared templates are in `labhelper/templates/`. App-specific templates remain in `app_name/templates/`.
|
|
||||||
6. **Use get_object_or_404** instead of bare `.get()` calls
|
|
||||||
7. **Never commit SECRET_KEY** - use environment variables in production
|
|
||||||
8. **Be careful with process management**: Avoid blanket kills on ports (e.g., `lsof -ti:8000 | xargs kill -9`) as they can kill unintended processes like web browsers. Use specific process kills instead: `pkill -f "process_name"`
|
|
||||||
|
|
||||||
## Deployment Commands
|
|
||||||
|
|
||||||
### Prepare a Full Deployment
|
|
||||||
|
|
||||||
When instructed to "Prepare a full deployment", perform the following steps:
|
|
||||||
|
|
||||||
1. **Bump container versions**: In `argocd/deployment.yaml`, increment the version numbers by 0.001 for both containers:
|
|
||||||
- `labhelper-data-loader` (initContainer)
|
|
||||||
- `labhelper` (main container)
|
|
||||||
|
|
||||||
2. **Copy database**: Copy the current development database to the data-loader preload location:
|
|
||||||
```bash
|
|
||||||
cp data/db.sqlite3 data-loader/preload.sqlite3
|
|
||||||
```
|
|
||||||
|
|
||||||
### Prepare a Partial Deployment
|
|
||||||
|
|
||||||
When instructed to "Prepare a partial deployment", perform the following step:
|
|
||||||
|
|
||||||
1. **Bump main container version only**: In `argocd/deployment.yaml`, increment the version number by 0.001 for the main container only:
|
|
||||||
- `labhelper` (main container)
|
|
||||||
|
|
||||||
Do NOT bump the data-loader version or copy the database.
|
|
||||||
272
AGENTS.md.backup
272
AGENTS.md.backup
@@ -1,272 +0,0 @@
|
|||||||
# AGENTS.md - AI Coding Agent Guidelines
|
|
||||||
|
|
||||||
This document provides guidelines for AI coding agents working in the labhelper repository.
|
|
||||||
|
|
||||||
## Project Overview
|
|
||||||
|
|
||||||
- **Type**: Django web application
|
|
||||||
- **Python**: 3.13.7
|
|
||||||
- **Django**: 5.2.9
|
|
||||||
- **Database**: SQLite (development)
|
|
||||||
- **Virtual Environment**: `.venv/`
|
|
||||||
|
|
||||||
## Build/Run Commands
|
|
||||||
|
|
||||||
### Development Server
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python manage.py runserver # Start dev server on port 8000
|
|
||||||
python manage.py runserver 0.0.0.0:8000 # Bind to all interfaces
|
|
||||||
```
|
|
||||||
|
|
||||||
### Database Operations
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python manage.py makemigrations # Create migration files
|
|
||||||
python manage.py makemigrations boxes # Create migrations for specific app
|
|
||||||
python manage.py migrate # Apply all migrations
|
|
||||||
python manage.py showmigrations # List migration status
|
|
||||||
```
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run all tests
|
|
||||||
python manage.py test
|
|
||||||
|
|
||||||
# Run tests for a specific app
|
|
||||||
python manage.py test boxes
|
|
||||||
|
|
||||||
# Run a specific test class
|
|
||||||
python manage.py test boxes.tests.TestClassName
|
|
||||||
|
|
||||||
# Run a single test method
|
|
||||||
python manage.py test boxes.tests.TestClassName.test_method_name
|
|
||||||
|
|
||||||
# Run tests with verbosity
|
|
||||||
python manage.py test -v 2
|
|
||||||
|
|
||||||
# Run tests with coverage
|
|
||||||
coverage run manage.py test
|
|
||||||
coverage report
|
|
||||||
coverage html # Generate HTML report
|
|
||||||
```
|
|
||||||
|
|
||||||
### Django Shell
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python manage.py shell # Interactive Django shell
|
|
||||||
python manage.py createsuperuser # Create admin user
|
|
||||||
python manage.py collectstatic # Collect static files
|
|
||||||
```
|
|
||||||
|
|
||||||
### Production
|
|
||||||
|
|
||||||
```bash
|
|
||||||
gunicorn labhelper.wsgi:application # Run with Gunicorn
|
|
||||||
```
|
|
||||||
|
|
||||||
## Code Style Guidelines
|
|
||||||
|
|
||||||
### Python Style
|
|
||||||
|
|
||||||
- Follow PEP 8 conventions
|
|
||||||
- Use 4-space indentation (no tabs)
|
|
||||||
- Maximum line length: 79 characters (PEP 8 standard)
|
|
||||||
- Use single quotes for strings: `'string'`
|
|
||||||
- Use double quotes for docstrings: `"""Docstring."""`
|
|
||||||
|
|
||||||
### Import Order
|
|
||||||
|
|
||||||
Organize imports in this order, with blank lines between groups:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# 1. Standard library imports
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# 2. Django imports
|
|
||||||
from django.db import models
|
|
||||||
from django.contrib import admin
|
|
||||||
from django.shortcuts import render, redirect
|
|
||||||
from django.http import HttpResponse, JsonResponse
|
|
||||||
|
|
||||||
# 3. Third-party imports
|
|
||||||
import requests
|
|
||||||
from markdown import markdown
|
|
||||||
|
|
||||||
# 4. Local application imports
|
|
||||||
from .models import MyModel
|
|
||||||
from .forms import MyForm
|
|
||||||
```
|
|
||||||
|
|
||||||
### Naming Conventions
|
|
||||||
|
|
||||||
| Type | Convention | Example |
|
|
||||||
|------|------------|---------|
|
|
||||||
| Modules | lowercase_with_underscores | `user_profile.py` |
|
|
||||||
| Classes | PascalCase | `UserProfile` |
|
|
||||||
| Functions | lowercase_with_underscores | `get_user_data()` |
|
|
||||||
| Constants | UPPERCASE_WITH_UNDERSCORES | `MAX_CONNECTIONS` |
|
|
||||||
| Variables | lowercase_with_underscores | `user_count` |
|
|
||||||
| Django Models | PascalCase (singular) | `Box`, `UserProfile` |
|
|
||||||
| Django Apps | lowercase (short) | `boxes`, `users` |
|
|
||||||
|
|
||||||
### Django-Specific Conventions
|
|
||||||
|
|
||||||
**Models:**
|
|
||||||
```python
|
|
||||||
from django.db import models
|
|
||||||
|
|
||||||
class Box(models.Model):
|
|
||||||
"""A storage box in the lab."""
|
|
||||||
|
|
||||||
name = models.CharField(max_length=255)
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name_plural = 'boxes'
|
|
||||||
ordering = ['-created_at']
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return self.name
|
|
||||||
```
|
|
||||||
|
|
||||||
**Views:**
|
|
||||||
```python
|
|
||||||
from django.shortcuts import render, get_object_or_404
|
|
||||||
from django.http import Http404
|
|
||||||
|
|
||||||
def box_detail(request, box_id):
|
|
||||||
"""Display details for a specific box."""
|
|
||||||
box = get_object_or_404(Box, pk=box_id)
|
|
||||||
return render(request, 'boxes/detail.html', {'box': box})
|
|
||||||
```
|
|
||||||
|
|
||||||
### Error Handling
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Use specific exceptions
|
|
||||||
try:
|
|
||||||
result = some_operation()
|
|
||||||
except SpecificError as exc:
|
|
||||||
raise CustomError('Descriptive message') from exc
|
|
||||||
|
|
||||||
# Django: Use get_object_or_404 for model lookups
|
|
||||||
box = get_object_or_404(Box, pk=box_id)
|
|
||||||
|
|
||||||
# Log errors appropriately
|
|
||||||
import logging
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
logger.error('Error message: %s', error_detail)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Type Hints (Recommended)
|
|
||||||
|
|
||||||
```python
|
|
||||||
from typing import Optional
|
|
||||||
from django.http import HttpRequest, HttpResponse
|
|
||||||
|
|
||||||
def get_box(request: HttpRequest, box_id: int) -> HttpResponse:
|
|
||||||
"""Retrieve a box by ID."""
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
labhelper/
|
|
||||||
├── manage.py # Django CLI entry point
|
|
||||||
├── requirements.txt # Python dependencies
|
|
||||||
├── labhelper/ # Project configuration
|
|
||||||
│ ├── settings.py # Django settings
|
|
||||||
│ ├── urls.py # Root URL routing
|
|
||||||
│ ├── wsgi.py # WSGI application
|
|
||||||
│ └── asgi.py # ASGI application
|
|
||||||
└── boxes/ # Django app
|
|
||||||
├── admin.py # Admin configuration
|
|
||||||
├── apps.py # App configuration
|
|
||||||
├── models.py # Data models
|
|
||||||
├── views.py # View functions
|
|
||||||
├── tests.py # Test cases
|
|
||||||
├── migrations/ # Database migrations
|
|
||||||
└── templates/ # HTML templates
|
|
||||||
```
|
|
||||||
|
|
||||||
## Available Django Extensions
|
|
||||||
|
|
||||||
The project includes these pre-installed packages:
|
|
||||||
|
|
||||||
- **django-mptt**: Tree structures (categories, hierarchies)
|
|
||||||
- **django-mptt-admin**: Admin interface for MPTT models
|
|
||||||
- **django-admin-sortable2**: Drag-and-drop ordering in admin
|
|
||||||
- **django-nested-admin**: Nested inline forms in admin
|
|
||||||
- **django-nested-inline**: Additional nested inline support
|
|
||||||
- **django-revproxy**: Reverse proxy functionality
|
|
||||||
|
|
||||||
## Testing Guidelines
|
|
||||||
|
|
||||||
- Use `django.test.TestCase` for database tests
|
|
||||||
- Use `django.test.SimpleTestCase` for tests without database
|
|
||||||
- Name test files `test_*.py` or `*_tests.py`
|
|
||||||
- Name test methods `test_*`
|
|
||||||
- Use descriptive test method names
|
|
||||||
|
|
||||||
```python
|
|
||||||
from django.test import TestCase
|
|
||||||
from .models import Box
|
|
||||||
|
|
||||||
class BoxModelTests(TestCase):
|
|
||||||
"""Tests for the Box model."""
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
"""Set up test fixtures."""
|
|
||||||
self.box = Box.objects.create(name='Test Box')
|
|
||||||
|
|
||||||
def test_box_str_returns_name(self):
|
|
||||||
"""Box __str__ should return the box name."""
|
|
||||||
self.assertEqual(str(self.box), 'Test Box')
|
|
||||||
```
|
|
||||||
|
|
||||||
## Files to Never Commit
|
|
||||||
|
|
||||||
Per `.gitignore`:
|
|
||||||
- `__pycache__/`, `*.pyc` - Python bytecode
|
|
||||||
- `.venv/` - Virtual environment
|
|
||||||
- `.env` - Environment variables
|
|
||||||
- `data/db.sqlite3` - Database file
|
|
||||||
- `keys/` - Secret keys
|
|
||||||
|
|
||||||
## Common Pitfalls
|
|
||||||
|
|
||||||
1. **Always activate venv**: `source .venv/bin/activate`
|
|
||||||
2. **Run migrations after model changes**: `makemigrations` then `migrate`
|
|
||||||
3. **Add new apps to INSTALLED_APPS** in `settings.py`
|
|
||||||
4. **Use get_object_or_404** instead of bare `.get()` calls
|
|
||||||
5. **Never commit SECRET_KEY** - use environment variables in production
|
|
||||||
|
|
||||||
## Deployment Commands
|
|
||||||
|
|
||||||
### Prepare a Full Deployment
|
|
||||||
|
|
||||||
When instructed to "Prepare a full deployment", perform the following steps:
|
|
||||||
|
|
||||||
1. **Bump container versions**: In `argocd/deployment.yaml`, increment the version numbers by 0.001 for both containers:
|
|
||||||
- `labhelper-data-loader` (initContainer)
|
|
||||||
- `labhelper` (main container)
|
|
||||||
|
|
||||||
2. **Copy database**: Copy the current development database to the data-loader preload location:
|
|
||||||
```bash
|
|
||||||
cp data/db.sqlite3 data-loader/preload.sqlite3
|
|
||||||
```
|
|
||||||
|
|
||||||
### Prepare a Partial Deployment
|
|
||||||
|
|
||||||
When instructed to "Prepare a partial deployment", perform the following step:
|
|
||||||
|
|
||||||
1. **Bump main container version only**: In `argocd/deployment.yaml`, increment the version number by 0.001 for the main container only:
|
|
||||||
- `labhelper` (main container)
|
|
||||||
|
|
||||||
Do NOT bump the data-loader version or copy the database.
|
|
||||||
@@ -46,6 +46,9 @@ RUN rm -rvf /app/Dockerfile* \
|
|||||||
/app/requirements.txt \
|
/app/requirements.txt \
|
||||||
/app/node_modules \
|
/app/node_modules \
|
||||||
/app/*.json \
|
/app/*.json \
|
||||||
|
/app/AGENTS* \
|
||||||
|
/app/*.md \
|
||||||
|
/app/k8s-templates \
|
||||||
/app/test_*.py && \
|
/app/test_*.py && \
|
||||||
python3 /app/manage.py collectstatic --noinput
|
python3 /app/manage.py collectstatic --noinput
|
||||||
CMD ["sh", "-c", "python manage.py thumbnail clear && gunicorn --bind 0.0.0.0:8000 --workers 3 $GUNICORN_OPTS 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
207
Keycloak-installation.md
Normal 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
|
||||||
|
```
|
||||||
@@ -14,8 +14,11 @@ data:
|
|||||||
STATIC_URL: "/static/"
|
STATIC_URL: "/static/"
|
||||||
MEDIA_URL: "/media/"
|
MEDIA_URL: "/media/"
|
||||||
CSRF_TRUSTED_ORIGINS: "https://labhelper.adebaumann.com"
|
CSRF_TRUSTED_ORIGINS: "https://labhelper.adebaumann.com"
|
||||||
LOGIN_URL: "login"
|
LOGIN_URL: "oidc_authentication_init"
|
||||||
|
OIDC_OP_BASE_URL: "https://sso.baumann.gr/realms/homelab"
|
||||||
|
OIDC_RP_CLIENT_ID: "labhelper"
|
||||||
LOGIN_REDIRECT_URL: "index"
|
LOGIN_REDIRECT_URL: "index"
|
||||||
LOGOUT_REDIRECT_URL: "login"
|
LOGOUT_REDIRECT_URL: "/login/"
|
||||||
|
OIDC_AUTHENTICATION_FAILURE_REDIRECT_URL: "/login/"
|
||||||
GUNICORN_OPTS: "--access-logfile -"
|
GUNICORN_OPTS: "--access-logfile -"
|
||||||
IMAGE_TAG: "0.064"
|
IMAGE_TAG: "0.079"
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ spec:
|
|||||||
mountPath: /data
|
mountPath: /data
|
||||||
containers:
|
containers:
|
||||||
- name: web
|
- name: web
|
||||||
image: git.baumann.gr/adebaumann/labhelper:0.065
|
image: git.baumann.gr/adebaumann/labhelper:0.082
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 8000
|
- containerPort: 8000
|
||||||
@@ -92,6 +92,21 @@ spec:
|
|||||||
configMapKeyRef:
|
configMapKeyRef:
|
||||||
name: django-config
|
name: django-config
|
||||||
key: LOGIN_URL
|
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
|
- name: LOGIN_REDIRECT_URL
|
||||||
valueFrom:
|
valueFrom:
|
||||||
configMapKeyRef:
|
configMapKeyRef:
|
||||||
@@ -102,6 +117,11 @@ spec:
|
|||||||
configMapKeyRef:
|
configMapKeyRef:
|
||||||
name: django-config
|
name: django-config
|
||||||
key: LOGOUT_REDIRECT_URL
|
key: LOGOUT_REDIRECT_URL
|
||||||
|
- name: OIDC_AUTHENTICATION_FAILURE_REDIRECT_URL
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: django-config
|
||||||
|
key: OIDC_AUTHENTICATION_FAILURE_REDIRECT_URL
|
||||||
- name: GUNICORN_OPTS
|
- name: GUNICORN_OPTS
|
||||||
valueFrom:
|
valueFrom:
|
||||||
configMapKeyRef:
|
configMapKeyRef:
|
||||||
@@ -117,7 +137,7 @@ spec:
|
|||||||
mountPath: /app/data
|
mountPath: /app/data
|
||||||
readinessProbe:
|
readinessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /
|
path: /health/
|
||||||
port: 8000
|
port: 8000
|
||||||
initialDelaySeconds: 5
|
initialDelaySeconds: 5
|
||||||
periodSeconds: 10
|
periodSeconds: 10
|
||||||
@@ -125,7 +145,7 @@ spec:
|
|||||||
failureThreshold: 6
|
failureThreshold: 6
|
||||||
livenessProbe:
|
livenessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /
|
path: /health/
|
||||||
port: 8000
|
port: 8000
|
||||||
initialDelaySeconds: 20
|
initialDelaySeconds: 20
|
||||||
periodSeconds: 20
|
periodSeconds: 20
|
||||||
|
|||||||
@@ -5,6 +5,13 @@ 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/"
|
||||||
spec:
|
spec:
|
||||||
ingressClassName: traefik
|
ingressClassName: traefik
|
||||||
rules:
|
rules:
|
||||||
|
|||||||
@@ -15,4 +15,4 @@ spec:
|
|||||||
storageClassName: nfs
|
storageClassName: nfs
|
||||||
nfs:
|
nfs:
|
||||||
server: 192.168.17.199
|
server: 192.168.17.199
|
||||||
path: /mnt/user/labhelper
|
path: /mnt/user/kubernetesdata/labhelper
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
from adminsortable2.admin import SortableAdminMixin
|
import json
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.contrib.admin import SimpleListFilter
|
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.utils.html import format_html
|
||||||
|
from django.views.decorators.http import require_POST
|
||||||
|
|
||||||
from .models import Box, BoxType, Facet, Tag, Thing, ThingFile, ThingLink
|
from .models import Box, BoxType, Facet, Tag, Thing, ThingFile, ThingLink
|
||||||
|
|
||||||
@@ -48,12 +52,37 @@ class BoxTypeAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
|
|
||||||
@admin.register(Box)
|
@admin.register(Box)
|
||||||
class BoxAdmin(SortableAdminMixin, admin.ModelAdmin):
|
class BoxAdmin(admin.ModelAdmin):
|
||||||
"""Admin configuration for Box model."""
|
"""Admin configuration for Box model."""
|
||||||
|
|
||||||
list_display = ('id', 'box_type')
|
ordering = ['sort_order']
|
||||||
|
list_display = ('id', 'box_type', 'sort_order')
|
||||||
list_filter = ('box_type',)
|
list_filter = ('box_type',)
|
||||||
search_fields = ('id',)
|
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):
|
class ThingFileInline(admin.TabularInline):
|
||||||
|
|||||||
22
boxes/migrations/0011_alter_box_options_box_sort_order.py
Normal file
22
boxes/migrations/0011_alter_box_options_box_sort_order.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
383
boxes/static/css/base.css
Normal file
383
boxes/static/css/base.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
21
boxes/static/css/edit_thing.css
Normal file
21
boxes/static/css/edit_thing.css
Normal 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;
|
||||||
|
}
|
||||||
115
boxes/static/css/thing_detail.css
Normal file
115
boxes/static/css/thing_detail.css
Normal 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;
|
||||||
|
}
|
||||||
126
boxes/templates/admin/boxes/box/change_list.html
Normal file
126
boxes/templates/admin/boxes/box/change_list.html
Normal 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 %}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
{% load static %}
|
||||||
{% load thumbnail %}
|
{% load thumbnail %}
|
||||||
{% load dict_extras %}
|
{% load dict_extras %}
|
||||||
|
|
||||||
@@ -283,32 +284,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_css %}
|
{% block extra_css %}
|
||||||
<style>
|
<link rel="stylesheet" href="{% static 'css/edit_thing.css' %}">
|
||||||
#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>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
{% load static %}
|
||||||
{% load thumbnail %}
|
{% load thumbnail %}
|
||||||
{% load dict_extras %}
|
{% load dict_extras %}
|
||||||
|
|
||||||
@@ -97,7 +98,7 @@
|
|||||||
<div style="display: flex; align-items: center; gap: 10px; padding: 12px 15px; background: #f8f9fa; border-radius: 8px; border:1px solid #e9ecef;">
|
<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)'">
|
<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;">
|
<div style="flex-grow: 1;">
|
||||||
<a href="{{ file.file.url }}" target="_blank" style="color: #667eea; text-decoration: none; font-weight: 500; font-size: 15px;">{{ file.title }}</a>
|
<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>
|
<span style="color: #999; font-size: 12px;">({{ file.filename }})</span>
|
||||||
</div>
|
</div>
|
||||||
<i class="fas fa-expand lightbox-trigger" data-url="{{ file.file.url }}" style="color: #999; font-size: 14px; cursor: pointer;"></i>
|
<i class="fas fa-expand lightbox-trigger" data-url="{{ file.file.url }}" style="color: #999; font-size: 14px; cursor: pointer;"></i>
|
||||||
@@ -169,121 +170,5 @@ $(document).ready(function() {
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_css %}
|
{% block extra_css %}
|
||||||
<style>
|
<link rel="stylesheet" href="{% static 'css/thing_detail.css' %}">
|
||||||
.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;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ from .forms import (
|
|||||||
from .models import Box, BoxType, Facet, Tag, Thing, ThingFile, ThingLink
|
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):
|
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:
|
||||||
@@ -373,6 +378,21 @@ def delete_box(request, box_id):
|
|||||||
return redirect('box_management')
|
return redirect('box_management')
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def delete_thing(request, thing_id):
|
||||||
|
"""Delete a thing and its associated files."""
|
||||||
|
thing = get_object_or_404(Thing, pk=thing_id)
|
||||||
|
if request.method == 'POST':
|
||||||
|
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
|
@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."""
|
||||||
|
|||||||
18
gunicorn.conf.py
Normal file
18
gunicorn.conf.py
Normal 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
54
labhelper/auth_backend.py
Normal 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)
|
||||||
@@ -9,9 +9,9 @@ class Command(BaseCommand):
|
|||||||
self.stdout.write('Creating default users and groups...')
|
self.stdout.write('Creating default users and groups...')
|
||||||
|
|
||||||
groups = {
|
groups = {
|
||||||
'Lab Administrators': 'Full access to all lab functions',
|
'LabHelper Administrators': 'Full access to all lab functions',
|
||||||
'Lab Staff': 'Can view and search items, add things to boxes',
|
'LabHelper Staff': 'Can view and search items, add things to boxes',
|
||||||
'Lab Viewers': 'Read-only access to view and search',
|
'LabHelper Viewers': 'Read-only access to view and search',
|
||||||
}
|
}
|
||||||
|
|
||||||
for group_name, description in groups.items():
|
for group_name, description in groups.items():
|
||||||
@@ -22,9 +22,9 @@ class Command(BaseCommand):
|
|||||||
self.stdout.write(f'Group already exists: {group_name}')
|
self.stdout.write(f'Group already exists: {group_name}')
|
||||||
|
|
||||||
users = {
|
users = {
|
||||||
'admin': ('Lab Administrators', True),
|
'admin': ('LabHelper Administrators', True),
|
||||||
'staff': ('Lab Staff', False),
|
'staff': ('LabHelper Staff', False),
|
||||||
'viewer': ('Lab Viewers', False),
|
'viewer': ('LabHelper Viewers', False),
|
||||||
}
|
}
|
||||||
|
|
||||||
for username, (group_name, is_superuser) in users.items():
|
for username, (group_name, is_superuser) in users.items():
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ INSTALLED_APPS = [
|
|||||||
'django.contrib.sessions',
|
'django.contrib.sessions',
|
||||||
'django.contrib.messages',
|
'django.contrib.messages',
|
||||||
'django.contrib.staticfiles',
|
'django.contrib.staticfiles',
|
||||||
'adminsortable2',
|
'mozilla_django_oidc',
|
||||||
'mptt',
|
'mptt',
|
||||||
'django_mptt_admin',
|
'django_mptt_admin',
|
||||||
'sorl.thumbnail',
|
'sorl.thumbnail',
|
||||||
@@ -53,6 +53,7 @@ MIDDLEWARE = [
|
|||||||
'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',
|
||||||
'django.contrib.messages.middleware.MessageMiddleware',
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||||
]
|
]
|
||||||
@@ -138,8 +139,47 @@ MEDIA_ROOT = BASE_DIR / 'data' / 'media'
|
|||||||
|
|
||||||
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').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', 'login')
|
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 = [
|
||||||
|
'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']
|
||||||
|
|||||||
@@ -7,388 +7,8 @@
|
|||||||
<title>{% block title %}LabHelper{% endblock %}</title>
|
<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">
|
<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>
|
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
||||||
<style>
|
<link rel="stylesheet" href="{% static 'css/base.css' %}">
|
||||||
* {
|
{% block extra_css %}{% endblock %}
|
||||||
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>
|
|
||||||
{% block extra_head %}{% endblock %}
|
{% block extra_head %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -402,7 +22,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<div class="navbar-nav" id="navbar-nav">
|
<div class="navbar-nav" id="navbar-nav">
|
||||||
<a href="/"><i class="fas fa-home"></i> Home</a>
|
<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 %}
|
{% if user.is_authenticated %}
|
||||||
<div class="dropdown">
|
<div class="dropdown">
|
||||||
<button class="dropdown-btn">
|
<button class="dropdown-btn">
|
||||||
@@ -413,12 +33,12 @@
|
|||||||
<a href="/resources/"><i class="fas fa-folder-open"></i> Resources</a>
|
<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="/fixme/"><i class="fas fa-exclamation-triangle"></i> Fixme</a>
|
||||||
<a href="/admin/"><i class="fas fa-cog"></i> Admin</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 %}
|
{% 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>
|
</form>
|
||||||
|
<a href="#" onclick="document.getElementById('logout-form').submit(); return false;">
|
||||||
|
<i class="fas fa-sign-out-alt"></i> Logout
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
@@ -9,70 +9,24 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="section" style="max-width: 500px; margin: 0 auto;">
|
<div class="section" style="max-width: 500px; margin: 0 auto; text-align: center;">
|
||||||
{% if form.errors %}
|
{% if request.GET.next and user.is_authenticated %}
|
||||||
<div class="alert alert-error">
|
<div class="alert alert-error" style="margin-bottom: 24px;">
|
||||||
<i class="fas fa-exclamation-circle"></i> Your username and password didn't match. Please try again.
|
<i class="fas fa-info-circle"></i> Your account doesn't have access to this page.
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% elif request.GET.next %}
|
||||||
|
<div class="alert alert-error" style="margin-bottom: 24px;">
|
||||||
{% 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">
|
|
||||||
<i class="fas fa-info-circle"></i> Please login to see this page.
|
<i class="fas fa-info-circle"></i> Please login to see this page.
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<form method="post" action="{% url 'login' %}" style="display: flex; flex-direction: column; gap: 20px;">
|
<p style="color: #555; margin-bottom: 28px;">
|
||||||
{% csrf_token %}
|
Authentication is handled via Single Sign-On. Click below to continue to the login page.
|
||||||
|
</p>
|
||||||
<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>
|
|
||||||
|
|
||||||
<div>
|
<a href="{% url 'oidc_authentication_init' %}{% if request.GET.next %}?next={{ request.GET.next }}{% endif %}"
|
||||||
<label for="{{ form.password.id_for_label }}" style="display: block; font-weight: 600; margin-bottom: 8px; color: #555;">
|
class="btn" style="justify-content: center; display: inline-flex;">
|
||||||
<i class="fas fa-lock"></i> Password
|
<i class="fas fa-sign-in-alt"></i> Login with SSO
|
||||||
</label>
|
</a>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% 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 %}
|
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ Including another URLconf
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.conf.urls.static import static
|
from django.conf.urls.static import static
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.urls import path
|
from django.urls import include, path
|
||||||
from django.contrib.auth import views as auth_views
|
from django.views.generic import TemplateView
|
||||||
|
|
||||||
from boxes.views import (
|
from boxes.views import (
|
||||||
add_box,
|
add_box,
|
||||||
@@ -29,10 +29,12 @@ from boxes.views import (
|
|||||||
boxes_list,
|
boxes_list,
|
||||||
delete_box,
|
delete_box,
|
||||||
delete_box_type,
|
delete_box_type,
|
||||||
|
delete_thing,
|
||||||
edit_box,
|
edit_box,
|
||||||
edit_box_type,
|
edit_box_type,
|
||||||
edit_thing,
|
edit_thing,
|
||||||
fixme,
|
fixme,
|
||||||
|
health_check,
|
||||||
index,
|
index,
|
||||||
resources_list,
|
resources_list,
|
||||||
search_api,
|
search_api,
|
||||||
@@ -40,8 +42,9 @@ from boxes.views import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('login/', auth_views.LoginView.as_view(template_name='login.html'), name='login'),
|
path('oidc/', include('mozilla_django_oidc.urls')),
|
||||||
path('logout/', auth_views.LogoutView.as_view(), name='logout'),
|
path('login/', TemplateView.as_view(template_name='login.html'), name='login'),
|
||||||
|
path('health/', health_check, name='health_check'),
|
||||||
path('', index, name='index'),
|
path('', index, name='index'),
|
||||||
path('box-management/', box_management, name='box_management'),
|
path('box-management/', box_management, name='box_management'),
|
||||||
path('box-type/add/', add_box_type, name='add_box_type'),
|
path('box-type/add/', add_box_type, name='add_box_type'),
|
||||||
@@ -53,9 +56,10 @@ urlpatterns = [
|
|||||||
path('box/<str:box_id>/', box_detail, name='box_detail'),
|
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>/', thing_detail, name='thing_detail'),
|
||||||
path('thing/<int:thing_id>/edit/', edit_thing, name='edit_thing'),
|
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('box/<str:box_id>/add/', add_things, name='add_things'),
|
||||||
path('boxes/', boxes_list, name='boxes_list'),
|
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('search/api/', search_api, name='search_api'),
|
||||||
path('resources/', resources_list, name='resources_list'),
|
path('resources/', resources_list, name='resources_list'),
|
||||||
path('fixme/', fixme, name='fixme'),
|
path('fixme/', fixme, name='fixme'),
|
||||||
|
|||||||
@@ -37,3 +37,4 @@ sorl-thumbnail==12.11.0
|
|||||||
bleach==6.1.0
|
bleach==6.1.0
|
||||||
coverage==7.6.1
|
coverage==7.6.1
|
||||||
whitenoise==6.8.2
|
whitenoise==6.8.2
|
||||||
|
mozilla-django-oidc==4.0.1
|
||||||
|
|||||||
Reference in New Issue
Block a user