31 Commits

Author SHA1 Message Date
88c1dd704f Autodiscovery annotations added to Ingress 2026-03-15 20:47:42 +01:00
ed47530c3c NFS adapted to use share "Kubernetesdata" 2026-02-10 14:58:59 +01:00
e537ec2ac0 Deployment 0.076
All checks were successful
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/labhelper) (push) Successful in 27s
Build containers when image tags change / build-if-image-changed (data-loader, loader, initContainers, init-container, git.baumann.gr/adebaumann/labhelper-data-loader) (push) Successful in 7s
2026-01-28 13:51:22 +01:00
ec102dd1cc Menu structure cleaned up 2026-01-28 13:29:32 +01:00
7bae0d12de CSS extracted into separate file
All checks were successful
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/labhelper) (push) Successful in 26s
Build containers when image tags change / build-if-image-changed (data-loader, loader, initContainers, init-container, git.baumann.gr/adebaumann/labhelper-data-loader) (push) Successful in 8s
2026-01-28 10:15:14 +01:00
dbfb38bb8a Revert - actual ip information not available in headers
All checks were successful
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/labhelper) (push) Successful in 27s
Build containers when image tags change / build-if-image-changed (data-loader, loader, initContainers, init-container, git.baumann.gr/adebaumann/labhelper-data-loader) (push) Successful in 7s
2026-01-28 09:55:00 +01:00
20239242ce Not logging correct IP yet
All checks were successful
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/labhelper) (push) Successful in 28s
Build containers when image tags change / build-if-image-changed (data-loader, loader, initContainers, init-container, git.baumann.gr/adebaumann/labhelper-data-loader) (push) Successful in 7s
2026-01-28 09:46:42 +01:00
60e13822ee Not logging correct IP yet
All checks were successful
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/labhelper) (push) Successful in 26s
Build containers when image tags change / build-if-image-changed (data-loader, loader, initContainers, init-container, git.baumann.gr/adebaumann/labhelper-data-loader) (push) Successful in 7s
2026-01-28 09:40:45 +01:00
4d492ded4e Not logging correct IP yet
All checks were successful
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/labhelper) (push) Successful in 26s
Build containers when image tags change / build-if-image-changed (data-loader, loader, initContainers, init-container, git.baumann.gr/adebaumann/labhelper-data-loader) (push) Successful in 7s
2026-01-28 09:33:42 +01:00
3b53967c40 Not logging correct IP yet 2026-01-28 09:33:15 +01:00
65868c043e Not logging yet
All checks were successful
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/labhelper) (push) Successful in 27s
Build containers when image tags change / build-if-image-changed (data-loader, loader, initContainers, init-container, git.baumann.gr/adebaumann/labhelper-data-loader) (push) Successful in 7s
2026-01-28 09:06:39 +01:00
5c9b45715b Deploy last commit
All checks were successful
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/labhelper) (push) Successful in 2m40s
Build containers when image tags change / build-if-image-changed (data-loader, loader, initContainers, init-container, git.baumann.gr/adebaumann/labhelper-data-loader) (push) Successful in 7s
2026-01-28 08:58:07 +01:00
bbed20813a Forward proxying and suppression of health checks in access logs
All checks were successful
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/labhelper) (push) Successful in 11s
Build containers when image tags change / build-if-image-changed (data-loader, loader, initContainers, init-container, git.baumann.gr/adebaumann/labhelper-data-loader) (push) Successful in 8s
2026-01-28 08:55:19 +01:00
22f5b87a20 Search menu entry changed to inventory
All checks were successful
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/labhelper) (push) Successful in 33s
Build containers when image tags change / build-if-image-changed (data-loader, loader, initContainers, init-container, git.baumann.gr/adebaumann/labhelper-data-loader) (push) Successful in 7s
2026-01-20 01:03:04 +01:00
1dede761e3 ...and deploy
All checks were successful
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/labhelper) (push) Successful in 28s
Build containers when image tags change / build-if-image-changed (data-loader, loader, initContainers, init-container, git.baumann.gr/adebaumann/labhelper-data-loader) (push) Successful in 6s
2026-01-20 00:18:39 +01:00
860e80a552 Box sorting works 2026-01-20 00:18:05 +01:00
a1bc7967c5 Box sorting field added and put into effect
All checks were successful
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/labhelper) (push) Successful in 1m41s
Build containers when image tags change / build-if-image-changed (data-loader, loader, initContainers, init-container, git.baumann.gr/adebaumann/labhelper-data-loader) (push) Successful in 7s
2026-01-19 23:56:26 +01:00
2a825646a3 Kubernetes PVC changed to shared NFS 2026-01-19 22:16:48 +01:00
7c990998a4 Revert 2026-01-19 09:04:24 +01:00
bf62ddcbd7 NFS test 2026-01-19 08:58:14 +01:00
985460ff84 Fixed management command to remove orphan files
All checks were successful
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/labhelper) (push) Successful in 2m13s
Build containers when image tags change / build-if-image-changed (data-loader, loader, initContainers, init-container, git.baumann.gr/adebaumann/labhelper-data-loader) (push) Successful in 8s
2026-01-17 14:46:45 +01:00
2705f6c16e Statif media files now served in non-DEBUG-context
All checks were successful
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/labhelper) (push) Successful in 15s
Build containers when image tags change / build-if-image-changed (data-loader, loader, initContainers, init-container, git.baumann.gr/adebaumann/labhelper-data-loader) (push) Successful in 3s
2026-01-16 13:41:52 +01:00
887247028a Static file serving out of DEBUG mode addressed
All checks were successful
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/labhelper) (push) Successful in 55s
Build containers when image tags change / build-if-image-changed (data-loader, loader, initContainers, init-container, git.baumann.gr/adebaumann/labhelper-data-loader) (push) Successful in 4s
2026-01-16 13:15:40 +01:00
4cbd3e2f87 Gunicorn options in config map; Image tag displays on page
All checks were successful
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/labhelper) (push) Successful in 1m3s
Build containers when image tags change / build-if-image-changed (data-loader, loader, initContainers, init-container, git.baumann.gr/adebaumann/labhelper-data-loader) (push) Successful in 4s
2026-01-16 12:22:50 +01:00
935392d27d Variables now in config map
All checks were successful
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/labhelper) (push) Successful in 6s
Build containers when image tags change / build-if-image-changed (data-loader, loader, initContainers, init-container, git.baumann.gr/adebaumann/labhelper-data-loader) (push) Successful in 4s
2026-01-16 11:35:01 +01:00
30657be6c2 Secret now properly deployable 2026-01-16 11:28:03 +01:00
4bca3ae403 Stupid mistake in argocd secret... 2026-01-16 11:24:34 +01:00
db80ddf069 Main image now also "Lightboxes"
All checks were successful
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/labhelper) (push) Successful in 15s
Build containers when image tags change / build-if-image-changed (data-loader, loader, initContainers, init-container, git.baumann.gr/adebaumann/labhelper-data-loader) (push) Successful in 3s
2026-01-06 15:14:44 +01:00
0602347539 Images on detail page now show up as thumbnails with lightbox functionality 2026-01-06 14:54:56 +01:00
384c0d58e6 Merge pull request 'Deployment' (#7) from improvement/redesign into master
All checks were successful
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/labhelper) (push) Successful in 4s
Build containers when image tags change / build-if-image-changed (data-loader, loader, initContainers, init-container, git.baumann.gr/adebaumann/labhelper-data-loader) (push) Successful in 3s
Reviewed-on: #7
2026-01-06 12:12:51 +00:00
7e96fcef8b Merge pull request 'Restructured pages' (#6) from improvement/redesign into master
Reviewed-on: #6
2026-01-06 11:55:54 +00:00
30 changed files with 1056 additions and 1436 deletions

586
AGENTS.md
View File

@@ -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.

View File

@@ -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.

View File

@@ -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"]

View File

@@ -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
View 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"

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

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

View File

@@ -1,7 +1,12 @@
import json
from django import forms from django 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):

View File

@@ -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")

View File

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

View File

@@ -41,9 +41,15 @@ class Box(models.Model):
on_delete=models.PROTECT, 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
View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
{% extends "base.html" %} {% 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 %}

View File

@@ -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">&times;</span>
<img id="lightbox-image" src="" alt="">
</div>
{% endblock %}
{% block extra_js %}
<script>
$(document).ready(function() {
$('.lightbox-trigger').on('click', function(e) {
e.preventDefault();
const imageUrl = $(this).data('url');
$('#lightbox-image').attr('src', imageUrl);
$('#lightbox').addClass('active');
$('body').css('overflow', 'hidden');
});
$('#lightbox').on('click', function(e) {
if (e.target === this || e.target.classList.contains('lightbox-close')) {
$('#lightbox').removeClass('active');
$('body').css('overflow', 'auto');
}
});
$(document).on('keydown', function(e) {
if (e.key === 'Escape') {
$('#lightbox').removeClass('active');
$('body').css('overflow', 'auto');
}
});
});
</script>
{% endblock %} {% 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 %}

View File

@@ -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
View File

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

View File

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

View File

@@ -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')

View File

@@ -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>&copy; 2025 LabHelper. Built with <i class="fas fa-heart"></i> for science.</p> <p>&copy; 2025 LabHelper. Built with <i class="fas fa-heart"></i> for science.</p>
<p style="font-size: 12px; opacity: 0.7; margin-top: 10px;">Image: {{ image_tag }}</p>
</footer> </footer>
{% block extra_js %}{% endblock %} {% block extra_js %}{% endblock %}

View File

@@ -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,
}),
]

View File

@@ -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

View File

@@ -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

View File

@@ -36,10 +36,14 @@ NEW_MAIN_VERSION=$(echo "$MAIN_VERSION + 0.001" | bc | sed 's/^\./0./')
sed -i "s|image: git.baumann.gr/adebaumann/labhelper-data-loader:$LOADER_VERSION|image: git.baumann.gr/adebaumann/labhelper-data-loader:$NEW_LOADER_VERSION|" "$DEPLOYMENT_FILE" sed -i "s|image: git.baumann.gr/adebaumann/labhelper-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"

View File

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