Compare commits
1 Commits
master
...
feature/ss
| Author | SHA1 | Date | |
|---|---|---|---|
| 158af49727 |
629
AGENTS.md
629
AGENTS.md
@@ -1,606 +1,71 @@
|
|||||||
# AGENTS.md - AI Coding Agent Guidelines
|
# AGENTS.md - labhelper
|
||||||
|
|
||||||
This document provides guidelines for AI coding agents working in the labhelper repository.
|
**Type**: Django 5.2 web app (lab inventory)
|
||||||
|
**Python**: 3.13 | **DB**: SQLite (dev) | **Env**: `.venv/`
|
||||||
|
|
||||||
## Project Overview
|
## Essential Commands
|
||||||
|
|
||||||
- **Type**: Django web application (lab inventory management system)
|
|
||||||
- **Python**: 3.13.7
|
|
||||||
- **Django**: 5.2.9
|
|
||||||
- **Database**: SQLite (development)
|
|
||||||
- **Virtual Environment**: `.venv/`
|
|
||||||
|
|
||||||
## Build/Run Commands
|
|
||||||
|
|
||||||
### Development Server
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python manage.py runserver # Start dev server on port 8000
|
# Activate venv first
|
||||||
python manage.py runserver 0.0.0.0:8000 # Bind to all interfaces
|
source .venv/bin/activate
|
||||||
```
|
|
||||||
|
|
||||||
### Database Operations
|
# Dev server
|
||||||
|
python manage.py runserver # localhost:8000
|
||||||
|
python manage.py runserver 0.0.0.0:8000 # all interfaces
|
||||||
|
|
||||||
```bash
|
# Database
|
||||||
python manage.py makemigrations # Create migration files
|
python manage.py makemigrations boxes # after model changes
|
||||||
python manage.py makemigrations boxes # Create migrations for specific app
|
python manage.py migrate
|
||||||
python manage.py migrate # Apply all migrations
|
python manage.py showmigrations
|
||||||
python manage.py showmigrations # List migration status
|
|
||||||
```
|
|
||||||
|
|
||||||
### Testing
|
# Testing
|
||||||
|
python manage.py test # all
|
||||||
|
python manage.py test boxes # app only
|
||||||
|
python manage.py test boxes.tests.BoxModelTests.test_method # specific
|
||||||
|
|
||||||
```bash
|
# Custom commands
|
||||||
# Run all tests
|
python manage.py create_default_users # admin/admin123, staff/staff123, viewer/viewer123
|
||||||
python manage.py test
|
|
||||||
|
|
||||||
# Run tests for a specific app
|
|
||||||
python manage.py test boxes
|
|
||||||
|
|
||||||
# Run a specific test class
|
|
||||||
python manage.py test boxes.tests.TestClassName
|
|
||||||
|
|
||||||
# Run a single test method
|
|
||||||
python manage.py test boxes.tests.TestClassName.test_method_name
|
|
||||||
|
|
||||||
# Run tests with verbosity
|
|
||||||
python manage.py test -v 2
|
|
||||||
|
|
||||||
# Run tests with coverage
|
|
||||||
coverage run manage.py test
|
|
||||||
coverage report
|
|
||||||
coverage html # Generate HTML report
|
|
||||||
```
|
|
||||||
|
|
||||||
### Django Shell
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python manage.py shell # Interactive Django shell
|
|
||||||
python manage.py createsuperuser # Create admin user
|
|
||||||
python manage.py collectstatic # Collect static files
|
|
||||||
```
|
|
||||||
|
|
||||||
### Production
|
|
||||||
|
|
||||||
```bash
|
|
||||||
gunicorn labhelper.wsgi:application # Run with Gunicorn
|
|
||||||
```
|
|
||||||
|
|
||||||
### Custom Management Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Create default users and groups
|
|
||||||
python manage.py create_default_users
|
|
||||||
|
|
||||||
# Clean up orphaned files from deleted things
|
|
||||||
python manage.py clean_orphaned_files
|
|
||||||
python manage.py clean_orphaned_files --dry-run
|
python manage.py clean_orphaned_files --dry-run
|
||||||
|
|
||||||
# Clean up orphaned images and thumbnails
|
|
||||||
python manage.py clean_orphaned_images
|
|
||||||
python manage.py clean_orphaned_images --dry-run
|
python manage.py clean_orphaned_images --dry-run
|
||||||
|
python manage.py collectstatic # after CSS changes
|
||||||
```
|
```
|
||||||
|
|
||||||
## Code Style Guidelines
|
## Critical Rules
|
||||||
|
|
||||||
### Python Style
|
1. **NEVER commit/push without explicit permission** - wait for user to say "commit and push"
|
||||||
|
2. Use `get_object_or_404()` not bare `.get()` for model lookups
|
||||||
|
3. Run `makemigrations` then `migrate` after any model change
|
||||||
|
4. Default users: `admin/admin123` (superuser), `staff/staff123`, `viewer/viewer123`
|
||||||
|
|
||||||
- Follow PEP 8 conventions
|
## Data Model
|
||||||
- Use 4-space indentation (no tabs)
|
|
||||||
- Maximum line length: 79 characters (PEP 8 standard)
|
|
||||||
- Use single quotes for strings: `'string'`
|
|
||||||
- Use double quotes for docstrings: `"""Docstring."""`
|
|
||||||
|
|
||||||
### Import Order
|
- **BoxType** → Box (1:N, PROTECT)
|
||||||
|
- **Box** → Thing (1:N, PROTECT) — Box.pk is CharField(max=10)
|
||||||
|
- **Facet** → Tag (1:N, CASCADE) — Facet.cardinality: single/multiple
|
||||||
|
- **Thing** ↔ Tag (M2M)
|
||||||
|
- **Thing** → ThingFile, ThingLink (1:N, CASCADE)
|
||||||
|
|
||||||
Organize imports in this order, with blank lines between groups:
|
## Deployment (when instructed)
|
||||||
|
|
||||||
```python
|
**Full deploy**: bump both versions in `argocd/deployment.yaml` (+0.001), then:
|
||||||
# 1. Standard library imports
|
|
||||||
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
|
|
||||||
│ ├── static/
|
|
||||||
│ │ └── css/
|
|
||||||
│ │ ├── base.css # Base styles (layout, navbar, buttons, alerts, etc.)
|
|
||||||
│ │ ├── edit_thing.css # Edit thing form styles
|
|
||||||
│ │ └── thing_detail.css # Markdown content + lightbox styles
|
|
||||||
│ ├── templates/
|
|
||||||
│ │ └── boxes/
|
|
||||||
│ │ ├── add_things.html # Form to add multiple things
|
|
||||||
│ │ ├── box_detail.html # Box contents view
|
|
||||||
│ │ ├── box_management.html # Box/BoxType CRUD management
|
|
||||||
│ │ ├── boxes_list.html # Boxes list page with tabular view
|
|
||||||
│ │ ├── edit_thing.html # Edit thing page (name, description, picture, tags, files, links)
|
|
||||||
│ │ ├── index.html # Home page with search and tags
|
|
||||||
│ │ ├── resources_list.html # List all links and files from things
|
|
||||||
│ │ └── thing_detail.html # Read-only thing details view
|
|
||||||
│ ├── templatetags/
|
|
||||||
│ │ └── dict_extras.py # Custom template filters: get_item, render_markdown, truncate_markdown
|
|
||||||
│ ├── admin.py # Admin configuration
|
|
||||||
│ ├── apps.py # App configuration
|
|
||||||
│ ├── forms.py # All forms and formsets
|
|
||||||
│ ├── models.py # Data models
|
|
||||||
│ ├── tests.py # Test cases
|
|
||||||
│ └── views.py # View functions
|
|
||||||
├── data-loader/ # Init container for database preload
|
|
||||||
│ ├── Dockerfile # Alpine-based init container
|
|
||||||
│ └── preload.sqlite3 # Preloaded database for deployment
|
|
||||||
├── labhelper/ # Project configuration
|
|
||||||
│ ├── management/
|
|
||||||
│ │ └── commands/
|
|
||||||
│ │ └── create_default_users.py # Create default users/groups
|
|
||||||
│ ├── templates/
|
|
||||||
│ │ ├── base.html # Base template with navigation
|
|
||||||
│ │ └── login.html # Login page
|
|
||||||
│ ├── asgi.py # ASGI configuration
|
|
||||||
│ ├── settings.py # Django settings
|
|
||||||
│ ├── urls.py # Root URL configuration
|
|
||||||
│ └── wsgi.py # WSGI configuration
|
|
||||||
├── scripts/
|
|
||||||
│ ├── deploy_secret.sh # Generate and deploy Django secret
|
|
||||||
│ ├── full_deploy.sh # Bump both container versions + copy DB
|
|
||||||
│ └── partial_deploy.sh # Bump main container version only
|
|
||||||
├── .gitignore
|
|
||||||
├── AGENTS.md # This file
|
|
||||||
├── Dockerfile # Multi-stage build for main container
|
|
||||||
├── manage.py # Django CLI entry point
|
|
||||||
└── requirements.txt # Python dependencies
|
|
||||||
```
|
|
||||||
|
|
||||||
## Data Models
|
|
||||||
|
|
||||||
### boxes app
|
|
||||||
|
|
||||||
| Model | Description | Key Fields |
|
|
||||||
|-------|-------------|------------|
|
|
||||||
| **BoxType** | Type of storage box with dimensions | `name`, `width`, `height`, `length` (in mm) |
|
|
||||||
| **Box** | A storage box in the lab | `id` (CharField PK, max 10), `box_type` (FK) |
|
|
||||||
| **Facet** | A category of tags (e.g., Priority, Category) | `name`, `slug`, `color`, `cardinality` (single/multiple) |
|
|
||||||
| **Tag** | A tag value for a specific facet | `facet` (FK), `name` |
|
|
||||||
| **Thing** | An item stored in a box | `name`, `box` (FK), `description` (Markdown), `picture`, `tags` (M2M) |
|
|
||||||
| **ThingFile** | File attachment for a Thing | `thing` (FK), `file`, `title`, `uploaded_at` |
|
|
||||||
| **ThingLink** | Hyperlink for a Thing | `thing` (FK), `url`, `title`, `uploaded_at` |
|
|
||||||
|
|
||||||
**Model Relationships:**
|
|
||||||
- BoxType -> Box (1:N via `boxes` related_name, PROTECT on delete)
|
|
||||||
- Box -> Thing (1:N via `things` related_name, PROTECT on delete)
|
|
||||||
- Facet -> Tag (1:N via `tags` related_name, CASCADE on delete)
|
|
||||||
- Thing <-> Tag (M2M via `tags` related_name on Thing, `things` related_name on Tag)
|
|
||||||
- Thing -> ThingFile (1:N via `files` related_name, CASCADE on delete)
|
|
||||||
- Thing -> ThingLink (1:N via `links` related_name, CASCADE on delete)
|
|
||||||
|
|
||||||
**Facet Cardinality:**
|
|
||||||
- `single`: A thing can have at most one tag from this facet (e.g., Priority: High/Medium/Low)
|
|
||||||
- `multiple`: A thing can have multiple tags from this facet (e.g., Category: Electronics, Tools)
|
|
||||||
|
|
||||||
## Available Django Extensions
|
|
||||||
|
|
||||||
The project includes these pre-installed packages:
|
|
||||||
|
|
||||||
- **django-mptt**: Tree structures (categories, hierarchies)
|
|
||||||
- **django-mptt-admin**: Admin interface for MPTT models
|
|
||||||
- **django-admin-sortable2**: Drag-and-drop ordering in admin
|
|
||||||
- **django-nested-admin**: Nested inline forms in admin
|
|
||||||
- **django-nested-inline**: Additional nested inline support
|
|
||||||
- **django-revproxy**: Reverse proxy functionality
|
|
||||||
- **sorl-thumbnail**: Image thumbnailing
|
|
||||||
- **Pillow**: Image processing
|
|
||||||
- **gunicorn**: Production WSGI server
|
|
||||||
- **Markdown**: Markdown processing
|
|
||||||
- **bleach**: HTML sanitization
|
|
||||||
- **coverage**: Test coverage
|
|
||||||
- **Font Awesome**: Icon library (loaded via CDN)
|
|
||||||
- **jQuery**: JavaScript library (loaded via CDN)
|
|
||||||
|
|
||||||
## Frontend/CSS Guidelines
|
|
||||||
|
|
||||||
### Base Template
|
|
||||||
|
|
||||||
The project uses a base template system at `labhelper/templates/base.html`. All page templates should extend this base:
|
|
||||||
|
|
||||||
```django
|
|
||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Page Title - LabHelper{% endblock %}
|
|
||||||
|
|
||||||
{% block page_header %}
|
|
||||||
<!-- Optional page header with breadcrumbs -->
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<!-- Main page content -->
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_css %}{% endblock %}
|
|
||||||
{% block extra_js %}{% endblock %}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Design System
|
|
||||||
|
|
||||||
**Color Scheme:**
|
|
||||||
- Primary gradient: `#667eea` (purple) to `#764ba2` (purple-blue)
|
|
||||||
- Success: Green gradient
|
|
||||||
- Error: Red gradient
|
|
||||||
- Background: Light gray `#f5f5f5` with gradient overlays
|
|
||||||
- Cards: White with subtle shadows
|
|
||||||
|
|
||||||
**Components:**
|
|
||||||
- **Navigation**: Glassmorphism effect with blur backdrop. Desktop (≥769px) shows horizontal menu with dropdown for authenticated user (contains Box Management, Resources, Admin, Logout)
|
|
||||||
- **Buttons**: Gradient backgrounds with hover lift effect
|
|
||||||
- **Cards**: White with rounded corners and box shadows
|
|
||||||
- **Tables**: Gradient headers with hover row effects
|
|
||||||
- **Alerts**: Gradient backgrounds with icons
|
|
||||||
- **Form Inputs**: Focused states with color transitions
|
|
||||||
|
|
||||||
**Typography:**
|
|
||||||
- System fonts: `-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif`
|
|
||||||
- Headings: Bold, colored
|
|
||||||
- Body: Regular, dark gray
|
|
||||||
|
|
||||||
**Icons:**
|
|
||||||
- Font Awesome 6.5.1 (CDN)
|
|
||||||
- Use semantic icons for actions
|
|
||||||
- Color: Match context or inherit from parent
|
|
||||||
|
|
||||||
**Responsive Design:**
|
|
||||||
- Mobile-first approach
|
|
||||||
- Grid layouts with `repeat(auto-fill, minmax(250px, 1fr))`
|
|
||||||
- Flexbox for component layouts
|
|
||||||
- Breakpoints handled by grid and flex-wrap
|
|
||||||
- **Navigation**: Responsive navbar with hamburger menu on mobile (≤768px) and horizontal menu with user dropdown on desktop (≥769px). Mobile keeps all items in the dropdown list
|
|
||||||
|
|
||||||
### CSS Guidelines
|
|
||||||
|
|
||||||
**Static CSS Files:**
|
|
||||||
- Base styles live in `boxes/static/css/base.css` (loaded by `base.html` via `{% static %}`)
|
|
||||||
- Page-specific styles live in separate static CSS files (e.g., `thing_detail.css`, `edit_thing.css`)
|
|
||||||
- Child templates load their CSS via `{% block extra_css %}` with `<link>` tags
|
|
||||||
- WhiteNoise serves and cache-busts static files via `CompressedManifestStaticFilesStorage`
|
|
||||||
- Run `python manage.py collectstatic` after adding or modifying static CSS files
|
|
||||||
|
|
||||||
**Naming:**
|
|
||||||
- Use descriptive class names
|
|
||||||
- BEM pattern encouraged for complex components
|
|
||||||
- Inline styles allowed for template-specific one-off styling
|
|
||||||
|
|
||||||
**Styles:**
|
|
||||||
- Use base CSS classes when possible
|
|
||||||
- Page-specific styles in dedicated static CSS files loaded via `{% block extra_css %}`
|
|
||||||
- JavaScript in `{% block extra_js %}`
|
|
||||||
- Smooth transitions (0.2s - 0.3s)
|
|
||||||
- Hover effects with transform and box-shadow
|
|
||||||
|
|
||||||
**jQuery Usage:**
|
|
||||||
- Loaded in base template
|
|
||||||
- Use for interactive elements (toggles, hovers)
|
|
||||||
- Event delegation for dynamically added elements
|
|
||||||
- Focus/blur events for form inputs
|
|
||||||
|
|
||||||
### Available Pages/Views
|
|
||||||
|
|
||||||
| View Function | URL Pattern | Name | Description |
|
|
||||||
|---------------|-------------|------|-------------|
|
|
||||||
| `index` | `/` | `index` | Home page with search and tags overview |
|
|
||||||
| `boxes_list` | `/search/` | `search`, `boxes_list` | Boxes list page with tabular view |
|
|
||||||
| `box_management` | `/box-management/` | `box_management` | Manage boxes and box types |
|
|
||||||
| `add_box_type` | `/box-type/add/` | `add_box_type` | Add new box type |
|
|
||||||
| `edit_box_type` | `/box-type/<int:type_id>/edit/` | `edit_box_type` | Edit box type |
|
|
||||||
| `delete_box_type` | `/box-type/<int:type_id>/delete/` | `delete_box_type` | Delete box type |
|
|
||||||
| `add_box` | `/box/add/` | `add_box` | Add new box |
|
|
||||||
| `edit_box` | `/box/<str:box_id>/edit/` | `edit_box` | Edit box |
|
|
||||||
| `delete_box` | `/box/<str:box_id>/delete/` | `delete_box` | Delete box |
|
|
||||||
| `box_detail` | `/box/<str:box_id>/` | `box_detail` | View box contents |
|
|
||||||
| `thing_detail` | `/thing/<int:thing_id>/` | `thing_detail` | Read-only view of thing details |
|
|
||||||
| `edit_thing` | `/thing/<int:thing_id>/edit/` | `edit_thing` | Edit thing (name, description, picture, tags, files, links, move) |
|
|
||||||
| `add_things` | `/box/<str:box_id>/add/` | `add_things` | Add multiple things to a box |
|
|
||||||
| `search_api` | `/search/api/` | `search_api` | AJAX search endpoint |
|
|
||||||
| `resources_list` | `/resources/` | `resources_list` | List all links and files from things (sorted by thing name) |
|
|
||||||
| `LoginView` | `/login/` | `login` | Django auth login |
|
|
||||||
| `LogoutView` | `/logout/` | `logout` | Django auth logout |
|
|
||||||
| `admin.site` | `/admin/` | - | Django admin |
|
|
||||||
|
|
||||||
**All views except login require authentication via `@login_required`.**
|
|
||||||
|
|
||||||
### Template Best Practices
|
|
||||||
|
|
||||||
1. **Always extend base template**
|
|
||||||
```django
|
|
||||||
{% extends "base.html" %}
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Use block system for content injection**
|
|
||||||
- `title`: Page title tag
|
|
||||||
- `page_header`: Page header with breadcrumbs
|
|
||||||
- `content`: Main page content
|
|
||||||
- `extra_css`: Additional CSS via `<link>` tags to static files
|
|
||||||
- `extra_head`: Additional head elements
|
|
||||||
- `extra_js`: Additional JavaScript
|
|
||||||
|
|
||||||
3. **Load required template tags**
|
|
||||||
```django
|
|
||||||
{% load static %} {# Required when using {% static %} for CSS/asset links #}
|
|
||||||
{% load mptt_tags %}
|
|
||||||
{% load thumbnail %}
|
|
||||||
{% load dict_extras %}
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Link page-specific CSS from static files**
|
|
||||||
```django
|
|
||||||
{% block extra_css %}
|
|
||||||
<link rel="stylesheet" href="{% static 'css/thing_detail.css' %}">
|
|
||||||
{% endblock %}
|
|
||||||
```
|
|
||||||
|
|
||||||
5. **Use URL names for links**
|
|
||||||
```django
|
|
||||||
<a href="{% url 'box_detail' box.id %}">
|
|
||||||
```
|
|
||||||
|
|
||||||
6. **Use icons with Font Awesome**
|
|
||||||
```django
|
|
||||||
<i class="fas fa-box"></i>
|
|
||||||
```
|
|
||||||
|
|
||||||
7. **Add breadcrumbs for navigation**
|
|
||||||
```django
|
|
||||||
<p class="breadcrumb">
|
|
||||||
<a href="/"><i class="fas fa-home"></i> Home</a> /
|
|
||||||
<a href="/box/{{ box.id }}/"><i class="fas fa-box"></i> Box {{ box.id }}</a>
|
|
||||||
</p>
|
|
||||||
```
|
|
||||||
|
|
||||||
8. **Icon alignment in lists**: When using icons in list items, use fixed width containers to ensure proper alignment
|
|
||||||
```django
|
|
||||||
<a href="{% url 'thing_detail' thing.id %}" style="display: inline-block; width: 20px; text-align: center;">
|
|
||||||
<i class="fas fa-link"></i>
|
|
||||||
</a>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Markdown Support
|
|
||||||
|
|
||||||
The `Thing.description` field supports Markdown formatting with HTML sanitization for security.
|
|
||||||
|
|
||||||
**Available Template Filters:**
|
|
||||||
|
|
||||||
- `render_markdown`: Converts Markdown text to sanitized HTML with automatic link handling
|
|
||||||
- Converts Markdown syntax (headers, lists, bold, italic, links, code, tables, etc.)
|
|
||||||
- Sanitizes HTML using `bleach` to prevent XSS attacks
|
|
||||||
- Automatically adds `target="_blank"` and `rel="noopener noreferrer"` to external links
|
|
||||||
- Use in `thing_detail.html` for full rendered Markdown
|
|
||||||
|
|
||||||
- `truncate_markdown`: Converts Markdown to plain text and truncates
|
|
||||||
- Strips HTML tags after Markdown conversion
|
|
||||||
- Adds ellipsis (`...`) if text exceeds specified length (default: 100)
|
|
||||||
- Use in `box_detail.html` or search API previews where space is limited
|
|
||||||
|
|
||||||
**Usage Examples:**
|
|
||||||
```django
|
|
||||||
<!-- Full Markdown rendering -->
|
|
||||||
<div class="markdown-content">
|
|
||||||
{{ thing.description|render_markdown }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Truncated plain text preview -->
|
|
||||||
{{ thing.description|truncate_markdown:100 }}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Supported Markdown Features:**
|
|
||||||
- Bold: `**text**` or `__text__`
|
|
||||||
- Italic: `*text*` or `_text_`
|
|
||||||
- Headers: `# Header 1`, `## Header 2`, etc.
|
|
||||||
- Lists: `- item` or `1. item`
|
|
||||||
- Links: `[text](url)`
|
|
||||||
- Code: `` `code` `` or ` ```code block```
|
|
||||||
- Blockquotes: `> quote`
|
|
||||||
- Tables: `| A | B |\n|---|---|`
|
|
||||||
|
|
||||||
**Security:**
|
|
||||||
- All Markdown is sanitized before rendering
|
|
||||||
- Dangerous HTML tags (`<script>`, `<iframe>`, etc.) are stripped
|
|
||||||
- Only safe HTML tags and attributes are allowed
|
|
||||||
- External links automatically get `target="_blank"` and security attributes
|
|
||||||
|
|
||||||
## Forms
|
|
||||||
|
|
||||||
| Form | Model | Purpose |
|
|
||||||
|------|-------|---------|
|
|
||||||
| `ThingForm` | Thing | Add/edit a thing (name, description, picture) - tags managed separately |
|
|
||||||
| `ThingPictureForm` | Thing | Upload/change thing picture only |
|
|
||||||
| `ThingFileForm` | ThingFile | Add file attachment |
|
|
||||||
| `ThingLinkForm` | ThingLink | Add link |
|
|
||||||
| `BoxTypeForm` | BoxType | Add/edit box type |
|
|
||||||
| `BoxForm` | Box | Add/edit box |
|
|
||||||
| `ThingFormSet` | Thing | Formset for adding multiple things |
|
|
||||||
|
|
||||||
## Management Commands
|
|
||||||
|
|
||||||
### boxes app
|
|
||||||
|
|
||||||
| Command | Description | Options |
|
|
||||||
|---------|-------------|---------|
|
|
||||||
| `clean_orphaned_files` | Clean up orphaned files from deleted things | `--dry-run` |
|
|
||||||
| `clean_orphaned_images` | Clean up orphaned images and thumbnails | `--dry-run` |
|
|
||||||
|
|
||||||
### labhelper project
|
|
||||||
|
|
||||||
| Command | Description |
|
|
||||||
|---------|-------------|
|
|
||||||
| `create_default_users` | Create default users and groups (admin/admin123, staff/staff123, viewer/viewer123) |
|
|
||||||
|
|
||||||
## Testing Guidelines
|
|
||||||
|
|
||||||
- Use `django.test.TestCase` for database tests
|
|
||||||
- Use `django.test.SimpleTestCase` for tests without database
|
|
||||||
- Name test files `test_*.py` or `*_tests.py`
|
|
||||||
- Name test methods `test_*`
|
|
||||||
- Use descriptive test method names
|
|
||||||
|
|
||||||
```python
|
|
||||||
from django.test import TestCase
|
|
||||||
from .models import Box
|
|
||||||
|
|
||||||
class BoxModelTests(TestCase):
|
|
||||||
"""Tests for the Box model."""
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
"""Set up test fixtures."""
|
|
||||||
self.box = Box.objects.create(name='Test Box')
|
|
||||||
|
|
||||||
def test_box_str_returns_name(self):
|
|
||||||
"""Box __str__ should return the box name."""
|
|
||||||
self.assertEqual(str(self.box), 'Test Box')
|
|
||||||
```
|
|
||||||
|
|
||||||
## Files to Never Commit
|
|
||||||
|
|
||||||
Per `.gitignore`:
|
|
||||||
- `__pycache__/`, `*.pyc` - Python bytecode
|
|
||||||
- `.venv/` - Virtual environment
|
|
||||||
- `.env` - Environment variables
|
|
||||||
- `data/db.sqlite3` - Database file
|
|
||||||
- `keys/` - Secret keys
|
|
||||||
|
|
||||||
## Common Pitfalls
|
|
||||||
|
|
||||||
1. **NEVER commit or push without explicit permission**: Always ask the user before running `git commit` or `git push`. The user will explicitly say "commit and push" when they want you to do this. Do NOT automatically commit/push after making changes unless instructed to do so.
|
|
||||||
2. **Always activate venv**: `source .venv/bin/activate`
|
|
||||||
3. **Run migrations after model changes**: `makemigrations` then `migrate`
|
|
||||||
4. **Add new apps to INSTALLED_APPS** in `settings.py`
|
|
||||||
5. **Templates in labhelper/templates/**: The base template and shared templates are in `labhelper/templates/`. App-specific templates remain in `app_name/templates/`.
|
|
||||||
6. **Use get_object_or_404** instead of bare `.get()` calls
|
|
||||||
7. **Never commit SECRET_KEY** - use environment variables in production
|
|
||||||
8. **Be careful with process management**: Avoid blanket kills on ports (e.g., `lsof -ti:8000 | xargs kill -9`) as they can kill unintended processes like web browsers. Use specific process kills instead: `pkill -f "process_name"`
|
|
||||||
|
|
||||||
## Deployment Commands
|
|
||||||
|
|
||||||
### Prepare a Full Deployment
|
|
||||||
|
|
||||||
When instructed to "Prepare a full deployment", perform the following steps:
|
|
||||||
|
|
||||||
1. **Bump container versions**: In `argocd/deployment.yaml`, increment the version numbers by 0.001 for both containers:
|
|
||||||
- `labhelper-data-loader` (initContainer)
|
|
||||||
- `labhelper` (main container)
|
|
||||||
|
|
||||||
2. **Copy database**: Copy the current development database to the data-loader preload location:
|
|
||||||
```bash
|
```bash
|
||||||
cp data/db.sqlite3 data-loader/preload.sqlite3
|
cp data/db.sqlite3 data-loader/preload.sqlite3
|
||||||
```
|
```
|
||||||
|
|
||||||
### Prepare a Partial Deployment
|
**Partial deploy**: bump main container only, skip DB copy.
|
||||||
|
|
||||||
When instructed to "Prepare a partial deployment", perform the following step:
|
## Key Directories
|
||||||
|
|
||||||
1. **Bump main container version only**: In `argocd/deployment.yaml`, increment the version number by 0.001 for the main container only:
|
- `boxes/` — main app (models, views, forms, templates)
|
||||||
- `labhelper` (main container)
|
- `labhelper/` — project settings, base template
|
||||||
|
- `argocd/` — Kubernetes manifests for production
|
||||||
|
- `data-loader/` — init container with preloaded DB
|
||||||
|
|
||||||
Do NOT bump the data-loader version or copy the database.
|
## Gotchas
|
||||||
|
|
||||||
|
- Template base: `labhelper/templates/base.html`
|
||||||
|
- App templates: `boxes/templates/boxes/`
|
||||||
|
- Box ID is CharField (e.g., "A1-001"), not auto-increment
|
||||||
|
- Views use `conditional_login_required` - bypasses login for IPs in `ALLOWED_CIDR_NETS` env var
|
||||||
|
- Markdown via `{{ text|render_markdown }}` (sanitized with bleach)
|
||||||
|
- Third-party: django-mptt, sorl-thumbnail, bleach, markdown
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ metadata:
|
|||||||
data:
|
data:
|
||||||
DEBUG: "False"
|
DEBUG: "False"
|
||||||
ALLOWED_HOSTS: "labhelper.adebaumann.com,*"
|
ALLOWED_HOSTS: "labhelper.adebaumann.com,*"
|
||||||
ALLOWED_CIDR_NETS: "10.0.0.0/16"
|
ALLOWED_CIDR_NETS: "10.0.0.0/16,192.168.0.0/16"
|
||||||
LANGUAGE_CODE: "en-us"
|
LANGUAGE_CODE: "en-us"
|
||||||
TIME_ZONE: "UTC"
|
TIME_ZONE: "UTC"
|
||||||
USE_I18N: "True"
|
USE_I18N: "True"
|
||||||
@@ -21,4 +21,4 @@ data:
|
|||||||
LOGOUT_REDIRECT_URL: "/login/"
|
LOGOUT_REDIRECT_URL: "/login/"
|
||||||
OIDC_AUTHENTICATION_FAILURE_REDIRECT_URL: "/login/"
|
OIDC_AUTHENTICATION_FAILURE_REDIRECT_URL: "/login/"
|
||||||
GUNICORN_OPTS: "--access-logfile -"
|
GUNICORN_OPTS: "--access-logfile -"
|
||||||
IMAGE_TAG: "0.079"
|
IMAGE_TAG: "0.083"
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ spec:
|
|||||||
mountPath: /data
|
mountPath: /data
|
||||||
containers:
|
containers:
|
||||||
- name: web
|
- name: web
|
||||||
image: git.baumann.gr/adebaumann/labhelper:0.082
|
image: git.baumann.gr/adebaumann/labhelper:0.083
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 8000
|
- containerPort: 8000
|
||||||
|
|||||||
31
boxes/decorators.py
Normal file
31
boxes/decorators.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import functools
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
|
def conditional_login_required(view_func):
|
||||||
|
"""Skip login_required if client IP is in ALLOWED_CIDR_NETS."""
|
||||||
|
|
||||||
|
@functools.wraps(view_func)
|
||||||
|
def wrapper(request, *args, **kwargs):
|
||||||
|
# Get client IP
|
||||||
|
x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
|
||||||
|
if x_forwarded_for:
|
||||||
|
ip = x_forwarded_for.split(",")[0].strip()
|
||||||
|
else:
|
||||||
|
ip = request.META.get("REMOTE_ADDR", "")
|
||||||
|
|
||||||
|
# Check if IP is in allowed networks
|
||||||
|
from ipaddress import ip_address, ip_network
|
||||||
|
|
||||||
|
client_ip = ip_address(ip)
|
||||||
|
for net in getattr(settings, "ALLOWED_CIDR_NETS", []):
|
||||||
|
if client_ip in ip_network(net, strict=False):
|
||||||
|
return view_func(request, *args, **kwargs)
|
||||||
|
|
||||||
|
# Fall back to login_required
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
|
||||||
|
return login_required(view_func)(request, *args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
386
boxes/views.py
386
boxes/views.py
@@ -1,7 +1,7 @@
|
|||||||
import bleach
|
import bleach
|
||||||
import markdown
|
import markdown
|
||||||
|
|
||||||
from django.contrib.auth.decorators import login_required
|
from boxes.decorators import conditional_login_required as login_required
|
||||||
from django.db.models import Q, Prefetch
|
from django.db.models import Q, Prefetch
|
||||||
from django.http import HttpResponse, JsonResponse
|
from django.http import HttpResponse, JsonResponse
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
@@ -20,25 +20,25 @@ from .models import Box, BoxType, Facet, Tag, Thing, ThingFile, ThingLink
|
|||||||
|
|
||||||
def health_check(request):
|
def health_check(request):
|
||||||
"""Health check endpoint for Kubernetes liveness/readiness probes."""
|
"""Health check endpoint for Kubernetes liveness/readiness probes."""
|
||||||
return HttpResponse('OK', status=200)
|
return HttpResponse("OK", status=200)
|
||||||
|
|
||||||
|
|
||||||
def _strip_markdown(text, max_length=100):
|
def _strip_markdown(text, max_length=100):
|
||||||
"""Convert Markdown to plain text and truncate."""
|
"""Convert Markdown to plain text and truncate."""
|
||||||
if not text:
|
if not text:
|
||||||
return ''
|
return ""
|
||||||
html = markdown.markdown(text)
|
html = markdown.markdown(text)
|
||||||
plain_text = bleach.clean(html, tags=[], strip=True)
|
plain_text = bleach.clean(html, tags=[], strip=True)
|
||||||
plain_text = ' '.join(plain_text.split())
|
plain_text = " ".join(plain_text.split())
|
||||||
if len(plain_text) > max_length:
|
if len(plain_text) > max_length:
|
||||||
return plain_text[:max_length].rsplit(' ', 1)[0] + '...'
|
return plain_text[:max_length].rsplit(" ", 1)[0] + "..."
|
||||||
return plain_text
|
return plain_text
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def index(request):
|
def index(request):
|
||||||
"""Home page with search and tags."""
|
"""Home page with search and tags."""
|
||||||
facets = Facet.objects.all().prefetch_related('tags')
|
facets = Facet.objects.all().prefetch_related("tags")
|
||||||
|
|
||||||
facet_tag_counts = {}
|
facet_tag_counts = {}
|
||||||
for facet in facets:
|
for facet in facets:
|
||||||
@@ -49,95 +49,107 @@ def index(request):
|
|||||||
facet_tag_counts[facet] = []
|
facet_tag_counts[facet] = []
|
||||||
facet_tag_counts[facet].append((tag, count))
|
facet_tag_counts[facet].append((tag, count))
|
||||||
|
|
||||||
return render(request, 'boxes/index.html', {
|
return render(
|
||||||
'facets': facets,
|
request,
|
||||||
'facet_tag_counts': facet_tag_counts,
|
"boxes/index.html",
|
||||||
})
|
{
|
||||||
|
"facets": facets,
|
||||||
|
"facet_tag_counts": facet_tag_counts,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def box_detail(request, box_id):
|
def box_detail(request, box_id):
|
||||||
"""Display contents of a box."""
|
"""Display contents of a box."""
|
||||||
box = get_object_or_404(Box, pk=box_id)
|
box = get_object_or_404(Box, pk=box_id)
|
||||||
things = box.things.prefetch_related('tags').all()
|
things = box.things.prefetch_related("tags").all()
|
||||||
return render(request, 'boxes/box_detail.html', {
|
return render(
|
||||||
'box': box,
|
request,
|
||||||
'things': things,
|
"boxes/box_detail.html",
|
||||||
})
|
{
|
||||||
|
"box": box,
|
||||||
|
"things": things,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def thing_detail(request, thing_id):
|
def thing_detail(request, thing_id):
|
||||||
"""Display details of a thing (read-only)."""
|
"""Display details of a thing (read-only)."""
|
||||||
thing = get_object_or_404(
|
thing = get_object_or_404(
|
||||||
Thing.objects.select_related('box', 'box__box_type').prefetch_related('files', 'links', 'tags'),
|
Thing.objects.select_related("box", "box__box_type").prefetch_related(
|
||||||
pk=thing_id
|
"files", "links", "tags"
|
||||||
|
),
|
||||||
|
pk=thing_id,
|
||||||
)
|
)
|
||||||
return render(request, 'boxes/thing_detail.html', {'thing': thing})
|
return render(request, "boxes/thing_detail.html", {"thing": thing})
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def edit_thing(request, thing_id):
|
def edit_thing(request, thing_id):
|
||||||
"""Edit a thing's details."""
|
"""Edit a thing's details."""
|
||||||
thing = get_object_or_404(
|
thing = get_object_or_404(
|
||||||
Thing.objects.select_related('box', 'box__box_type').prefetch_related('files', 'links', 'tags'),
|
Thing.objects.select_related("box", "box__box_type").prefetch_related(
|
||||||
pk=thing_id
|
"files", "links", "tags"
|
||||||
|
),
|
||||||
|
pk=thing_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
boxes = Box.objects.select_related('box_type').all()
|
boxes = Box.objects.select_related("box_type").all()
|
||||||
facets = Facet.objects.all().prefetch_related('tags')
|
facets = Facet.objects.all().prefetch_related("tags")
|
||||||
picture_form = ThingPictureForm(instance=thing)
|
picture_form = ThingPictureForm(instance=thing)
|
||||||
file_form = ThingFileForm()
|
file_form = ThingFileForm()
|
||||||
link_form = ThingLinkForm()
|
link_form = ThingLinkForm()
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == "POST":
|
||||||
action = request.POST.get('action')
|
action = request.POST.get("action")
|
||||||
|
|
||||||
if action == 'save_details':
|
if action == "save_details":
|
||||||
form = ThingForm(request.POST, request.FILES, instance=thing)
|
form = ThingForm(request.POST, request.FILES, instance=thing)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
form.save()
|
form.save()
|
||||||
return redirect('thing_detail', thing_id=thing.id)
|
return redirect("thing_detail", thing_id=thing.id)
|
||||||
|
|
||||||
elif action == 'move':
|
elif action == "move":
|
||||||
new_box_id = request.POST.get('new_box')
|
new_box_id = request.POST.get("new_box")
|
||||||
if new_box_id:
|
if new_box_id:
|
||||||
new_box = get_object_or_404(Box, pk=new_box_id)
|
new_box = get_object_or_404(Box, pk=new_box_id)
|
||||||
thing.box = new_box
|
thing.box = new_box
|
||||||
thing.save()
|
thing.save()
|
||||||
return redirect('edit_thing', thing_id=thing.id)
|
return redirect("edit_thing", thing_id=thing.id)
|
||||||
|
|
||||||
elif action == 'upload_picture':
|
elif action == "upload_picture":
|
||||||
picture_form = ThingPictureForm(request.POST, request.FILES, instance=thing)
|
picture_form = ThingPictureForm(request.POST, request.FILES, instance=thing)
|
||||||
if picture_form.is_valid():
|
if picture_form.is_valid():
|
||||||
picture_form.save()
|
picture_form.save()
|
||||||
return redirect('edit_thing', thing_id=thing.id)
|
return redirect("edit_thing", thing_id=thing.id)
|
||||||
|
|
||||||
elif action == 'delete_picture':
|
elif action == "delete_picture":
|
||||||
if thing.picture:
|
if thing.picture:
|
||||||
thing.picture.delete()
|
thing.picture.delete()
|
||||||
thing.picture = None
|
thing.picture = None
|
||||||
thing.save()
|
thing.save()
|
||||||
return redirect('edit_thing', thing_id=thing.id)
|
return redirect("edit_thing", thing_id=thing.id)
|
||||||
|
|
||||||
elif action == 'add_file':
|
elif action == "add_file":
|
||||||
file_form = ThingFileForm(request.POST, request.FILES)
|
file_form = ThingFileForm(request.POST, request.FILES)
|
||||||
if file_form.is_valid():
|
if file_form.is_valid():
|
||||||
thing_file = file_form.save(commit=False)
|
thing_file = file_form.save(commit=False)
|
||||||
thing_file.thing = thing
|
thing_file.thing = thing
|
||||||
thing_file.save()
|
thing_file.save()
|
||||||
return redirect('edit_thing', thing_id=thing.id)
|
return redirect("edit_thing", thing_id=thing.id)
|
||||||
|
|
||||||
elif action == 'add_link':
|
elif action == "add_link":
|
||||||
link_form = ThingLinkForm(request.POST)
|
link_form = ThingLinkForm(request.POST)
|
||||||
if link_form.is_valid():
|
if link_form.is_valid():
|
||||||
thing_link = link_form.save(commit=False)
|
thing_link = link_form.save(commit=False)
|
||||||
thing_link.thing = thing
|
thing_link.thing = thing
|
||||||
thing_link.save()
|
thing_link.save()
|
||||||
return redirect('edit_thing', thing_id=thing.id)
|
return redirect("edit_thing", thing_id=thing.id)
|
||||||
|
|
||||||
elif action == 'delete_file':
|
elif action == "delete_file":
|
||||||
file_id = request.POST.get('file_id')
|
file_id = request.POST.get("file_id")
|
||||||
if file_id:
|
if file_id:
|
||||||
try:
|
try:
|
||||||
thing_file = ThingFile.objects.get(pk=file_id, thing=thing)
|
thing_file = ThingFile.objects.get(pk=file_id, thing=thing)
|
||||||
@@ -145,20 +157,20 @@ def edit_thing(request, thing_id):
|
|||||||
thing_file.delete()
|
thing_file.delete()
|
||||||
except ThingFile.DoesNotExist:
|
except ThingFile.DoesNotExist:
|
||||||
pass
|
pass
|
||||||
return redirect('edit_thing', thing_id=thing.id)
|
return redirect("edit_thing", thing_id=thing.id)
|
||||||
|
|
||||||
elif action == 'delete_link':
|
elif action == "delete_link":
|
||||||
link_id = request.POST.get('link_id')
|
link_id = request.POST.get("link_id")
|
||||||
if link_id:
|
if link_id:
|
||||||
try:
|
try:
|
||||||
thing_link = ThingLink.objects.get(pk=link_id, thing=thing)
|
thing_link = ThingLink.objects.get(pk=link_id, thing=thing)
|
||||||
thing_link.delete()
|
thing_link.delete()
|
||||||
except ThingLink.DoesNotExist:
|
except ThingLink.DoesNotExist:
|
||||||
pass
|
pass
|
||||||
return redirect('edit_thing', thing_id=thing.id)
|
return redirect("edit_thing", thing_id=thing.id)
|
||||||
|
|
||||||
elif action == 'add_tag':
|
elif action == "add_tag":
|
||||||
tag_id = request.POST.get('tag_id')
|
tag_id = request.POST.get("tag_id")
|
||||||
if tag_id:
|
if tag_id:
|
||||||
try:
|
try:
|
||||||
tag = Tag.objects.get(pk=tag_id)
|
tag = Tag.objects.get(pk=tag_id)
|
||||||
@@ -169,102 +181,120 @@ def edit_thing(request, thing_id):
|
|||||||
thing.tags.add(tag)
|
thing.tags.add(tag)
|
||||||
except Tag.DoesNotExist:
|
except Tag.DoesNotExist:
|
||||||
pass
|
pass
|
||||||
return redirect('edit_thing', thing_id=thing.id)
|
return redirect("edit_thing", thing_id=thing.id)
|
||||||
|
|
||||||
elif action == 'remove_tag':
|
elif action == "remove_tag":
|
||||||
tag_id = request.POST.get('tag_id')
|
tag_id = request.POST.get("tag_id")
|
||||||
if tag_id:
|
if tag_id:
|
||||||
try:
|
try:
|
||||||
tag = Tag.objects.get(pk=tag_id)
|
tag = Tag.objects.get(pk=tag_id)
|
||||||
thing.tags.remove(tag)
|
thing.tags.remove(tag)
|
||||||
except Tag.DoesNotExist:
|
except Tag.DoesNotExist:
|
||||||
pass
|
pass
|
||||||
return redirect('edit_thing', thing_id=thing.id)
|
return redirect("edit_thing", thing_id=thing.id)
|
||||||
|
|
||||||
thing_form = ThingForm(instance=thing)
|
thing_form = ThingForm(instance=thing)
|
||||||
|
|
||||||
return render(request, 'boxes/edit_thing.html', {
|
return render(
|
||||||
'thing': thing,
|
request,
|
||||||
'boxes': boxes,
|
"boxes/edit_thing.html",
|
||||||
'facets': facets,
|
{
|
||||||
'picture_form': picture_form,
|
"thing": thing,
|
||||||
'file_form': file_form,
|
"boxes": boxes,
|
||||||
'link_form': link_form,
|
"facets": facets,
|
||||||
'thing_form': thing_form,
|
"picture_form": picture_form,
|
||||||
})
|
"file_form": file_form,
|
||||||
|
"link_form": link_form,
|
||||||
|
"thing_form": thing_form,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def boxes_list(request):
|
def boxes_list(request):
|
||||||
"""Boxes list page showing all boxes with contents."""
|
"""Boxes list page showing all boxes with contents."""
|
||||||
boxes = Box.objects.select_related('box_type').prefetch_related('things').all()
|
boxes = Box.objects.select_related("box_type").prefetch_related("things").all()
|
||||||
return render(request, 'boxes/boxes_list.html', {
|
return render(
|
||||||
'boxes': boxes,
|
request,
|
||||||
})
|
"boxes/boxes_list.html",
|
||||||
|
{
|
||||||
|
"boxes": boxes,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def search_api(request):
|
def search_api(request):
|
||||||
"""AJAX endpoint for searching things."""
|
"""AJAX endpoint for searching things."""
|
||||||
query = request.GET.get('q', '').strip()
|
query = request.GET.get("q", "").strip()
|
||||||
if len(query) < 2:
|
if len(query) < 2:
|
||||||
return JsonResponse({'results': []})
|
return JsonResponse({"results": []})
|
||||||
|
|
||||||
# Check for "Facet:Word" format
|
# Check for "Facet:Word" format
|
||||||
if ':' in query:
|
if ":" in query:
|
||||||
parts = query.split(':',1)
|
parts = query.split(":", 1)
|
||||||
facet_name = parts[0].strip()
|
facet_name = parts[0].strip()
|
||||||
tag_name = parts[1].strip()
|
tag_name = parts[1].strip()
|
||||||
|
|
||||||
# Search for things with specific facet and tag
|
# Search for things with specific facet and tag
|
||||||
things = Thing.objects.filter(
|
things = (
|
||||||
Q(tags__facet__name__icontains=facet_name) &
|
Thing.objects.filter(
|
||||||
Q(tags__name__icontains=tag_name)
|
Q(tags__facet__name__icontains=facet_name)
|
||||||
).prefetch_related('files', 'links', 'tags').select_related('box').distinct()[:50]
|
& Q(tags__name__icontains=tag_name)
|
||||||
|
)
|
||||||
|
.prefetch_related("files", "links", "tags")
|
||||||
|
.select_related("box")
|
||||||
|
.distinct()[:50]
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
# Normal search
|
# Normal search
|
||||||
things = Thing.objects.filter(
|
things = (
|
||||||
Q(name__icontains=query) |
|
Thing.objects.filter(
|
||||||
Q(description__icontains=query) |
|
Q(name__icontains=query)
|
||||||
Q(files__title__icontains=query) |
|
| Q(description__icontains=query)
|
||||||
Q(files__file__icontains=query) |
|
| Q(files__title__icontains=query)
|
||||||
Q(links__title__icontains=query) |
|
| Q(files__file__icontains=query)
|
||||||
Q(links__url__icontains=query) |
|
| Q(links__title__icontains=query)
|
||||||
Q(tags__name__icontains=query) |
|
| Q(links__url__icontains=query)
|
||||||
Q(tags__facet__name__icontains=query)
|
| Q(tags__name__icontains=query)
|
||||||
).prefetch_related('files', 'links', 'tags').select_related('box').distinct()[:50]
|
| Q(tags__facet__name__icontains=query)
|
||||||
|
)
|
||||||
|
.prefetch_related("files", "links", "tags")
|
||||||
|
.select_related("box")
|
||||||
|
.distinct()[:50]
|
||||||
|
)
|
||||||
|
|
||||||
results = [
|
results = [
|
||||||
{
|
{
|
||||||
'id': thing.id,
|
"id": thing.id,
|
||||||
'name': thing.name,
|
"name": thing.name,
|
||||||
'box': thing.box.id,
|
"box": thing.box.id,
|
||||||
'description': _strip_markdown(thing.description),
|
"description": _strip_markdown(thing.description),
|
||||||
'tags': [
|
"tags": [
|
||||||
{
|
{
|
||||||
'name': tag.name,
|
"name": tag.name,
|
||||||
'color': tag.facet.color,
|
"color": tag.facet.color,
|
||||||
}
|
}
|
||||||
for tag in thing.tags.all()
|
for tag in thing.tags.all()
|
||||||
],
|
],
|
||||||
'files': [
|
"files": [
|
||||||
{
|
{
|
||||||
'title': f.title,
|
"title": f.title,
|
||||||
'filename': f.filename(),
|
"filename": f.filename(),
|
||||||
}
|
}
|
||||||
for f in thing.files.all()
|
for f in thing.files.all()
|
||||||
],
|
],
|
||||||
'links': [
|
"links": [
|
||||||
{
|
{
|
||||||
'title': l.title,
|
"title": l.title,
|
||||||
'url': l.url,
|
"url": l.url,
|
||||||
}
|
}
|
||||||
for l in thing.links.all()
|
for l in thing.links.all()
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
for thing in things
|
for thing in things
|
||||||
]
|
]
|
||||||
return JsonResponse({'results': results})
|
return JsonResponse({"results": results})
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@@ -274,8 +304,10 @@ def add_things(request, box_id):
|
|||||||
|
|
||||||
success_message = None
|
success_message = None
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == "POST":
|
||||||
formset = ThingFormSet(request.POST, request.FILES, queryset=Thing.objects.filter(box=box))
|
formset = ThingFormSet(
|
||||||
|
request.POST, request.FILES, queryset=Thing.objects.filter(box=box)
|
||||||
|
)
|
||||||
|
|
||||||
if formset.is_valid():
|
if formset.is_valid():
|
||||||
things = formset.save(commit=False)
|
things = formset.save(commit=False)
|
||||||
@@ -286,167 +318,189 @@ def add_things(request, box_id):
|
|||||||
thing.save()
|
thing.save()
|
||||||
created_count += 1
|
created_count += 1
|
||||||
if created_count > 0:
|
if created_count > 0:
|
||||||
success_message = f'Added {created_count} thing{"s" if created_count > 1 else ""} successfully.'
|
success_message = f"Added {created_count} thing{'s' if created_count > 1 else ''} successfully."
|
||||||
formset = ThingFormSet(queryset=Thing.objects.filter(box=box))
|
formset = ThingFormSet(queryset=Thing.objects.filter(box=box))
|
||||||
else:
|
else:
|
||||||
formset = ThingFormSet(queryset=Thing.objects.filter(box=box))
|
formset = ThingFormSet(queryset=Thing.objects.filter(box=box))
|
||||||
|
|
||||||
return render(request, 'boxes/add_things.html', {
|
return render(
|
||||||
'box': box,
|
request,
|
||||||
'formset': formset,
|
"boxes/add_things.html",
|
||||||
'success_message': success_message,
|
{
|
||||||
})
|
"box": box,
|
||||||
|
"formset": formset,
|
||||||
|
"success_message": success_message,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def box_management(request):
|
def box_management(request):
|
||||||
"""Main page for managing boxes and box types."""
|
"""Main page for managing boxes and box types."""
|
||||||
box_types = BoxType.objects.all().prefetch_related('boxes')
|
box_types = BoxType.objects.all().prefetch_related("boxes")
|
||||||
boxes = Box.objects.select_related('box_type').all().prefetch_related('things')
|
boxes = Box.objects.select_related("box_type").all().prefetch_related("things")
|
||||||
box_type_form = BoxTypeForm()
|
box_type_form = BoxTypeForm()
|
||||||
box_form = BoxForm()
|
box_form = BoxForm()
|
||||||
|
|
||||||
return render(request, 'boxes/box_management.html', {
|
return render(
|
||||||
'box_types': box_types,
|
request,
|
||||||
'boxes': boxes,
|
"boxes/box_management.html",
|
||||||
'box_type_form': box_type_form,
|
{
|
||||||
'box_form': box_form,
|
"box_types": box_types,
|
||||||
})
|
"boxes": boxes,
|
||||||
|
"box_type_form": box_type_form,
|
||||||
|
"box_form": box_form,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def add_box_type(request):
|
def add_box_type(request):
|
||||||
"""Add a new box type."""
|
"""Add a new box type."""
|
||||||
if request.method == 'POST':
|
if request.method == "POST":
|
||||||
form = BoxTypeForm(request.POST)
|
form = BoxTypeForm(request.POST)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
form.save()
|
form.save()
|
||||||
return redirect('box_management')
|
return redirect("box_management")
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def edit_box_type(request, type_id):
|
def edit_box_type(request, type_id):
|
||||||
"""Edit an existing box type."""
|
"""Edit an existing box type."""
|
||||||
box_type = get_object_or_404(BoxType, pk=type_id)
|
box_type = get_object_or_404(BoxType, pk=type_id)
|
||||||
if request.method == 'POST':
|
if request.method == "POST":
|
||||||
form = BoxTypeForm(request.POST, instance=box_type)
|
form = BoxTypeForm(request.POST, instance=box_type)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
form.save()
|
form.save()
|
||||||
return redirect('box_management')
|
return redirect("box_management")
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def delete_box_type(request, type_id):
|
def delete_box_type(request, type_id):
|
||||||
"""Delete a box type."""
|
"""Delete a box type."""
|
||||||
box_type = get_object_or_404(BoxType, pk=type_id)
|
box_type = get_object_or_404(BoxType, pk=type_id)
|
||||||
if request.method == 'POST':
|
if request.method == "POST":
|
||||||
if box_type.boxes.exists():
|
if box_type.boxes.exists():
|
||||||
return redirect('box_management')
|
return redirect("box_management")
|
||||||
box_type.delete()
|
box_type.delete()
|
||||||
return redirect('box_management')
|
return redirect("box_management")
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def add_box(request):
|
def add_box(request):
|
||||||
"""Add a new box."""
|
"""Add a new box."""
|
||||||
if request.method == 'POST':
|
if request.method == "POST":
|
||||||
form = BoxForm(request.POST)
|
form = BoxForm(request.POST)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
form.save()
|
form.save()
|
||||||
return redirect('box_management')
|
return redirect("box_management")
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def edit_box(request, box_id):
|
def edit_box(request, box_id):
|
||||||
"""Edit an existing box."""
|
"""Edit an existing box."""
|
||||||
box = get_object_or_404(Box, pk=box_id)
|
box = get_object_or_404(Box, pk=box_id)
|
||||||
if request.method == 'POST':
|
if request.method == "POST":
|
||||||
form = BoxForm(request.POST, instance=box)
|
form = BoxForm(request.POST, instance=box)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
form.save()
|
form.save()
|
||||||
return redirect('box_management')
|
return redirect("box_management")
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def delete_box(request, box_id):
|
def delete_box(request, box_id):
|
||||||
"""Delete a box."""
|
"""Delete a box."""
|
||||||
box = get_object_or_404(Box, pk=box_id)
|
box = get_object_or_404(Box, pk=box_id)
|
||||||
if request.method == 'POST':
|
if request.method == "POST":
|
||||||
if box.things.exists():
|
if box.things.exists():
|
||||||
return redirect('box_management')
|
return redirect("box_management")
|
||||||
box.delete()
|
box.delete()
|
||||||
return redirect('box_management')
|
return redirect("box_management")
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def delete_thing(request, thing_id):
|
def delete_thing(request, thing_id):
|
||||||
"""Delete a thing and its associated files."""
|
"""Delete a thing and its associated files."""
|
||||||
thing = get_object_or_404(Thing, pk=thing_id)
|
thing = get_object_or_404(Thing, pk=thing_id)
|
||||||
if request.method == 'POST':
|
if request.method == "POST":
|
||||||
box_id = thing.box.id
|
box_id = thing.box.id
|
||||||
if thing.picture:
|
if thing.picture:
|
||||||
thing.picture.delete(save=False)
|
thing.picture.delete(save=False)
|
||||||
for thing_file in thing.files.all():
|
for thing_file in thing.files.all():
|
||||||
thing_file.file.delete(save=False)
|
thing_file.file.delete(save=False)
|
||||||
thing.delete()
|
thing.delete()
|
||||||
return redirect('box_detail', box_id=box_id)
|
return redirect("box_detail", box_id=box_id)
|
||||||
return redirect('edit_thing', thing_id=thing_id)
|
return redirect("edit_thing", thing_id=thing_id)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def resources_list(request):
|
def resources_list(request):
|
||||||
"""List all links and files from things that have them."""
|
"""List all links and files from things that have them."""
|
||||||
things_with_files = Thing.objects.filter(files__isnull=False).prefetch_related('files').distinct()
|
things_with_files = (
|
||||||
things_with_links = Thing.objects.filter(links__isnull=False).prefetch_related('links').distinct()
|
Thing.objects.filter(files__isnull=False).prefetch_related("files").distinct()
|
||||||
|
)
|
||||||
|
things_with_links = (
|
||||||
|
Thing.objects.filter(links__isnull=False).prefetch_related("links").distinct()
|
||||||
|
)
|
||||||
|
|
||||||
all_things = (things_with_files | things_with_links).distinct().order_by('name')
|
all_things = (things_with_files | things_with_links).distinct().order_by("name")
|
||||||
|
|
||||||
resources = []
|
resources = []
|
||||||
for thing in all_things.prefetch_related('files', 'links'):
|
for thing in all_things.prefetch_related("files", "links"):
|
||||||
for file in thing.files.all():
|
for file in thing.files.all():
|
||||||
resources.append({
|
resources.append(
|
||||||
'type': 'file',
|
{
|
||||||
'thing_name': thing.name,
|
"type": "file",
|
||||||
'thing_id': thing.id,
|
"thing_name": thing.name,
|
||||||
'title': file.title,
|
"thing_id": thing.id,
|
||||||
'url': file.file.url,
|
"title": file.title,
|
||||||
})
|
"url": file.file.url,
|
||||||
|
}
|
||||||
|
)
|
||||||
for link in thing.links.all():
|
for link in thing.links.all():
|
||||||
resources.append({
|
resources.append(
|
||||||
'type': 'link',
|
{
|
||||||
'thing_name': thing.name,
|
"type": "link",
|
||||||
'thing_id': thing.id,
|
"thing_name": thing.name,
|
||||||
'title': link.title,
|
"thing_id": thing.id,
|
||||||
'url': link.url,
|
"title": link.title,
|
||||||
})
|
"url": link.url,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return render(request, 'boxes/resources_list.html', {
|
return render(
|
||||||
'resources': resources,
|
request,
|
||||||
})
|
"boxes/resources_list.html",
|
||||||
|
{
|
||||||
|
"resources": resources,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def fixme(request):
|
def fixme(request):
|
||||||
"""Page to find and fix things missing tags for specific facets."""
|
"""Page to find and fix things missing tags for specific facets."""
|
||||||
facets = Facet.objects.all().prefetch_related('tags')
|
facets = Facet.objects.all().prefetch_related("tags")
|
||||||
|
|
||||||
selected_facet = None
|
selected_facet = None
|
||||||
missing_things = []
|
missing_things = []
|
||||||
|
|
||||||
if request.method == 'GET' and 'facet_id' in request.GET:
|
if request.method == "GET" and "facet_id" in request.GET:
|
||||||
try:
|
try:
|
||||||
selected_facet = Facet.objects.get(pk=request.GET['facet_id'])
|
selected_facet = Facet.objects.get(pk=request.GET["facet_id"])
|
||||||
# Find things that don't have any tag from this facet
|
# Find things that don't have any tag from this facet
|
||||||
missing_things = Thing.objects.exclude(
|
missing_things = (
|
||||||
tags__facet=selected_facet
|
Thing.objects.exclude(tags__facet=selected_facet)
|
||||||
).select_related('box', 'box__box_type').prefetch_related('tags')
|
.select_related("box", "box__box_type")
|
||||||
|
.prefetch_related("tags")
|
||||||
|
)
|
||||||
except Facet.DoesNotExist:
|
except Facet.DoesNotExist:
|
||||||
selected_facet = None
|
selected_facet = None
|
||||||
|
|
||||||
elif request.method == 'POST':
|
elif request.method == "POST":
|
||||||
facet_id = request.POST.get('facet_id')
|
facet_id = request.POST.get("facet_id")
|
||||||
tag_ids = request.POST.getlist('tag_ids')
|
tag_ids = request.POST.getlist("tag_ids")
|
||||||
thing_ids = request.POST.getlist('thing_ids')
|
thing_ids = request.POST.getlist("thing_ids")
|
||||||
|
|
||||||
if facet_id and tag_ids and thing_ids:
|
if facet_id and tag_ids and thing_ids:
|
||||||
facet = get_object_or_404(Facet, pk=facet_id)
|
facet = get_object_or_404(Facet, pk=facet_id)
|
||||||
@@ -462,10 +516,14 @@ def fixme(request):
|
|||||||
if tag.facet == facet:
|
if tag.facet == facet:
|
||||||
thing.tags.add(tag)
|
thing.tags.add(tag)
|
||||||
|
|
||||||
return redirect('fixme')
|
return redirect("fixme")
|
||||||
|
|
||||||
return render(request, 'boxes/fixme.html', {
|
return render(
|
||||||
'facets': facets,
|
request,
|
||||||
'selected_facet': selected_facet,
|
"boxes/fixme.html",
|
||||||
'missing_things': missing_things,
|
{
|
||||||
})
|
"facets": facets,
|
||||||
|
"selected_facet": selected_facet,
|
||||||
|
"missing_things": missing_things,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
@@ -21,71 +21,76 @@ BASE_DIR = Path(__file__).resolve().parent.parent
|
|||||||
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
|
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
|
||||||
|
|
||||||
# SECURITY WARNING: keep the secret key used in production secret!
|
# SECURITY WARNING: keep the secret key used in production secret!
|
||||||
SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY', 'f0arjg8q3ut4iuqrguqfjaruf0eripIZZN3t1kymy8ugqnj$li2knhha0@gc5v8f3bge=$+gbybj2$jt28uqm')
|
SECRET_KEY = os.environ.get(
|
||||||
|
"DJANGO_SECRET_KEY",
|
||||||
|
"f0arjg8q3ut4iuqrguqfjaruf0eripIZZN3t1kymy8ugqnj$li2knhha0@gc5v8f3bge=$+gbybj2$jt28uqm",
|
||||||
|
)
|
||||||
|
|
||||||
# SECURITY WARNING: don't run with debug turned on in production!
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
DEBUG = os.environ.get('DEBUG', 'True').lower() == 'true'
|
DEBUG = os.environ.get("DEBUG", "True").lower() == "true"
|
||||||
|
|
||||||
ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '*').split(',')
|
ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "*").split(",")
|
||||||
ALLOWED_CIDR_NETS = os.environ.get('ALLOWED_CIDR_NETS', '10.0.0.0/16').split(',')
|
ALLOWED_CIDR_NETS = os.environ.get(
|
||||||
|
"ALLOWED_CIDR_NETS", "10.0.0.0/16,192.168.0.0/16"
|
||||||
|
).split(",")
|
||||||
|
|
||||||
|
|
||||||
# Application definition
|
# Application definition
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
'django.contrib.admin',
|
"django.contrib.admin",
|
||||||
'django.contrib.auth',
|
"django.contrib.auth",
|
||||||
'django.contrib.contenttypes',
|
"django.contrib.contenttypes",
|
||||||
'django.contrib.sessions',
|
"django.contrib.sessions",
|
||||||
'django.contrib.messages',
|
"django.contrib.messages",
|
||||||
'django.contrib.staticfiles',
|
"django.contrib.staticfiles",
|
||||||
'mozilla_django_oidc',
|
"mozilla_django_oidc",
|
||||||
'mptt',
|
"mptt",
|
||||||
'django_mptt_admin',
|
"django_mptt_admin",
|
||||||
'sorl.thumbnail',
|
"sorl.thumbnail",
|
||||||
'boxes',
|
"boxes",
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
'django.middleware.security.SecurityMiddleware',
|
"django.middleware.security.SecurityMiddleware",
|
||||||
'whitenoise.middleware.WhiteNoiseMiddleware',
|
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
'django.middleware.common.CommonMiddleware',
|
"django.middleware.common.CommonMiddleware",
|
||||||
'django.middleware.csrf.CsrfViewMiddleware',
|
"django.middleware.csrf.CsrfViewMiddleware",
|
||||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
'mozilla_django_oidc.middleware.SessionRefresh',
|
"mozilla_django_oidc.middleware.SessionRefresh",
|
||||||
'django.contrib.messages.middleware.MessageMiddleware',
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
]
|
]
|
||||||
|
|
||||||
ROOT_URLCONF = 'labhelper.urls'
|
ROOT_URLCONF = "labhelper.urls"
|
||||||
|
|
||||||
TEMPLATES = [
|
TEMPLATES = [
|
||||||
{
|
{
|
||||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||||
'DIRS': [BASE_DIR / 'labhelper' / 'templates'],
|
"DIRS": [BASE_DIR / "labhelper" / "templates"],
|
||||||
'APP_DIRS': True,
|
"APP_DIRS": True,
|
||||||
'OPTIONS': {
|
"OPTIONS": {
|
||||||
'context_processors': [
|
"context_processors": [
|
||||||
'django.template.context_processors.request',
|
"django.template.context_processors.request",
|
||||||
'django.contrib.auth.context_processors.auth',
|
"django.contrib.auth.context_processors.auth",
|
||||||
'django.contrib.messages.context_processors.messages',
|
"django.contrib.messages.context_processors.messages",
|
||||||
'labhelper.context_processors.image_tag',
|
"labhelper.context_processors.image_tag",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
WSGI_APPLICATION = 'labhelper.wsgi.application'
|
WSGI_APPLICATION = "labhelper.wsgi.application"
|
||||||
|
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
|
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
|
||||||
|
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
'default': {
|
"default": {
|
||||||
'ENGINE': 'django.db.backends.sqlite3',
|
"ENGINE": "django.db.backends.sqlite3",
|
||||||
'NAME': BASE_DIR / 'data' / 'db.sqlite3',
|
"NAME": BASE_DIR / "data" / "db.sqlite3",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,16 +100,16 @@ DATABASES = {
|
|||||||
|
|
||||||
AUTH_PASSWORD_VALIDATORS = [
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
{
|
{
|
||||||
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -112,43 +117,45 @@ AUTH_PASSWORD_VALIDATORS = [
|
|||||||
# Internationalization
|
# Internationalization
|
||||||
# https://docs.djangoproject.com/en/5.2/topics/i18n/
|
# https://docs.djangoproject.com/en/5.2/topics/i18n/
|
||||||
|
|
||||||
LANGUAGE_CODE = os.environ.get('LANGUAGE_CODE', 'en-us')
|
LANGUAGE_CODE = os.environ.get("LANGUAGE_CODE", "en-us")
|
||||||
|
|
||||||
TIME_ZONE = os.environ.get('TIME_ZONE', 'UTC')
|
TIME_ZONE = os.environ.get("TIME_ZONE", "UTC")
|
||||||
|
|
||||||
USE_I18N = os.environ.get('USE_I18N', 'True').lower() == 'true'
|
USE_I18N = os.environ.get("USE_I18N", "True").lower() == "true"
|
||||||
|
|
||||||
USE_TZ = os.environ.get('USE_TZ', 'True').lower() == 'true'
|
USE_TZ = os.environ.get("USE_TZ", "True").lower() == "true"
|
||||||
|
|
||||||
|
|
||||||
# Static files (CSS, JavaScript, Images)
|
# Static files (CSS, JavaScript, Images)
|
||||||
# https://docs.djangoproject.com/en/5.2/howto/static-files/
|
# https://docs.djangoproject.com/en/5.2/howto/static-files/
|
||||||
|
|
||||||
STATIC_URL = os.environ.get('STATIC_URL', '/static/')
|
STATIC_URL = os.environ.get("STATIC_URL", "/static/")
|
||||||
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
STATIC_ROOT = BASE_DIR / "staticfiles"
|
||||||
|
|
||||||
# WhiteNoise static file serving configuration
|
# WhiteNoise static file serving configuration
|
||||||
STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'
|
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
|
||||||
|
|
||||||
# Media files (user uploads)
|
# Media files (user uploads)
|
||||||
MEDIA_URL = os.environ.get('MEDIA_URL', '/media/')
|
MEDIA_URL = os.environ.get("MEDIA_URL", "/media/")
|
||||||
MEDIA_ROOT = BASE_DIR / 'data' / 'media'
|
MEDIA_ROOT = BASE_DIR / "data" / "media"
|
||||||
|
|
||||||
# Default primary key field type
|
# Default primary key field type
|
||||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
|
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
|
||||||
|
|
||||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||||
|
|
||||||
CSRF_TRUSTED_ORIGINS=os.environ.get('CSRF_TRUSTED_ORIGINS', 'https://labhelper.adebaumann.com,http://127.0.0.1:8000').split(',')
|
CSRF_TRUSTED_ORIGINS = os.environ.get(
|
||||||
|
"CSRF_TRUSTED_ORIGINS", "https://labhelper.adebaumann.com,http://127.0.0.1:8000"
|
||||||
|
).split(",")
|
||||||
|
|
||||||
LOGIN_URL = os.environ.get('LOGIN_URL', 'oidc_authentication_init')
|
LOGIN_URL = os.environ.get("LOGIN_URL", "oidc_authentication_init")
|
||||||
LOGIN_REDIRECT_URL = os.environ.get('LOGIN_REDIRECT_URL', 'index')
|
LOGIN_REDIRECT_URL = os.environ.get("LOGIN_REDIRECT_URL", "index")
|
||||||
LOGOUT_REDIRECT_URL = os.environ.get('LOGOUT_REDIRECT_URL', 'login')
|
LOGOUT_REDIRECT_URL = os.environ.get("LOGOUT_REDIRECT_URL", "login")
|
||||||
|
|
||||||
AUTHENTICATION_BACKENDS = [
|
AUTHENTICATION_BACKENDS = [
|
||||||
'labhelper.auth_backend.KeycloakOIDCBackend',
|
"labhelper.auth_backend.KeycloakOIDCBackend",
|
||||||
# ModelBackend kept as fallback for Django admin emergency access
|
# ModelBackend kept as fallback for Django admin emergency access
|
||||||
'django.contrib.auth.backends.ModelBackend',
|
"django.contrib.auth.backends.ModelBackend",
|
||||||
]
|
]
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -160,26 +167,40 @@ AUTHENTICATION_BACKENDS = [
|
|||||||
# All individual endpoints are derived from OIDC_OP_BASE_URL automatically.
|
# All individual endpoints are derived from OIDC_OP_BASE_URL automatically.
|
||||||
# You can override any individual endpoint with its own env var.
|
# You can override any individual endpoint with its own env var.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
_oidc_base = os.environ.get('OIDC_OP_BASE_URL', '').rstrip('/')
|
_oidc_base = os.environ.get("OIDC_OP_BASE_URL", "").rstrip("/")
|
||||||
_oidc_connect = f'{_oidc_base}/protocol/openid-connect' if _oidc_base else ''
|
_oidc_connect = f"{_oidc_base}/protocol/openid-connect" if _oidc_base else ""
|
||||||
|
|
||||||
OIDC_RP_CLIENT_ID = os.environ.get('OIDC_RP_CLIENT_ID', '')
|
OIDC_RP_CLIENT_ID = os.environ.get("OIDC_RP_CLIENT_ID", "")
|
||||||
OIDC_RP_CLIENT_SECRET = os.environ.get('OIDC_RP_CLIENT_SECRET', '')
|
OIDC_RP_CLIENT_SECRET = os.environ.get("OIDC_RP_CLIENT_SECRET", "")
|
||||||
OIDC_RP_SIGN_ALGO = 'RS256'
|
OIDC_RP_SIGN_ALGO = "RS256"
|
||||||
OIDC_RP_SCOPES = 'openid email profile'
|
OIDC_RP_SCOPES = "openid email profile"
|
||||||
OIDC_USE_PKCE = True
|
OIDC_USE_PKCE = True
|
||||||
|
|
||||||
OIDC_OP_AUTHORIZATION_ENDPOINT = os.environ.get('OIDC_OP_AUTHORIZATION_ENDPOINT', f'{_oidc_connect}/auth')
|
OIDC_OP_AUTHORIZATION_ENDPOINT = os.environ.get(
|
||||||
OIDC_OP_TOKEN_ENDPOINT = os.environ.get('OIDC_OP_TOKEN_ENDPOINT', f'{_oidc_connect}/token')
|
"OIDC_OP_AUTHORIZATION_ENDPOINT", f"{_oidc_connect}/auth"
|
||||||
OIDC_OP_USER_ENDPOINT = os.environ.get('OIDC_OP_USER_ENDPOINT', f'{_oidc_connect}/userinfo')
|
)
|
||||||
OIDC_OP_JWKS_ENDPOINT = os.environ.get('OIDC_OP_JWKS_ENDPOINT', f'{_oidc_connect}/certs')
|
OIDC_OP_TOKEN_ENDPOINT = os.environ.get(
|
||||||
OIDC_OP_LOGOUT_ENDPOINT = os.environ.get('OIDC_OP_LOGOUT_ENDPOINT', f'{_oidc_connect}/logout')
|
"OIDC_OP_TOKEN_ENDPOINT", f"{_oidc_connect}/token"
|
||||||
|
)
|
||||||
|
OIDC_OP_USER_ENDPOINT = os.environ.get(
|
||||||
|
"OIDC_OP_USER_ENDPOINT", f"{_oidc_connect}/userinfo"
|
||||||
|
)
|
||||||
|
OIDC_OP_JWKS_ENDPOINT = os.environ.get(
|
||||||
|
"OIDC_OP_JWKS_ENDPOINT", f"{_oidc_connect}/certs"
|
||||||
|
)
|
||||||
|
OIDC_OP_LOGOUT_ENDPOINT = os.environ.get(
|
||||||
|
"OIDC_OP_LOGOUT_ENDPOINT", f"{_oidc_connect}/logout"
|
||||||
|
)
|
||||||
|
|
||||||
# Store the ID token in the session so Keycloak logout can use id_token_hint
|
# Store the ID token in the session so Keycloak logout can use id_token_hint
|
||||||
OIDC_STORE_ID_TOKEN = True
|
OIDC_STORE_ID_TOKEN = True
|
||||||
|
|
||||||
# Redirect to the static login page on auth failure instead of looping back into OIDC
|
# Redirect to the static login page on auth failure instead of looping back into OIDC
|
||||||
OIDC_AUTHENTICATION_FAILURE_REDIRECT_URL = os.environ.get('OIDC_AUTHENTICATION_FAILURE_REDIRECT_URL', '/login/')
|
OIDC_AUTHENTICATION_FAILURE_REDIRECT_URL = os.environ.get(
|
||||||
|
"OIDC_AUTHENTICATION_FAILURE_REDIRECT_URL", "/login/"
|
||||||
|
)
|
||||||
|
|
||||||
# Exempt AJAX endpoints from the session-refresh middleware redirect
|
# Exempt AJAX endpoints and media files from the session-refresh middleware redirect
|
||||||
OIDC_EXEMPT_URLS = ['search_api']
|
import re
|
||||||
|
|
||||||
|
OIDC_EXEMPT_URLS = ["search_api", "health_check", re.compile(r"^/media/")]
|
||||||
|
|||||||
Reference in New Issue
Block a user