34 Commits

Author SHA1 Message Date
ee9a76dcc8 Partial deployment
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 20s
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-01 16:00:56 +01:00
11d2579c7e Things back in admin 2026-01-01 15:45:39 +01:00
7410f8c607 Search now also includes Files and URLs
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 2m53s
Build containers when image tags change / build-if-image-changed (data-loader, loader, initContainers, init-container, git.baumann.gr/adebaumann/labhelper-data-loader) (push) Successful in 5s
2026-01-01 15:24:42 +01:00
b465e7365f Full deployment
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 21s
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 16s
2026-01-01 14:54:05 +01:00
a4f9274da4 Added arbitrary files and links to boxes 2026-01-01 14:50:07 +01:00
acde0cb2f8 Box management page added
All checks were successful
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/labhelper) (push) Successful in 20s
Build containers when image tags change / build-if-image-changed (data-loader, loader, initContainers, init-container, git.baumann.gr/adebaumann/labhelper-data-loader) (push) Successful in 5s
2026-01-01 14:11:33 +01:00
10cc24ff03 Add/remove/change image on box detail page added
All checks were successful
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/labhelper) (push) Successful in 25s
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-01 13:48:09 +01:00
c566e31ab5 Search now also includes type and description
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 24s
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
2025-12-30 17:24:53 +01:00
bd36132946 SECRET_KEY now uses a kubernetes secret with a fallback value for local testing
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 3m9s
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
2025-12-30 17:05:30 +01:00
20e5e0b0c1 Thing types now start off collapsed
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 12s
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
2025-12-30 01:36:47 +01:00
0f5011d8f7 Added command to remove orphaned images and thumbnails.
All checks were successful
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/labhelper) (push) Successful in 14s
Build containers when image tags change / build-if-image-changed (data-loader, loader, initContainers, init-container, git.baumann.gr/adebaumann/labhelper-data-loader) (push) Successful in 3s
2025-12-30 01:31:42 +01:00
88a5c12bbc Aggregated counts on front page; "Add items" page now saves pictures
All checks were successful
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/labhelper) (push) Successful in 14s
Build containers when image tags change / build-if-image-changed (data-loader, loader, initContainers, init-container, git.baumann.gr/adebaumann/labhelper-data-loader) (push) Successful in 4s
2025-12-30 01:10:40 +01:00
17e713964c POST for logout to avoid 405
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
2025-12-30 00:31:41 +01:00
e172e2f9dc Login added, tests completed
All checks were successful
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/labhelper) (push) Successful in 16s
Build containers when image tags change / build-if-image-changed (data-loader, loader, initContainers, init-container, git.baumann.gr/adebaumann/labhelper-data-loader) (push) Successful in 3s
2025-12-30 00:26:19 +01:00
eb8284fdd2 Thing detail template adjusted
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 59s
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
2025-12-29 20:11:25 +01:00
1d1c80a267 Image handling refined (filenames, mobile rendering)
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 1m9s
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 56s
2025-12-29 19:52:22 +01:00
d28c13d339 Full deployment - bump versions and copy database
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 1m9s
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 11s
- Bump labhelper-data-loader to 0.010
- Bump labhelper to 0.032
- Copy cleaned database to data-loader/preload.sqlite3
2025-12-29 00:56:04 +01:00
0eeedaff97 Fix initContainer - remove cd to non-existent /app
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
- Remove cd /app since directory doesn't exist in initContainer
- python manage.py is accessible from container root
- Only main container mounts to /app/data, initContainer mounts to /data
2025-12-29 00:51:27 +01:00
b0b44eeed4 Preserve production database - only copy if missing/empty
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
- Add conditional logic to only copy database if target doesn't exist or is 0 bytes
- This preserves production data across pod restarts
- Only copy from preload when database is missing or corrupted
- Keep debug ls output to verify file size
2025-12-29 00:46:58 +01:00
39762037fe Fix database copy - remove -n flag and add debug
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
- Change cp -n to cp -f to force overwrite
- Add ls -la to debug database size after copy
- This should fix 0 byte database issue on NFS mount
2025-12-29 00:45:53 +01:00
150fd1c59d Full deployment - bump versions and copy database
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 13s
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 11s
- Bump labhelper-data-loader to 0.009
- Bump labhelper to 0.031
- Copy cleaned database to data-loader/preload.sqlite3
2025-12-29 00:43:31 +01:00
8d23713526 Partial deployment - bump main container to v0.030
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 2m41s
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
2025-12-29 00:36:14 +01:00
4d3ace5395 Fix deployment - run thumbnail migrations
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 8s
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
- Add migrate thumbnail command after copying database
- Ensures thumbnail_kvstore table exists for sorl-thumbnail
2025-12-29 00:34:16 +01:00
2a2d3ead0b Full deployment - bump versions and copy database
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 34s
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 24s
- Bump labhelper-data-loader to 0.008
- Bump labhelper to 0.029
- Copy cleaned database to data-loader/preload.sqlite3
2025-12-29 00:30:51 +01:00
fe39d2c067 Update AGENTS.md with frontend guidelines
- Add Django extensions (sorl-thumbnail, Font Awesome, jQuery)
- Add Frontend/CSS Guidelines section
- Document design system (colors, components, typography, icons)
- Add CSS guidelines and naming conventions
- Document jQuery usage patterns
- Add available pages/views table
- Add template best practices section
- Update Common Pitfalls for templates directory structure
- Create backup of original AGENTS.md
2025-12-29 00:27:08 +01:00
9db47a0ab7 Merge pull request 'improvement/design' (#3) from improvement/design 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 7s
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
Reviewed-on: #3
2025-12-28 23:21:39 +00:00
2a84a92025 Fix base template path and bump deployment version
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
- Add labhelper/templates to TEMPLATES DIRS for base.html
- Bump container version to 0.028
2025-12-29 00:20:20 +01:00
c9d48255e2 Add modern design with base template
- Create base.html with snazzy gradient design
- Add navigation header with glassmorphism effect
- Add Font Awesome icons throughout
- Update all templates to extend base:
  - index.html: Home page with boxes and thing types
  - box_detail.html: Box contents table
  - thing_detail.html: Thing details with move form
  - thing_type_detail.html: Type hierarchy and things
  - search.html: Search functionality
  - add_things.html: Form for adding things
- Add hover effects, smooth transitions, and modern UI
- Use purple gradient color scheme (#667eea to #764ba2)
- Add breadcrumbs for navigation
- Improve accessibility with proper focus states
2025-12-29 00:15:31 +01:00
02e949d0ad Add new front page with boxes and thing types tree
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 29s
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
- Replace simple HTML index with full template
- Add grid of all boxes with details and item counts
- Add expandable tree view of thing types using MPTT
- Add 'mptt' to INSTALLED_APPS for recursetree tag
- Add jQuery for tree toggle functionality

Bump container version to 0.027
2025-12-29 00:09:23 +01:00
fbd3c9bee5 Add thing type detail page and move functionality
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 29s
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
- Add thing type detail page showing hierarchical structure and things
- Add move to box functionality on thing detail page
- Fix add things form to only show items in current box
- Update thumbnails to 50x50 on box detail page
- Make thing names linkable on box detail page

Bump container version to 0.026
2025-12-28 23:33:47 +01:00
bcba59b5e4 fixed Unbound error 2025-12-28 23:22:13 +01:00
ed44deb5a6 Updated boxes page with links and smaller thumbnails 2025-12-28 23:19:51 +01:00
00861f8945 Merge pull request 'feature/boxform' (#2) from feature/boxform 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 9s
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
Reviewed-on: #2
2025-12-28 22:12:06 +00:00
da1ef00072 Merge pull request 'Add form to add multiple things to a box' (#1) from feature/boxform 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 8s
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
Reviewed-on: #1
2025-12-28 21:55:14 +00:00
37 changed files with 3561 additions and 653 deletions

4
.gitignore vendored
View File

@@ -12,7 +12,5 @@ keys/
node_modules/ node_modules/
package-lock.json package-lock.json
package.json package.json
/data
# Diagram cache directory
.env .env
data/db.sqlite3

136
AGENTS.md
View File

@@ -205,6 +205,136 @@ The project includes these pre-installed packages:
- **django-nested-admin**: Nested inline forms in admin - **django-nested-admin**: Nested inline forms in admin
- **django-nested-inline**: Additional nested inline support - **django-nested-inline**: Additional nested inline support
- **django-revproxy**: Reverse proxy functionality - **django-revproxy**: Reverse proxy functionality
- **sorl-thumbnail**: Image thumbnailing
- **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
- **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
### 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 Name | URL Pattern | Description |
|-----------|--------------|-------------|
| `index` | `/` | Home page with boxes grid and thing types tree |
| `box_detail` | `/box/<str:box_id>/` | List items in a specific box |
| `thing_detail` | `/thing/<int:thing_id>/` | Thing details with move-to-box form |
| `thing_type_detail` | `/thing-type/<int:type_id>/` | Thing type hierarchy and items |
| `add_things` | `/box/<str:box_id>/add/` | Form to add/edit items in a box |
| `search` | `/search/` | Search page with AJAX autocomplete |
| `search_api` | `/search/api/` | AJAX endpoint for search results |
### 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 %}
```
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>
```
## Testing Guidelines ## Testing Guidelines
@@ -241,9 +371,11 @@ Per `.gitignore`:
## Common Pitfalls ## Common Pitfalls
1. **Always activate venv**: `source .venv/bin/activate` 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. **Run migrations after model changes**: `makemigrations` then `migrate` 2. **Always activate venv**: `source .venv/bin/activate`
3. **Run migrations after model changes**: `makemigrations` then `migrate`
3. **Add new apps to INSTALLED_APPS** in `settings.py` 3. **Add new apps to INSTALLED_APPS** in `settings.py`
4. **Templates in labhelper/templates/**: The base template and shared templates are in `labhelper/templates/`. App-specific templates remain in `app_name/templates/`.
4. **Use get_object_or_404** instead of bare `.get()` calls 4. **Use get_object_or_404** instead of bare `.get()` calls
5. **Never commit SECRET_KEY** - use environment variables in production 5. **Never commit SECRET_KEY** - use environment variables in production

272
AGENTS.md.backup Normal file
View File

@@ -0,0 +1,272 @@
# 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

@@ -18,19 +18,25 @@ spec:
fsGroupChangePolicy: "OnRootMismatch" fsGroupChangePolicy: "OnRootMismatch"
initContainers: initContainers:
- name: loader - name: loader
image: git.baumann.gr/adebaumann/labhelper-data-loader:0.007 image: git.baumann.gr/adebaumann/labhelper-data-loader:0.012
securityContext: securityContext:
runAsUser: 0 runAsUser: 0
command: [ "sh","-c","cp -n preload/preload.sqlite3 /data/db.sqlite3; mkdir -p /data/media/cache /data/media/things; chmod -R 775 /data/media; exit 0" ] command: [ "sh","-c","if [ ! -f /data/db.sqlite3 ] || [ ! -s /data/db.sqlite3 ]; then cp preload/preload.sqlite3 /data/db.sqlite3 && echo 'Database copied from preload'; else echo 'Existing database preserved'; fi" ]
volumeMounts: volumeMounts:
- name: data - name: data
mountPath: /data mountPath: /data
containers: containers:
- name: web - name: web
image: git.baumann.gr/adebaumann/labhelper:0.025 image: git.baumann.gr/adebaumann/labhelper:0.046
imagePullPolicy: Always imagePullPolicy: Always
ports: ports:
- containerPort: 8000 - containerPort: 8000
env:
- name: DJANGO_SECRET_KEY
valueFrom:
secretKeyRef:
name: django-secret
key: secret-key
volumeMounts: volumeMounts:
- name: data - name: data
mountPath: /app/data mountPath: /app/data

8
argocd/secret.yaml Normal file
View File

@@ -0,0 +1,8 @@
apiVersion: v1
kind: Secret
metadata:
name: django-secret
namespace: labhelper
type: Opaque
stringData:
secret-key: "CHANGE_ME_TO_RANDOM_STRING"

View File

@@ -1,7 +1,7 @@
from django.contrib import admin from django.contrib import admin
from django_mptt_admin.admin import DjangoMpttAdmin from django_mptt_admin.admin import DjangoMpttAdmin
from .models import Box, BoxType, Thing, ThingType from .models import Box, BoxType, Thing, ThingFile, ThingLink, ThingType
@admin.register(BoxType) @admin.register(BoxType)
@@ -28,10 +28,47 @@ class ThingTypeAdmin(DjangoMpttAdmin):
search_fields = ('name',) search_fields = ('name',)
@admin.register(Thing) class ThingFileInline(admin.TabularInline):
"""Inline admin for Thing files."""
model = ThingFile
extra = 1
fields = ('title', 'file')
class ThingLinkInline(admin.TabularInline):
"""Inline admin for Thing links."""
model = ThingLink
extra = 1
fields = ('title', 'url')
class ThingAdmin(admin.ModelAdmin): class ThingAdmin(admin.ModelAdmin):
"""Admin configuration for Thing model.""" """Admin configuration for Thing model."""
list_display = ('name', 'thing_type', 'box') list_display = ('name', 'thing_type', 'box')
list_filter = ('thing_type', 'box') list_filter = ('thing_type', 'box')
search_fields = ('name', 'description') search_fields = ('name', 'description')
inlines = [ThingFileInline, ThingLinkInline]
admin.site.register(Thing, ThingAdmin)
@admin.register(ThingFile)
class ThingFileAdmin(admin.ModelAdmin):
"""Admin configuration for ThingFile model."""
list_display = ('thing', 'title', 'uploaded_at')
list_filter = ('thing',)
search_fields = ('title',)
@admin.register(ThingLink)
class ThingLinkAdmin(admin.ModelAdmin):
"""Admin configuration for ThingLink model."""
list_display = ('thing', 'title', 'url', 'uploaded_at')
list_filter = ('thing',)
search_fields = ('title', 'url')

View File

@@ -1,6 +1,6 @@
from django import forms from django import forms
from .models import Thing from .models import Box, BoxType, Thing, ThingFile, ThingLink
class ThingForm(forms.ModelForm): class ThingForm(forms.ModelForm):
@@ -16,6 +16,64 @@ class ThingForm(forms.ModelForm):
} }
class ThingPictureForm(forms.ModelForm):
"""Form for uploading/changing a Thing picture."""
class Meta:
model = Thing
fields = ('picture',)
class ThingFileForm(forms.ModelForm):
"""Form for adding a file to a Thing."""
class Meta:
model = ThingFile
fields = ('title', 'file')
widgets = {
'title': forms.TextInput(attrs={'style': 'width: 100%; padding: 10px 15px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 14px;'}),
'file': forms.FileInput(attrs={'style': 'width: 100%;'}),
}
class ThingLinkForm(forms.ModelForm):
"""Form for adding a link to a Thing."""
class Meta:
model = ThingLink
fields = ('title', 'url')
widgets = {
'title': forms.TextInput(attrs={'style': 'width: 100%; padding: 10px 15px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 14px;'}),
'url': forms.URLInput(attrs={'style': 'width: 100%; padding: 10px 15px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 14px;'}),
}
class BoxTypeForm(forms.ModelForm):
"""Form for adding/editing a BoxType."""
class Meta:
model = BoxType
fields = ('name', 'width', 'height', 'length')
widgets = {
'name': forms.TextInput(attrs={'style': 'width: 100%; max-width: 300px; padding: 10px 15px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 14px;'}),
'width': forms.NumberInput(attrs={'style': 'width: 100%; max-width: 150px; padding: 10px 15px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 14px;'}),
'height': forms.NumberInput(attrs={'style': 'width: 100%; max-width: 150px; padding: 10px 15px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 14px;'}),
'length': forms.NumberInput(attrs={'style': 'width: 100%; max-width: 150px; padding: 10px 15px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 14px;'}),
}
class BoxForm(forms.ModelForm):
"""Form for adding/editing a Box."""
class Meta:
model = Box
fields = ('id', 'box_type')
widgets = {
'id': forms.TextInput(attrs={'style': 'width: 100%; max-width: 200px; padding: 10px 15px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 14px; text-transform: uppercase;'}),
'box_type': forms.Select(attrs={'style': 'width: 100%; max-width: 300px; padding: 10px 15px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 14px;'}),
}
ThingFormSet = forms.modelformset_factory( ThingFormSet = forms.modelformset_factory(
Thing, Thing,
form=ThingForm, form=ThingForm,

View File

View File

View File

@@ -0,0 +1,79 @@
import os
from django.conf import settings
from django.core.management.base import BaseCommand
from boxes.models import ThingFile
class Command(BaseCommand):
help = 'Clean up orphaned files from deleted things'
def add_arguments(self, parser):
parser.add_argument(
'--dry-run',
action='store_true',
dest='dry_run',
help='Show what would be deleted without actually deleting',
)
def handle(self, *args, **options):
dry_run = options.get('dry_run', False)
if dry_run:
self.stdout.write(self.style.WARNING('DRY RUN - No files will be deleted'))
self.stdout.write('Finding orphaned files...')
media_root = settings.MEDIA_ROOT
things_files_root = os.path.join(media_root, 'things', 'files')
if not os.path.exists(things_files_root):
self.stdout.write(self.style.WARNING('No things/files directory found'))
return
valid_paths = set()
for thing_file in ThingFile.objects.all():
if thing_file.file:
file_path = thing_file.file.path
if os.path.exists(file_path):
valid_paths.add(os.path.relpath(file_path, things_files_root))
self.stdout.write(f'Found {len(valid_paths)} valid files in database')
deleted_count = 0
empty_dirs_removed = 0
for root, dirs, files in os.walk(things_files_root, topdown=False):
for filename in files:
file_path = os.path.join(root, filename)
relative_path = os.path.relpath(file_path, things_files_root)
if relative_path not in valid_paths:
deleted_count += 1
if dry_run:
self.stdout.write(f'Would delete: {file_path}')
else:
try:
os.remove(file_path)
self.stdout.write(f'Deleted: {file_path}')
except OSError as e:
self.stdout.write(self.style.ERROR(f'Failed to delete {file_path}: {e}'))
for dirname in dirs:
dir_path = os.path.join(root, dirname)
if not os.listdir(dir_path):
if dry_run:
self.stdout.write(f'Would remove empty directory: {dir_path}')
else:
try:
os.rmdir(dir_path)
self.stdout.write(f'Removed empty directory: {dir_path}')
empty_dirs_removed += 1
except OSError as e:
self.stdout.write(self.style.ERROR(f'Failed to remove {dir_path}: {e}'))
if dry_run:
self.stdout.write(self.style.WARNING(f'\nDry run complete. Would delete {deleted_count} files'))
self.stdout.write(f'Would remove {empty_dirs_removed} empty directories')
else:
self.stdout.write(self.style.SUCCESS(f'\nCleanup complete! Deleted {deleted_count} orphaned files'))
self.stdout.write(f'Removed {empty_dirs_removed} empty directories')

View File

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

View File

@@ -0,0 +1,19 @@
# Generated by Django 5.2.9 on 2025-12-29 18:26
import boxes.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('boxes', '0003_convert_thingtype_to_mptt'),
]
operations = [
migrations.AlterField(
model_name='thing',
name='picture',
field=models.ImageField(blank=True, upload_to=boxes.models.thing_picture_upload_path),
),
]

View File

@@ -0,0 +1,41 @@
# Generated by Django 5.2.9 on 2026-01-01 13:15
import boxes.models
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('boxes', '0004_alter_thing_picture'),
]
operations = [
migrations.CreateModel(
name='ThingFile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('file', models.FileField(upload_to=boxes.models.thing_file_upload_path)),
('title', models.CharField(help_text='Descriptive name for the file', max_length=255)),
('uploaded_at', models.DateTimeField(auto_now_add=True)),
('thing', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='files', to='boxes.thing')),
],
options={
'ordering': ['-uploaded_at'],
},
),
migrations.CreateModel(
name='ThingLink',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('url', models.URLField(max_length=2048)),
('title', models.CharField(help_text='Descriptive title for the link', max_length=255)),
('uploaded_at', models.DateTimeField(auto_now_add=True)),
('thing', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='links', to='boxes.thing')),
],
options={
'ordering': ['-uploaded_at'],
},
),
]

View File

@@ -1,7 +1,19 @@
import os
from django.db import models from django.db import models
from django.utils.text import slugify
from mptt.models import MPTTModel, TreeForeignKey from mptt.models import MPTTModel, TreeForeignKey
def thing_picture_upload_path(instance, filename):
"""Generate a custom path for thing pictures in format: <id>-<name>.<extension>"""
extension = os.path.splitext(filename)[1]
safe_name = slugify(instance.name)
if instance.pk:
return f'things/{instance.pk}-{safe_name}{extension}'
else:
return f'things/temp-{safe_name}{extension}'
class BoxType(models.Model): class BoxType(models.Model):
"""A type of storage box with specific dimensions.""" """A type of storage box with specific dimensions."""
@@ -72,10 +84,76 @@ class Thing(models.Model):
related_name='things' related_name='things'
) )
description = models.TextField(blank=True) description = models.TextField(blank=True)
picture = models.ImageField(upload_to='things/', blank=True) picture = models.ImageField(upload_to=thing_picture_upload_path, blank=True)
class Meta: class Meta:
ordering = ['name'] ordering = ['name']
def save(self, *args, **kwargs):
"""Override save to rename picture file after instance gets a pk."""
if self.picture and not self.pk:
picture = self.picture
super().save(*args, **kwargs)
new_path = thing_picture_upload_path(self, picture.name)
if picture.name != new_path:
try:
old_path = self.picture.path
if os.path.exists(old_path):
new_full_path = os.path.join(os.path.dirname(old_path), os.path.basename(new_path))
os.rename(old_path, new_full_path)
self.picture.name = new_path
super().save(update_fields=['picture'])
except (AttributeError, FileNotFoundError):
pass
else:
super().save(*args, **kwargs)
def __str__(self): def __str__(self):
return self.name return self.name
def thing_file_upload_path(instance, filename):
"""Generate a custom path for thing files in format: things/files/<thing_id>/<filename>"""
return f'things/files/{instance.thing.id}/{filename}'
class ThingFile(models.Model):
"""A file attachment for a Thing."""
thing = models.ForeignKey(
Thing,
on_delete=models.CASCADE,
related_name='files'
)
file = models.FileField(upload_to=thing_file_upload_path)
title = models.CharField(max_length=255, help_text='Descriptive name for the file')
uploaded_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-uploaded_at']
def __str__(self):
return f'{self.thing.name} - {self.title}'
def filename(self):
"""Return the original filename."""
return os.path.basename(self.file.name)
class ThingLink(models.Model):
"""A hyperlink for a Thing."""
thing = models.ForeignKey(
Thing,
on_delete=models.CASCADE,
related_name='links'
)
url = models.URLField(max_length=2048)
title = models.CharField(max_length=255, help_text='Descriptive title for the link')
uploaded_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-uploaded_at']
def __str__(self):
return f'{self.thing.name} - {self.title}'

View File

@@ -1,213 +1,128 @@
<!DOCTYPE html> {% extends "base.html" %}
<html lang="en">
<head> {% block title %}Add Things to Box {{ box.id }} - LabHelper{% endblock %}
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> {% block page_header %}
<title>Add Things to Box {{ box.id }} - LabHelper</title> <div class="page-header">
<style> <h1><i class="fas fa-plus-circle"></i> Add Things to Box {{ box.id }}</h1>
body { <p class="breadcrumb">
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; <a href="/"><i class="fas fa-home"></i> Home</a> /
margin: 20px; <a href="/box/{{ box.id }}/"><i class="fas fa-box"></i> Box {{ box.id }}</a> /
background-color: #f5f5f5; Add Things
} </p>
h1 { </div>
color: #333; {% endblock %}
}
.container { {% block content %}
background: white; <div class="section">
padding: 20px; <div style="margin-bottom: 20px; padding: 15px; background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); border-radius: 10px; border-left: 4px solid #667eea;">
border-radius: 8px; <span style="color: #555; font-size: 16px;">
box-shadow: 0 1px 3px rgba(0,0,0,0.1); <strong><i class="fas fa-info-circle"></i> Box:</strong> {{ box.id }} ({{ box.box_type.name }})
} </span>
form { </div>
display: table;
width: 100%;
}
.form-row {
display: table-row;
}
.form-cell {
display: table-cell;
padding: 8px;
}
.form-header {
font-weight: 600;
color: #333;
padding-bottom: 8px;
}
.form-header-cell {
padding-top: 0;
}
.form-cell input {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
box-sizing: border-box;
font-size: 14px;
}
.form-cell input:focus {
outline: none;
border-color: #4a90a4;
}
.form-cell textarea {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
box-sizing: border-box;
font-size: 14px;
resize: vertical;
}
.form-cell textarea:focus {
outline: none;
border-color: #4a90a4;
}
.form-cell select {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
box-sizing: border-box;
font-size: 14px;
background-color: white;
}
.form-cell select:focus {
outline: none;
border-color: #4a90a4;
}
.btn {
background-color: #4a90a4;
color: white;
padding: 12px 24px;
border: none;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
font-weight: 600;
}
.btn:hover {
background-color: #3d7a96;
}
.back-link {
margin-bottom: 20px;
display: inline-block;
color: #4a90a4;
text-decoration: none;
}
.back-link:hover {
text-decoration: underline;
}
.error-list {
color: #d9534f;
list-style: none;
padding: 0;
}
.error-list li {
padding: 8px 0;
margin-bottom: 8px;
}
.success-message {
background-color: #d4edda;
color: #155724;
padding: 15px;
border-radius: 6px;
margin-bottom: 20px;
}
.required {
color: #d9534f;
}
</style>
</head>
<body>
<a href="/" class="back-link">&larr; Home</a>
<h1>Add Things to Box {{ box.id }}</h1> {% if formset.non_form_errors %}
<div class="alert alert-error">
<div class="container"> <i class="fas fa-exclamation-triangle"></i>
<p> {% for form_errors in formset.non_form_errors %}
<strong>Box:</strong> {{ box.id }} ({{ box.box_type.name }}) {% for field, errors in form_errors.items %}
</p> {% for error in errors %}
{{ error }}
{% if formset.non_form_errors %}
<div class="error-list">
{% for form_errors in formset.non_form_errors %}
{% for field, errors in form_errors.items %}
{% for error in errors %}
<li>{{ error }}</li>
{% endfor %}
{% endfor %} {% endfor %}
{% endfor %} {% endfor %}
</div> {% endfor %}
{% endif %} </div>
{% endif %}
{% if formset.total_form_count %}
<form method="post" action=""> {% if success_message %}
{% csrf_token %} <div class="alert alert-success">
<table> <i class="fas fa-check-circle"></i> {{ success_message }}
<tr class="form-row"> </div>
<th class="form-header-cell"></th> {% endif %}
<th class="form-header form-header-cell">Name</th>
<th class="form-header form-header-cell">Type</th> {% if formset.total_form_count %}
<th class="form-header form-header-cell">Description</th> <form method="post" enctype="multipart/form-data" style="overflow-x: auto;">
<th class="form-header form-header-cell">Picture</th> {% csrf_token %}
<table style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">
<thead>
<tr style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white;">
<th style="padding: 12px 15px; text-align: left; font-weight: 600;"></th>
<th style="padding: 12px 15px; text-align: left; font-weight: 600;">Name</th>
<th style="padding: 12px 15px; text-align: left; font-weight: 600;">Type</th>
<th style="padding: 12px 15px; text-align: left; font-weight: 600;">Description</th>
<th style="padding: 12px 15px; text-align: left; font-weight: 600;">Picture</th>
</tr> </tr>
{{ formset.management_form }} </thead>
{% for form in formset %} {{ formset.management_form }}
<tr class="form-row"> {% for form in formset %}
<td class="form-cell"> <tr style="border-bottom: 1px solid #e0e0e0; background: {% if forloop.counter|divisibleby:2 %}#f8f9fa{% endif %};">
{{ form.id }} <td style="padding: 12px 15px;">
</td> {{ form.id }}
<td class="form-cell"> </td>
<td style="padding: 12px 15px;">
<div style="display: flex; gap: 5px; flex-wrap: wrap; align-items: center;">
{{ form.name }} {{ form.name }}
{% for error in form.name.errors %} {% for error in form.name.errors %}
<div class="error-list"> <div style="color: #e74c3c; font-size: 13px; margin-top: 5px;">
<li>{{ error }}</li> <i class="fas fa-exclamation-circle"></i> {{ error }}
</div> </div>
{% endfor %} {% endfor %}
<label class="required">*</label> <span style="color: #e74c3c;">*</span>
</td> </div>
<td class="form-cell"> </td>
<td style="padding: 12px 15px;">
<div style="display: flex; gap: 5px; flex-wrap: wrap; align-items: center;">
{{ form.thing_type }} {{ form.thing_type }}
{% for error in form.thing_type.errors %} {% for error in form.thing_type.errors %}
<div class="error-list"> <div style="color: #e74c3c; font-size: 13px; margin-top: 5px;">
<li>{{ error }}</li> <i class="fas fa-exclamation-circle"></i> {{ error }}
</div> </div>
{% endfor %} {% endfor %}
<label class="required">*</label> <span style="color: #e74c3c;">*</span>
</td> </div>
<td class="form-cell"> </td>
<td style="padding: 12px 15px;">
<div style="display: flex; gap: 5px; flex-wrap: wrap; align-items: center;">
{{ form.description }} {{ form.description }}
{% for error in form.description.errors %} {% for error in form.description.errors %}
<div class="error-list"> <div style="color: #e74c3c; font-size: 13px; margin-top: 5px;">
<li>{{ error }}</li> <i class="fas fa-exclamation-circle"></i> {{ error }}
</div> </div>
{% endfor %} {% endfor %}
</td> </div>
<td class="form-cell"> </td>
<td style="padding: 12px 15px;">
<div style="display: flex; gap: 5px; flex-wrap: wrap; align-items: center;">
{{ form.picture }} {{ form.picture }}
{% for error in form.picture.errors %} {% for error in form.picture.errors %}
<div class="error-list"> <div style="color: #e74c3c; font-size: 13px; margin-top: 5px;">
<li>{{ error }}</li> <i class="fas fa-exclamation-circle"></i> {{ error }}
</div> </div>
{% endfor %} {% endfor %}
</td> </div>
</tr> </td>
{% endfor %} </tr>
<tr class="form-row"> {% endfor %}
<td class="form-cell" colspan="5"> </table>
<button type="submit" class="btn">Save Things</button>
</td>
</tr>
</table>
</form>
{% endif %}
{% if success_message %} <div style="text-align: center; margin-top: 30px;">
<div class="success-message"> <button type="submit" class="btn">
{{ success_message }} <i class="fas fa-save"></i> Save Things
</button>
</div> </div>
{% endif %} </form>
</div> {% endif %}
</body> </div>
</html> {% endblock %}
{% block extra_js %}
<script>
$('form input, form select, form textarea').on('focus', function() {
$(this).css('border-color', '#667eea');
$(this).css('box-shadow', '0 0 0 3px rgba(102, 126, 234, 0.1)');
}).on('blur', function() {
$(this).css('border-color', '#e0e0e0');
$(this).css('box-shadow', 'none');
});
</script>
{% endblock %}

View File

@@ -1,121 +1,90 @@
{% extends "base.html" %}
{% load thumbnail %} {% load thumbnail %}
<!DOCTYPE html>
<html lang="en"> {% block title %}Box {{ box.id }} - LabHelper{% endblock %}
<head>
<meta charset="UTF-8"> {% block page_header %}
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <div class="page-header">
<title>Box {{ box.id }} - LabHelper</title> <h1><i class="fas fa-box"></i> Box {{ box.id }}</h1>
<style> <p class="breadcrumb">
body { <a href="/"><i class="fas fa-home"></i> Home</a> / Box {{ box.id }}
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; </p>
margin: 20px; </div>
background-color: #f5f5f5; {% endblock %}
}
h1 { {% block content %}
color: #333; <div class="section">
} <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; flex-wrap: wrap; gap: 15px;">
.box-info { <div>
background: white; <div style="font-size: 16px; color: #555; margin-bottom: 5px;">
padding: 15px; <strong><i class="fas fa-cube"></i> Type:</strong> {{ box.box_type.name }}
border-radius: 8px; </div>
margin-bottom: 20px; <div style="font-size: 14px; color: #777;">
box-shadow: 0 1px 3px rgba(0,0,0,0.1); <i class="fas fa-ruler-combined"></i> {{ box.box_type.width }} x {{ box.box_type.height }} x {{ box.box_type.length }} mm
} </div>
table { </div>
width: 100%; <a href="{% url 'add_things' box.id %}" class="btn">
border-collapse: collapse; <i class="fas fa-plus"></i> Add Things
background: white; </a>
border-radius: 8px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
th, td {
padding: 12px 15px;
text-align: left;
border-bottom: 1px solid #eee;
}
th {
background-color: #4a90a4;
color: white;
font-weight: 600;
}
tr:hover {
background-color: #f8f9fa;
}
.thumbnail {
width: 200px;
height: 200px;
object-fit: cover;
border-radius: 4px;
}
.no-image {
width: 200px;
height: 200px;
background-color: #e0e0e0;
display: flex;
align-items: center;
justify-content: center;
color: #999;
border-radius: 4px;
}
.back-link {
margin-bottom: 20px;
display: inline-block;
}
.empty-message {
background: white;
padding: 40px;
text-align: center;
border-radius: 8px;
color: #666;
}
</style>
</head>
<body>
<a href="/" class="back-link">&larr; Back to Home</a>
<h1>Box {{ box.id }}</h1>
<div class="box-info">
<strong>Type:</strong> {{ box.box_type.name }}
({{ box.box_type.width }} x {{ box.box_type.height }} x {{ box.box_type.length }} mm)
<br><br>
<a href="/box/{{ box.id }}/add/">+ Add Things</a>
</div> </div>
</div>
{% if things %}
<table> {% if things %}
<thead> <div class="section">
<tr> <div style="overflow-x: auto;">
<th>Picture</th> <table style="width: 100%; border-collapse: collapse;">
<th>Name</th> <thead>
<th>Type</th> <tr style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white;">
<th>Description</th> <th style="padding: 15px 20px; text-align: left; font-weight: 600;">Picture</th>
</tr> <th style="padding: 15px 20px; text-align: left; font-weight: 600;">Name</th>
</thead> <th style="padding: 15px 20px; text-align: left; font-weight: 600;">Type</th>
<tbody> <th style="padding: 15px 20px; text-align: left; font-weight: 600;">Description</th>
{% for thing in things %} </tr>
<tr> </thead>
<td> <tbody>
{% if thing.picture %} {% for thing in things %}
{% thumbnail thing.picture "200x200" crop="center" as thumb %} <tr style="border-bottom: 1px solid #e0e0e0; transition: background 0.2s;">
<img src="{{ thumb.url }}" alt="{{ thing.name }}" class="thumbnail"> <td style="padding: 15px 20px;">
{% endthumbnail %} {% if thing.picture %}
{% else %} {% thumbnail thing.picture "50x50" crop="center" as thumb %}
<div class="no-image">No image</div> <img src="{{ thumb.url }}" alt="{{ thing.name }}" style="width: 50px; height: 50px; object-fit: cover; border-radius: 8px;">
{% endif %} {% endthumbnail %}
</td> {% else %}
<td>{{ thing.name }}</td> <div style="width: 50px; height: 50px; background: linear-gradient(135deg, #e0e0e0 0%, #f0f0f0 100%); display: flex; align-items: center; justify-content: center; color: #999; border-radius: 8px; font-size: 11px;">No image</div>
<td>{{ thing.thing_type.name }}</td> {% endif %}
<td>{{ thing.description|default:"-" }}</td> </td>
</tr> <td style="padding: 15px 20px;">
{% endfor %} <a href="{% url 'thing_detail' thing.id %}" style="color: #667eea; text-decoration: none; font-weight: 500;">{{ thing.name }}</a>
</tbody> </td>
</table> <td style="padding: 15px 20px; color: #555;">{{ thing.thing_type.name }}</td>
{% else %} <td style="padding: 15px 20px; color: #777;">{{ thing.description|default:"-" }}</td>
<div class="empty-message"> </tr>
This box is empty. {% endfor %}
</tbody>
</table>
</div> </div>
{% endif %} </div>
</body> {% else %}
</html> <div class="section" style="text-align: center; padding: 60px 30px;">
<i class="fas fa-box-open" style="font-size: 64px; color: #ddd; margin-bottom: 20px; display: block;"></i>
<h3 style="color: #888; font-size: 20px;">This box is empty</h3>
<p style="color: #999; margin-top: 10px;">Add some items to get started!</p>
<a href="{% url 'add_things' box.id %}" class="btn" style="margin-top: 20px;">
<i class="fas fa-plus"></i> Add Things
</a>
</div>
{% endif %}
{% endblock %}
{% block extra_js %}
<script>
$('tbody tr').hover(
function() {
$(this).css('background', '#f8f9fa');
},
function() {
$(this).css('background', 'white');
}
);
</script>
{% endblock %}

View File

@@ -0,0 +1,216 @@
{% extends "base.html" %}
{% block title %}Box Management - LabHelper{% endblock %}
{% block page_header %}
<div class="page-header">
<h1><i class="fas fa-boxes"></i> Box Management</h1>
<p class="breadcrumb">
<a href="/"><i class="fas fa-home"></i> Home</a> /
Box Management
</p>
</div>
{% endblock %}
{% block content %}
<div class="section">
<h2><i class="fas fa-cube"></i> Box Types</h2>
<form method="post" action="{% url 'add_box_type' %}" style="margin-bottom: 30px; padding: 20px; background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%); border-radius: 10px;">
{% csrf_token %}
<h3 style="margin-bottom: 15px; color: #667eea; font-size: 18px;">Add New Box Type</h3>
<div style="display: flex; gap: 15px; flex-wrap: wrap; align-items: flex-end;">
<div>
<label style="display: block; font-weight: 600; color: #555; margin-bottom: 8px; font-size: 14px;">Name</label>
{{ box_type_form.name }}
</div>
<div>
<label style="display: block; font-weight: 600; color: #555; margin-bottom: 8px; font-size: 14px;">Width (mm)</label>
{{ box_type_form.width }}
</div>
<div>
<label style="display: block; font-weight: 600; color: #555; margin-bottom: 8px; font-size: 14px;">Height (mm)</label>
{{ box_type_form.height }}
</div>
<div>
<label style="display: block; font-weight: 600; color: #555; margin-bottom: 8px; font-size: 14px;">Length (mm)</label>
{{ box_type_form.length }}
</div>
<button type="submit" class="btn">
<i class="fas fa-plus"></i> Add
</button>
</div>
</form>
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px;">
{% for box_type in box_types %}
<div class="box-type-card" style="background: white; padding: 20px; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.08); border: 2px solid #f0f0f0; transition: all 0.3s;" id="box-type-card-{{ box_type.id }}">
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 15px;">
<h3 style="margin: 0; color: #667eea; font-size: 20px; font-weight: 700;" id="box-type-name-{{ box_type.id }}">{{ box_type.name }}</h3>
<div style="display: flex; gap: 8px;">
<button onclick="toggleEditBoxType({{ box_type.id }})" id="edit-btn-{{ box_type.id }}" style="background: none; border: none; cursor: pointer; color: #667eea; padding: 5px; transition: all 0.3s;" onmouseover="this.style.color='#764ba2'" onmouseout="this.style.color='#667eea'">
<i class="fas fa-edit" style="font-size: 18px;"></i>
</button>
{% if not box_type.boxes.exists %}
<form method="post" action="{% url 'delete_box_type' box_type.id %}" style="display: inline;" onsubmit="return confirm('Are you sure you want to delete this box type?');">
{% csrf_token %}
<button type="submit" style="background: none; border: none; cursor: pointer; color: #e74c3c; padding: 5px; transition: all 0.3s;" onmouseover="this.style.color='#c0392b'" onmouseout="this.style.color='#e74c3c'">
<i class="fas fa-trash" style="font-size: 18px;"></i>
</button>
</form>
{% endif %}
</div>
</div>
<div id="box-type-view-{{ box_type.id }}" style="color: #666; font-size: 14px; line-height: 1.6;">
<p style="margin: 5px 0;"><i class="fas fa-ruler-horizontal" style="width: 20px; color: #999;"></i> Width: <strong>{{ box_type.width }} mm</strong></p>
<p style="margin: 5px 0;"><i class="fas fa-ruler-vertical" style="width: 20px; color: #999;"></i> Height: <strong>{{ box_type.height }} mm</strong></p>
<p style="margin: 5px 0;"><i class="fas fa-arrows-alt-h" style="width: 20px; color: #999;"></i> Length: <strong>{{ box_type.length }} mm</strong></p>
</div>
<form id="box-type-edit-{{ box_type.id }}" method="post" action="{% url 'edit_box_type' box_type.id %}" style="display: none;" onsubmit="return confirm('Save changes?');">
{% csrf_token %}
<div style="display: flex; flex-direction: column; gap: 10px;">
<div>
<label style="display: block; font-weight: 600; color: #555; margin-bottom: 5px; font-size: 12px;">Name</label>
<input type="text" name="name" value="{{ box_type.name }}" style="width: 100%; padding: 8px 12px; border: 2px solid #e0e0e0; border-radius: 6px; font-size: 14px;">
</div>
<div style="display: flex; gap: 10px;">
<div style="flex: 1;">
<label style="display: block; font-weight: 600; color: #555; margin-bottom: 5px; font-size: 12px;">Width</label>
<input type="number" name="width" value="{{ box_type.width }}" style="width: 100%; padding: 8px 12px; border: 2px solid #e0e0e0; border-radius: 6px; font-size: 14px;">
</div>
<div style="flex: 1;">
<label style="display: block; font-weight: 600; color: #555; margin-bottom: 5px; font-size: 12px;">Height</label>
<input type="number" name="height" value="{{ box_type.height }}" style="width: 100%; padding: 8px 12px; border: 2px solid #e0e0e0; border-radius: 6px; font-size: 14px;">
</div>
<div style="flex: 1;">
<label style="display: block; font-weight: 600; color: #555; margin-bottom: 5px; font-size: 12px;">Length</label>
<input type="number" name="length" value="{{ box_type.length }}" style="width: 100%; padding: 8px 12px; border: 2px solid #e0e0e0; border-radius: 6px; font-size: 14px;">
</div>
</div>
<button type="submit" class="btn btn-sm" style="width: 100%;">
<i class="fas fa-save"></i> Save
</button>
</div>
</form>
<div style="margin-top: 15px; padding-top: 15px; border-top: 1px solid #e0e0e0; color: #888; font-size: 13px;">
<i class="fas fa-box"></i> {{ box_type.boxes.count }} box{{ box_type.boxes.count|pluralize:"es" }}
</div>
</div>
{% endfor %}
</div>
</div>
<div class="section">
<h2><i class="fas fa-box"></i> Boxes</h2>
<form method="post" action="{% url 'add_box' %}" style="margin-bottom: 30px; padding: 20px; background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%); border-radius: 10px;">
{% csrf_token %}
<h3 style="margin-bottom: 15px; color: #667eea; font-size: 18px;">Add New Box</h3>
<div style="display: flex; gap: 15px; flex-wrap: wrap; align-items: flex-end;">
<div>
<label style="display: block; font-weight: 600; color: #555; margin-bottom: 8px; font-size: 14px;">Box ID</label>
{{ box_form.id }}
</div>
<div>
<label style="display: block; font-weight: 600; color: #555; margin-bottom: 8px; font-size: 14px;">Box Type</label>
{{ box_form.box_type }}
</div>
<button type="submit" class="btn">
<i class="fas fa-plus"></i> Add
</button>
</div>
</form>
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 20px;">
{% for box in boxes %}
<div class="box-card" style="background: white; padding: 20px; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.08); border: 2px solid #f0f0f0; transition: all 0.3s;" id="box-card-{{ box.id }}">
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 15px;">
<h3 style="margin: 0; color: #667eea; font-size: 24px; font-weight: 700;" id="box-id-{{ box.id }}">{{ box.id }}</h3>
<div style="display: flex; gap: 8px;">
<button onclick="toggleEditBox('{{ box.id }}')" id="edit-box-btn-{{ box.id }}" style="background: none; border: none; cursor: pointer; color: #667eea; padding: 5px; transition: all 0.3s;" onmouseover="this.style.color='#764ba2'" onmouseout="this.style.color='#667eea'">
<i class="fas fa-edit" style="font-size: 18px;"></i>
</button>
{% if not box.things.exists %}
<form method="post" action="{% url 'delete_box' box.id %}" style="display: inline;" onsubmit="return confirm('Are you sure you want to delete this box?');">
{% csrf_token %}
<button type="submit" style="background: none; border: none; cursor: pointer; color: #e74c3c; padding: 5px; transition: all 0.3s;" onmouseover="this.style.color='#c0392b'" onmouseout="this.style.color='#e74c3c'">
<i class="fas fa-trash" style="font-size: 18px;"></i>
</button>
</form>
{% endif %}
</div>
</div>
<div id="box-view-{{ box.id }}" style="color: #666; font-size: 14px; margin-bottom: 15px;">
<p style="margin: 5px 0;"><i class="fas fa-cube" style="width: 20px; color: #999;"></i> Type: <strong>{{ box.box_type.name }}</strong></p>
</div>
<form id="box-edit-{{ box.id }}" method="post" action="{% url 'edit_box' box.id %}" style="display: none;" onsubmit="return confirm('Save changes?');">
{% csrf_token %}
<div style="display: flex; flex-direction: column; gap: 10px; margin-bottom: 15px;">
<div>
<label style="display: block; font-weight: 600; color: #555; margin-bottom: 5px; font-size: 12px;">Box ID</label>
<input type="text" name="id" value="{{ box.id }}" style="width: 100%; padding: 8px 12px; border: 2px solid #e0e0e0; border-radius: 6px; font-size: 14px;">
</div>
<div>
<label style="display: block; font-weight: 600; color: #555; margin-bottom: 5px; font-size: 12px;">Box Type</label>
<select name="box_type" style="width: 100%; padding: 8px 12px; border: 2px solid #e0e0e0; border-radius: 6px; font-size: 14px;">
{% for type in box_types %}
<option value="{{ type.id }}" {% if type.id == box.box_type.id %}selected{% endif %}>{{ type.name }}</option>
{% endfor %}
</select>
</div>
<button type="submit" class="btn btn-sm" style="width: 100%;">
<i class="fas fa-save"></i> Save
</button>
</div>
</form>
<div style="padding-top: 15px; border-top: 1px solid #e0e0e0;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<a href="{% url 'box_detail' box.id %}" class="btn btn-sm">
<i class="fas fa-eye"></i> View Contents
</a>
<span style="color: #888; font-size: 13px;">
<i class="fas fa-cube"></i> {{ box.things.count }} thing{{ box.things.count|pluralize }}
</span>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
function toggleEditBoxType(id) {
var viewDiv = document.getElementById('box-type-view-' + id);
var editForm = document.getElementById('box-type-edit-' + id);
var editBtn = document.getElementById('edit-btn-' + id);
if (editForm.style.display === 'none') {
viewDiv.style.display = 'none';
editForm.style.display = 'block';
editBtn.innerHTML = '<i class="fas fa-times" style="font-size: 18px;"></i>';
} else {
viewDiv.style.display = 'block';
editForm.style.display = 'none';
editBtn.innerHTML = '<i class="fas fa-edit" style="font-size: 18px;"></i>';
}
}
function toggleEditBox(id) {
var viewDiv = document.getElementById('box-view-' + id);
var editForm = document.getElementById('box-edit-' + id);
var editBtn = document.getElementById('edit-box-btn-' + id);
if (editForm.style.display === 'none') {
viewDiv.style.display = 'none';
editForm.style.display = 'block';
editBtn.innerHTML = '<i class="fas fa-times" style="font-size: 18px;"></i>';
} else {
viewDiv.style.display = 'block';
editForm.style.display = 'none';
editBtn.innerHTML = '<i class="fas fa-edit" style="font-size: 18px;"></i>';
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,106 @@
{% extends "base.html" %}
{% load mptt_tags %}
{% load dict_extras %}
{% block title %}LabHelper - Home{% endblock %}
{% block page_header %}
<div class="page-header">
<h1><i class="fas fa-home"></i> Welcome to LabHelper</h1>
<p class="breadcrumb">Organize and track your lab inventory</p>
</div>
{% endblock %}
{% block content %}
<div class="section">
<h2><i class="fas fa-box"></i> Boxes</h2>
{% if boxes %}
<div class="box-grid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 20px;">
{% for box in boxes %}
<div class="box-card" style="background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); padding: 20px; border-radius: 12px; border: 1px solid #e0e0e0; transition: all 0.3s ease; cursor: pointer;">
<a href="{% url 'box_detail' box.id %}" style="text-decoration: none; color: #333; display: block;">
<div class="box-id" style="font-size: 20px; font-weight: 700; color: #667eea; margin-bottom: 8px;">
<i class="fas fa-cube"></i> Box {{ box.id }}
</div>
<div class="box-type" style="font-size: 15px; color: #555; margin-bottom: 5px;">
{{ box.box_type.name }}
</div>
<div class="box-type" style="font-size: 13px; color: #777; margin-bottom: 5px;">
<i class="fas fa-ruler-combined"></i> {{ box.box_type.width }} x {{ box.box_type.height }} x {{ box.box_type.length }} mm
</div>
<div class="box-type" style="font-size: 13px; color: #777;">
<i class="fas fa-layer-group"></i> {{ box.things.count }} item{{ box.things.count|pluralize }}
</div>
</a>
</div>
{% endfor %}
</div>
{% else %}
<p style="text-align: center; color: #888; font-size: 16px; padding: 40px;">
<i class="fas fa-box-open" style="font-size: 48px; margin-bottom: 15px; display: block;"></i>
No boxes found.
</p>
{% endif %}
</div>
<div class="section">
<h2><i class="fas fa-folder-tree"></i> Thing Types</h2>
{% if thing_types %}
<ul class="tree" style="list-style: none; padding-left: 0;">
{% recursetree thing_types %}
<li style="padding: 8px 0;">
<div class="tree-item" style="display: flex; align-items: center; gap: 8px;">
{% if children %}
<span class="toggle-handle" style="display: inline-block; width: 24px; color: #667eea; font-weight: bold; cursor: pointer; transition: transform 0.2s;">[+]</span>
{% else %}
<span class="toggle-handle" style="display: inline-block; width: 24px; color: #ccc;">&nbsp;</span>
{% endif %}
<a href="{% url 'thing_type_detail' node.pk %}" style="color: #667eea; text-decoration: none; font-size: 16px; font-weight: 500; transition: color 0.2s;">{{ node.name }}</a>
{% with count=type_counts|get_item:node.pk %}
{% if count and count > 0 %}
<span style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 3px 10px; border-radius: 20px; font-size: 12px; font-weight: 600;">{{ count }}</span>
{% endif %}
{% endwith %}
</div>
{% if children %}
<ul style="list-style: none; padding-left: 32px; display: none;">
{{ children }}
</ul>
{% endif %}
</li>
{% endrecursetree %}
</ul>
{% else %}
<p style="text-align: center; color: #888; font-size: 16px; padding: 40px;">
<i class="fas fa-folder-open" style="font-size: 48px; margin-bottom: 15px; display: block;"></i>
No thing types found.
</p>
{% endif %}
</div>
{% endblock %}
{% block extra_js %}
<script>
$(document).ready(function() {
$('.toggle-handle').click(function(e) {
e.stopPropagation();
var $ul = $(this).closest('li').children('ul');
if ($ul.length) {
$ul.slideToggle(200);
$(this).text($ul.is(':visible') ? '[-]' : '[+]');
}
});
$('.box-card').hover(
function() {
$(this).css('transform', 'translateY(-5px)');
$(this).css('box-shadow', '0 12px 24px rgba(102, 126, 234, 0.2)');
},
function() {
$(this).css('transform', 'translateY(0)');
$(this).css('box-shadow', 'none');
}
);
});
</script>
{% endblock %}

View File

@@ -1,187 +1,125 @@
<!DOCTYPE html> {% extends "base.html" %}
<html lang="en">
<head> {% block title %}Search - LabHelper{% endblock %}
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> {% block page_header %}
<title>Search - LabHelper</title> <div class="page-header">
<style> <h1><i class="fas fa-search"></i> Search Things</h1>
body { <p class="breadcrumb">
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; <a href="/"><i class="fas fa-home"></i> Home</a> / Search
margin: 20px; </p>
background-color: #f5f5f5; </div>
} {% endblock %}
h1 {
color: #333; {% block content %}
} <div class="section">
.search-container { <input type="text"
background: white; id="search-input"
padding: 20px; placeholder="Search for things..."
border-radius: 8px; style="width: 100%; padding: 16px 20px; font-size: 18px; border: 2px solid #e0e0e0; border-radius: 12px; box-sizing: border-box; transition: all 0.3s;">
margin-bottom: 20px; <p style="color: #888; font-size: 14px; margin-top: 10px;">
box-shadow: 0 1px 3px rgba(0,0,0,0.1); <i class="fas fa-info-circle"></i> Type at least 2 characters to search
} </p>
.search-input { </div>
width: 100%;
padding: 12px 15px;
font-size: 16px;
border: 2px solid #ddd;
border-radius: 6px;
box-sizing: border-box;
}
.search-input:focus {
outline: none;
border-color: #4a90a4;
}
.search-hint {
color: #666;
font-size: 14px;
margin-top: 8px;
}
table {
width: 100%;
border-collapse: collapse;
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
th, td {
padding: 10px 12px;
text-align: left;
border-bottom: 1px solid #eee;
}
th {
background-color: #4a90a4;
color: white;
font-weight: 600;
}
tr:hover {
background-color: #f8f9fa;
}
a {
color: #4a90a4;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.back-link {
margin-bottom: 20px;
display: inline-block;
}
.no-results {
background: white;
padding: 40px;
text-align: center;
border-radius: 8px;
color: #666;
}
#results-container {
display: none;
}
.description {
color: #666;
font-size: 13px;
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
</head>
<body>
<a href="/" class="back-link">&larr; Back to Home</a>
<h1>Search Things</h1> <div id="results-container" style="display: none;">
<div class="section" style="overflow-x: auto; padding: 0;">
<div class="search-container"> <table style="width: 100%; border-collapse: collapse;">
<input type="text"
id="search-input"
class="search-input"
placeholder="Search for things..."
autocomplete="off">
<div class="search-hint">Type at least 2 characters to search</div>
</div>
<div id="results-container">
<table>
<thead> <thead>
<tr> <tr style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white;">
<th>Name</th> <th style="padding: 15px 20px; text-align: left; font-weight: 600;">Name</th>
<th>Type</th> <th style="padding: 15px 20px; text-align: left; font-weight: 600;">Type</th>
<th>Box</th> <th style="padding: 15px 20px; text-align: left; font-weight: 600;">Box</th>
<th>Description</th> <th style="padding: 15px 20px; text-align: left; font-weight: 600;">Description</th>
</tr> </tr>
</thead> </thead>
<tbody id="results-body"> <tbody id="results-body">
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
<div id="no-results" class="no-results" style="display: none;"> <div id="no-results" class="section" style="text-align: center; padding: 60px 30px; display: none;">
No results found. <i class="fas fa-search-minus" style="font-size: 64px; color: #ddd; margin-bottom: 20px; display: block;"></i>
</div> <h3 style="color: #888; font-size: 20px;">No results found</h3>
<p style="color: #999; margin-top: 10px;">Try different keywords or browse the full inventory.</p>
</div>
{% endblock %}
<script> {% block extra_js %}
const searchInput = document.getElementById('search-input'); <script>
const resultsContainer = document.getElementById('results-container'); const searchInput = document.getElementById('search-input');
const resultsBody = document.getElementById('results-body'); const resultsContainer = document.getElementById('results-container');
const noResults = document.getElementById('no-results'); const resultsBody = document.getElementById('results-body');
const noResults = document.getElementById('no-results');
let searchTimeout = null;
let searchTimeout = null;
searchInput.addEventListener('input', function() {
const query = this.value.trim(); searchInput.addEventListener('input', function() {
const query = this.value.trim();
// Clear previous timeout
if (searchTimeout) { if (searchTimeout) {
clearTimeout(searchTimeout); clearTimeout(searchTimeout);
} }
// Hide results if query too short if (query.length < 2) {
if (query.length < 2) { resultsContainer.style.display = 'none';
resultsContainer.style.display = 'none'; noResults.style.display = 'none';
return;
}
searchInput.style.borderColor = '#667eea';
searchInput.style.boxShadow = '0 0 0 3px rgba(102, 126, 234, 0.1)';
searchTimeout = setTimeout(function() {
fetch('/search/api/?q=' + encodeURIComponent(query))
.then(response => response.json())
.then(data => {
resultsBody.innerHTML = '';
if (data.results.length === 0) {
resultsContainer.style.display = 'none';
noResults.style.display = 'block';
return;
}
noResults.style.display = 'none'; noResults.style.display = 'none';
return; resultsContainer.style.display = 'block';
}
data.results.forEach(function(thing) {
// Debounce search const row = document.createElement('tr');
searchTimeout = setTimeout(function() { row.style.borderBottom = '1px solid #e0e0e0';
fetch('/search/api/?q=' + encodeURIComponent(query)) row.style.transition = 'background 0.2s';
.then(response => response.json()) row.innerHTML =
.then(data => { '<td style="padding: 15px 20px;"><a href="/thing/' + thing.id + '/">' + escapeHtml(thing.name) + '</a></td>' +
resultsBody.innerHTML = ''; '<td style="padding: 15px 20px; color: #555;">' + escapeHtml(thing.type) + '</td>' +
'<td style="padding: 15px 20px;"><a href="/box/' + escapeHtml(thing.box) + '/">' + escapeHtml(thing.box) + '</a></td>' +
if (data.results.length === 0) { '<td style="padding: 15px 20px; color: #777;" class="description">' + escapeHtml(thing.description) + '</td>';
resultsContainer.style.display = 'none';
noResults.style.display = 'block'; row.addEventListener('mouseenter', function() {
return; this.style.background = '#f8f9fa';
}
noResults.style.display = 'none';
resultsContainer.style.display = 'block';
data.results.forEach(function(thing) {
const row = document.createElement('tr');
row.innerHTML =
'<td><a href="/thing/' + thing.id + '/">' + escapeHtml(thing.name) + '</a></td>' +
'<td>' + escapeHtml(thing.type) + '</td>' +
'<td><a href="/box/' + escapeHtml(thing.box) + '/">' + escapeHtml(thing.box) + '</a></td>' +
'<td class="description">' + escapeHtml(thing.description) + '</td>';
resultsBody.appendChild(row);
});
}); });
}, 200); row.addEventListener('mouseleave', function() {
}); this.style.background = 'white';
});
function escapeHtml(text) {
const div = document.createElement('div'); resultsBody.appendChild(row);
div.textContent = text; });
return div.innerHTML; });
} }, 200);
});
// Focus search input on page load
searchInput.focus(); searchInput.addEventListener('blur', function() {
</script> searchInput.style.borderColor = '#e0e0e0';
</body> searchInput.style.boxShadow = 'none';
</html> });
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
searchInput.focus();
</script>
{% endblock %}

View File

@@ -1,131 +1,226 @@
{% extends "base.html" %}
{% load thumbnail %} {% load thumbnail %}
<!DOCTYPE html>
<html lang="en"> {% block title %}{{ thing.name }} - LabHelper{% endblock %}
<head>
<meta charset="UTF-8"> {% block page_header %}
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <div class="page-header">
<title>{{ thing.name }} - LabHelper</title> <h1><i class="fas fa-cube"></i> {{ thing.name }}</h1>
<style> <p class="breadcrumb">
body { <a href="/"><i class="fas fa-home"></i> Home</a> /
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; <a href="/box/{{ thing.box.id }}/"><i class="fas fa-box"></i> Box {{ thing.box.id }}</a> /
margin: 20px; {{ thing.name }}
background-color: #f5f5f5; </p>
} </div>
h1 { {% endblock %}
color: #333;
} {% block content %}
.thing-card { <div class="section">
background: white; <div class="thing-card" style="display: flex; gap: 40px; flex-wrap: wrap;">
padding: 20px; <div class="thing-image" style="flex-shrink: 0; width: 100%; max-width: 400px;">
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
display: flex;
gap: 30px;
}
.thing-image {
flex-shrink: 0;
}
.thing-image img {
width: 300px;
height: 300px;
object-fit: cover;
border-radius: 8px;
}
.no-image {
width: 300px;
height: 300px;
background-color: #e0e0e0;
display: flex;
align-items: center;
justify-content: center;
color: #999;
border-radius: 8px;
}
.thing-details {
flex-grow: 1;
}
.detail-row {
margin-bottom: 15px;
}
.detail-label {
font-weight: 600;
color: #666;
font-size: 14px;
margin-bottom: 4px;
}
.detail-value {
font-size: 16px;
color: #333;
}
.detail-value a {
color: #4a90a4;
text-decoration: none;
}
.detail-value a:hover {
text-decoration: underline;
}
.description {
white-space: pre-wrap;
line-height: 1.5;
}
.back-link {
margin-bottom: 20px;
display: inline-block;
color: #4a90a4;
text-decoration: none;
}
.back-link:hover {
text-decoration: underline;
}
.nav-links {
margin-bottom: 20px;
}
.nav-links a {
margin-right: 15px;
}
</style>
</head>
<body>
<div class="nav-links">
<a href="/" class="back-link">&larr; Home</a>
<a href="/search/" class="back-link">Search</a>
<a href="/box/{{ thing.box.id }}/" class="back-link">Box {{ thing.box.id }}</a>
</div>
<h1>{{ thing.name }}</h1>
<div class="thing-card">
<div class="thing-image">
{% if thing.picture %} {% if thing.picture %}
{% thumbnail thing.picture "300x300" crop="center" as thumb %} {% thumbnail thing.picture "400x400" crop="center" as thumb %}
<img src="{{ thumb.url }}" alt="{{ thing.name }}"> <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);">
{% endthumbnail %} {% endthumbnail %}
{% else %} {% else %}
<div class="no-image">No image</div> <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="text-align: center;">
<i class="fas fa-image" style="font-size: 64px; margin-bottom: 15px; display: block;"></i>
No image
</div>
</div>
{% endif %} {% endif %}
<form method="post" enctype="multipart/form-data" style="margin-top: 20px;">
{% csrf_token %}
<input type="hidden" name="action" value="upload_picture">
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
<label class="btn" style="cursor: pointer; display: inline-flex; align-items: center; gap: 8px;">
<i class="fas fa-camera"></i>
<span>{% if thing.picture %}Change picture{% else %}Add picture{% endif %}</span>
<input type="file" id="picture_upload" name="picture" accept="image/*" style="display: none;" onchange="this.form.submit();">
</label>
{% if thing.picture %}
<button type="submit" name="action" value="delete_picture" class="btn" style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); border: none;" onclick="return confirm('Are you sure you want to delete this picture?');">
<i class="fas fa-trash"></i>
<span>Remove</span>
</button>
{% endif %}
</div>
</form>
</div> </div>
<div class="thing-details"> <div class="thing-details" style="flex-grow: 1; min-width: 300px;">
<div class="detail-row"> <div class="detail-row" style="margin-bottom: 25px;">
<div class="detail-label">Type</div> <div style="font-size: 14px; color: #888; font-weight: 600; margin-bottom: 8px;">
<div class="detail-value">{{ thing.thing_type.name }}</div> <i class="fas fa-tag"></i> Type
</div>
<div style="font-size: 18px; color: #333; font-weight: 500;">
{{ thing.thing_type.name }}
</div>
</div> </div>
<div class="detail-row"> <div class="detail-row" style="margin-bottom: 25px;">
<div class="detail-label">Location</div> <div style="font-size: 14px; color: #888; font-weight: 600; margin-bottom: 8px;">
<div class="detail-value"> <i class="fas fa-map-marker-alt"></i> Location
<a href="/box/{{ thing.box.id }}/">Box {{ thing.box.id }}</a> </div>
({{ thing.box.box_type.name }}) <div style="font-size: 18px; color: #333;">
<a href="{% url 'box_detail' thing.box.id %}" style="color: #667eea; text-decoration: none; font-weight: 500;">Box {{ thing.box.id }}</a>
<span style="color: #999;"> ({{ thing.box.box_type.name }})</span>
</div> </div>
</div> </div>
{% if thing.description %} {% if thing.description %}
<div class="detail-row"> <div class="detail-row" style="margin-bottom: 25px;">
<div class="detail-label">Description</div> <div style="font-size: 14px; color: #888; font-weight: 600; margin-bottom: 8px;">
<div class="detail-value description">{{ thing.description }}</div> <i class="fas fa-align-left"></i> Description
</div>
<div style="font-size: 16px; color: #555; line-height: 1.6; white-space: pre-wrap;">
{% spaceless %}{{ thing.description }}{% endspaceless %}
</div>
</div>
{% endif %}
{% if thing.files.all %}
<div class="detail-row" style="margin-bottom: 25px;">
<div style="font-size: 14px; color: #888; font-weight: 600; margin-bottom: 8px;">
<i class="fas fa-file-alt"></i> Files
</div>
<div style="display: flex; flex-direction: column; gap: 10px;">
{% for file in thing.files.all %}
<div style="display: flex; align-items: center; justify-content: space-between; padding: 12px 15px; background: #f8f9fa; border-radius: 8px; border: 1px solid #e9ecef;">
<div style="display: flex; align-items: center; gap: 10px;">
<i class="fas fa-paperclip" style="color: #667eea; font-size: 16px;"></i>
<a href="{{ file.file.url }}" target="_blank" style="color: #667eea; text-decoration: none; font-weight: 500; font-size: 15px;">{{ file.title }}</a>
<span style="color: #999; font-size: 12px;">({{ file.filename }})</span>
</div>
<form method="post" style="display: inline;" onsubmit="return confirm('Are you sure you want to delete this file?');">
{% csrf_token %}
<input type="hidden" name="action" value="delete_file">
<input type="hidden" name="file_id" value="{{ file.id }}">
<button type="submit" style="background: none; border: none; cursor: pointer; color: #e74c3c; padding: 5px; transition: all 0.3s;" onmouseover="this.style.color='#c0392b'" onmouseout="this.style.color='#e74c3c'">
<i class="fas fa-times" style="font-size: 14px;"></i>
</button>
</form>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% if thing.links.all %}
<div class="detail-row" style="margin-bottom: 25px;">
<div style="font-size: 14px; color: #888; font-weight: 600; margin-bottom: 8px;">
<i class="fas fa-link"></i> Links
</div>
<div style="display: flex; flex-direction: column; gap: 10px;">
{% for link in thing.links.all %}
<div style="display: flex; align-items: center; justify-content: space-between; padding: 12px 15px; background: #f8f9fa; border-radius: 8px; border: 1px solid #e9ecef;">
<div style="display: flex; align-items: center; gap: 10px;">
<i class="fas fa-external-link-alt" style="color: #667eea; font-size: 16px;"></i>
<a href="{{ link.url }}" target="_blank" style="color: #667eea; text-decoration: none; font-weight: 500; font-size: 15px;">{{ link.title }}</a>
</div>
<form method="post" style="display: inline;" onsubmit="return confirm('Are you sure you want to delete this link?');">
{% csrf_token %}
<input type="hidden" name="action" value="delete_link">
<input type="hidden" name="link_id" value="{{ link.id }}">
<button type="submit" style="background: none; border: none; cursor: pointer; color: #e74c3c; padding: 5px; transition: all 0.3s;" onmouseover="this.style.color='#c0392b'" onmouseout="this.style.color='#e74c3c'">
<i class="fas fa-times" style="font-size: 14px;"></i>
</button>
</form>
</div>
{% endfor %}
</div>
</div> </div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
</body> </div>
</html>
<div class="section">
<h2 style="color: #667eea; font-size: 20px; font-weight: 700; margin-top: 0; margin-bottom: 20px; display: flex; align-items: center; gap: 10px;">
<i class="fas fa-plus-circle"></i> Add Attachments
</h2>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px,1fr)); gap: 30px;">
<div>
<h3 style="margin: 0 0 15px 0; color: #667eea; font-size: 16px; font-weight: 600;">
<i class="fas fa-file-upload"></i> Upload File
</h3>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
<input type="hidden" name="action" value="add_file">
<div style="display: flex; flex-direction: column; gap: 15px;">
<div>
<label for="file_title" style="font-weight: 600; color: #666; font-size: 14px; margin-bottom: 8px; display: block;">Title</label>
<input type="text" id="file_title" name="title" style="width: 100%; padding: 10px 15px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 14px;">
</div>
<div>
<label for="file_upload" style="font-weight: 600; color: #666; font-size: 14px; margin-bottom: 8px; display: block;">File</label>
<input type="file" id="file_upload" name="file" style="width: 100%; padding: 10px 15px; border: 2px dashed #e0e0e0; border-radius: 8px; font-size: 14px; background: #f8f9fa;">
</div>
<button type="submit" class="btn">
<i class="fas fa-upload"></i> Upload File
</button>
</div>
</form>
</div>
<div>
<h3 style="margin: 0 0 15px 0; color: #667eea; font-size: 16px; font-weight: 600;">
<i class="fas fa-link"></i> Add Link
</h3>
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="add_link">
<div style="display: flex; flex-direction: column; gap: 15px;">
<div>
<label for="link_title" style="font-weight: 600; color: #666; font-size: 14px; margin-bottom: 8px; display: block;">Title</label>
<input type="text" id="link_title" name="title" style="width: 100%; padding: 10px 15px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 14px;">
</div>
<div>
<label for="link_url" style="font-weight: 600; color: #666; font-size: 14px; margin-bottom: 8px; display: block;">URL</label>
<input type="url" id="link_url" name="url" style="width: 100%; padding: 10px 15px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 14px;">
</div>
<button type="submit" class="btn">
<i class="fas fa-plus"></i> Add Link
</button>
</div>
</form>
</div>
</div>
<form method="post" class="section">
{% csrf_token %}
<input type="hidden" name="action" value="move">
<div style="display: flex; align-items: center; gap: 15px; flex-wrap: wrap;">
<div style="flex-grow: 1;">
<label for="new_box" style="font-weight: 600; color: #666; font-size: 14px; margin-bottom: 8px; display: block;">
<i class="fas fa-exchange-alt"></i> Move to:
</label>
<select name="new_box" id="new_box" style="width: 100%; max-width: 400px; padding: 12px 16px; border: 2px solid #e0e0e0; border-radius: 10px; font-size: 15px; background: white; cursor: pointer; transition: all 0.3s;">
<option value="">Select a box...</option>
{% for box in boxes %}
<option value="{{ box.id }}" {% if box.id == thing.box.id %}selected{% endif %}>
Box {{ box.id }} ({{ box.box_type.name }})
</option>
{% endfor %}
</select>
</div>
<button type="submit" class="btn" style="height: 48px; min-width: 120px; margin-top: 24px;">
<i class="fas fa-arrows-alt"></i> Move
</button>
</div>
</form>
{% endblock %}
{% block extra_js %}
<script>
$('#new_box').on('focus', function() {
$(this).css('border-color', '#667eea');
$(this).css('box-shadow', '0 0 0 3px rgba(102, 126, 234, 0.1)');
}).on('blur', function() {
$(this).css('border-color', '#e0e0e0');
$(this).css('box-shadow', 'none');
});
</script>
{% endblock %}

View File

@@ -0,0 +1,106 @@
{% extends "base.html" %}
{% load thumbnail %}
{% block title %}{{ thing_type.name }} - LabHelper{% endblock %}
{% block page_header %}
<div class="page-header">
<h1><i class="fas fa-folder"></i> {{ thing_type.name }}</h1>
<p class="breadcrumb">
<a href="/"><i class="fas fa-home"></i> Home</a> / {{ thing_type.name }}
</p>
</div>
{% endblock %}
{% block content %}
<div class="section" style="padding: 20px;">
{% if thing_type.parent %}
<div style="margin-bottom: 15px; padding: 15px; background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); border-radius: 10px; border-left: 4px solid #667eea;">
<span style="color: #666; font-size: 14px;">
<i class="fas fa-level-up-alt"></i> Parent:
<a href="{% url 'thing_type_detail' thing_type.parent.id %}" style="color: #667eea; text-decoration: none; font-weight: 500;">{{ thing_type.parent.name }}</a>
</span>
</div>
{% endif %}
{% if thing_type.children.exists %}
<div style="margin-bottom: 20px; padding: 15px; background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); border-radius: 10px; border-left: 4px solid #764ba2;">
<span style="color: #666; font-size: 14px;">
<i class="fas fa-sitemap"></i> Subtypes:
{% for child in thing_type.children.all %}
<a href="{% url 'thing_type_detail' child.id %}" style="color: #667eea; text-decoration: none; font-weight: 500; margin-left: 8px;">{{ child.name }}</a>
{% endfor %}
</span>
</div>
{% endif %}
</div>
{% if things_by_type %}
{% for subtype, things in things_by_type.items %}
<div class="section">
<h2><i class="fas fa-cubes"></i> {{ subtype.name }}</h2>
{% if things %}
<div style="overflow-x: auto;">
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white;">
<th style="padding: 15px 20px; text-align: left; font-weight: 600;">Picture</th>
<th style="padding: 15px 20px; text-align: left; font-weight: 600;">Name</th>
<th style="padding: 15px 20px; text-align: left; font-weight: 600;">Box</th>
<th style="padding: 15px 20px; text-align: left; font-weight: 600;">Description</th>
</tr>
</thead>
<tbody>
{% for thing in things %}
<tr style="border-bottom: 1px solid #e0e0e0; transition: background 0.2s;">
<td style="padding: 15px 20px;">
{% if thing.picture %}
{% thumbnail thing.picture "50x50" crop="center" as thumb %}
<img src="{{ thumb.url }}" alt="{{ thing.name }}" style="width: 50px; height: 50px; object-fit: cover; border-radius: 8px;">
{% endthumbnail %}
{% else %}
<div style="width: 50px; height: 50px; background: linear-gradient(135deg, #e0e0e0 0%, #f0f0f0 100%); display: flex; align-items: center; justify-content: center; color: #999; border-radius: 8px; font-size: 11px;">No image</div>
{% endif %}
</td>
<td style="padding: 15px 20px;">
<a href="{% url 'thing_detail' thing.id %}" style="color: #667eea; text-decoration: none; font-weight: 500;">{{ thing.name }}</a>
</td>
<td style="padding: 15px 20px;">
<a href="{% url 'box_detail' thing.box.id %}" style="color: #667eea; text-decoration: none;">Box {{ thing.box.id }}</a>
<br><span style="color: #999; font-size: 13px;">{{ thing.box.box_type.name }}</span>
</td>
<td style="padding: 15px 20px; color: #777;">{{ thing.description|default:"-" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div style="text-align: center; padding: 40px; color: #999;">
<i class="fas fa-inbox" style="font-size: 48px; margin-bottom: 15px; display: block;"></i>
No things in this category
</div>
{% endif %}
</div>
{% endfor %}
{% else %}
<div class="section" style="text-align: center; padding: 60px 30px;">
<i class="fas fa-folder-open" style="font-size: 64px; color: #ddd; margin-bottom: 20px; display: block;"></i>
<h3 style="color: #888; font-size: 20px;">No things found</h3>
<p style="color: #999; margin-top: 10px;">This category or its subcategories are empty.</p>
</div>
{% endif %}
{% endblock %}
{% block extra_js %}
<script>
$('tbody tr').hover(
function() {
$(this).css('background', '#f8f9fa');
},
function() {
$(this).css('background', 'white');
}
);
</script>
{% endblock %}

View File

View File

@@ -0,0 +1,7 @@
from django import template
register = template.Library()
@register.filter
def get_item(dictionary, key):
return dictionary.get(key)

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +1,39 @@
from django.contrib.auth.decorators import login_required
from django.db.models import Q
from django.http import HttpResponse, JsonResponse from django.http import HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from .forms import ThingFormSet from .forms import (
from .models import Box, Thing BoxForm,
BoxTypeForm,
ThingFileForm,
ThingFormSet,
ThingLinkForm,
ThingPictureForm,
)
from .models import Box, BoxType, Thing, ThingFile, ThingLink, ThingType
@login_required
def index(request): def index(request):
"""Simple index page.""" """Home page with boxes and thing types."""
html = '<h1>LabHelper</h1><p><a href="/search/">Search Things</a> | <a href="/admin/">Admin</a></p>' boxes = Box.objects.select_related('box_type').all().order_by('id')
return HttpResponse(html) thing_types = ThingType.objects.all()
type_counts = {}
for thing_type in thing_types:
descendants = thing_type.get_descendants(include_self=True)
count = Thing.objects.filter(thing_type__in=descendants).count()
type_counts[thing_type.pk] = count
return render(request, 'boxes/index.html', {
'boxes': boxes,
'thing_types': thing_types,
'type_counts': type_counts,
})
@login_required
def box_detail(request, box_id): def box_detail(request, box_id):
"""Display contents of a box.""" """Display contents of a box."""
box = get_object_or_404(Box, pk=box_id) box = get_object_or_404(Box, pk=box_id)
@@ -21,20 +44,96 @@ def box_detail(request, box_id):
}) })
@login_required
def thing_detail(request, thing_id): def thing_detail(request, thing_id):
"""Display details of a thing.""" """Display details of a thing."""
thing = get_object_or_404( thing = get_object_or_404(
Thing.objects.select_related('thing_type', 'box', 'box__box_type'), Thing.objects.select_related('thing_type', 'box', 'box__box_type').prefetch_related('files', 'links'),
pk=thing_id pk=thing_id
) )
return render(request, 'boxes/thing_detail.html', {'thing': thing})
boxes = Box.objects.select_related('box_type').all().order_by('id')
picture_form = ThingPictureForm(instance=thing)
file_form = ThingFileForm()
link_form = ThingLinkForm()
if request.method == 'POST':
action = request.POST.get('action')
if action == 'move':
new_box_id = request.POST.get('new_box')
if new_box_id:
new_box = get_object_or_404(Box, pk=new_box_id)
thing.box = new_box
thing.save()
return redirect('thing_detail', thing_id=thing.id)
elif action == 'upload_picture':
picture_form = ThingPictureForm(request.POST, request.FILES, instance=thing)
if picture_form.is_valid():
picture_form.save()
return redirect('thing_detail', thing_id=thing.id)
elif action == 'delete_picture':
if thing.picture:
thing.picture.delete()
thing.picture = None
thing.save()
return redirect('thing_detail', thing_id=thing.id)
elif action == 'add_file':
file_form = ThingFileForm(request.POST, request.FILES)
if file_form.is_valid():
thing_file = file_form.save(commit=False)
thing_file.thing = thing
thing_file.save()
return redirect('thing_detail', thing_id=thing.id)
elif action == 'add_link':
link_form = ThingLinkForm(request.POST)
if link_form.is_valid():
thing_link = link_form.save(commit=False)
thing_link.thing = thing
thing_link.save()
return redirect('thing_detail', thing_id=thing.id)
elif action == 'delete_file':
file_id = request.POST.get('file_id')
if file_id:
try:
thing_file = ThingFile.objects.get(pk=file_id, thing=thing)
thing_file.file.delete()
thing_file.delete()
except ThingFile.DoesNotExist:
pass
return redirect('thing_detail', thing_id=thing.id)
elif action == 'delete_link':
link_id = request.POST.get('link_id')
if link_id:
try:
thing_link = ThingLink.objects.get(pk=link_id, thing=thing)
thing_link.delete()
except ThingLink.DoesNotExist:
pass
return redirect('thing_detail', thing_id=thing.id)
return render(request, 'boxes/thing_detail.html', {
'thing': thing,
'boxes': boxes,
'picture_form': picture_form,
'file_form': file_form,
'link_form': link_form,
})
@login_required
def search(request): def search(request):
"""Search page for things.""" """Search page for things."""
return render(request, 'boxes/search.html') return render(request, 'boxes/search.html')
@login_required
def search_api(request): def search_api(request):
"""AJAX endpoint for searching things.""" """AJAX endpoint for searching things."""
query = request.GET.get('q', '').strip() query = request.GET.get('q', '').strip()
@@ -42,8 +141,14 @@ def search_api(request):
return JsonResponse({'results': []}) return JsonResponse({'results': []})
things = Thing.objects.filter( things = Thing.objects.filter(
name__icontains=query Q(name__icontains=query) |
).select_related('thing_type', 'box')[:50] Q(description__icontains=query) |
Q(thing_type__name__icontains=query) |
Q(files__title__icontains=query) |
Q(files__file__icontains=query) |
Q(links__title__icontains=query) |
Q(links__url__icontains=query)
).prefetch_related('files', 'links').select_related('thing_type', 'box').distinct()[:50]
results = [ results = [
{ {
@@ -52,12 +157,27 @@ def search_api(request):
'type': thing.thing_type.name, 'type': thing.thing_type.name,
'box': thing.box.id, 'box': thing.box.id,
'description': thing.description[:100] if thing.description else '', 'description': thing.description[:100] if thing.description else '',
'files': [
{
'title': f.title,
'filename': f.filename(),
}
for f in thing.files.all()
],
'links': [
{
'title': l.title,
'url': l.url,
}
for l in thing.links.all()
],
} }
for thing in things for thing in things
] ]
return JsonResponse({'results': results}) return JsonResponse({'results': results})
@login_required
def add_things(request, box_id): def add_things(request, box_id):
"""Add multiple things to a box at once.""" """Add multiple things to a box at once."""
box = get_object_or_404(Box, pk=box_id) box = get_object_or_404(Box, pk=box_id)
@@ -65,7 +185,7 @@ def add_things(request, box_id):
success_message = None success_message = None
if request.method == 'POST': if request.method == 'POST':
formset = ThingFormSet(request.POST) formset = ThingFormSet(request.POST, request.FILES, queryset=Thing.objects.filter(box=box))
if formset.is_valid(): if formset.is_valid():
things = formset.save(commit=False) things = formset.save(commit=False)
@@ -77,10 +197,111 @@ def add_things(request, box_id):
created_count += 1 created_count += 1
if created_count > 0: if created_count > 0:
success_message = f'Added {created_count} thing{"s" if created_count > 1 else ""} successfully.' success_message = f'Added {created_count} thing{"s" if created_count > 1 else ""} successfully.'
formset = ThingFormSet() formset = ThingFormSet(queryset=Thing.objects.filter(box=box))
else:
formset = ThingFormSet(queryset=Thing.objects.filter(box=box))
return render(request, 'boxes/add_things.html', { return render(request, 'boxes/add_things.html', {
'box': box, 'box': box,
'formset': formset, 'formset': formset,
'success_message': success_message, 'success_message': success_message,
}) })
@login_required
def thing_type_detail(request, type_id):
"""Display details of a thing type with its hierarchy and things."""
thing_type = get_object_or_404(ThingType, pk=type_id)
descendants = thing_type.get_descendants(include_self=True)
things_by_type = {}
for descendant in descendants:
things = descendant.things.select_related('box', 'box__box_type').all()
if things:
things_by_type[descendant] = things
return render(request, 'boxes/thing_type_detail.html', {
'thing_type': thing_type,
'things_by_type': things_by_type,
})
@login_required
def box_management(request):
"""Main page for managing boxes and box types."""
box_types = BoxType.objects.all().prefetch_related('boxes')
boxes = Box.objects.select_related('box_type').all().prefetch_related('things')
box_type_form = BoxTypeForm()
box_form = BoxForm()
return render(request, 'boxes/box_management.html', {
'box_types': box_types,
'boxes': boxes,
'box_type_form': box_type_form,
'box_form': box_form,
})
@login_required
def add_box_type(request):
"""Add a new box type."""
if request.method == 'POST':
form = BoxTypeForm(request.POST)
if form.is_valid():
form.save()
return redirect('box_management')
@login_required
def edit_box_type(request, type_id):
"""Edit an existing box type."""
box_type = get_object_or_404(BoxType, pk=type_id)
if request.method == 'POST':
form = BoxTypeForm(request.POST, instance=box_type)
if form.is_valid():
form.save()
return redirect('box_management')
@login_required
def delete_box_type(request, type_id):
"""Delete a box type."""
box_type = get_object_or_404(BoxType, pk=type_id)
if request.method == 'POST':
if box_type.boxes.exists():
return redirect('box_management')
box_type.delete()
return redirect('box_management')
@login_required
def add_box(request):
"""Add a new box."""
if request.method == 'POST':
form = BoxForm(request.POST)
if form.is_valid():
form.save()
return redirect('box_management')
@login_required
def edit_box(request, box_id):
"""Edit an existing box."""
box = get_object_or_404(Box, pk=box_id)
if request.method == 'POST':
form = BoxForm(request.POST, instance=box)
if form.is_valid():
form.save()
return redirect('box_management')
@login_required
def delete_box(request, box_id):
"""Delete a box."""
box = get_object_or_404(Box, pk=box_id)
if request.method == 'POST':
if box.things.exists():
return redirect('box_management')
box.delete()
return redirect('box_management')

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

View File

View File

@@ -0,0 +1,60 @@
from django.contrib.auth.models import Group, User
from django.core.management.base import BaseCommand
class Command(BaseCommand):
help = 'Create default users and groups for LabHelper'
def handle(self, *args, **options):
self.stdout.write('Creating default users and groups...')
groups = {
'Lab Administrators': 'Full access to all lab functions',
'Lab Staff': 'Can view and search items, add things to boxes',
'Lab Viewers': 'Read-only access to view and search',
}
for group_name, description in groups.items():
group, created = Group.objects.get_or_create(name=group_name)
if created:
self.stdout.write(self.style.SUCCESS(f'Created group: {group_name}'))
else:
self.stdout.write(f'Group already exists: {group_name}')
users = {
'admin': ('Lab Administrators', True),
'staff': ('Lab Staff', False),
'viewer': ('Lab Viewers', False),
}
for username, (group_name, is_superuser) in users.items():
if User.objects.filter(username=username).exists():
self.stdout.write(f'User already exists: {username}')
continue
user = User.objects.create_user(
username=username,
email=f'{username}@labhelper.local',
password=f'{username}123',
is_superuser=is_superuser,
is_staff=is_superuser,
)
group = Group.objects.get(name=group_name)
user.groups.add(group)
if is_superuser:
self.stdout.write(
self.style.SUCCESS(f'Created superuser: {username} (password: {username}123)')
)
else:
self.stdout.write(
self.style.SUCCESS(f'Created user: {username} (password: {username}123)')
)
self.stdout.write(self.style.SUCCESS('\nDefault users and groups created successfully!'))
self.stdout.write('\nLogin credentials:')
self.stdout.write(' admin / admin123')
self.stdout.write(' staff / staff123')
self.stdout.write(' viewer / viewer123')
self.stdout.write('\nPlease change these passwords after first login!')

View File

@@ -10,6 +10,7 @@ For the full list of settings and their values, see
https://docs.djangoproject.com/en/5.2/ref/settings/ https://docs.djangoproject.com/en/5.2/ref/settings/
""" """
import os
from pathlib import Path from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'. # Build paths inside the project like this: BASE_DIR / 'subdir'.
@@ -20,7 +21,7 @@ BASE_DIR = Path(__file__).resolve().parent.parent
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ # See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret! # SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = '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 = True
@@ -38,6 +39,7 @@ INSTALLED_APPS = [
'django.contrib.sessions', 'django.contrib.sessions',
'django.contrib.messages', 'django.contrib.messages',
'django.contrib.staticfiles', 'django.contrib.staticfiles',
'mptt',
'django_mptt_admin', 'django_mptt_admin',
'sorl.thumbnail', 'sorl.thumbnail',
'boxes', 'boxes',
@@ -58,7 +60,7 @@ ROOT_URLCONF = 'labhelper.urls'
TEMPLATES = [ TEMPLATES = [
{ {
'BACKEND': 'django.template.backends.django.DjangoTemplates', 'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [], 'DIRS': [BASE_DIR / 'labhelper' / 'templates'],
'APP_DIRS': True, 'APP_DIRS': True,
'OPTIONS': { 'OPTIONS': {
'context_processors': [ 'context_processors': [
@@ -129,3 +131,7 @@ MEDIA_ROOT = BASE_DIR / 'data' / 'media'
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field # https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
LOGIN_URL = 'login'
LOGIN_REDIRECT_URL = 'index'
LOGOUT_REDIRECT_URL = 'login'

View File

@@ -0,0 +1,259 @@
{% load static %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}LabHelper{% endblock %}</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 0 20px;
}
.navbar {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 15px;
padding: 15px 30px;
margin: 20px auto;
max-width: 1200px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 15px;
}
.navbar-brand {
font-size: 28px;
font-weight: 700;
color: #667eea;
text-decoration: none;
display: flex;
align-items: center;
gap: 10px;
}
.navbar-brand i {
font-size: 24px;
}
.navbar-nav {
display: flex;
gap: 20px;
align-items: center;
}
.navbar-nav a {
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;
}
.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;
}
{% block extra_css %}{% endblock %}
</style>
{% block extra_head %}{% endblock %}
</head>
<body>
<nav class="navbar">
<a href="/" class="navbar-brand">
<i class="fas fa-flask"></i>
LabHelper
</a>
<div class="navbar-nav">
<a href="/"><i class="fas fa-home"></i> Home</a>
<a href="/box-management/"><i class="fas fa-boxes"></i> Box Management</a>
<a href="/search/"><i class="fas fa-search"></i> Search</a>
<a href="/admin/"><i class="fas fa-cog"></i> Admin</a>
{% if user.is_authenticated %}
<form method="post" action="{% url 'logout' %}" style="display: inline;">
{% csrf_token %}
<button type="submit" style="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;">
<i class="fas fa-sign-out-alt"></i> Logout ({{ user.username }})
</button>
</form>
{% else %}
<a href="{% url 'login' %}"><i class="fas fa-sign-in-alt"></i> Login</a>
{% endif %}
</div>
</nav>
<div class="container">
{% block page_header %}{% endblock %}
{% block content %}{% endblock %}
</div>
<footer class="footer">
<p>&copy; 2025 LabHelper. Built with <i class="fas fa-heart"></i> for science.</p>
</footer>
{% block extra_js %}{% endblock %}
</body>
</html>

View File

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

View File

@@ -18,13 +18,39 @@ from django.conf import settings
from django.conf.urls.static import static from django.conf.urls.static import static
from django.contrib import admin from django.contrib import admin
from django.urls import path from django.urls import path
from django.contrib.auth import views as auth_views
from boxes.views import add_things, box_detail, index, search, search_api, thing_detail from boxes.views import (
add_box,
add_box_type,
add_things,
box_detail,
box_management,
delete_box,
delete_box_type,
edit_box,
edit_box_type,
index,
search,
search_api,
thing_detail,
thing_type_detail,
)
urlpatterns = [ urlpatterns = [
path('login/', auth_views.LoginView.as_view(template_name='login.html'), name='login'),
path('logout/', auth_views.LogoutView.as_view(), name='logout'),
path('', index, name='index'), path('', index, name='index'),
path('box-management/', box_management, name='box_management'),
path('box-type/add/', add_box_type, name='add_box_type'),
path('box-type/<int:type_id>/edit/', edit_box_type, name='edit_box_type'),
path('box-type/<int:type_id>/delete/', delete_box_type, name='delete_box_type'),
path('box/add/', add_box, name='add_box'),
path('box/<str:box_id>/edit/', edit_box, name='edit_box'),
path('box/<str:box_id>/delete/', delete_box, name='delete_box'),
path('box/<str:box_id>/', box_detail, name='box_detail'), path('box/<str:box_id>/', box_detail, name='box_detail'),
path('thing/<int:thing_id>/', thing_detail, name='thing_detail'), path('thing/<int:thing_id>/', thing_detail, name='thing_detail'),
path('thing-type/<int:type_id>/', thing_type_detail, name='thing_type_detail'),
path('box/<str:box_id>/add/', add_things, name='add_things'), path('box/<str:box_id>/add/', add_things, name='add_things'),
path('search/', search, name='search'), path('search/', search, name='search'),
path('search/api/', search_api, name='search_api'), path('search/api/', search_api, name='search_api'),

45
scripts/deploy_secret.sh Executable file
View File

@@ -0,0 +1,45 @@
#!/bin/bash
# Generate and deploy Django secret key to Kubernetes
NAMESPACE="labhelper"
SECRET_NAME="django-secret"
SECRET_FILE="argocd/secret.yaml"
# Check if secret file exists
if [ ! -f "$SECRET_FILE" ]; then
echo "Error: $SECRET_FILE not found"
exit 1
fi
# Generate random secret key
SECRET_KEY=$(python3 -c "import secrets; print(secrets.token_urlsafe(50))")
# Create temporary secret file with generated key
TEMP_SECRET_FILE=$(mktemp)
cat "$SECRET_FILE" | sed "s/CHANGE_ME_TO_RANDOM_STRING/$SECRET_KEY/g" > "$TEMP_SECRET_FILE"
# Check if secret already exists
if kubectl get secret "$SECRET_NAME" -n "$NAMESPACE" &>/dev/null; then
echo "Secret $SECRET_NAME already exists in namespace $NAMESPACE"
read -p "Do you want to replace it? (y/N): " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Aborted"
rm "$TEMP_SECRET_FILE"
exit 0
fi
kubectl apply -f "$TEMP_SECRET_FILE"
echo "Secret updated successfully"
else
kubectl apply -f "$TEMP_SECRET_FILE"
echo "Secret created successfully"
fi
# Clean up
rm "$TEMP_SECRET_FILE"
echo ""
echo "Secret deployed:"
echo " Name: $SECRET_NAME"
echo " Namespace: $NAMESPACE"
echo " Key: secret-key"

45
scripts/full_deploy.sh Executable file
View File

@@ -0,0 +1,45 @@
#!/bin/bash
# Full deployment script - bumps both container versions by 0.001 and copies database
DEPLOYMENT_FILE="argocd/deployment.yaml"
DB_SOURCE="data/db.sqlite3"
DB_DEST="data-loader/preload.sqlite3"
# Check if deployment file exists
if [ ! -f "$DEPLOYMENT_FILE" ]; then
echo "Error: $DEPLOYMENT_FILE not found"
exit 1
fi
# Check if source database exists
if [ ! -f "$DB_SOURCE" ]; then
echo "Error: $DB_SOURCE not found"
exit 1
fi
# Extract current version of data-loader
LOADER_VERSION=$(grep -E "image: git.baumann.gr/adebaumann/labhelper-data-loader:[0-9]" "$DEPLOYMENT_FILE" | sed -E 's/.*:([0-9.]+)/\1/')
# Extract current version of main container
MAIN_VERSION=$(grep -E "image: git.baumann.gr/adebaumann/labhelper:[0-9]" "$DEPLOYMENT_FILE" | grep -v "data-loader" | sed -E 's/.*:([0-9.]+)/\1/')
if [ -z "$LOADER_VERSION" ] || [ -z "$MAIN_VERSION" ]; then
echo "Error: Could not find current versions"
exit 1
fi
# Calculate new versions (add 0.001), preserve leading zero
NEW_LOADER_VERSION=$(echo "$LOADER_VERSION + 0.001" | bc | sed 's/^\./0./')
NEW_MAIN_VERSION=$(echo "$MAIN_VERSION + 0.001" | bc | sed 's/^\./0./')
# Update the 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"
# Copy database
cp "$DB_SOURCE" "$DB_DEST"
echo "Full deployment prepared:"
echo " Data loader: $LOADER_VERSION -> $NEW_LOADER_VERSION"
echo " Main container: $MAIN_VERSION -> $NEW_MAIN_VERSION"
echo " Database copied to $DB_DEST"

27
scripts/partial_deploy.sh Executable file
View File

@@ -0,0 +1,27 @@
#!/bin/bash
# Partial deployment script - bumps main container version by 0.001
DEPLOYMENT_FILE="argocd/deployment.yaml"
# Check if file exists
if [ ! -f "$DEPLOYMENT_FILE" ]; then
echo "Error: $DEPLOYMENT_FILE not found"
exit 1
fi
# Extract current version of main container (labhelper, not labhelper-data-loader)
CURRENT_VERSION=$(grep -E "image: git.baumann.gr/adebaumann/labhelper:[0-9]" "$DEPLOYMENT_FILE" | grep -v "data-loader" | sed -E 's/.*:([0-9.]+)/\1/')
if [ -z "$CURRENT_VERSION" ]; then
echo "Error: Could not find current version"
exit 1
fi
# Calculate new version (add 0.001), preserve leading zero
NEW_VERSION=$(echo "$CURRENT_VERSION + 0.001" | bc | sed 's/^\./0./')
# Update the deployment file (only the main container, not the data-loader)
sed -i "s|image: git.baumann.gr/adebaumann/labhelper:$CURRENT_VERSION|image: git.baumann.gr/adebaumann/labhelper:$NEW_VERSION|" "$DEPLOYMENT_FILE"
echo "Partial deployment prepared:"
echo " Main container: $CURRENT_VERSION -> $NEW_VERSION"