57 Commits

Author SHA1 Message Date
d46f0385c9 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 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 4s
2026-01-06 13:11:41 +01:00
ebf3b9d00a Restructured pages 2026-01-06 12:55:04 +01:00
be2a0028f4 Troubleshooting NFS PV 2026-01-06 12:25:30 +01:00
4efaf17776 Troubleshooting NFS PV 2026-01-06 12:22:48 +01:00
d02f6d1d1d Kubernetes storageClass changed to "nfs-labhelper" in order not to conflict with Vorgabenportal
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 8s
2026-01-06 11:21:15 +01:00
9599807752 FIXME created for quick tagging
All checks were successful
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
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/labhelper) (push) Successful in 4s
2026-01-06 01:06:44 +01:00
56db405839 "Show Count" on ThingAdmin fixed 2026-01-06 00:39:19 +01:00
074f9263dd Display fixes on resources, links 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 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 4s
2026-01-05 14:52:12 +01:00
35140e9686 Menu changed to dropdown on full screen as well (for certain things)
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 4s
2026-01-05 14:27:47 +01:00
b756e1b411 Resources 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 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
2026-01-05 14:06:51 +01:00
11e593f8ce Responsive main menu added, AGENTS updated.
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 4s
2026-01-05 13:37:09 +01:00
da506221f7 Box edit taken out into it's own page; Editing of all fields 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 18s
Build containers when image tags change / build-if-image-changed (data-loader, loader, initContainers, init-container, git.baumann.gr/adebaumann/labhelper-data-loader) (push) Successful in 4s
2026-01-05 13:28:10 +01:00
ca50832b54 Markdown support for description fields added; Tests updated
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 1m44s
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-05 11:00:16 +01:00
5c0b09f78e Tests and AGENTS.md updated 2026-01-05 09:33:39 +01:00
73b39ec189 Merge pull request 'Some bugs (box-management didn't work); Tags now on search and in box content' (#5) from feature/tagging 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 6s
Build containers when image tags change / build-if-image-changed (data-loader, loader, initContainers, init-container, git.baumann.gr/adebaumann/labhelper-data-loader) (push) Successful in 3s
Reviewed-on: #5
2026-01-04 10:12:24 +00:00
232d2270c3 Some bugs (box-management didn't work); Tags now on search and in box content
All checks were successful
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/labhelper) (push) Successful in 1m3s
Build containers when image tags change / build-if-image-changed (data-loader, loader, initContainers, init-container, git.baumann.gr/adebaumann/labhelper-data-loader) (push) Successful in 12s
2026-01-04 11:10:12 +01:00
8263afb2a5 Merge pull request 'feature/tagging' (#4) from feature/tagging 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 3s
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
Reviewed-on: #4
2026-01-03 21:28:38 +00:00
68bd013ac9 Full deployment with new 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 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 12s
2026-01-03 22:26:17 +01:00
cd04a21157 Complete replacement of Thing types with tag system 2026-01-03 22:23:35 +01:00
cb3e9d6aec CSV output management command addede 2026-01-02 17:08:23 +01:00
bb23f7f574 Partial deployment
Some checks failed
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/labhelper) (push) Failing after 3m54s
Build containers when image tags change / build-if-image-changed (data-loader, loader, initContainers, init-container, git.baumann.gr/adebaumann/labhelper-data-loader) (push) Successful in 7s
2026-01-01 17:07:43 +01:00
b51bf23726 CSRF trusted hosts added 2026-01-01 17:06:35 +01:00
a4783bea2c Agents file updated 2026-01-01 16:42:17 +01:00
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
51 changed files with 5623 additions and 934 deletions

4
.gitignore vendored
View File

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

356
AGENTS.md
View File

@@ -4,7 +4,7 @@ This document provides guidelines for AI coding agents working in the labhelper
## Project Overview
- **Type**: Django web application
- **Type**: Django web application (lab inventory management system)
- **Python**: 3.13.7
- **Django**: 5.2.9
- **Database**: SQLite (development)
@@ -66,6 +66,21 @@ python manage.py collectstatic # Collect static files
gunicorn labhelper.wsgi:application # Run with Gunicorn
```
### Custom Management Commands
```bash
# Create default users and groups
python manage.py create_default_users
# Clean up orphaned files from deleted things
python manage.py clean_orphaned_files
python manage.py clean_orphaned_files --dry-run
# Clean up orphaned images and thumbnails
python manage.py clean_orphaned_images
python manage.py clean_orphaned_images --dry-run
```
## Code Style Guidelines
### Python Style
@@ -178,23 +193,91 @@ def get_box(request: HttpRequest, box_id: int) -> HttpResponse:
```
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
├── .gitea/
│ └── workflows/
│ └── build-containers-on-demand.yml # CI/CD workflow
├── argocd/ # Kubernetes deployment manifests
│ ├── 001_pvc.yaml # PersistentVolumeClaim
│ ├── deployment.yaml # Deployment + Service
── ingress.yaml # Traefik ingress
│ ├── nfs-pv.yaml # NFS PersistentVolume
├── nfs-storageclass.yaml # NFS StorageClass
── secret.yaml # Django secret key template
├── boxes/ # Main Django app
├── management/
│ └── commands/
│ │ ├── clean_orphaned_files.py # Cleanup orphaned ThingFile attachments
│ │ └── clean_orphaned_images.py # Cleanup orphaned Thing images
│ ├── migrations/ # Database migrations
│ ├── templates/
│ │ └── boxes/
│ │ ├── add_things.html # Form to add multiple things
│ │ ├── box_detail.html # Box contents view
│ │ ├── box_management.html # Box/BoxType CRUD management
│ │ ├── boxes_list.html # Boxes list page with tabular view
│ │ ├── edit_thing.html # Edit thing page (name, description, picture, tags, files, links)
│ │ ├── index.html # Home page with search and tags
│ │ ├── resources_list.html # List all links and files from things
│ │ └── thing_detail.html # Read-only thing details view
│ ├── templatetags/
│ │ └── dict_extras.py # Custom template filters: get_item, render_markdown, truncate_markdown
│ ├── admin.py # Admin configuration
│ ├── apps.py # App configuration
│ ├── forms.py # All forms and formsets
│ ├── models.py # Data models
│ ├── tests.py # Test cases
│ └── views.py # View functions
├── data-loader/ # Init container for database preload
│ ├── Dockerfile # Alpine-based init container
│ └── preload.sqlite3 # Preloaded database for deployment
├── labhelper/ # Project configuration
│ ├── management/
│ │ └── commands/
│ │ └── create_default_users.py # Create default users/groups
│ ├── templates/
│ │ ├── base.html # Base template with navigation
│ │ └── login.html # Login page
│ ├── asgi.py # ASGI configuration
│ ├── settings.py # Django settings
│ ├── urls.py # Root URL configuration
│ └── wsgi.py # WSGI configuration
├── scripts/
│ ├── deploy_secret.sh # Generate and deploy Django secret
│ ├── full_deploy.sh # Bump both container versions + copy DB
│ └── partial_deploy.sh # Bump main container version only
├── .gitignore
├── AGENTS.md # This file
├── Dockerfile # Multi-stage build for main container
├── manage.py # Django CLI entry point
└── requirements.txt # Python dependencies
```
## Data Models
### boxes app
| Model | Description | Key Fields |
|-------|-------------|------------|
| **BoxType** | Type of storage box with dimensions | `name`, `width`, `height`, `length` (in mm) |
| **Box** | A storage box in the lab | `id` (CharField PK, max 10), `box_type` (FK) |
| **Facet** | A category of tags (e.g., Priority, Category) | `name`, `slug`, `color`, `cardinality` (single/multiple) |
| **Tag** | A tag value for a specific facet | `facet` (FK), `name` |
| **Thing** | An item stored in a box | `name`, `box` (FK), `description` (Markdown), `picture`, `tags` (M2M) |
| **ThingFile** | File attachment for a Thing | `thing` (FK), `file`, `title`, `uploaded_at` |
| **ThingLink** | Hyperlink for a Thing | `thing` (FK), `url`, `title`, `uploaded_at` |
**Model Relationships:**
- BoxType -> Box (1:N via `boxes` related_name, PROTECT on delete)
- Box -> Thing (1:N via `things` related_name, PROTECT on delete)
- Facet -> Tag (1:N via `tags` related_name, CASCADE on delete)
- Thing <-> Tag (M2M via `tags` related_name on Thing, `things` related_name on Tag)
- Thing -> ThingFile (1:N via `files` related_name, CASCADE on delete)
- Thing -> ThingLink (1:N via `links` related_name, CASCADE on delete)
**Facet Cardinality:**
- `single`: A thing can have at most one tag from this facet (e.g., Priority: High/Medium/Low)
- `multiple`: A thing can have multiple tags from this facet (e.g., Category: Electronics, Tools)
## Available Django Extensions
The project includes these pre-installed packages:
@@ -205,6 +288,234 @@ The project includes these pre-installed packages:
- **django-nested-admin**: Nested inline forms in admin
- **django-nested-inline**: Additional nested inline support
- **django-revproxy**: Reverse proxy functionality
- **sorl-thumbnail**: Image thumbnailing
- **Pillow**: Image processing
- **gunicorn**: Production WSGI server
- **Markdown**: Markdown processing
- **bleach**: HTML sanitization
- **coverage**: Test coverage
- **Font Awesome**: Icon library (loaded via CDN)
- **jQuery**: JavaScript library (loaded via CDN)
## Frontend/CSS Guidelines
### Base Template
The project uses a base template system at `labhelper/templates/base.html`. All page templates should extend this base:
```django
{% extends "base.html" %}
{% block title %}Page Title - LabHelper{% endblock %}
{% block page_header %}
<!-- Optional page header with breadcrumbs -->
{% endblock %}
{% block content %}
<!-- Main page content -->
{% endblock %}
{% block extra_css %}{% endblock %}
{% block extra_js %}{% endblock %}
```
### Design System
**Color Scheme:**
- Primary gradient: `#667eea` (purple) to `#764ba2` (purple-blue)
- Success: Green gradient
- Error: Red gradient
- Background: Light gray `#f5f5f5` with gradient overlays
- Cards: White with subtle shadows
**Components:**
- **Navigation**: Glassmorphism effect with blur backdrop. Desktop (≥769px) shows horizontal menu with dropdown for authenticated user (contains Box Management, Resources, Admin, Logout)
- **Buttons**: Gradient backgrounds with hover lift effect
- **Cards**: White with rounded corners and box shadows
- **Tables**: Gradient headers with hover row effects
- **Alerts**: Gradient backgrounds with icons
- **Form Inputs**: Focused states with color transitions
**Typography:**
- System fonts: `-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif`
- Headings: Bold, colored
- Body: Regular, dark gray
**Icons:**
- Font Awesome 6.5.1 (CDN)
- Use semantic icons for actions
- Color: Match context or inherit from parent
**Responsive Design:**
- Mobile-first approach
- Grid layouts with `repeat(auto-fill, minmax(250px, 1fr))`
- Flexbox for component layouts
- Breakpoints handled by grid and flex-wrap
- **Navigation**: Responsive navbar with hamburger menu on mobile (≤768px) and horizontal menu with user dropdown on desktop (≥769px). Mobile keeps all items in the dropdown list
### CSS Guidelines
**Naming:**
- Use descriptive class names
- BEM pattern encouraged for complex components
- Inline styles allowed for template-specific styling
**Styles:**
- Use base template styles when possible
- Template-specific styles in `{% block extra_css %}`
- JavaScript in `{% block extra_js %}`
- Smooth transitions (0.2s - 0.3s)
- Hover effects with transform and box-shadow
**jQuery Usage:**
- Loaded in base template
- Use for interactive elements (toggles, hovers)
- Event delegation for dynamically added elements
- Focus/blur events for form inputs
### Available Pages/Views
| View Function | URL Pattern | Name | Description |
|---------------|-------------|------|-------------|
| `index` | `/` | `index` | Home page with search and tags overview |
| `boxes_list` | `/search/` | `search`, `boxes_list` | Boxes list page with tabular view |
| `box_management` | `/box-management/` | `box_management` | Manage boxes and box types |
| `add_box_type` | `/box-type/add/` | `add_box_type` | Add new box type |
| `edit_box_type` | `/box-type/<int:type_id>/edit/` | `edit_box_type` | Edit box type |
| `delete_box_type` | `/box-type/<int:type_id>/delete/` | `delete_box_type` | Delete box type |
| `add_box` | `/box/add/` | `add_box` | Add new box |
| `edit_box` | `/box/<str:box_id>/edit/` | `edit_box` | Edit box |
| `delete_box` | `/box/<str:box_id>/delete/` | `delete_box` | Delete box |
| `box_detail` | `/box/<str:box_id>/` | `box_detail` | View box contents |
| `thing_detail` | `/thing/<int:thing_id>/` | `thing_detail` | Read-only view of thing details |
| `edit_thing` | `/thing/<int:thing_id>/edit/` | `edit_thing` | Edit thing (name, description, picture, tags, files, links, move) |
| `add_things` | `/box/<str:box_id>/add/` | `add_things` | Add multiple things to a box |
| `search_api` | `/search/api/` | `search_api` | AJAX search endpoint |
| `resources_list` | `/resources/` | `resources_list` | List all links and files from things (sorted by thing name) |
| `LoginView` | `/login/` | `login` | Django auth login |
| `LogoutView` | `/logout/` | `logout` | Django auth logout |
| `admin.site` | `/admin/` | - | Django admin |
**All views except login require authentication via `@login_required`.**
### Template Best Practices
1. **Always extend base template**
```django
{% extends "base.html" %}
```
2. **Use block system for content injection**
- `title`: Page title tag
- `page_header`: Page header with breadcrumbs
- `content`: Main page content
- `extra_css`: Additional styles
- `extra_js`: Additional JavaScript
3. **Load required template tags**
```django
{% load static %}
{% load mptt_tags %}
{% load thumbnail %}
{% load dict_extras %}
```
4. **Use URL names for links**
```django
<a href="{% url 'box_detail' box.id %}">
```
5. **Use icons with Font Awesome**
```django
<i class="fas fa-box"></i>
```
6. **Add breadcrumbs for navigation**
```django
<p class="breadcrumb">
<a href="/"><i class="fas fa-home"></i> Home</a> /
<a href="/box/{{ box.id }}/"><i class="fas fa-box"></i> Box {{ box.id }}</a>
</p>
```
7. **Icon alignment in lists**: When using icons in list items, use fixed width containers to ensure proper alignment
```django
<a href="{% url 'thing_detail' thing.id %}" style="display: inline-block; width: 20px; text-align: center;">
<i class="fas fa-link"></i>
</a>
```
### Markdown Support
The `Thing.description` field supports Markdown formatting with HTML sanitization for security.
**Available Template Filters:**
- `render_markdown`: Converts Markdown text to sanitized HTML with automatic link handling
- Converts Markdown syntax (headers, lists, bold, italic, links, code, tables, etc.)
- Sanitizes HTML using `bleach` to prevent XSS attacks
- Automatically adds `target="_blank"` and `rel="noopener noreferrer"` to external links
- Use in `thing_detail.html` for full rendered Markdown
- `truncate_markdown`: Converts Markdown to plain text and truncates
- Strips HTML tags after Markdown conversion
- Adds ellipsis (`...`) if text exceeds specified length (default: 100)
- Use in `box_detail.html` or search API previews where space is limited
**Usage Examples:**
```django
<!-- Full Markdown rendering -->
<div class="markdown-content">
{{ thing.description|render_markdown }}
</div>
<!-- Truncated plain text preview -->
{{ thing.description|truncate_markdown:100 }}
```
**Supported Markdown Features:**
- Bold: `**text**` or `__text__`
- Italic: `*text*` or `_text_`
- Headers: `# Header 1`, `## Header 2`, etc.
- Lists: `- item` or `1. item`
- Links: `[text](url)`
- Code: `` `code` `` or ` ```code block```
- Blockquotes: `> quote`
- Tables: `| A | B |\n|---|---|`
**Security:**
- All Markdown is sanitized before rendering
- Dangerous HTML tags (`<script>`, `<iframe>`, etc.) are stripped
- Only safe HTML tags and attributes are allowed
- External links automatically get `target="_blank"` and security attributes
## Forms
| Form | Model | Purpose |
|------|-------|---------|
| `ThingForm` | Thing | Add/edit a thing (name, description, picture) - tags managed separately |
| `ThingPictureForm` | Thing | Upload/change thing picture only |
| `ThingFileForm` | ThingFile | Add file attachment |
| `ThingLinkForm` | ThingLink | Add link |
| `BoxTypeForm` | BoxType | Add/edit box type |
| `BoxForm` | Box | Add/edit box |
| `ThingFormSet` | Thing | Formset for adding multiple things |
## Management Commands
### boxes app
| Command | Description | Options |
|---------|-------------|---------|
| `clean_orphaned_files` | Clean up orphaned files from deleted things | `--dry-run` |
| `clean_orphaned_images` | Clean up orphaned images and thumbnails | `--dry-run` |
### labhelper project
| Command | Description |
|---------|-------------|
| `create_default_users` | Create default users and groups (admin/admin123, staff/staff123, viewer/viewer123) |
## Testing Guidelines
@@ -241,11 +552,14 @@ Per `.gitignore`:
## 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
1. **NEVER commit or push without explicit permission**: Always ask the user before running `git commit` or `git push`. The user will explicitly say "commit and push" when they want you to do this. Do NOT automatically commit/push after making changes unless instructed to do so.
2. **Always activate venv**: `source .venv/bin/activate`
3. **Run migrations after model changes**: `makemigrations` then `migrate`
4. **Add new apps to INSTALLED_APPS** in `settings.py`
5. **Templates in labhelper/templates/**: The base template and shared templates are in `labhelper/templates/`. App-specific templates remain in `app_name/templates/`.
6. **Use get_object_or_404** instead of bare `.get()` calls
7. **Never commit SECRET_KEY** - use environment variables in production
8. **Be careful with process management**: Avoid blanket kills on ports (e.g., `lsof -ti:8000 | xargs kill -9`) as they can kill unintended processes like web browsers. Use specific process kills instead: `pkill -f "process_name"`
## Deployment Commands

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

@@ -6,7 +6,7 @@ metadata:
spec:
accessModes:
- ReadWriteMany
storageClassName: nfs
storageClassName: nfs-labhelper
resources:
requests:
storage: 2Gi

View File

@@ -18,19 +18,25 @@ spec:
fsGroupChangePolicy: "OnRootMismatch"
initContainers:
- name: loader
image: git.baumann.gr/adebaumann/labhelper-data-loader:0.007
image: git.baumann.gr/adebaumann/labhelper-data-loader:0.014
securityContext:
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:
- name: data
mountPath: /data
containers:
- name: web
image: git.baumann.gr/adebaumann/labhelper:0.025
image: git.baumann.gr/adebaumann/labhelper:0.058
imagePullPolicy: Always
ports:
- containerPort: 8000
env:
- name: DJANGO_SECRET_KEY
valueFrom:
secretKeyRef:
name: django-secret
key: secret-key
volumeMounts:
- name: data
mountPath: /app/data

View File

@@ -9,7 +9,7 @@ spec:
accessModes:
- ReadWriteMany
persistentVolumeReclaimPolicy: Retain
storageClassName: nfs
storageClassName: nfs-labhelper
nfs:
server: 192.168.17.199
path: /mnt/user/labhelper

View File

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

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,41 @@
from django import forms
from django.contrib import admin
from django_mptt_admin.admin import DjangoMpttAdmin
from django.contrib.admin import SimpleListFilter
from django.utils.html import format_html
from .models import Box, BoxType, Thing, ThingType
from .models import Box, BoxType, Facet, Tag, Thing, ThingFile, ThingLink
class BoxFilter(SimpleListFilter):
"""Custom filter for boxes using pk to avoid spaces in aliases."""
title = 'box'
parameter_name = 'box__pk'
def lookups(self, request, model_admin):
boxes = Box.objects.select_related('box_type').order_by('id')
return [(box.pk, str(box)) for box in boxes]
def queryset(self, request, queryset):
if self.value():
return queryset.filter(box__pk=self.value())
return queryset
class TagsFilter(SimpleListFilter):
"""Custom filter for tags using pk to avoid spaces in aliases."""
title = 'tags'
parameter_name = 'tags__pk'
def lookups(self, request, model_admin):
tags = Tag.objects.select_related('facet').order_by('facet__name', 'name')
return [(tag.pk, str(tag)) for tag in tags]
def queryset(self, request, queryset):
if self.value():
return queryset.filter(tags__pk=self.value())
return queryset
@admin.register(BoxType)
@@ -21,17 +55,95 @@ class BoxAdmin(admin.ModelAdmin):
search_fields = ('id',)
@admin.register(ThingType)
class ThingTypeAdmin(DjangoMpttAdmin):
"""Admin configuration for ThingType model."""
class ThingFileInline(admin.TabularInline):
"""Inline admin for Thing files."""
search_fields = ('name',)
model = ThingFile
extra = 1
fields = ('title', 'file')
class ThingLinkInline(admin.TabularInline):
"""Inline admin for Thing links."""
model = ThingLink
extra = 1
fields = ('title', 'url')
@admin.register(Thing)
class ThingAdmin(admin.ModelAdmin):
"""Admin configuration for Thing model."""
list_display = ('name', 'thing_type', 'box')
list_filter = ('thing_type', 'box')
list_display = ('name', 'box')
list_filter = (BoxFilter, TagsFilter)
search_fields = ('name', 'description')
filter_horizontal = ('tags',)
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',)
class ColorInput(forms.TextInput):
"""Color picker widget using HTML5 color input."""
input_type = 'color'
class FacetAdminForm(forms.ModelForm):
"""Form for Facet model with color picker widget."""
class Meta:
model = Facet
fields = '__all__'
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['color'].widget = ColorInput(attrs={'type': 'color', 'style': 'height: 50px; width: 100px;'})
@admin.register(Facet)
class FacetAdmin(admin.ModelAdmin):
"""Admin configuration for Facet model."""
form = FacetAdminForm
list_display = ('name', 'color_preview', 'cardinality')
search_fields = ('name',)
prepopulated_fields = {'slug': ('name',)}
list_filter = ('cardinality',)
def color_preview(self, obj):
return format_html('<span style="color: {}; font-weight: bold;">■ {}</span>', obj.color, obj.color)
@admin.register(Tag)
class TagAdmin(admin.ModelAdmin):
"""Admin configuration for Tag model."""
list_display = ('__str__', 'facet_with_color')
list_filter = ('facet',)
search_fields = ('name', 'facet__name')
def facet_with_color(self, obj):
if obj.facet:
return format_html('<span style="color: {}; font-weight: bold;">{}</span>', obj.facet.color, obj.facet.name)
return '-'
facet_with_color.short_description = 'Facet'
@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,21 +1,78 @@
from django import forms
from .models import Thing
from .models import Box, BoxType, Thing, ThingFile, ThingLink
class ThingForm(forms.ModelForm):
"""Form for adding a Thing."""
"""Form for adding/editing a Thing."""
class Meta:
model = Thing
fields = ('name', 'thing_type', 'description', 'picture')
fields = ('name', 'description', 'picture')
widgets = {
'name': forms.TextInput(attrs={'class': 'form-control'}),
'thing_type': forms.Select(attrs={'class': 'form-control'}),
'description': forms.Textarea(attrs={'class': 'form-control', 'rows': 4}),
}
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(
Thing,
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,25 @@
from django.core.management.base import BaseCommand
from boxes.models import Thing
class Command(BaseCommand):
help = 'List all things with their full thing type path and box ID'
def get_thing_type_path(self, thing_type):
"""Get the full path of a thing type with underscores instead of spaces."""
ancestors = list(thing_type.get_ancestors(include_self=True))
path_parts = [ancestor.name.replace(' ', '_') for ancestor in ancestors]
return '/'.join(path_parts)
def handle(self, *args, **options):
things = Thing.objects.select_related('thing_type', 'box').all()
if not things.exists():
self.stdout.write(self.style.WARNING('No things found'))
return
for thing in things:
type_path = self.get_thing_type_path(thing.thing_type)
self.stdout.write(f'{thing.name}: {type_path}, box {thing.box.id}')
self.stdout.write(self.style.SUCCESS(f'\nTotal: {things.count()} things'))

View File

@@ -5,14 +5,6 @@ import mptt.fields
from django.db import migrations, models
def rebuild_tree(apps, schema_editor):
"""Rebuild MPTT tree after adding fields."""
ThingType = apps.get_model('boxes', 'ThingType')
# Import the actual model to use rebuild
from boxes.models import ThingType as RealThingType
RealThingType.objects.rebuild()
class Migration(migrations.Migration):
dependencies = [
@@ -20,49 +12,4 @@ class Migration(migrations.Migration):
]
operations = [
migrations.AddField(
model_name='thingtype',
name='parent',
field=mptt.fields.TreeForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name='children',
to='boxes.thingtype'
),
),
migrations.AddField(
model_name='thingtype',
name='level',
field=models.PositiveIntegerField(default=0, editable=False),
preserve_default=False,
),
migrations.AddField(
model_name='thingtype',
name='lft',
field=models.PositiveIntegerField(default=0, editable=False),
preserve_default=False,
),
migrations.AddField(
model_name='thingtype',
name='rght',
field=models.PositiveIntegerField(default=0, editable=False),
preserve_default=False,
),
migrations.AddField(
model_name='thingtype',
name='tree_id',
field=models.PositiveIntegerField(db_index=True, default=0, editable=False),
preserve_default=False,
),
migrations.AlterModelOptions(
name='thingtype',
options={},
),
migrations.AlterField(
model_name='thingtype',
name='name',
field=models.CharField(max_length=255),
),
migrations.RunPython(rebuild_tree, migrations.RunPython.noop),
]

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

@@ -0,0 +1,35 @@
# Generated by Django 5.2.9 on 2026-01-02 16:21
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('boxes', '0005_thingfile_thinglink'),
]
operations = [
migrations.CreateModel(
name='Tag',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)),
],
options={
'ordering': ['name'],
},
),
migrations.AlterField(
model_name='thing',
name='thing_type',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='things', to='boxes.thingtype'),
),
migrations.AddField(
model_name='thing',
name='tags',
field=models.ManyToManyField(blank=True, related_name='things', to='boxes.tag'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.9 on 2026-01-02 16:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('boxes', '0006_tag_alter_thing_thing_type_thing_tags'),
]
operations = [
migrations.AddField(
model_name='tag',
name='color',
field=models.CharField(default='#667eea', help_text='Hex color code (e.g., #667eea)', max_length=7),
),
]

View File

@@ -0,0 +1,53 @@
# Generated by Django 5.2.9 on 2026-01-02 16:44
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('boxes', '0007_tag_color'),
]
operations = [
migrations.CreateModel(
name='Facet',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)),
('color', models.CharField(default='#667eea', help_text='Hex color code (e.g., #667eea)', max_length=7)),
('cardinality', models.CharField(choices=[('single', 'Single (0..1)'), ('multiple', 'Multiple (0..n)')], default='multiple', help_text='Can a thing have multiple tags of this facet?', max_length=10)),
],
options={
'ordering': ['name'],
},
),
migrations.AlterModelOptions(
name='tag',
options={'ordering': ['facet', 'name']},
),
migrations.AlterField(
model_name='tag',
name='name',
field=models.CharField(help_text='Tag description (e.g., "High", "Electronics")', max_length=100),
),
migrations.AddField(
model_name='tag',
name='facet',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='tags', to='boxes.facet'),
),
migrations.AlterUniqueTogether(
name='tag',
unique_together={('facet', 'name')},
),
migrations.RemoveField(
model_name='tag',
name='color',
),
migrations.RemoveField(
model_name='tag',
name='slug',
),
]

View File

@@ -0,0 +1,75 @@
# Generated by Django 5.2.9 on 2026-01-02 16:44
from django.db import migrations
def migrate_tags_to_facets(apps, schema_editor):
"""Migrate existing tags to facet-based system."""
Tag = apps.get_model('boxes', 'Tag')
Facet = apps.get_model('boxes', 'Facet')
Thing = apps.get_model('boxes', 'Thing')
# Store old tag data with colors from dump file
tag_colors = {}
try:
with open('/tmp/tags_dump.txt', 'r') as f:
for line in f:
tag_id, name, slug, color = line.strip().split(',')
tag_colors[int(tag_id)] = color
except FileNotFoundError:
pass
# Parse tags and create facets
facets = {}
old_tags = list(Tag.objects.all())
for old_tag in old_tags:
tag_id = old_tag.id
name = old_tag.name
color = tag_colors.get(tag_id, '#667eea')
# Check if tag uses "Facet:Description" format
if ':' in name:
facet_name, tag_description = name.split(':', 1)
facet_name = facet_name.strip()
tag_description = tag_description.strip()
else:
# Simple tags go to "General" facet
facet_name = 'General'
tag_description = name
# Get or create facet
if facet_name not in facets:
facet, created = Facet.objects.get_or_create(
name=facet_name,
defaults={'color': color, 'slug': facet_name.lower().replace(' ', '-')}
)
facets[facet_name] = facet
# Update existing tag with facet and new name
old_tag.facet = facets[facet_name]
old_tag.name = tag_description
old_tag.save()
def reverse_migrate_tags_to_facets(apps, schema_editor):
"""Reverse migration: convert back to simple tags."""
Tag = apps.get_model('boxes', 'Tag')
# Convert all tags back to simple format
for tag in Tag.objects.all():
if tag.facet and tag.facet.name != 'General':
# Format as "Facet:Description"
tag.name = f"{tag.facet.name}:{tag.name}"
tag.facet = None
tag.save()
class Migration(migrations.Migration):
dependencies = [
('boxes', '0008_facet_alter_tag_options_alter_tag_name_tag_facet_and_more'),
]
operations = [
migrations.RunPython(migrate_tags_to_facets, reverse_migrate_tags_to_facets),
]

View File

@@ -0,0 +1,35 @@
# Migration to remove ThingType hierarchy
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('boxes', '0009_migrate_tags_to_facets'),
]
operations = [
# Remove thing_type field from Thing
migrations.AlterField(
model_name='thing',
name='thing_type',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='boxes.thingtype', related_name='things'),
),
# Remove thing_type field from Thing completely
migrations.RemoveField(
model_name='thing',
name='thing_type',
),
# Make facet field non-nullable in Tag
migrations.AlterField(
model_name='tag',
name='facet',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='boxes.facet', related_name='tags'),
),
# Delete ThingType model
migrations.DeleteModel(
name='ThingType',
),
]

View File

@@ -1,5 +1,16 @@
import os
from django.db import models
from mptt.models import MPTTModel, TreeForeignKey
from django.utils.text import slugify
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):
@@ -38,44 +49,145 @@ class Box(models.Model):
return self.id
class ThingType(MPTTModel):
"""A hierarchical type/category for things stored in boxes."""
class Facet(models.Model):
"""A category of tags (e.g., Priority, Category, Status)."""
name = models.CharField(max_length=255)
parent = TreeForeignKey(
'self',
on_delete=models.CASCADE,
null=True,
blank=True,
related_name='children'
class Cardinality(models.TextChoices):
SINGLE = 'single', 'Single (0..1)'
MULTIPLE = 'multiple', 'Multiple (0..n)'
name = models.CharField(max_length=100, unique=True)
slug = models.SlugField(max_length=100, unique=True)
color = models.CharField(
max_length=7,
default='#667eea',
help_text='Hex color code (e.g., #667eea)'
)
class MPTTMeta:
order_insertion_by = ['name']
def __str__(self):
return self.name
class Thing(models.Model):
"""An item stored in a box."""
name = models.CharField(max_length=255)
thing_type = models.ForeignKey(
ThingType,
on_delete=models.PROTECT,
related_name='things'
cardinality = models.CharField(
max_length=10,
choices=Cardinality.choices,
default=Cardinality.MULTIPLE,
help_text='Can a thing have multiple tags of this facet?'
)
box = models.ForeignKey(
Box,
on_delete=models.PROTECT,
related_name='things'
)
description = models.TextField(blank=True)
picture = models.ImageField(upload_to='things/', blank=True)
class Meta:
ordering = ['name']
def __str__(self):
return self.name
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.name)
super().save(*args, **kwargs)
class Tag(models.Model):
"""A tag value for a specific facet."""
facet = models.ForeignKey(
Facet,
on_delete=models.CASCADE,
related_name='tags'
)
name = models.CharField(
max_length=100,
help_text='Tag description (e.g., "High", "Electronics")'
)
class Meta:
ordering = ['facet', 'name']
unique_together = [['facet', 'name']]
def __str__(self):
return f'{self.facet.name}:{self.name}'
class Thing(models.Model):
"""An item stored in a box."""
name = models.CharField(max_length=255)
box = models.ForeignKey(
Box,
on_delete=models.PROTECT,
related_name='things'
)
description = models.TextField(blank=True)
picture = models.ImageField(upload_to=thing_picture_upload_path, blank=True)
tags = models.ManyToManyField(
Tag,
blank=True,
related_name='things'
)
class Meta:
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):
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>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Add Things to Box {{ box.id }} - LabHelper</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 20px;
background-color: #f5f5f5;
}
h1 {
color: #333;
}
.container {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
form {
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>
{% extends "base.html" %}
<h1>Add Things to Box {{ box.id }}</h1>
{% block title %}Add Things to Box {{ box.id }} - LabHelper{% endblock %}
<div class="container">
<p>
<strong>Box:</strong> {{ box.id }} ({{ box.box_type.name }})
</p>
{% block page_header %}
<div class="page-header">
<h1><i class="fas fa-plus-circle"></i> Add Things to Box {{ box.id }}</h1>
<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> /
Add Things
</p>
</div>
{% endblock %}
{% 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 %}
{% block content %}
<div class="section">
<div style="margin-bottom: 20px; padding: 15px; background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); border-radius: 10px; border-left: 4px solid #667eea;">
<span style="color: #555; font-size: 16px;">
<strong><i class="fas fa-info-circle"></i> Box:</strong> {{ box.id }} ({{ box.box_type.name }})
</span>
</div>
{% if formset.non_form_errors %}
<div class="alert alert-error">
<i class="fas fa-exclamation-triangle"></i>
{% for form_errors in formset.non_form_errors %}
{% for field, errors in form_errors.items %}
{% for error in errors %}
{{ error }}
{% endfor %}
{% endfor %}
</div>
{% endif %}
{% endfor %}
</div>
{% endif %}
{% if formset.total_form_count %}
<form method="post" action="">
{% csrf_token %}
<table>
<tr class="form-row">
<th class="form-header-cell"></th>
<th class="form-header form-header-cell">Name</th>
<th class="form-header form-header-cell">Type</th>
<th class="form-header form-header-cell">Description</th>
<th class="form-header form-header-cell">Picture</th>
{% if success_message %}
<div class="alert alert-success">
<i class="fas fa-check-circle"></i> {{ success_message }}
</div>
{% endif %}
{% if formset.total_form_count %}
<form method="post" enctype="multipart/form-data" style="overflow-x: auto;">
{% 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>
{{ formset.management_form }}
{% for form in formset %}
<tr class="form-row">
<td class="form-cell">
{{ form.id }}
</td>
<td class="form-cell">
</thead>
{{ formset.management_form }}
{% for form in formset %}
<tr style="border-bottom: 1px solid #e0e0e0; background: {% if forloop.counter|divisibleby:2 %}#f8f9fa{% endif %};">
<td style="padding: 12px 15px;">
{{ form.id }}
</td>
<td style="padding: 12px 15px;">
<div style="display: flex; gap: 5px; flex-wrap: wrap; align-items: center;">
{{ form.name }}
{% for error in form.name.errors %}
<div class="error-list">
<li>{{ error }}</li>
<div style="color: #e74c3c; font-size: 13px; margin-top: 5px;">
<i class="fas fa-exclamation-circle"></i> {{ error }}
</div>
{% endfor %}
<label class="required">*</label>
</td>
<td class="form-cell">
<span style="color: #e74c3c;">*</span>
</div>
</td>
<td style="padding: 12px 15px;">
<div style="display: flex; gap: 5px; flex-wrap: wrap; align-items: center;">
{{ form.thing_type }}
{% for error in form.thing_type.errors %}
<div class="error-list">
<li>{{ error }}</li>
<div style="color: #e74c3c; font-size: 13px; margin-top: 5px;">
<i class="fas fa-exclamation-circle"></i> {{ error }}
</div>
{% endfor %}
<label class="required">*</label>
</td>
<td class="form-cell">
<span style="color: #e74c3c;">*</span>
</div>
</td>
<td style="padding: 12px 15px;">
<div style="display: flex; gap: 5px; flex-wrap: wrap; align-items: center;">
{{ form.description }}
{% for error in form.description.errors %}
<div class="error-list">
<li>{{ error }}</li>
<div style="color: #e74c3c; font-size: 13px; margin-top: 5px;">
<i class="fas fa-exclamation-circle"></i> {{ error }}
</div>
{% endfor %}
</td>
<td class="form-cell">
</div>
</td>
<td style="padding: 12px 15px;">
<div style="display: flex; gap: 5px; flex-wrap: wrap; align-items: center;">
{{ form.picture }}
{% for error in form.picture.errors %}
<div class="error-list">
<li>{{ error }}</li>
<div style="color: #e74c3c; font-size: 13px; margin-top: 5px;">
<i class="fas fa-exclamation-circle"></i> {{ error }}
</div>
{% endfor %}
</td>
</tr>
{% endfor %}
<tr class="form-row">
<td class="form-cell" colspan="5">
<button type="submit" class="btn">Save Things</button>
</td>
</tr>
</table>
</form>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</table>
{% if success_message %}
<div class="success-message">
{{ success_message }}
<div style="text-align: center; margin-top: 30px;">
<button type="submit" class="btn">
<i class="fas fa-save"></i> Save Things
</button>
</div>
{% endif %}
</div>
</body>
</html>
</form>
{% endif %}
</div>
{% 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,99 @@
{% extends "base.html" %}
{% load thumbnail %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Box {{ box.id }} - LabHelper</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 20px;
background-color: #f5f5f5;
}
h1 {
color: #333;
}
.box-info {
background: white;
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
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: 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>
{% load dict_extras %}
<h1>Box {{ box.id }}</h1>
{% block title %}Box {{ box.id }} - LabHelper{% endblock %}
<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>
{% block page_header %}
<div class="page-header">
<h1><i class="fas fa-box"></i> Box {{ box.id }}</h1>
<p class="breadcrumb">
<a href="/"><i class="fas fa-home"></i> Home</a> / Box {{ box.id }}
</p>
</div>
{% endblock %}
{% block content %}
<div class="section">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; flex-wrap: wrap; gap: 15px;">
<div>
<div style="font-size: 16px; color: #555; margin-bottom: 5px;">
<strong><i class="fas fa-cube"></i> Type:</strong> {{ box.box_type.name }}
</div>
<div style="font-size: 14px; color: #777;">
<i class="fas fa-ruler-combined"></i> {{ box.box_type.width }} x {{ box.box_type.height }} x {{ box.box_type.length }} mm
</div>
</div>
<a href="{% url 'add_things' box.id %}" class="btn">
<i class="fas fa-plus"></i> Add Things
</a>
</div>
</div>
{% if things %}
<table>
<thead>
<tr>
<th>Picture</th>
<th>Name</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
{% for thing in things %}
<tr>
<td>
{% if thing.picture %}
{% thumbnail thing.picture "200x200" crop="center" as thumb %}
<img src="{{ thumb.url }}" alt="{{ thing.name }}" class="thumbnail">
{% endthumbnail %}
{% else %}
<div class="no-image">No image</div>
{% endif %}
</td>
<td>{{ thing.name }}</td>
<td>{{ thing.thing_type.name }}</td>
<td>{{ thing.description|default:"-" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-message">
This box is empty.
{% if things %}
<div class="section">
<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;">Tags</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;">
{% if thing.tags.all %}
{% for tag in thing.tags.all %}
<span style="display: inline-block; padding: 3px 8px; margin: 2px; border-radius: 12px; font-size: 11px; background: {{ tag.facet.color }}20; color: {{ tag.facet.color }}; border: 1px solid {{ tag.facet.color }}40;">{{ tag.name }}</span>
{% endfor %}
{% else %}
<span style="color: #999; font-style: italic; font-size: 13px;">-</span>
{% endif %}
</td>
<td style="padding: 15px 20px; color: #777;">{{ thing.description|truncate_markdown:100|default:"-" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</body>
</html>
</div>
{% else %}
<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,74 @@
{% extends "base.html" %}
{% block title %}Boxes - LabHelper{% endblock %}
{% block page_header %}
<div class="page-header">
<h1><i class="fas fa-boxes"></i> Boxes</h1>
<p class="breadcrumb">
<a href="/"><i class="fas fa-home"></i> Home</a> / Boxes
</p>
</div>
{% endblock %}
{% block content %}
{% if boxes %}
<div class="section" 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;">Box ID</th>
<th style="padding: 15px 20px; text-align: left; font-weight: 600;">Type</th>
<th style="padding: 15px 20px; text-align: left; font-weight: 600;">Dimensions (mm)</th>
<th style="padding: 15px 20px; text-align: left; font-weight: 600;">Contents</th>
<th style="padding: 15px 20px; text-align: left; font-weight: 600;">Item Count</th>
</tr>
</thead>
<tbody>
{% for box in boxes %}
<tr style="border-bottom: 1px solid #e0e0e0; transition: background 0.2s;" class="box-row">
<td style="padding: 15px 20px; font-weight: 700; color: #667eea;">
<a href="{% url 'box_detail' box.id %}" style="text-decoration: none; color: inherit;">Box {{ box.id }}</a>
</td>
<td style="padding: 15px 20px;">{{ box.box_type.name }}</td>
<td style="padding: 15px 20px;">{{ box.box_type.width }} x {{ box.box_type.height }} x {{ box.box_type.length }}</td>
<td style="padding: 15px 20px; color: #555;">
{% if box.things.all %}
<div style="display: flex; flex-wrap: wrap; gap: 5px;">
{% for thing in box.things.all %}
<a href="{% url 'thing_detail' thing.id %}" style="display: inline-block; padding: 4px 10px; background: #f0f0f0; border-radius: 12px; text-decoration: none; color: #333; font-size: 13px; font-weight: 500;">{{ thing.name }}</a>
{% endfor %}
</div>
{% else %}
<span style="color: #999; font-style: italic;">Empty</span>
{% endif %}
</td>
<td style="padding: 15px 20px; font-weight: 600;">{{ box.things.count }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<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;">No boxes found</h3>
<p style="color: #999; margin-top: 10px;">Create your first box to get started.</p>
</div>
{% endif %}
{% endblock %}
{% block extra_js %}
<script>
$(document).ready(function() {
$('.box-row').hover(
function() {
$(this).css('background', '#f8f9fa');
},
function() {
$(this).css('background', 'white');
}
);
});
</script>
{% endblock %}

View File

@@ -0,0 +1,324 @@
{% extends "base.html" %}
{% load thumbnail %}
{% load dict_extras %}
{% block title %}Edit {{ thing.name }} - LabHelper{% endblock %}
{% block page_header %}
<div class="page-header">
<h1><i class="fas fa-edit"></i> Edit {{ thing.name }}</h1>
<p class="breadcrumb">
<a href="/"><i class="fas fa-home"></i> Home</a> /
<a href="/box/{{ thing.box.id }}/"><i class="fas fa-box"></i> Box {{ thing.box.id }}</a> /
<a href="{% url 'thing_detail' thing.id %}">{{ thing.name }}</a> /
Edit
</p>
</div>
{% endblock %}
{% block content %}
<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-info-circle"></i> Basic Information
</h2>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
<input type="hidden" name="action" value="save_details">
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px,1fr)); gap: 20px;">
<div>
<label for="id_name" style="font-weight: 600; color: #666; font-size: 14px; margin-bottom: 8px; display: block;">
<i class="fas fa-cube"></i> Name
</label>
{{ thing_form.name }}
</div>
<div>
<label for="id_description" style="font-weight: 600; color: #666; font-size: 14px; margin-bottom: 8px; display: block;">
<i class="fas fa-align-left"></i> Description (Markdown)
</label>
{{ thing_form.description }}
</div>
</div>
<div style="margin-top: 20px;">
<button type="submit" class="btn">
<i class="fas fa-save"></i> Save Changes
</button>
<a href="{% url 'thing_detail' thing.id %}" class="btn" style="background: linear-gradient(135deg, #95a5a6 0%, #7f8c8d 100%);">
<i class="fas fa-times"></i> Cancel
</a>
</div>
</form>
</div>
<div class="section">
<div style="display: flex; gap: 40px; flex-wrap: wrap;">
<div class="thing-image" style="flex-shrink: 0; width: 100%; max-width: 400px;">
{% if thing.picture %}
{% thumbnail thing.picture "400x400" crop="center" as thumb %}
<img src="{{ thumb.url }}" alt="{{ thing.name }}" style="width: 100%; height: auto; max-height: 400px; object-fit: cover; border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.15);">
{% endthumbnail %}
{% else %}
<div style="width: 100%; aspect-ratio: 1; max-width: 400px; max-height: 400px; background: linear-gradient(135deg, #e0e0e0 0%, #f0f0f0 100%); display: flex; align-items: center; justify-content: center; color: #999; border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.15);">
<div style="text-align: center;">
<i class="fas fa-image" style="font-size: 64px; margin-bottom: 15px; display: block;"></i>
No image
</div>
</div>
{% 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 class="thing-details" style="flex-grow: 1; min-width: 300px;">
<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-tags"></i> Tags
</div>
<div style="display: flex; flex-direction: column; gap: 12px;">
{% regroup thing.tags.all by facet as facet_list %}
{% for facet in facet_list %}
<div>
<div style="font-size: 12px; color: {{ facet.grouper.color }}; font-weight: 700; margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.5px;">
{{ facet.grouper.name }}
</div>
<div style="display: flex; flex-wrap: wrap; gap: 8px;">
{% for tag in facet.list %}
<form method="post" style="display: inline;" onsubmit="return confirm('Remove tag {{ tag.facet.name }}:{{ tag.name }}?');">
{% csrf_token %}
<input type="hidden" name="action" value="remove_tag">
<input type="hidden" name="tag_id" value="{{ tag.id }}">
<button type="submit" style="display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; background: {{ facet.grouper.color }}20; color: {{ facet.grouper.color }}; border: 2px solid {{ facet.grouper.color }}; border-radius: 20px; font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.3s;">
{{ tag.name }}
<i class="fas fa-times" style="font-size: 12px;"></i>
</button>
</form>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</div>
<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-map-marker-alt"></i> Location
</div>
<form method="post" style="display: inline;">
{% csrf_token %}
<input type="hidden" name="action" value="move">
<div style="display: flex; align-items: center; gap: 15px; flex-wrap: wrap;">
<select name="new_box" style="padding: 10px 15px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 15px; background: white; cursor: pointer; transition: all 0.3s;">
{% 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>
<button type="submit" class="btn" style="height: 42px;">
<i class="fas fa-arrows-alt"></i> Move
</button>
</div>
</form>
</div>
{% 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>
{% endif %}
</div>
</div>
</div>
</div>
<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 Tags
</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-tag"></i> Add Tag
</h3>
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="add_tag">
<div style="display: flex; flex-direction: column; gap: 15px;">
<div>
<label for="tag_select" style="font-weight: 600; color: #666; font-size: 14px; margin-bottom: 8px; display: block;">Select Tag</label>
<select name="tag_id" id="tag_select" style="width: 100%; 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 tag --</option>
{% for facet in facets %}
<optgroup label="{{ facet.name }} ({{ facet.get_cardinality_display }})">
{% for tag in facet.tags.all %}
{% if tag not in thing.tags.all %}
<option value="{{ tag.id }}">
{{ tag.name }}
</option>
{% endif %}
{% endfor %}
</optgroup>
{% endfor %}
</select>
</div>
<button type="submit" class="btn">
<i class="fas fa-plus"></i> Add Tag
</button>
</div>
</form>
</div>
</div>
</div>
<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>
{% endblock %}
{% block extra_css %}
<style>
#id_name, #id_description {
width: 100%;
padding: 10px 15px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 15px;
background: white;
transition: all 0.3s;
}
#id_name:focus, #id_description:focus {
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
outline: none;
}
#id_description {
min-height: 120px;
font-family: inherit;
}
.detail-row {
margin-bottom: 25px;
}
</style>
{% endblock %}
{% block extra_js %}
<script>
$('#tag_select').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,145 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Fixme - LabHelper{% endblock %}
{% block page_header %}
<div class="page-header">
<h1><i class="fas fa-exclamation-triangle"></i> Fixme</h1>
<p class="breadcrumb">Find and fix things missing tags for specific facets</p>
</div>
{% endblock %}
{% block content %}
<div class="section">
<h2><i class="fas fa-tags"></i> Select a Facet</h2>
{% if facets %}
<form method="get" action="{% url 'fixme' %}" class="facet-selector">
<select name="facet_id" id="facet-select" onchange="this.form.submit()" class="form-control">
<option value="">-- Choose a facet --</option>
{% for facet in facets %}
<option value="{{ facet.id }}" {% if selected_facet and selected_facet.id == facet.id %}selected{% endif %}>
{{ facet.name }}
</option>
{% endfor %}
</select>
<noscript>
<button type="submit" class="btn btn-sm">
<i class="fas fa-search"></i> Show Missing Things
</button>
</noscript>
</form>
{% else %}
<p style="color: #888;">No facets found. Please create some facets first.</p>
{% endif %}
</div>
{% if selected_facet %}
<div class="section">
<h2><i class="fas fa-exclamation-circle"></i> Things Missing "{{ selected_facet.name }}" Tags</h2>
{% if missing_things %}
<form method="post" action="{% url 'fixme' %}" id="fixme-form">
{% csrf_token %}
<input type="hidden" name="facet_id" value="{{ selected_facet.id }}">
<div class="tags-selection" style="margin-bottom: 20px;">
<h3><i class="fas fa-plus-circle"></i> Add Tags:</h3>
{% if selected_facet.tags.all %}
{% for tag in selected_facet.tags.all %}
<label style="display: inline-block; margin-right: 15px; margin-bottom: 10px;">
<input type="checkbox" name="tag_ids" value="{{ tag.id }}"
{% if selected_facet.cardinality == 'single' %}onclick="uncheckOtherTags(this)"{% endif %}>
<span style="background: {{ tag.facet.color }}; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px;">
{{ tag.name }}
</span>
</label>
{% endfor %}
{% if selected_facet.cardinality == 'single' %}
<p style="color: #888; font-size: 12px; margin-top: 10px;">
<i class="fas fa-info-circle"></i> This facet allows only one tag per thing.
</p>
{% endif %}
{% else %}
<p style="color: #888;">No tags available for this facet. Please create some tags first.</p>
{% endif %}
</div>
<div class="things-list" style="margin-bottom: 20px;">
<h3><i class="fas fa-box"></i> Things to Update:</h3>
{% for thing in missing_things %}
<div style="background: #f8f9fa; padding: 10px; margin: 5px 0; border-radius: 5px; border-left: 4px solid #667eea;">
<label style="display: block; cursor: pointer; width: 100%;">
<input type="checkbox" name="thing_ids" value="{{ thing.id }}" style="margin-right: 10px;">
<strong>{{ thing.name }}</strong>
<span style="color: #888; margin-left: 10px;">(Box: {{ thing.box.id }})</span>
</label>
</div>
{% endfor %}
</div>
<div class="actions">
<button type="submit" class="btn">
<i class="fas fa-save"></i> Add Selected Tags to Selected Things
</button>
<a href="{% url 'fixme' %}" class="btn btn-secondary">
<i class="fas fa-times"></i> Clear Selection
</a>
</div>
</form>
{% else %}
<p style="color: #28a745;">
<i class="fas fa-check-circle"></i> All things have tags for "{{ selected_facet.name }}" facet!
</p>
<a href="{% url 'fixme' %}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> Back to Facet Selection
</a>
{% endif %}
</div>
{% endif %}
{% block extra_js %}
<script>
function uncheckOtherTags(checkbox) {
// For single cardinality facets, uncheck all other checkboxes
var checkboxes = document.querySelectorAll('input[name="tag_ids"]');
checkboxes.forEach(function(cb) {
if (cb !== checkbox) {
cb.checked = false;
}
});
}
// Add select all/none functionality for things
document.addEventListener('DOMContentLoaded', function() {
var fixmeForm = document.getElementById('fixme-form');
if (fixmeForm) {
var thingsList = fixmeForm.querySelector('.things-list');
if (thingsList) {
var header = document.createElement('div');
header.style.marginBottom = '10px';
header.innerHTML = `
<small>
<a href="#" onclick="selectAllThings(); return false;">Select All</a> |
<a href="#" onclick="deselectAllThings(); return false;">Deselect All</a>
</small>
`;
thingsList.insertBefore(header, thingsList.firstChild.nextSibling);
}
}
});
function selectAllThings() {
var checkboxes = document.querySelectorAll('input[name="thing_ids"]');
checkboxes.forEach(function(cb) { cb.checked = true; });
}
function deselectAllThings() {
var checkboxes = document.querySelectorAll('input[name="thing_ids"]');
checkboxes.forEach(function(cb) { cb.checked = false; });
}
</script>
{% endblock %}
{% endblock %}

View File

@@ -0,0 +1,198 @@
{% extends "base.html" %}
{% 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">
<input type="text"
id="search-input"
placeholder="Search for things..."
style="width: 100%; padding: 16px 20px; font-size: 18px; border: 2px solid #e0e0e0; border-radius: 12px; box-sizing: border-box; transition: all 0.3s;"
{% if request.GET.q %}value="{{ request.GET.q }}"{% endif %}>
<p style="color: #888; font-size: 14px; margin-top: 10px;">
<i class="fas fa-info-circle"></i> Type at least 2 characters to search
</p>
</div>
<div id="results-container" style="display: none;">
<div class="section" style="overflow-x: auto; padding: 0;">
<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;">Name</th>
<th style="padding: 15px 20px; text-align: left; font-weight: 600;">Tags</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 id="results-body">
</tbody>
</table>
</div>
</div>
<div id="no-results" class="section" style="text-align: center; padding: 60px 30px; display: none;">
<i class="fas fa-search-minus" style="font-size: 64px; color: #ddd; margin-bottom: 20px; display: block;"></i>
<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>
<div class="section">
<h2><i class="fas fa-tags"></i> Tags</h2>
{% if facet_tag_counts %}
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px;">
{% for facet, tags_with_counts in facet_tag_counts.items %}
<div class="facet-card" style="background: white; border-radius: 12px; border: 1px solid #e0e0e0; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.05);">
<div class="facet-header" style="padding: 15px 20px; background: linear-gradient(135deg, {{ facet.color }} 0%, {{ facet.color }}dd 100%); color: white; display: flex; align-items: center; justify-content: space-between; cursor: pointer;">
<div style="display: flex; align-items: center; gap: 10px;">
<i class="fas fa-chevron-right facet-toggle" style="transition: transform 0.3s;"></i>
<span style="font-size: 18px; font-weight: 700;">{{ facet.name }}</span>
</div>
<span style="background: rgba(255,255,255,0.3); padding: 4px 12px; border-radius: 20px; font-size: 13px; font-weight: 600;">{{ facet.cardinality }}</span>
</div>
<div class="facet-tags" style="padding: 15px 20px; display: none;">
<div style="display: flex; flex-wrap: wrap; gap: 8px;">
{% for tag, count in tags_with_counts %}
<a href="/?q={{ facet.name }}:{{ tag.name }}" style="display: inline-block; padding: 6px 12px; background: {{ facet.color }}20; color: {{ facet.color }}; border: 1px solid {{ facet.color }}; border-radius: 15px; text-decoration: none; font-size: 14px; font-weight: 600; transition: all 0.2s;">
{{ tag.name }}
<span style="background: {{ facet.color }}; color: white; padding: 1px 8px; border-radius: 10px; margin-left: 6px; font-size: 12px;">{{ count }}</span>
</a>
{% endfor %}
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p style="text-align: center; color: #888; font-size: 16px; padding: 40px;">
<i class="fas fa-tag" style="font-size: 48px; margin-bottom: 15px; display: block;"></i>
No tags found.
</p>
{% endif %}
</div>
{% endblock %}
{% block extra_js %}
<script>
$(document).ready(function() {
const searchInput = document.getElementById('search-input');
const resultsContainer = document.getElementById('results-container');
const resultsBody = document.getElementById('results-body');
const noResults = document.getElementById('no-results');
let searchTimeout = null;
function performSearch(query) {
if (query.length < 2) {
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';
resultsContainer.style.display = 'block';
data.results.forEach(function(thing) {
const row = document.createElement('tr');
row.style.borderBottom = '1px solid #e0e0e0';
row.style.transition = 'background 0.2s';
let tagsHtml = thing.tags.length > 0
? thing.tags.map(tag =>
'<span style="display: inline-block; padding: 3px 8px; margin: 2px; border-radius: 12px; font-size: 11px; background: ' + escapeHtml(tag.color) + '20; color: ' + escapeHtml(tag.color) + '; border: 1px solid ' + escapeHtml(tag.color) + '40;">' + escapeHtml(tag.name) + '</span>'
).join('')
: '<span style="color: #999; font-style: italic; font-size: 13px;">-</span>';
row.innerHTML =
'<td style="padding: 15px 20px;"><a href="/thing/' + thing.id + '/">' + escapeHtml(thing.name) + '</a></td>' +
'<td style="padding: 15px 20px;">' + tagsHtml + '</td>' +
'<td style="padding: 15px 20px;"><a href="/box/' + escapeHtml(thing.box) + '/">' + escapeHtml(thing.box) + '</a></td>' +
'<td style="padding: 15px 20px; color: #777;" class="description">' + escapeHtml(thing.description) + '</td>';
row.addEventListener('mouseenter', function() {
this.style.background = '#f8f9fa';
});
row.addEventListener('mouseleave', function() {
this.style.background = 'white';
});
resultsBody.appendChild(row);
});
});
}, 200);
}
searchInput.addEventListener('input', function() {
const query = this.value.trim();
if (searchTimeout) {
clearTimeout(searchTimeout);
}
performSearch(query);
});
searchInput.addEventListener('blur', function() {
searchInput.style.borderColor = '#e0e0e0';
searchInput.style.boxShadow = 'none';
});
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
const urlParams = new URLSearchParams(window.location.search);
const initialQuery = urlParams.get('q');
if (initialQuery) {
searchInput.value = initialQuery;
performSearch(initialQuery.trim());
}
$('.facet-header').click(function() {
const $content = $(this).next('.facet-tags');
const $icon = $(this).find('.facet-toggle');
$content.slideToggle(200);
if ($content.is(':visible')) {
$icon.css('transform', 'rotate(90deg)');
} else {
$icon.css('transform', 'rotate(0deg)');
}
});
$('.facet-card a').hover(
function() {
$(this).css('transform', 'scale(1.05)');
},
function() {
$(this).css('transform', 'scale(1)');
}
);
});
</script>
{% endblock %}

View File

@@ -0,0 +1,41 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Resources - LabHelper{% endblock %}
{% block page_header %}
<div class="page-header">
<h1><i class="fas fa-folder-open"></i> Resources</h1>
<p class="breadcrumb">All links and files from things</p>
</div>
{% endblock %}
{% block content %}
<div class="section">
<h2><i class="fas fa-list"></i> All Resources</h2>
{% if resources %}
<ul style="list-style: none; padding: 0;">
{% for resource in resources %}
<li style="padding: 8px 0; border-bottom: 1px solid #eee;">
<a href="{% url 'thing_detail' resource.thing_id %}" style="display: inline-block; width: 20px; text-align: center; color: #667eea; text-decoration: none;">
{% if resource.type == 'link' %}
<i class="fas fa-link"></i>
{% else %}
<i class="fas fa-file"></i>
{% endif %}
</a>
<strong>{{ resource.thing_name }}</strong>:
{% if resource.type == 'link' %}
<a href="{{ resource.url }}" target="_blank" rel="noopener noreferrer">{{ resource.title }}</a>
{% else %}
<a href="{{ resource.url }}">{{ resource.title }}</a>
{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<p style="color: #888;">No resources found.</p>
{% endif %}
</div>
{% endblock %}

View File

@@ -1,187 +1,146 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Search - LabHelper</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 20px;
background-color: #f5f5f5;
}
h1 {
color: #333;
}
.search-container {
background: white;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.search-input {
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>
{% extends "base.html" %}
<h1>Search Things</h1>
{% block title %}Search - LabHelper{% endblock %}
<div class="search-container">
<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>
{% block page_header %}
<div class="page-header">
<h1><i class="fas fa-search"></i> Search Things</h1>
<p class="breadcrumb">
<a href="/"><i class="fas fa-home"></i> Home</a> / Search
</p>
</div>
{% endblock %}
<div id="results-container">
<table>
{% block content %}
<div class="section">
<input type="text"
id="search-input"
placeholder="Search for things..."
style="width: 100%; padding: 16px 20px; font-size: 18px; border: 2px solid #e0e0e0; border-radius: 12px; box-sizing: border-box; transition: all 0.3s;"
{% if request.GET.q %}value="{{ request.GET.q }}"{% endif %}>
<p style="color: #888; font-size: 14px; margin-top: 10px;">
<i class="fas fa-info-circle"></i> Type at least 2 characters to search
</p>
</div>
<div id="results-container" style="display: none;">
<div class="section" style="overflow-x: auto; padding: 0;">
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Box</th>
<th>Description</th>
<tr style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white;">
<th style="padding: 15px 20px; text-align: left; font-weight: 600;">Name</th>
<th style="padding: 15px 20px; text-align: left; font-weight: 600;">Tags</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 id="results-body">
</tbody>
</table>
</div>
</div>
<div id="no-results" class="no-results" style="display: none;">
No results found.
</div>
<div id="no-results" class="section" style="text-align: center; padding: 60px 30px; display: none;">
<i class="fas fa-search-minus" style="font-size: 64px; color: #ddd; margin-bottom: 20px; display: block;"></i>
<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>
const searchInput = document.getElementById('search-input');
const resultsContainer = document.getElementById('results-container');
const resultsBody = document.getElementById('results-body');
const noResults = document.getElementById('no-results');
{% block extra_js %}
<script>
const searchInput = document.getElementById('search-input');
const resultsContainer = document.getElementById('results-container');
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();
function performSearch(query) {
if (query.length < 2) {
resultsContainer.style.display = 'none';
noResults.style.display = 'none';
return;
}
// Clear previous timeout
if (searchTimeout) {
clearTimeout(searchTimeout);
}
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;
}
// Hide results if query too short
if (query.length < 2) {
resultsContainer.style.display = 'none';
noResults.style.display = 'none';
return;
}
resultsContainer.style.display = 'block';
// Debounce search
searchTimeout = setTimeout(function() {
fetch('/search/api/?q=' + encodeURIComponent(query))
.then(response => response.json())
.then(data => {
resultsBody.innerHTML = '';
data.results.forEach(function(thing) {
const row = document.createElement('tr');
row.style.borderBottom = '1px solid #e0e0e0';
row.style.transition = 'background 0.2s';
if (data.results.length === 0) {
resultsContainer.style.display = 'none';
noResults.style.display = 'block';
return;
}
let tagsHtml = thing.tags.length > 0
? thing.tags.map(tag =>
'<span style="display: inline-block; padding: 3px 8px; margin: 2px; border-radius: 12px; font-size: 11px; background: ' + escapeHtml(tag.color) + '20; color: ' + escapeHtml(tag.color) + '; border: 1px solid ' + escapeHtml(tag.color) + '40;">' + escapeHtml(tag.name) + '</span>'
).join('')
: '<span style="color: #999; font-style: italic; font-size: 13px;">-</span>';
noResults.style.display = 'none';
resultsContainer.style.display = 'block';
row.innerHTML =
'<td style="padding: 15px 20px;"><a href="/thing/' + thing.id + '/">' + escapeHtml(thing.name) + '</a></td>' +
'<td style="padding: 15px 20px;">' + tagsHtml + '</td>' +
'<td style="padding: 15px 20px;"><a href="/box/' + escapeHtml(thing.box) + '/">' + escapeHtml(thing.box) + '</a></td>' +
'<td style="padding: 15px 20px; color: #777;" class="description">' + escapeHtml(thing.description) + '</td>';
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);
});
row.addEventListener('mouseenter', function() {
this.style.background = '#f8f9fa';
});
row.addEventListener('mouseleave', function() {
this.style.background = 'white';
});
}, 200);
});
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
resultsBody.appendChild(row);
});
});
}, 200);
}
// Focus search input on page load
searchInput.focus();
</script>
</body>
</html>
searchInput.addEventListener('input', function() {
const query = this.value.trim();
if (searchTimeout) {
clearTimeout(searchTimeout);
}
performSearch(query);
});
searchInput.addEventListener('blur', function() {
searchInput.style.borderColor = '#e0e0e0';
searchInput.style.boxShadow = 'none';
});
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Check for query parameter on page load
const urlParams = new URLSearchParams(window.location.search);
const initialQuery = urlParams.get('q');
if (initialQuery) {
searchInput.value = initialQuery;
performSearch(initialQuery.trim());
}
searchInput.focus();
</script>
{% endblock %}

View File

@@ -1,131 +1,202 @@
{% extends "base.html" %}
{% load thumbnail %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ thing.name }} - LabHelper</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 20px;
background-color: #f5f5f5;
}
h1 {
color: #333;
}
.thing-card {
background: white;
padding: 20px;
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>
{% load dict_extras %}
{% block title %}{{ thing.name }} - LabHelper{% endblock %}
{% block page_header %}
<div class="page-header" style="display: flex; justify-content: space-between; align-items: start;">
<div>
<h1><i class="fas fa-cube"></i> {{ thing.name }}</h1>
<p class="breadcrumb">
<a href="/"><i class="fas fa-home"></i> Home</a> /
<a href="/box/{{ thing.box.id }}/"><i class="fas fa-box"></i> Box {{ thing.box.id }}</a> /
{{ thing.name }}
</p>
</div>
<a href="{% url 'edit_thing' thing.id %}" class="btn" style="margin-top: 10px;">
<i class="fas fa-edit"></i> Edit
</a>
</div>
{% endblock %}
<h1>{{ thing.name }}</h1>
<div class="thing-card">
<div class="thing-image">
{% block content %}
<div class="section">
<div class="thing-card" style="display: flex; gap: 40px; flex-wrap: wrap;">
<div class="thing-image" style="flex-shrink: 0; width: 100%; max-width: 400px;">
{% if thing.picture %}
{% thumbnail thing.picture "300x300" crop="center" as thumb %}
<img src="{{ thumb.url }}" alt="{{ thing.name }}">
{% thumbnail thing.picture "400x400" crop="center" as thumb %}
<img src="{{ thumb.url }}" alt="{{ thing.name }}" style="width: 100%; height: auto; max-height: 400px; object-fit: cover; border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.15);">
{% endthumbnail %}
{% 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 %}
</div>
<div class="thing-details">
<div class="detail-row">
<div class="detail-label">Type</div>
<div class="detail-value">{{ thing.thing_type.name }}</div>
<div class="thing-details" style="flex-grow: 1; min-width: 300px;">
{% if thing.tags.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-tags"></i> Tags
</div>
<div style="display: flex; flex-direction: column; gap: 12px;">
{% regroup thing.tags.all by facet as facet_list %}
{% for facet in facet_list %}
<div>
<div style="font-size: 12px; color: {{ facet.grouper.color }}; font-weight: 700; margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.5px;">
{{ facet.grouper.name }}
</div>
<div style="display: flex; flex-wrap: wrap; gap: 8px;">
{% for tag in facet.list %}
<span style="display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; background: {{ facet.grouper.color }}20; color: {{ facet.grouper.color }}; border: 2px solid {{ facet.grouper.color }}; border-radius: 20px; font-size: 14px; font-weight: 600;">
{{ tag.name }}
</span>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<div class="detail-row">
<div class="detail-label">Location</div>
<div class="detail-value">
<a href="/box/{{ thing.box.id }}/">Box {{ thing.box.id }}</a>
({{ thing.box.box_type.name }})
<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-map-marker-alt"></i> Location
</div>
<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>
{% if thing.description %}
<div class="detail-row">
<div class="detail-label">Description</div>
<div class="detail-value description">{{ thing.description }}</div>
<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-align-left"></i> Description
</div>
<div class="markdown-content" style="font-size: 16px; color: #555; line-height: 1.6;">
{{ thing.description|render_markdown }}
</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; gap: 10px; padding: 12px 15px; background: #f8f9fa; border-radius: 8px; border: 1px solid #e9ecef;">
<i class="fas fa-paperclip" style="color: #667eea; font-size: 16px;"></i>
<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>
{% 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; gap: 10px; padding: 12px 15px; background: #f8f9fa; border-radius: 8px; border: 1px solid #e9ecef;">
<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>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>
</body>
</html>
</div>
{% endblock %}
{% block extra_css %}
<style>
.markdown-content p {
margin: 0 0 1em 0;
}
.markdown-content p:last-child {
margin-bottom: 0;
}
.markdown-content h1, .markdown-content h2, .markdown-content h3,
.markdown-content h4, .markdown-content h5, .markdown-content h6 {
margin: 1.5em 0 0.5em 0;
color: #333;
font-weight: 600;
}
.markdown-content h1:first-child, .markdown-content h2:first-child,
.markdown-content h3:first-child {
margin-top: 0;
}
.markdown-content ul, .markdown-content ol {
margin: 0.5em 0;
padding-left: 2em;
}
.markdown-content li {
margin: 0.25em 0;
}
.markdown-content code {
background: #f4f4f4;
padding: 2px 6px;
border-radius: 4px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.9em;
}
.markdown-content pre {
background: #f4f4f4;
padding: 15px;
border-radius: 8px;
overflow-x: auto;
margin: 1em 0;
}
.markdown-content pre code {
background: none;
padding: 0;
}
.markdown-content blockquote {
border-left: 4px solid #667eea;
margin: 1em 0;
padding: 0.5em 1em;
background: #f8f9fa;
color: #666;
}
.markdown-content a {
color: #667eea;
text-decoration: none;
}
.markdown-content a:hover {
text-decoration: underline;
}
.markdown-content table {
border-collapse: collapse;
width: 100%;
margin: 1em 0;
}
.markdown-content th, .markdown-content td {
border: 1px solid #e0e0e0;
padding: 8px 12px;
text-align: left;
}
.markdown-content th {
background: #f8f9fa;
font-weight: 600;
}
.markdown-content hr {
border: none;
border-top: 2px solid #e0e0e0;
margin: 1.5em 0;
}
</style>
{% endblock %}

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,95 @@
import bleach
import markdown
from django import template
from django.utils.safestring import mark_safe
register = template.Library()
@register.filter
def get_item(dictionary, key):
return dictionary.get(key)
@register.filter
def render_markdown(text):
"""
Convert Markdown text to sanitized HTML.
Uses bleach to sanitize HTML output and prevent XSS attacks.
Allows common formatting tags: bold, italic, links, lists, code, etc.
"""
if not text:
return ''
# Convert Markdown to HTML
html = markdown.markdown(
text,
extensions=[
'markdown.extensions.fenced_code',
'markdown.extensions.tables',
'markdown.extensions.nl2br',
]
)
# Allowed HTML tags and attributes for sanitization
allowed_tags = [
'p', 'br', 'strong', 'em', 'b', 'i', 'u',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'ul', 'ol', 'li',
'a', 'code', 'pre',
'blockquote', 'hr',
'table', 'thead', 'tbody', 'tr', 'th', 'td',
]
allowed_attrs = {
'a': ['href', 'title', 'target'],
'th': ['align'],
'td': ['align'],
}
# Sanitize HTML
clean_html = bleach.clean(
html,
tags=allowed_tags,
attributes=allowed_attrs,
strip=True
)
# Add target="_blank" and rel="noopener" to external links
clean_html = bleach.linkify(
clean_html,
callbacks=[_add_target_blank],
skip_tags=['pre', 'code']
)
return mark_safe(clean_html)
def _add_target_blank(attrs, new=False):
"""Add target="_blank" and rel="noopener noreferrer" to links."""
attrs[(None, 'target')] = '_blank'
attrs[(None, 'rel')] = 'noopener noreferrer'
return attrs
@register.filter
def truncate_markdown(text, length=100):
"""
Convert Markdown to plain text and truncate.
Useful for showing a preview of Markdown content in listings.
"""
if not text:
return ''
# Convert Markdown to HTML, then strip tags
html = markdown.markdown(text)
plain_text = bleach.clean(html, tags=[], strip=True)
# Normalize whitespace
plain_text = ' '.join(plain_text.split())
# Truncate
if len(plain_text) > length:
return plain_text[:length].rsplit(' ', 1)[0] + '...'
return plain_text

File diff suppressed because it is too large Load Diff

View File

@@ -1,63 +1,268 @@
import bleach
import markdown
from django.contrib.auth.decorators import login_required
from django.db.models import Q, Prefetch
from django.http import HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from .forms import ThingFormSet
from .models import Box, Thing
from .forms import (
BoxForm,
BoxTypeForm,
ThingFileForm,
ThingForm,
ThingFormSet,
ThingLinkForm,
ThingPictureForm,
)
from .models import Box, BoxType, Facet, Tag, Thing, ThingFile, ThingLink
def _strip_markdown(text, max_length=100):
"""Convert Markdown to plain text and truncate."""
if not text:
return ''
html = markdown.markdown(text)
plain_text = bleach.clean(html, tags=[], strip=True)
plain_text = ' '.join(plain_text.split())
if len(plain_text) > max_length:
return plain_text[:max_length].rsplit(' ', 1)[0] + '...'
return plain_text
@login_required
def index(request):
"""Simple index page."""
html = '<h1>LabHelper</h1><p><a href="/search/">Search Things</a> | <a href="/admin/">Admin</a></p>'
return HttpResponse(html)
"""Home page with search and tags."""
facets = Facet.objects.all().prefetch_related('tags')
facet_tag_counts = {}
for facet in facets:
for tag in facet.tags.all():
count = tag.things.count()
if count > 0:
if facet not in facet_tag_counts:
facet_tag_counts[facet] = []
facet_tag_counts[facet].append((tag, count))
return render(request, 'boxes/index.html', {
'facets': facets,
'facet_tag_counts': facet_tag_counts,
})
@login_required
def box_detail(request, box_id):
"""Display contents of a box."""
box = get_object_or_404(Box, pk=box_id)
things = box.things.select_related('thing_type').all()
things = box.things.prefetch_related('tags').all()
return render(request, 'boxes/box_detail.html', {
'box': box,
'things': things,
})
@login_required
def thing_detail(request, thing_id):
"""Display details of a thing."""
"""Display details of a thing (read-only)."""
thing = get_object_or_404(
Thing.objects.select_related('thing_type', 'box', 'box__box_type'),
Thing.objects.select_related('box', 'box__box_type').prefetch_related('files', 'links', 'tags'),
pk=thing_id
)
return render(request, 'boxes/thing_detail.html', {'thing': thing})
def search(request):
"""Search page for things."""
return render(request, 'boxes/search.html')
@login_required
def edit_thing(request, thing_id):
"""Edit a thing's details."""
thing = get_object_or_404(
Thing.objects.select_related('box', 'box__box_type').prefetch_related('files', 'links', 'tags'),
pk=thing_id
)
boxes = Box.objects.select_related('box_type').all().order_by('id')
facets = Facet.objects.all().prefetch_related('tags')
picture_form = ThingPictureForm(instance=thing)
file_form = ThingFileForm()
link_form = ThingLinkForm()
if request.method == 'POST':
action = request.POST.get('action')
if action == 'save_details':
form = ThingForm(request.POST, request.FILES, instance=thing)
if form.is_valid():
form.save()
return redirect('thing_detail', thing_id=thing.id)
elif 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('edit_thing', 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('edit_thing', thing_id=thing.id)
elif action == 'delete_picture':
if thing.picture:
thing.picture.delete()
thing.picture = None
thing.save()
return redirect('edit_thing', 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('edit_thing', 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('edit_thing', 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('edit_thing', 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('edit_thing', thing_id=thing.id)
elif action == 'add_tag':
tag_id = request.POST.get('tag_id')
if tag_id:
try:
tag = Tag.objects.get(pk=tag_id)
if tag.facet.cardinality == Facet.Cardinality.SINGLE:
existing_tags = list(thing.tags.filter(facet=tag.facet))
for existing_tag in existing_tags:
thing.tags.remove(existing_tag)
thing.tags.add(tag)
except Tag.DoesNotExist:
pass
return redirect('edit_thing', thing_id=thing.id)
elif action == 'remove_tag':
tag_id = request.POST.get('tag_id')
if tag_id:
try:
tag = Tag.objects.get(pk=tag_id)
thing.tags.remove(tag)
except Tag.DoesNotExist:
pass
return redirect('edit_thing', thing_id=thing.id)
thing_form = ThingForm(instance=thing)
return render(request, 'boxes/edit_thing.html', {
'thing': thing,
'boxes': boxes,
'facets': facets,
'picture_form': picture_form,
'file_form': file_form,
'link_form': link_form,
'thing_form': thing_form,
})
@login_required
def boxes_list(request):
"""Boxes list page showing all boxes with contents."""
boxes = Box.objects.select_related('box_type').prefetch_related('things').all().order_by('id')
return render(request, 'boxes/boxes_list.html', {
'boxes': boxes,
})
@login_required
def search_api(request):
"""AJAX endpoint for searching things."""
query = request.GET.get('q', '').strip()
if len(query) < 2:
return JsonResponse({'results': []})
things = Thing.objects.filter(
name__icontains=query
).select_related('thing_type', 'box')[:50]
# Check for "Facet:Word" format
if ':' in query:
parts = query.split(':',1)
facet_name = parts[0].strip()
tag_name = parts[1].strip()
# Search for things with specific facet and tag
things = Thing.objects.filter(
Q(tags__facet__name__icontains=facet_name) &
Q(tags__name__icontains=tag_name)
).prefetch_related('files', 'links', 'tags').select_related('box').distinct()[:50]
else:
# Normal search
things = Thing.objects.filter(
Q(name__icontains=query) |
Q(description__icontains=query) |
Q(files__title__icontains=query) |
Q(files__file__icontains=query) |
Q(links__title__icontains=query) |
Q(links__url__icontains=query) |
Q(tags__name__icontains=query) |
Q(tags__facet__name__icontains=query)
).prefetch_related('files', 'links', 'tags').select_related('box').distinct()[:50]
results = [
{
'id': thing.id,
'name': thing.name,
'type': thing.thing_type.name,
'box': thing.box.id,
'description': thing.description[:100] if thing.description else '',
'description': _strip_markdown(thing.description),
'tags': [
{
'name': tag.name,
'color': tag.facet.color,
}
for tag in thing.tags.all()
],
'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
]
return JsonResponse({'results': results})
@login_required
def add_things(request, box_id):
"""Add multiple things to a box at once."""
box = get_object_or_404(Box, pk=box_id)
@@ -65,22 +270,182 @@ def add_things(request, box_id):
success_message = None
if request.method == 'POST':
formset = ThingFormSet(request.POST)
formset = ThingFormSet(request.POST, request.FILES, queryset=Thing.objects.filter(box=box))
if formset.is_valid():
things = formset.save(commit=False)
created_count = 0
for thing in things:
if thing.name or thing.thing_type or thing.description or thing.picture:
if thing.name or thing.description or thing.picture:
thing.box = box
thing.save()
created_count += 1
if created_count > 0:
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', {
'box': box,
'formset': formset,
'success_message': success_message,
})
@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')
@login_required
def resources_list(request):
"""List all links and files from things that have them."""
things_with_files = Thing.objects.filter(files__isnull=False).prefetch_related('files').distinct()
things_with_links = Thing.objects.filter(links__isnull=False).prefetch_related('links').distinct()
all_things = (things_with_files | things_with_links).distinct().order_by('name')
resources = []
for thing in all_things.prefetch_related('files', 'links'):
for file in thing.files.all():
resources.append({
'type': 'file',
'thing_name': thing.name,
'thing_id': thing.id,
'title': file.title,
'url': file.file.url,
})
for link in thing.links.all():
resources.append({
'type': 'link',
'thing_name': thing.name,
'thing_id': thing.id,
'title': link.title,
'url': link.url,
})
return render(request, 'boxes/resources_list.html', {
'resources': resources,
})
@login_required
def fixme(request):
"""Page to find and fix things missing tags for specific facets."""
facets = Facet.objects.all().prefetch_related('tags')
selected_facet = None
missing_things = []
if request.method == 'GET' and 'facet_id' in request.GET:
try:
selected_facet = Facet.objects.get(pk=request.GET['facet_id'])
# Find things that don't have any tag from this facet
missing_things = Thing.objects.exclude(
tags__facet=selected_facet
).select_related('box', 'box__box_type').prefetch_related('tags')
except Facet.DoesNotExist:
selected_facet = None
elif request.method == 'POST':
facet_id = request.POST.get('facet_id')
tag_ids = request.POST.getlist('tag_ids')
thing_ids = request.POST.getlist('thing_ids')
if facet_id and tag_ids and thing_ids:
facet = get_object_or_404(Facet, pk=facet_id)
tags = Tag.objects.filter(id__in=tag_ids, facet=facet)
things = Thing.objects.filter(id__in=thing_ids)
for thing in things:
if facet.cardinality == Facet.Cardinality.SINGLE:
# Remove existing tags from this facet
thing.tags.remove(*thing.tags.filter(facet=facet))
# Add new tags
for tag in tags:
if tag.facet == facet:
thing.tags.add(tag)
return redirect('fixme')
return render(request, 'boxes/fixme.html', {
'facets': facets,
'selected_facet': selected_facet,
'missing_things': missing_things,
})

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/
"""
import os
from pathlib import Path
# 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/
# 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!
DEBUG = True
@@ -38,6 +39,7 @@ INSTALLED_APPS = [
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'mptt',
'django_mptt_admin',
'sorl.thumbnail',
'boxes',
@@ -58,7 +60,7 @@ ROOT_URLCONF = 'labhelper.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'DIRS': [BASE_DIR / 'labhelper' / 'templates'],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
@@ -129,3 +131,9 @@ MEDIA_ROOT = BASE_DIR / 'data' / 'media'
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
CSRF_TRUSTED_ORIGINS=["https://labhelper.adebaumann.com"]
LOGIN_URL = 'login'
LOGIN_REDIRECT_URL = 'index'
LOGOUT_REDIRECT_URL = 'login'

View File

@@ -0,0 +1,455 @@
{% 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-toggle {
display: none;
background: none;
border: none;
color: #555;
font-size: 24px;
cursor: pointer;
padding: 10px;
border-radius: 8px;
transition: all 0.3s ease;
}
.navbar-toggle:hover {
background: #667eea;
color: white;
}
.navbar-nav {
display: flex;
gap: 20px;
align-items: center;
}
.navbar-nav a,
.navbar-nav form {
color: #555;
text-decoration: none;
font-weight: 500;
font-size: 15px;
padding: 8px 16px;
border-radius: 8px;
transition: all 0.3s ease;
display: flex;
align-items: center;
gap: 8px;
}
.navbar-nav a:hover,
.navbar-nav button:hover {
background: #667eea;
color: white;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.navbar-nav a i {
font-size: 14px;
}
.navbar-nav button {
background: none;
border: none;
color: #555;
font: inherit;
cursor: pointer;
padding: 8px 16px;
border-radius: 8px;
transition: all 0.3s ease;
display: inline-flex;
align-items: center;
gap: 8px;
}
@media (max-width: 768px) {
.navbar {
padding: 15px 20px;
}
.navbar-brand {
font-size: 24px;
}
.navbar-toggle {
display: block;
}
.navbar-nav {
display: none;
width: 100%;
flex-direction: column;
gap: 0;
padding-top: 10px;
}
.navbar-nav.active {
display: flex;
}
.navbar-nav a,
.navbar-nav form {
width: 100%;
padding: 12px 16px;
border-radius: 0;
}
.navbar-nav a:first-child,
.navbar-nav form:first-child {
border-radius: 8px 8px 0 0;
}
.navbar-nav a:last-child,
.navbar-nav form:last-child {
border-radius: 0 0 8px 8px;
}
.navbar-nav button {
width: 100%;
justify-content: flex-start;
}
}
.container {
max-width: 1200px;
margin: 20px auto;
}
.page-header {
background: white;
padding: 30px;
border-radius: 15px;
margin-bottom: 30px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
}
.page-header h1 {
color: #333;
font-size: 32px;
font-weight: 700;
margin-bottom: 10px;
}
.page-header .breadcrumb {
color: #888;
font-size: 14px;
}
.page-header .breadcrumb a {
color: #667eea;
text-decoration: none;
}
.page-header .breadcrumb a:hover {
text-decoration: underline;
}
.section {
background: white;
padding: 30px;
border-radius: 15px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
margin-bottom: 30px;
}
.section h2 {
color: #667eea;
font-size: 24px;
font-weight: 700;
margin-top: 0;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 3px solid #667eea;
display: flex;
align-items: center;
gap: 10px;
}
.section h2 i {
font-size: 20px;
}
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 12px 24px;
border: none;
border-radius: 10px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
text-decoration: none;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.6);
}
.btn:active {
transform: translateY(0);
}
.btn-secondary {
background: linear-gradient(135deg, #7f8c8d 0%, #95a5a6 100%);
box-shadow: 0 4px 15px rgba(127, 140, 141, 0.4);
}
.btn-secondary:hover {
box-shadow: 0 6px 20px rgba(127, 140, 141, 0.6);
}
.btn-sm {
padding: 8px 16px;
font-size: 14px;
}
.alert {
padding: 15px 20px;
border-radius: 10px;
margin-bottom: 20px;
font-weight: 500;
}
.alert-success {
background: linear-gradient(135deg, #00b894 0%, #00cec9 100%);
color: white;
box-shadow: 0 4px 15px rgba(0, 184, 148, 0.3);
}
.alert-error {
background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%);
color: white;
box-shadow: 0 4px 15px rgba(231, 76, 60, 0.3);
}
.footer {
text-align: center;
color: white;
padding: 30px;
margin-top: 30px;
}
.footer a {
color: white;
text-decoration: none;
font-weight: 500;
}
.footer a:hover {
text-decoration: underline;
}
.dropdown {
position: relative;
display: inline-block;
}
.dropdown-content {
display: none;
position: absolute;
right: 0;
background: white;
min-width: 200px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
border-radius: 10px;
padding: 10px 0;
z-index: 1000;
top: 100%;
margin-top: 10px;
}
.dropdown-content a {
display: block;
padding: 12px 20px;
color: #555;
text-decoration: none;
transition: all 0.2s ease;
}
.dropdown-content a:hover {
background: #667eea;
color: white;
}
.dropdown-content a:first-child {
border-radius: 10px 10px 0 0;
}
.dropdown-content a:last-child {
border-radius: 0 0 10px 10px;
}
.dropdown-content button:hover {
background: #667eea;
color: white;
}
.dropdown:hover .dropdown-content,
.dropdown:focus-within .dropdown-content {
display: block;
}
.dropdown-btn {
background: none;
border: none;
color: #667eea;
font-weight: 600;
font-size: 15px;
cursor: pointer;
padding: 8px 16px;
border-radius: 8px;
transition: all 0.3s ease;
display: inline-flex;
align-items: center;
gap: 8px;
}
.dropdown-btn:hover {
background: #667eea;
color: white;
}
@media (max-width: 768px) {
.dropdown-content {
position: static;
box-shadow: none;
border-radius: 0;
margin-top: 0;
padding: 0;
}
.dropdown-content a {
width: 100%;
padding: 12px 16px;
border-radius: 0;
}
.dropdown-btn {
width: 100%;
justify-content: flex-start;
}
}
{% block extra_css %}{% endblock %}
</style>
{% block extra_head %}{% endblock %}
</head>
<body>
<nav class="navbar">
<a href="/" class="navbar-brand">
<i class="fas fa-flask"></i>
LabHelper
</a>
<button class="navbar-toggle" id="navbar-toggle">
<i class="fas fa-bars"></i>
</button>
<div class="navbar-nav" id="navbar-nav">
<a href="/"><i class="fas fa-home"></i> Home</a>
<a href="/search/"><i class="fas fa-search"></i> Search</a>
{% if user.is_authenticated %}
<div class="dropdown">
<button class="dropdown-btn">
<i class="fas fa-user"></i> {{ user.username }} <i class="fas fa-chevron-down"></i>
</button>
<div class="dropdown-content">
<a href="/box-management/"><i class="fas fa-boxes"></i> Box Management</a>
<a href="/resources/"><i class="fas fa-folder-open"></i> Resources</a>
<a href="/fixme/"><i class="fas fa-exclamation-triangle"></i> Fixme</a>
<a href="/admin/"><i class="fas fa-cog"></i> Admin</a>
<form method="post" action="{% url 'logout' %}" style="padding: 0; margin: 0; background: none; border: none;">
{% csrf_token %}
<button type="submit" style="width: 100%; text-align: left; padding: 12px 20px; background: none; border: none; color: #555; font: inherit; cursor: pointer; display: block; transition: all 0.2s ease;">
<i class="fas fa-sign-out-alt"></i> Logout
</button>
</form>
</div>
</div>
{% 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 %}
<script>
$(document).ready(function() {
$('#navbar-toggle').on('click', function() {
$('#navbar-nav').toggleClass('active');
const icon = $(this).find('i');
if ($('#navbar-nav').hasClass('active')) {
icon.removeClass('fa-bars').addClass('fa-times');
} else {
icon.removeClass('fa-times').addClass('fa-bars');
}
});
});
</script>
</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,16 +18,47 @@ from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
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,
boxes_list,
delete_box,
delete_box_type,
edit_box,
edit_box_type,
edit_thing,
fixme,
index,
resources_list,
search_api,
thing_detail,
)
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('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('thing/<int:thing_id>/', thing_detail, name='thing_detail'),
path('thing/<int:thing_id>/edit/', edit_thing, name='edit_thing'),
path('box/<str:box_id>/add/', add_things, name='add_things'),
path('search/', search, name='search'),
path('boxes/', boxes_list, name='boxes_list'),
path('search/', boxes_list, name='search'),
path('search/api/', search_api, name='search_api'),
path('resources/', resources_list, name='resources_list'),
path('fixme/', fixme, name='fixme'),
path('admin/', admin.site.urls),
]

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"