Compare commits
31 Commits
improvemen
...
88c1dd704f
| Author | SHA1 | Date | |
|---|---|---|---|
|
88c1dd704f
|
|||
|
ed47530c3c
|
|||
|
e537ec2ac0
|
|||
|
ec102dd1cc
|
|||
|
7bae0d12de
|
|||
|
dbfb38bb8a
|
|||
|
20239242ce
|
|||
|
60e13822ee
|
|||
|
4d492ded4e
|
|||
|
3b53967c40
|
|||
|
65868c043e
|
|||
|
5c9b45715b
|
|||
|
bbed20813a
|
|||
| 22f5b87a20 | |||
| 1dede761e3 | |||
| 860e80a552 | |||
| a1bc7967c5 | |||
| 2a825646a3 | |||
| 7c990998a4 | |||
| bf62ddcbd7 | |||
| 985460ff84 | |||
| 2705f6c16e | |||
| 887247028a | |||
| 4cbd3e2f87 | |||
| 935392d27d | |||
| 30657be6c2 | |||
| 4bca3ae403 | |||
| db80ddf069 | |||
| 0602347539 | |||
| 384c0d58e6 | |||
| 7e96fcef8b |
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.
|
|
||||||
@@ -34,6 +34,7 @@ WORKDIR /app
|
|||||||
COPY --chown=appuser:appuser . .
|
COPY --chown=appuser:appuser . .
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
ENV IMAGE_TAG=build
|
||||||
USER appuser
|
USER appuser
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
RUN rm -rvf /app/Dockerfile* \
|
RUN rm -rvf /app/Dockerfile* \
|
||||||
@@ -47,5 +48,5 @@ RUN rm -rvf /app/Dockerfile* \
|
|||||||
/app/*.json \
|
/app/*.json \
|
||||||
/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 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"]
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ metadata:
|
|||||||
spec:
|
spec:
|
||||||
accessModes:
|
accessModes:
|
||||||
- ReadWriteMany
|
- ReadWriteMany
|
||||||
storageClassName: nfs-labhelper
|
storageClassName: nfs
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
storage: 2Gi
|
storage: 2Gi
|
||||||
|
|||||||
21
argocd/configmap.yaml
Normal file
21
argocd/configmap.yaml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: django-config
|
||||||
|
namespace: labhelper
|
||||||
|
data:
|
||||||
|
DEBUG: "False"
|
||||||
|
ALLOWED_HOSTS: "labhelper.adebaumann.com,*"
|
||||||
|
ALLOWED_CIDR_NETS: "10.0.0.0/16"
|
||||||
|
LANGUAGE_CODE: "en-us"
|
||||||
|
TIME_ZONE: "UTC"
|
||||||
|
USE_I18N: "True"
|
||||||
|
USE_TZ: "True"
|
||||||
|
STATIC_URL: "/static/"
|
||||||
|
MEDIA_URL: "/media/"
|
||||||
|
CSRF_TRUSTED_ORIGINS: "https://labhelper.adebaumann.com"
|
||||||
|
LOGIN_URL: "login"
|
||||||
|
LOGIN_REDIRECT_URL: "index"
|
||||||
|
LOGOUT_REDIRECT_URL: "login"
|
||||||
|
GUNICORN_OPTS: "--access-logfile -"
|
||||||
|
IMAGE_TAG: "0.076"
|
||||||
@@ -27,7 +27,7 @@ spec:
|
|||||||
mountPath: /data
|
mountPath: /data
|
||||||
containers:
|
containers:
|
||||||
- name: web
|
- name: web
|
||||||
image: git.baumann.gr/adebaumann/labhelper:0.058
|
image: git.baumann.gr/adebaumann/labhelper:0.076
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 8000
|
- containerPort: 8000
|
||||||
@@ -37,6 +37,81 @@ spec:
|
|||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: django-secret
|
name: django-secret
|
||||||
key: secret-key
|
key: secret-key
|
||||||
|
- name: DEBUG
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: django-config
|
||||||
|
key: DEBUG
|
||||||
|
- name: ALLOWED_HOSTS
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: django-config
|
||||||
|
key: ALLOWED_HOSTS
|
||||||
|
- name: ALLOWED_CIDR_NETS
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: django-config
|
||||||
|
key: ALLOWED_CIDR_NETS
|
||||||
|
- name: LANGUAGE_CODE
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: django-config
|
||||||
|
key: LANGUAGE_CODE
|
||||||
|
- name: TIME_ZONE
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: django-config
|
||||||
|
key: TIME_ZONE
|
||||||
|
- name: USE_I18N
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: django-config
|
||||||
|
key: USE_I18N
|
||||||
|
- name: USE_TZ
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: django-config
|
||||||
|
key: USE_TZ
|
||||||
|
- name: STATIC_URL
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: django-config
|
||||||
|
key: STATIC_URL
|
||||||
|
- name: MEDIA_URL
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: django-config
|
||||||
|
key: MEDIA_URL
|
||||||
|
- name: CSRF_TRUSTED_ORIGINS
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: django-config
|
||||||
|
key: CSRF_TRUSTED_ORIGINS
|
||||||
|
- name: LOGIN_URL
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: django-config
|
||||||
|
key: LOGIN_URL
|
||||||
|
- name: LOGIN_REDIRECT_URL
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: django-config
|
||||||
|
key: LOGIN_REDIRECT_URL
|
||||||
|
- name: LOGOUT_REDIRECT_URL
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: django-config
|
||||||
|
key: LOGOUT_REDIRECT_URL
|
||||||
|
- name: GUNICORN_OPTS
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: django-config
|
||||||
|
key: GUNICORN_OPTS
|
||||||
|
- name: IMAGE_TAG
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: django-config
|
||||||
|
key: IMAGE_TAG
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: data
|
- name: data
|
||||||
mountPath: /app/data
|
mountPath: /app/data
|
||||||
|
|||||||
@@ -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"
|
||||||
spec:
|
spec:
|
||||||
ingressClassName: traefik
|
ingressClassName: traefik
|
||||||
rules:
|
rules:
|
||||||
|
|||||||
@@ -4,12 +4,15 @@ metadata:
|
|||||||
name: labhelper-data-pv
|
name: labhelper-data-pv
|
||||||
namespace: labhelper
|
namespace: labhelper
|
||||||
spec:
|
spec:
|
||||||
|
claimRef:
|
||||||
|
name: labhelper-data-pvc
|
||||||
|
namespace: labhelper
|
||||||
capacity:
|
capacity:
|
||||||
storage: 2Gi
|
storage: 2Gi
|
||||||
accessModes:
|
accessModes:
|
||||||
- ReadWriteMany
|
- ReadWriteMany
|
||||||
persistentVolumeReclaimPolicy: Retain
|
persistentVolumeReclaimPolicy: Retain
|
||||||
storageClassName: nfs-labhelper
|
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 +0,0 @@
|
|||||||
apiVersion: storage.k8s.io/v1
|
|
||||||
kind: StorageClass
|
|
||||||
metadata:
|
|
||||||
name: nfs-labhelper
|
|
||||||
provisioner: kubernetes.io/no-provisioner
|
|
||||||
allowVolumeExpansion: true
|
|
||||||
reclaimPolicy: Retain
|
|
||||||
volumeBindingMode: Immediate
|
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
|
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
|
||||||
|
|
||||||
@@ -13,7 +18,7 @@ class BoxFilter(SimpleListFilter):
|
|||||||
parameter_name = 'box__pk'
|
parameter_name = 'box__pk'
|
||||||
|
|
||||||
def lookups(self, request, model_admin):
|
def lookups(self, request, model_admin):
|
||||||
boxes = Box.objects.select_related('box_type').order_by('id')
|
boxes = Box.objects.select_related('box_type').order_by('sort_order')
|
||||||
return [(box.pk, str(box)) for box in boxes]
|
return [(box.pk, str(box)) for box in boxes]
|
||||||
|
|
||||||
def queryset(self, request, queryset):
|
def queryset(self, request, queryset):
|
||||||
@@ -50,9 +55,34 @@ class BoxTypeAdmin(admin.ModelAdmin):
|
|||||||
class BoxAdmin(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):
|
||||||
|
|||||||
@@ -4,65 +4,83 @@ from django.conf import settings
|
|||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from django.db.models import F
|
from django.db.models import F
|
||||||
from sorl.thumbnail.models import KVStore
|
from sorl.thumbnail.models import KVStore
|
||||||
from boxes.models import Thing
|
from boxes.models import Thing, ThingFile
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = 'Clean up orphaned images and thumbnails from deleted things'
|
help = "Clean up orphaned images, files, and thumbnails from deleted things"
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
def add_arguments(self, parser):
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
'--dry-run',
|
"--dry-run",
|
||||||
action='store_true',
|
action="store_true",
|
||||||
dest='dry_run',
|
dest="dry_run",
|
||||||
help='Show what would be deleted without actually deleting',
|
help="Show what would be deleted without actually deleting",
|
||||||
)
|
)
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
dry_run = options.get('dry_run', False)
|
dry_run = options.get("dry_run", False)
|
||||||
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
self.stdout.write(self.style.WARNING('DRY RUN - No files will be deleted'))
|
self.stdout.write(self.style.WARNING("DRY RUN - No files will be deleted"))
|
||||||
|
|
||||||
self.stdout.write('Finding orphaned images and thumbnails...')
|
self.stdout.write("Finding orphaned images and thumbnails...")
|
||||||
|
|
||||||
media_root = settings.MEDIA_ROOT
|
media_root = settings.MEDIA_ROOT
|
||||||
cache_root = os.path.join(media_root, 'cache')
|
cache_root = os.path.join(media_root, "cache")
|
||||||
things_root = os.path.join(media_root, 'things')
|
things_root = os.path.join(media_root, "things")
|
||||||
|
|
||||||
if not os.path.exists(things_root):
|
if not os.path.exists(things_root):
|
||||||
self.stdout.write(self.style.WARNING('No things directory found'))
|
self.stdout.write(self.style.WARNING("No things directory found"))
|
||||||
return
|
return
|
||||||
|
|
||||||
valid_paths = set()
|
valid_paths = set()
|
||||||
for thing in Thing.objects.exclude(picture__exact='').exclude(picture__isnull=True):
|
for thing in Thing.objects.exclude(picture__exact="").exclude(
|
||||||
|
picture__isnull=True
|
||||||
|
):
|
||||||
if thing.picture:
|
if thing.picture:
|
||||||
valid_paths.add(os.path.basename(thing.picture.name))
|
valid_paths.add(os.path.basename(thing.picture.name))
|
||||||
|
|
||||||
self.stdout.write(f'Found {len(valid_paths)} valid images in database')
|
for thing_file in ThingFile.objects.all():
|
||||||
|
if thing_file.file:
|
||||||
|
if thing_file.file.name.startswith("things/"):
|
||||||
|
relative_path = thing_file.file.name[7:]
|
||||||
|
valid_paths.add(relative_path)
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
f"Found {len(valid_paths)} valid images and files in database"
|
||||||
|
)
|
||||||
|
|
||||||
orphaned_thumbnail_paths = set()
|
orphaned_thumbnail_paths = set()
|
||||||
db_cache_paths = set()
|
db_cache_paths = set()
|
||||||
|
|
||||||
for kvstore in KVStore.objects.filter(key__startswith='sorl-thumbnail||image||'):
|
for kvstore in KVStore.objects.filter(
|
||||||
|
key__startswith="sorl-thumbnail||image||"
|
||||||
|
):
|
||||||
try:
|
try:
|
||||||
data = json.loads(kvstore.value)
|
data = json.loads(kvstore.value)
|
||||||
name = data.get('name', '')
|
name = data.get("name", "")
|
||||||
if name.startswith('things/'):
|
if name.startswith("things/"):
|
||||||
filename = os.path.basename(name)
|
filename = os.path.basename(name)
|
||||||
if filename not in valid_paths:
|
if filename not in valid_paths:
|
||||||
image_hash = kvstore.key.split('||')[-1]
|
image_hash = kvstore.key.split("||")[-1]
|
||||||
thumbnail_kvstore = KVStore.objects.filter(key=f'sorl-thumbnail||thumbnails||{image_hash}').first()
|
thumbnail_kvstore = KVStore.objects.filter(
|
||||||
|
key=f"sorl-thumbnail||thumbnails||{image_hash}"
|
||||||
|
).first()
|
||||||
if thumbnail_kvstore:
|
if thumbnail_kvstore:
|
||||||
thumbnail_list = json.loads(thumbnail_kvstore.value)
|
thumbnail_list = json.loads(thumbnail_kvstore.value)
|
||||||
for thumbnail_hash in thumbnail_list:
|
for thumbnail_hash in thumbnail_list:
|
||||||
thumbnail_image_kvstore = KVStore.objects.filter(key=f'sorl-thumbnail||image||{thumbnail_hash}').first()
|
thumbnail_image_kvstore = KVStore.objects.filter(
|
||||||
|
key=f"sorl-thumbnail||image||{thumbnail_hash}"
|
||||||
|
).first()
|
||||||
if thumbnail_image_kvstore:
|
if thumbnail_image_kvstore:
|
||||||
thumbnail_data = json.loads(thumbnail_image_kvstore.value)
|
thumbnail_data = json.loads(
|
||||||
thumbnail_path = thumbnail_data.get('name', '')
|
thumbnail_image_kvstore.value
|
||||||
if thumbnail_path.startswith('cache/'):
|
)
|
||||||
|
thumbnail_path = thumbnail_data.get("name", "")
|
||||||
|
if thumbnail_path.startswith("cache/"):
|
||||||
orphaned_thumbnail_paths.add(thumbnail_path)
|
orphaned_thumbnail_paths.add(thumbnail_path)
|
||||||
elif name.startswith('cache/'):
|
elif name.startswith("cache/"):
|
||||||
db_cache_paths.add(name)
|
db_cache_paths.add(name)
|
||||||
except (json.JSONDecodeError, KeyError, AttributeError):
|
except (json.JSONDecodeError, KeyError, AttributeError):
|
||||||
pass
|
pass
|
||||||
@@ -79,26 +97,30 @@ class Command(BaseCommand):
|
|||||||
if relative_path not in valid_paths:
|
if relative_path not in valid_paths:
|
||||||
deleted_count += 1
|
deleted_count += 1
|
||||||
if dry_run:
|
if dry_run:
|
||||||
self.stdout.write(f'Would delete: {file_path}')
|
self.stdout.write(f"Would delete: {file_path}")
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
os.remove(file_path)
|
os.remove(file_path)
|
||||||
self.stdout.write(f'Deleted: {file_path}')
|
self.stdout.write(f"Deleted: {file_path}")
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
self.stdout.write(self.style.ERROR(f'Failed to delete {file_path}: {e}'))
|
self.stdout.write(
|
||||||
|
self.style.ERROR(f"Failed to delete {file_path}: {e}")
|
||||||
|
)
|
||||||
|
|
||||||
for dirname in dirs:
|
for dirname in dirs:
|
||||||
dir_path = os.path.join(root, dirname)
|
dir_path = os.path.join(root, dirname)
|
||||||
if not os.listdir(dir_path):
|
if not os.listdir(dir_path):
|
||||||
if dry_run:
|
if dry_run:
|
||||||
self.stdout.write(f'Would remove empty directory: {dir_path}')
|
self.stdout.write(f"Would remove empty directory: {dir_path}")
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
os.rmdir(dir_path)
|
os.rmdir(dir_path)
|
||||||
self.stdout.write(f'Removed empty directory: {dir_path}')
|
self.stdout.write(f"Removed empty directory: {dir_path}")
|
||||||
empty_dirs_removed += 1
|
empty_dirs_removed += 1
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
self.stdout.write(self.style.ERROR(f'Failed to remove {dir_path}: {e}'))
|
self.stdout.write(
|
||||||
|
self.style.ERROR(f"Failed to remove {dir_path}: {e}")
|
||||||
|
)
|
||||||
|
|
||||||
if os.path.exists(cache_root):
|
if os.path.exists(cache_root):
|
||||||
for root, dirs, files in os.walk(cache_root, topdown=False):
|
for root, dirs, files in os.walk(cache_root, topdown=False):
|
||||||
@@ -109,39 +131,69 @@ class Command(BaseCommand):
|
|||||||
if relative_path in orphaned_thumbnail_paths:
|
if relative_path in orphaned_thumbnail_paths:
|
||||||
thumbnail_deleted_count += 1
|
thumbnail_deleted_count += 1
|
||||||
if dry_run:
|
if dry_run:
|
||||||
self.stdout.write(f'Would delete thumbnail (orphaned image): {file_path}')
|
self.stdout.write(
|
||||||
|
f"Would delete thumbnail (orphaned image): {file_path}"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
os.remove(file_path)
|
os.remove(file_path)
|
||||||
self.stdout.write(f'Deleted thumbnail (orphaned image): {file_path}')
|
self.stdout.write(
|
||||||
|
f"Deleted thumbnail (orphaned image): {file_path}"
|
||||||
|
)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
self.stdout.write(self.style.ERROR(f'Failed to delete {file_path}: {e}'))
|
self.stdout.write(
|
||||||
|
self.style.ERROR(
|
||||||
|
f"Failed to delete {file_path}: {e}"
|
||||||
|
)
|
||||||
|
)
|
||||||
elif relative_path not in db_cache_paths:
|
elif relative_path not in db_cache_paths:
|
||||||
thumbnail_deleted_count += 1
|
thumbnail_deleted_count += 1
|
||||||
if dry_run:
|
if dry_run:
|
||||||
self.stdout.write(f'Would delete thumbnail (no db entry): {file_path}')
|
self.stdout.write(
|
||||||
|
f"Would delete thumbnail (no db entry): {file_path}"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
os.remove(file_path)
|
os.remove(file_path)
|
||||||
self.stdout.write(f'Deleted thumbnail (no db entry): {file_path}')
|
self.stdout.write(
|
||||||
|
f"Deleted thumbnail (no db entry): {file_path}"
|
||||||
|
)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
self.stdout.write(self.style.ERROR(f'Failed to delete {file_path}: {e}'))
|
self.stdout.write(
|
||||||
|
self.style.ERROR(
|
||||||
|
f"Failed to delete {file_path}: {e}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
for dirname in dirs:
|
for dirname in dirs:
|
||||||
dir_path = os.path.join(root, dirname)
|
dir_path = os.path.join(root, dirname)
|
||||||
if not os.listdir(dir_path):
|
if not os.listdir(dir_path):
|
||||||
if dry_run:
|
if dry_run:
|
||||||
self.stdout.write(f'Would remove empty cache directory: {dir_path}')
|
self.stdout.write(
|
||||||
|
f"Would remove empty cache directory: {dir_path}"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
os.rmdir(dir_path)
|
os.rmdir(dir_path)
|
||||||
empty_dirs_removed += 1
|
empty_dirs_removed += 1
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
self.stdout.write(self.style.ERROR(f'Failed to remove {dir_path}: {e}'))
|
self.stdout.write(
|
||||||
|
self.style.ERROR(
|
||||||
|
f"Failed to remove {dir_path}: {e}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
self.stdout.write(self.style.WARNING(f'\nDry run complete. Would delete {deleted_count} images and {thumbnail_deleted_count} thumbnails'))
|
self.stdout.write(
|
||||||
self.stdout.write(f'Would remove {empty_dirs_removed} empty directories')
|
self.style.WARNING(
|
||||||
|
f"\nDry run complete. Would delete {deleted_count} files and {thumbnail_deleted_count} thumbnails"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.stdout.write(f"Would remove {empty_dirs_removed} empty directories")
|
||||||
else:
|
else:
|
||||||
self.stdout.write(self.style.SUCCESS(f'\nCleanup complete! Deleted {deleted_count} images and {thumbnail_deleted_count} thumbnails'))
|
self.stdout.write(
|
||||||
self.stdout.write(f'Removed {empty_dirs_removed} empty directories')
|
self.style.SUCCESS(
|
||||||
|
f"\nCleanup complete! Deleted {deleted_count} files and {thumbnail_deleted_count} thumbnails"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.stdout.write(f"Removed {empty_dirs_removed} empty directories")
|
||||||
|
|||||||
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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -41,9 +41,15 @@ class Box(models.Model):
|
|||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
related_name='boxes'
|
related_name='boxes'
|
||||||
)
|
)
|
||||||
|
sort_order = models.PositiveIntegerField(
|
||||||
|
default=0,
|
||||||
|
db_index=True,
|
||||||
|
help_text='Order in which boxes are displayed'
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name_plural = 'boxes'
|
verbose_name_plural = 'boxes'
|
||||||
|
ordering = ['sort_order']
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.id
|
return self.id
|
||||||
|
|||||||
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 %}
|
||||||
|
|
||||||
@@ -286,29 +287,7 @@
|
|||||||
{% 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 %}
|
||||||
|
|
||||||
@@ -26,7 +27,7 @@
|
|||||||
<div class="thing-image" style="flex-shrink: 0; width: 100%; max-width: 400px;">
|
<div class="thing-image" style="flex-shrink: 0; width: 100%; max-width: 400px;">
|
||||||
{% if thing.picture %}
|
{% if thing.picture %}
|
||||||
{% thumbnail thing.picture "400x400" crop="center" as thumb %}
|
{% thumbnail thing.picture "400x400" crop="center" as thumb %}
|
||||||
<img src="{{ thumb.url }}" alt="{{ thing.name }}" style="width: 100%; height: auto; max-height: 400px; object-fit: cover; border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.15);">
|
<img class="lightbox-trigger" data-url="{{ thing.picture.url }}" src="{{ thumb.url }}" alt="{{ thing.name }}" style="width: 100%; height: auto; max-height: 400px; object-fit: cover; border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.15); cursor: pointer; transition: transform 0.2s;" onmouseover="this.style.transform='scale(1.02)'" onmouseout="this.style.transform='scale(1)'">
|
||||||
{% endthumbnail %}
|
{% endthumbnail %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<div style="width: 100%; aspect-ratio: 1; max-width: 400px; max-height: 400px; background: linear-gradient(135deg, #e0e0e0 0%, #f0f0f0 100%); display: flex; align-items: center; justify-content: center; color: #999; border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.15);">
|
<div style="width: 100%; aspect-ratio: 1; max-width: 400px; max-height: 400px; background: linear-gradient(135deg, #e0e0e0 0%, #f0f0f0 100%); display: flex; align-items: center; justify-content: center; color: #999; border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.15);">
|
||||||
@@ -92,11 +93,24 @@
|
|||||||
</div>
|
</div>
|
||||||
<div style="display: flex; flex-direction: column; gap: 10px;">
|
<div style="display: flex; flex-direction: column; gap: 10px;">
|
||||||
{% for file in thing.files.all %}
|
{% for file in thing.files.all %}
|
||||||
|
{% if file.filename|lower|slice:"-4:" == '.jpg' or file.filename|lower|slice:"-5:" == '.jpeg' or file.filename|lower|slice:"-4:" == '.png' or file.filename|lower|slice:"-5:" == '.webp' or file.filename|lower|slice:"-4:" == '.gif' or file.filename|lower|slice:"-4:" == '.svg' or file.filename|lower|slice:"-4:" == '.bmp' or file.filename|lower|slice:"-5:" == '.tiff' or file.filename|lower|slice:"-4:" == '.ico' %}
|
||||||
|
{% thumbnail file.file "200x200" crop="center" as thumb %}
|
||||||
|
<div style="display: flex; align-items: center; gap: 10px; padding: 12px 15px; background: #f8f9fa; border-radius: 8px; border:1px solid #e9ecef;">
|
||||||
|
<img class="lightbox-trigger" data-url="{{ file.file.url }}" src="{{ thumb.url }}" alt="{{ file.title }}" style="width: 60px; height: 60px; object-fit: cover; border-radius: 6px; cursor: pointer; box-shadow: 0 2px 8px rgba(0,0,0,0.1); transition: transform 0.2s;" onmouseover="this.style.transform='scale(1.05)'" onmouseout="this.style.transform='scale(1)'">
|
||||||
|
<div style="flex-grow: 1;">
|
||||||
|
<a href="{{ file.file.url }}" target="_blank" style="color: #667eea; text-decoration: none; font-weight: 500; font-size: 15px;">{{ file.title }}</a>
|
||||||
|
<span style="color: #999; font-size: 12px;">({{ file.filename }})</span>
|
||||||
|
</div>
|
||||||
|
<i class="fas fa-expand lightbox-trigger" data-url="{{ file.file.url }}" style="color: #999; font-size: 14px; cursor: pointer;"></i>
|
||||||
|
</div>
|
||||||
|
{% endthumbnail %}
|
||||||
|
{% else %}
|
||||||
<div style="display: flex; align-items: center; gap: 10px; padding: 12px 15px; background: #f8f9fa; border-radius: 8px; border: 1px solid #e9ecef;">
|
<div style="display: flex; align-items: center; gap: 10px; padding: 12px 15px; background: #f8f9fa; border-radius: 8px; border: 1px solid #e9ecef;">
|
||||||
<i class="fas fa-paperclip" style="color: #667eea; font-size: 16px;"></i>
|
<i class="fas fa-paperclip" style="color: #667eea; font-size: 16px;"></i>
|
||||||
<a href="{{ file.file.url }}" target="_blank" style="color: #667eea; text-decoration: none; font-weight: 500; font-size: 15px;">{{ file.title }}</a>
|
<a href="{{ file.file.url }}" target="_blank" style="color: #667eea; text-decoration: none; font-weight: 500; font-size: 15px;">{{ file.title }}</a>
|
||||||
<span style="color: #999; font-size: 12px;">({{ file.filename }})</span>
|
<span style="color: #999; font-size: 12px;">({{ file.filename }})</span>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -120,83 +134,41 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="lightbox" id="lightbox">
|
||||||
|
<span class="lightbox-close">×</span>
|
||||||
|
<img id="lightbox-image" src="" alt="">
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
$(document).ready(function() {
|
||||||
|
$('.lightbox-trigger').on('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const imageUrl = $(this).data('url');
|
||||||
|
$('#lightbox-image').attr('src', imageUrl);
|
||||||
|
$('#lightbox').addClass('active');
|
||||||
|
$('body').css('overflow', 'hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#lightbox').on('click', function(e) {
|
||||||
|
if (e.target === this || e.target.classList.contains('lightbox-close')) {
|
||||||
|
$('#lightbox').removeClass('active');
|
||||||
|
$('body').css('overflow', 'auto');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$(document).on('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
$('#lightbox').removeClass('active');
|
||||||
|
$('body').css('overflow', 'auto');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% 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;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ def edit_thing(request, thing_id):
|
|||||||
pk=thing_id
|
pk=thing_id
|
||||||
)
|
)
|
||||||
|
|
||||||
boxes = Box.objects.select_related('box_type').all().order_by('id')
|
boxes = Box.objects.select_related('box_type').all()
|
||||||
facets = Facet.objects.all().prefetch_related('tags')
|
facets = Facet.objects.all().prefetch_related('tags')
|
||||||
picture_form = ThingPictureForm(instance=thing)
|
picture_form = ThingPictureForm(instance=thing)
|
||||||
file_form = ThingFileForm()
|
file_form = ThingFileForm()
|
||||||
@@ -192,7 +192,7 @@ def edit_thing(request, thing_id):
|
|||||||
@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().order_by('id')
|
boxes = Box.objects.select_related('box_type').prefetch_related('things').all()
|
||||||
return render(request, 'boxes/boxes_list.html', {
|
return render(request, 'boxes/boxes_list.html', {
|
||||||
'boxes': boxes,
|
'boxes': boxes,
|
||||||
})
|
})
|
||||||
|
|||||||
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
|
||||||
8
labhelper/context_processors.py
Normal file
8
labhelper/context_processors.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
def image_tag(request):
|
||||||
|
"""Add the image tag to all template contexts."""
|
||||||
|
return {
|
||||||
|
'image_tag': os.environ.get('IMAGE_TAG', 'dev')
|
||||||
|
}
|
||||||
@@ -24,10 +24,10 @@ BASE_DIR = Path(__file__).resolve().parent.parent
|
|||||||
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 = True
|
DEBUG = os.environ.get('DEBUG', 'True').lower() == 'true'
|
||||||
|
|
||||||
ALLOWED_HOSTS = ["*","labhelper.adebaumann.com"]
|
ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '*').split(',')
|
||||||
ALLOWED_CIDR_NETS = ['10.0.0.0/16']
|
ALLOWED_CIDR_NETS = os.environ.get('ALLOWED_CIDR_NETS', '10.0.0.0/16').split(',')
|
||||||
|
|
||||||
|
|
||||||
# Application definition
|
# Application definition
|
||||||
@@ -47,6 +47,7 @@ INSTALLED_APPS = [
|
|||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
'django.middleware.security.SecurityMiddleware',
|
'django.middleware.security.SecurityMiddleware',
|
||||||
|
'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',
|
||||||
@@ -67,6 +68,7 @@ TEMPLATES = [
|
|||||||
'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',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -108,23 +110,26 @@ 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 = 'en-us'
|
LANGUAGE_CODE = os.environ.get('LANGUAGE_CODE', 'en-us')
|
||||||
|
|
||||||
TIME_ZONE = 'UTC'
|
TIME_ZONE = os.environ.get('TIME_ZONE', 'UTC')
|
||||||
|
|
||||||
USE_I18N = True
|
USE_I18N = os.environ.get('USE_I18N', 'True').lower() == 'true'
|
||||||
|
|
||||||
USE_TZ = True
|
USE_TZ = os.environ.get('USE_TZ', 'True').lower() == 'true'
|
||||||
|
|
||||||
|
|
||||||
# Static files (CSS, JavaScript, Images)
|
# 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 = '/static/'
|
STATIC_URL = os.environ.get('STATIC_URL', '/static/')
|
||||||
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
||||||
|
|
||||||
|
# WhiteNoise static file serving configuration
|
||||||
|
STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'
|
||||||
|
|
||||||
# Media files (user uploads)
|
# Media files (user uploads)
|
||||||
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
|
||||||
@@ -132,8 +137,8 @@ MEDIA_ROOT = BASE_DIR / 'data' / 'media'
|
|||||||
|
|
||||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||||
|
|
||||||
CSRF_TRUSTED_ORIGINS=["https://labhelper.adebaumann.com"]
|
CSRF_TRUSTED_ORIGINS=os.environ.get('CSRF_TRUSTED_ORIGINS', 'https://labhelper.adebaumann.com').split(',')
|
||||||
|
|
||||||
LOGIN_URL = 'login'
|
LOGIN_URL = os.environ.get('LOGIN_URL', 'login')
|
||||||
LOGIN_REDIRECT_URL = 'index'
|
LOGIN_REDIRECT_URL = os.environ.get('LOGIN_REDIRECT_URL', 'index')
|
||||||
LOGOUT_REDIRECT_URL = 'login'
|
LOGOUT_REDIRECT_URL = os.environ.get('LOGOUT_REDIRECT_URL', 'login')
|
||||||
|
|||||||
@@ -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 '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 %}
|
||||||
@@ -435,6 +55,7 @@
|
|||||||
|
|
||||||
<footer class="footer">
|
<footer class="footer">
|
||||||
<p>© 2025 LabHelper. Built with <i class="fas fa-heart"></i> for science.</p>
|
<p>© 2025 LabHelper. Built with <i class="fas fa-heart"></i> for science.</p>
|
||||||
|
<p style="font-size: 12px; opacity: 0.7; margin-top: 10px;">Image: {{ image_tag }}</p>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
{% block extra_js %}{% endblock %}
|
{% block extra_js %}{% endblock %}
|
||||||
|
|||||||
@@ -55,12 +55,24 @@ urlpatterns = [
|
|||||||
path('thing/<int:thing_id>/edit/', edit_thing, name='edit_thing'),
|
path('thing/<int:thing_id>/edit/', edit_thing, name='edit_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'),
|
||||||
path('admin/', admin.site.urls),
|
path('admin/', admin.site.urls),
|
||||||
]
|
]
|
||||||
|
|
||||||
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
# Static files are served by WhiteNoise middleware in production
|
||||||
|
# Media files need to be served in all environments
|
||||||
|
from django.views.static import serve
|
||||||
|
from django.urls import re_path
|
||||||
|
|
||||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||||
|
|
||||||
|
# Add explicit media serving for production
|
||||||
|
if not settings.DEBUG:
|
||||||
|
urlpatterns += [
|
||||||
|
re_path(r'^media/(?P<path>.*)$', serve, {
|
||||||
|
'document_root': settings.MEDIA_ROOT,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
|||||||
@@ -36,3 +36,4 @@ Pillow==11.1.0
|
|||||||
sorl-thumbnail==12.11.0
|
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
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
NAMESPACE="labhelper"
|
NAMESPACE="labhelper"
|
||||||
SECRET_NAME="django-secret"
|
SECRET_NAME="django-secret"
|
||||||
SECRET_FILE="argocd/secret.yaml"
|
SECRET_FILE="k8s-templates/secret.yaml"
|
||||||
|
|
||||||
# Check if secret file exists
|
# Check if secret file exists
|
||||||
if [ ! -f "$SECRET_FILE" ]; then
|
if [ ! -f "$SECRET_FILE" ]; then
|
||||||
|
|||||||
@@ -36,10 +36,14 @@ NEW_MAIN_VERSION=$(echo "$MAIN_VERSION + 0.001" | bc | sed 's/^\./0./')
|
|||||||
sed -i "s|image: git.baumann.gr/adebaumann/labhelper-data-loader:$LOADER_VERSION|image: git.baumann.gr/adebaumann/labhelper-data-loader:$NEW_LOADER_VERSION|" "$DEPLOYMENT_FILE"
|
sed -i "s|image: git.baumann.gr/adebaumann/labhelper-data-loader:$LOADER_VERSION|image: git.baumann.gr/adebaumann/labhelper-data-loader:$NEW_LOADER_VERSION|" "$DEPLOYMENT_FILE"
|
||||||
sed -i "s|image: git.baumann.gr/adebaumann/labhelper:$MAIN_VERSION|image: git.baumann.gr/adebaumann/labhelper:$NEW_MAIN_VERSION|" "$DEPLOYMENT_FILE"
|
sed -i "s|image: git.baumann.gr/adebaumann/labhelper:$MAIN_VERSION|image: git.baumann.gr/adebaumann/labhelper:$NEW_MAIN_VERSION|" "$DEPLOYMENT_FILE"
|
||||||
|
|
||||||
|
# Update ConfigMap with new main container version
|
||||||
|
sed -i "s| IMAGE_TAG: \"$MAIN_VERSION\"| IMAGE_TAG: \"$NEW_MAIN_VERSION\"|" "argocd/configmap.yaml"
|
||||||
|
|
||||||
# Copy database
|
# Copy database
|
||||||
cp "$DB_SOURCE" "$DB_DEST"
|
cp "$DB_SOURCE" "$DB_DEST"
|
||||||
|
|
||||||
echo "Full deployment prepared:"
|
echo "Full deployment prepared:"
|
||||||
echo " Data loader: $LOADER_VERSION -> $NEW_LOADER_VERSION"
|
echo " Data loader: $LOADER_VERSION -> $NEW_LOADER_VERSION"
|
||||||
echo " Main container: $MAIN_VERSION -> $NEW_MAIN_VERSION"
|
echo " Main container: $MAIN_VERSION -> $NEW_MAIN_VERSION"
|
||||||
|
echo " ConfigMap IMAGE_TAG: $MAIN_VERSION -> $NEW_MAIN_VERSION"
|
||||||
echo " Database copied to $DB_DEST"
|
echo " Database copied to $DB_DEST"
|
||||||
|
|||||||
@@ -23,5 +23,9 @@ NEW_VERSION=$(echo "$CURRENT_VERSION + 0.001" | bc | sed 's/^\./0./')
|
|||||||
# Update the deployment file (only the main container, not the data-loader)
|
# Update the deployment file (only the main container, not the data-loader)
|
||||||
sed -i "s|image: git.baumann.gr/adebaumann/labhelper:$CURRENT_VERSION|image: git.baumann.gr/adebaumann/labhelper:$NEW_VERSION|" "$DEPLOYMENT_FILE"
|
sed -i "s|image: git.baumann.gr/adebaumann/labhelper:$CURRENT_VERSION|image: git.baumann.gr/adebaumann/labhelper:$NEW_VERSION|" "$DEPLOYMENT_FILE"
|
||||||
|
|
||||||
|
# Update ConfigMap with new main container version
|
||||||
|
sed -i "s| IMAGE_TAG: \"$CURRENT_VERSION\"| IMAGE_TAG: \"$NEW_VERSION\"|" "argocd/configmap.yaml"
|
||||||
|
|
||||||
echo "Partial deployment prepared:"
|
echo "Partial deployment prepared:"
|
||||||
echo " Main container: $CURRENT_VERSION -> $NEW_VERSION"
|
echo " Main container: $CURRENT_VERSION -> $NEW_VERSION"
|
||||||
|
echo " ConfigMap IMAGE_TAG: $CURRENT_VERSION -> $NEW_VERSION"
|
||||||
|
|||||||
Reference in New Issue
Block a user