49 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
53 changed files with 4908 additions and 509 deletions

4
.gitignore vendored
View File

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

352
AGENTS.md
View File

@@ -4,7 +4,7 @@ This document provides guidelines for AI coding agents working in the labhelper
## Project Overview ## Project Overview
- **Type**: Django web application - **Type**: Django web application (lab inventory management system)
- **Python**: 3.13.7 - **Python**: 3.13.7
- **Django**: 5.2.9 - **Django**: 5.2.9
- **Database**: SQLite (development) - **Database**: SQLite (development)
@@ -66,6 +66,21 @@ python manage.py collectstatic # Collect static files
gunicorn labhelper.wsgi:application # Run with Gunicorn 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 ## Code Style Guidelines
### Python Style ### Python Style
@@ -178,23 +193,91 @@ def get_box(request: HttpRequest, box_id: int) -> HttpResponse:
``` ```
labhelper/ labhelper/
├── manage.py # Django CLI entry point ├── .gitea/
├── requirements.txt # Python dependencies │ └── 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 ├── 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 │ ├── settings.py # Django settings
│ ├── urls.py # Root URL routing │ ├── urls.py # Root URL configuration
── wsgi.py # WSGI application ── wsgi.py # WSGI configuration
│ └── asgi.py # ASGI application ├── scripts/
└── boxes/ # Django app │ ├── deploy_secret.sh # Generate and deploy Django secret
├── admin.py # Admin configuration ├── full_deploy.sh # Bump both container versions + copy DB
── apps.py # App configuration ── partial_deploy.sh # Bump main container version only
├── models.py # Data models ├── .gitignore
├── views.py # View functions ├── AGENTS.md # This file
├── tests.py # Test cases ├── Dockerfile # Multi-stage build for main container
├── migrations/ # Database migrations ├── manage.py # Django CLI entry point
└── templates/ # HTML templates └── 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 ## Available Django Extensions
The project includes these pre-installed packages: 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-admin**: Nested inline forms in admin
- **django-nested-inline**: Additional nested inline support - **django-nested-inline**: Additional nested inline support
- **django-revproxy**: Reverse proxy functionality - **django-revproxy**: Reverse proxy functionality
- **sorl-thumbnail**: Image thumbnailing
- **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 ## Testing Guidelines
@@ -241,11 +552,14 @@ Per `.gitignore`:
## Common Pitfalls ## Common Pitfalls
1. **Always activate venv**: `source .venv/bin/activate` 1. **NEVER commit or push without explicit permission**: Always ask the user before running `git commit` or `git push`. The user will explicitly say "commit and push" when they want you to do this. Do NOT automatically commit/push after making changes unless instructed to do so.
2. **Run migrations after model changes**: `makemigrations` then `migrate` 2. **Always activate venv**: `source .venv/bin/activate`
3. **Add new apps to INSTALLED_APPS** in `settings.py` 3. **Run migrations after model changes**: `makemigrations` then `migrate`
4. **Use get_object_or_404** instead of bare `.get()` calls 4. **Add new apps to INSTALLED_APPS** in `settings.py`
5. **Never commit SECRET_KEY** - use environment variables in production 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 ## 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: spec:
accessModes: accessModes:
- ReadWriteMany - ReadWriteMany
storageClassName: nfs storageClassName: nfs-labhelper
resources: resources:
requests: requests:
storage: 2Gi storage: 2Gi

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
apiVersion: storage.k8s.io/v1 apiVersion: storage.k8s.io/v1
kind: StorageClass kind: StorageClass
metadata: metadata:
name: nfs name: nfs-labhelper
provisioner: kubernetes.io/no-provisioner provisioner: kubernetes.io/no-provisioner
allowVolumeExpansion: true allowVolumeExpansion: true
reclaimPolicy: Retain 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.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) @admin.register(BoxType)
@@ -21,17 +55,95 @@ class BoxAdmin(admin.ModelAdmin):
search_fields = ('id',) search_fields = ('id',)
@admin.register(ThingType) class ThingFileInline(admin.TabularInline):
class ThingTypeAdmin(DjangoMpttAdmin): """Inline admin for Thing files."""
"""Admin configuration for ThingType model."""
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): class ThingAdmin(admin.ModelAdmin):
"""Admin configuration for Thing model.""" """Admin configuration for Thing model."""
list_display = ('name', 'thing_type', 'box') list_display = ('name', 'box')
list_filter = ('thing_type', 'box') list_filter = (BoxFilter, TagsFilter)
search_fields = ('name', 'description') 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 django import forms
from .models import Thing from .models import Box, BoxType, Thing, ThingFile, ThingLink
class ThingForm(forms.ModelForm): class ThingForm(forms.ModelForm):
"""Form for adding a Thing.""" """Form for adding/editing a Thing."""
class Meta: class Meta:
model = Thing model = Thing
fields = ('name', 'thing_type', 'description', 'picture') fields = ('name', 'description', 'picture')
widgets = { widgets = {
'name': forms.TextInput(attrs={'class': 'form-control'}), 'name': forms.TextInput(attrs={'class': 'form-control'}),
'thing_type': forms.Select(attrs={'class': 'form-control'}),
'description': forms.Textarea(attrs={'class': 'form-control', 'rows': 4}), '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( ThingFormSet = forms.modelformset_factory(
Thing, Thing,
form=ThingForm, form=ThingForm,

View File

View File

View File

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

View File

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

View File

@@ -0,0 +1,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 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): class Migration(migrations.Migration):
dependencies = [ dependencies = [
@@ -20,49 +12,4 @@ class Migration(migrations.Migration):
] ]
operations = [ 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 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): class BoxType(models.Model):
@@ -38,44 +49,145 @@ class Box(models.Model):
return self.id return self.id
class ThingType(MPTTModel): class Facet(models.Model):
"""A hierarchical type/category for things stored in boxes.""" """A category of tags (e.g., Priority, Category, Status)."""
name = models.CharField(max_length=255) class Cardinality(models.TextChoices):
parent = TreeForeignKey( SINGLE = 'single', 'Single (0..1)'
'self', MULTIPLE = 'multiple', 'Multiple (0..n)'
on_delete=models.CASCADE,
null=True, name = models.CharField(max_length=100, unique=True)
blank=True, slug = models.SlugField(max_length=100, unique=True)
related_name='children' color = models.CharField(
max_length=7,
default='#667eea',
help_text='Hex color code (e.g., #667eea)'
) )
cardinality = models.CharField(
class MPTTMeta: max_length=10,
order_insertion_by = ['name'] choices=Cardinality.choices,
default=Cardinality.MULTIPLE,
def __str__(self): help_text='Can a thing have multiple tags of this facet?'
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'
) )
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: class Meta:
ordering = ['name'] ordering = ['name']
def __str__(self): def __str__(self):
return self.name 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

@@ -41,7 +41,7 @@
{% endif %} {% endif %}
{% if formset.total_form_count %} {% if formset.total_form_count %}
<form method="post" style="overflow-x: auto;"> <form method="post" enctype="multipart/form-data" style="overflow-x: auto;">
{% csrf_token %} {% csrf_token %}
<table style="width: 100%; border-collapse: collapse; margin-bottom: 20px;"> <table style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">
<thead> <thead>

View File

@@ -1,5 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load thumbnail %} {% load thumbnail %}
{% load dict_extras %}
{% block title %}Box {{ box.id }} - LabHelper{% endblock %} {% block title %}Box {{ box.id }} - LabHelper{% endblock %}
@@ -37,7 +38,7 @@
<tr style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white;"> <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;">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;">Name</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;">Tags</th>
<th style="padding: 15px 20px; text-align: left; font-weight: 600;">Description</th> <th style="padding: 15px 20px; text-align: left; font-weight: 600;">Description</th>
</tr> </tr>
</thead> </thead>
@@ -56,8 +57,16 @@
<td style="padding: 15px 20px;"> <td style="padding: 15px 20px;">
<a href="{% url 'thing_detail' thing.id %}" style="color: #667eea; text-decoration: none; font-weight: 500;">{{ thing.name }}</a> <a href="{% url 'thing_detail' thing.id %}" style="color: #667eea; text-decoration: none; font-weight: 500;">{{ thing.name }}</a>
</td> </td>
<td style="padding: 15px 20px; color: #555;">{{ thing.thing_type.name }}</td> <td style="padding: 15px 20px;">
<td style="padding: 15px 20px; color: #777;">{{ thing.description|default:"-" }}</td> {% 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> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

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

@@ -1,5 +1,4 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load mptt_tags %}
{% block title %}LabHelper - Home{% endblock %} {% block title %}LabHelper - Home{% endblock %}
@@ -12,65 +11,69 @@
{% block content %} {% block content %}
<div class="section"> <div class="section">
<h2><i class="fas fa-box"></i> Boxes</h2> <input type="text"
{% if boxes %} id="search-input"
<div class="box-grid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 20px;"> placeholder="Search for things..."
{% for box in boxes %} style="width: 100%; padding: 16px 20px; font-size: 18px; border: 2px solid #e0e0e0; border-radius: 12px; box-sizing: border-box; transition: all 0.3s;"
<div class="box-card" style="background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); padding: 20px; border-radius: 12px; border: 1px solid #e0e0e0; transition: all 0.3s ease; cursor: pointer;"> {% if request.GET.q %}value="{{ request.GET.q }}"{% endif %}>
<a href="{% url 'box_detail' box.id %}" style="text-decoration: none; color: #333; display: block;"> <p style="color: #888; font-size: 14px; margin-top: 10px;">
<div class="box-id" style="font-size: 20px; font-weight: 700; color: #667eea; margin-bottom: 8px;"> <i class="fas fa-info-circle"></i> Type at least 2 characters to search
<i class="fas fa-cube"></i> Box {{ box.id }} </p>
</div> </div>
<div class="box-type" style="font-size: 15px; color: #555; margin-bottom: 5px;">
{{ box.box_type.name }} <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 class="box-type" style="font-size: 13px; color: #777; margin-bottom: 5px;">
<i class="fas fa-ruler-combined"></i> {{ box.box_type.width }} x {{ box.box_type.height }} x {{ box.box_type.length }} mm
</div> </div>
<div class="box-type" style="font-size: 13px; color: #777;">
<i class="fas fa-layer-group"></i> {{ box.things.count }} item{{ box.things.count|pluralize }} <div 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>
<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> </a>
{% endfor %}
</div>
</div>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
{% else %} {% else %}
<p style="text-align: center; color: #888; font-size: 16px; padding: 40px;"> <p style="text-align: center; color: #888; font-size: 16px; padding: 40px;">
<i class="fas fa-box-open" style="font-size: 48px; margin-bottom: 15px; display: block;"></i> <i class="fas fa-tag" style="font-size: 48px; margin-bottom: 15px; display: block;"></i>
No boxes found. No tags found.
</p>
{% endif %}
</div>
<div class="section">
<h2><i class="fas fa-folder-tree"></i> Thing Types</h2>
{% if thing_types %}
<ul class="tree" style="list-style: none; padding-left: 0;">
{% recursetree thing_types %}
<li style="padding: 8px 0;">
<div class="tree-item" style="display: flex; align-items: center; gap: 8px;">
{% if children %}
<span class="toggle-handle" style="display: inline-block; width: 24px; color: #667eea; font-weight: bold; cursor: pointer; transition: transform 0.2s;">[-]</span>
{% else %}
<span class="toggle-handle" style="display: inline-block; width: 24px; color: #ccc;">&nbsp;</span>
{% endif %}
<a href="{% url 'thing_type_detail' node.pk %}" style="color: #667eea; text-decoration: none; font-size: 16px; font-weight: 500; transition: color 0.2s;">{{ node.name }}</a>
{% if node.things.exists %}
<span style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 3px 10px; border-radius: 20px; font-size: 12px; font-weight: 600;">{{ node.things.count }}</span>
{% endif %}
</div>
{% if children %}
<ul style="list-style: none; padding-left: 32px;">
{{ children }}
</ul>
{% endif %}
</li>
{% endrecursetree %}
</ul>
{% else %}
<p style="text-align: center; color: #888; font-size: 16px; padding: 40px;">
<i class="fas fa-folder-open" style="font-size: 48px; margin-bottom: 15px; display: block;"></i>
No thing types found.
</p> </p>
{% endif %} {% endif %}
</div> </div>
@@ -79,23 +82,115 @@
{% block extra_js %} {% block extra_js %}
<script> <script>
$(document).ready(function() { $(document).ready(function() {
$('.toggle-handle').click(function(e) { const searchInput = document.getElementById('search-input');
e.stopPropagation(); const resultsContainer = document.getElementById('results-container');
var $ul = $(this).closest('li').children('ul'); const resultsBody = document.getElementById('results-body');
if ($ul.length) { const noResults = document.getElementById('no-results');
$ul.slideToggle(200);
$(this).text($ul.is(':visible') ? '[-]' : '[+]'); 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)');
} }
}); });
$('.box-card').hover( $('.facet-card a').hover(
function() { function() {
$(this).css('transform', 'translateY(-5px)'); $(this).css('transform', 'scale(1.05)');
$(this).css('box-shadow', '0 12px 24px rgba(102, 126, 234, 0.2)');
}, },
function() { function() {
$(this).css('transform', 'translateY(0)'); $(this).css('transform', 'scale(1)');
$(this).css('box-shadow', 'none');
} }
); );
}); });

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

@@ -16,7 +16,8 @@
<input type="text" <input type="text"
id="search-input" id="search-input"
placeholder="Search for things..." 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;"> 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;"> <p style="color: #888; font-size: 14px; margin-top: 10px;">
<i class="fas fa-info-circle"></i> Type at least 2 characters to search <i class="fas fa-info-circle"></i> Type at least 2 characters to search
</p> </p>
@@ -28,7 +29,7 @@
<thead> <thead>
<tr style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white;"> <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;">Name</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;">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;">Box</th>
<th style="padding: 15px 20px; text-align: left; font-weight: 600;">Description</th> <th style="padding: 15px 20px; text-align: left; font-weight: 600;">Description</th>
</tr> </tr>
@@ -55,13 +56,7 @@ const noResults = document.getElementById('no-results');
let searchTimeout = null; let searchTimeout = null;
searchInput.addEventListener('input', function() { function performSearch(query) {
const query = this.value.trim();
if (searchTimeout) {
clearTimeout(searchTimeout);
}
if (query.length < 2) { if (query.length < 2) {
resultsContainer.style.display = 'none'; resultsContainer.style.display = 'none';
noResults.style.display = 'none'; noResults.style.display = 'none';
@@ -90,9 +85,16 @@ searchInput.addEventListener('input', function() {
const row = document.createElement('tr'); const row = document.createElement('tr');
row.style.borderBottom = '1px solid #e0e0e0'; row.style.borderBottom = '1px solid #e0e0e0';
row.style.transition = 'background 0.2s'; 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 = row.innerHTML =
'<td style="padding: 15px 20px;"><a href="/thing/' + thing.id + '/">' + escapeHtml(thing.name) + '</a></td>' + '<td style="padding: 15px 20px;"><a href="/thing/' + thing.id + '/">' + escapeHtml(thing.name) + '</a></td>' +
'<td style="padding: 15px 20px; color: #555;">' + escapeHtml(thing.type) + '</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;"><a href="/box/' + escapeHtml(thing.box) + '/">' + escapeHtml(thing.box) + '</a></td>' +
'<td style="padding: 15px 20px; color: #777;" class="description">' + escapeHtml(thing.description) + '</td>'; '<td style="padding: 15px 20px; color: #777;" class="description">' + escapeHtml(thing.description) + '</td>';
@@ -107,6 +109,16 @@ searchInput.addEventListener('input', function() {
}); });
}); });
}, 200); }, 200);
}
searchInput.addEventListener('input', function() {
const query = this.value.trim();
if (searchTimeout) {
clearTimeout(searchTimeout);
}
performSearch(query);
}); });
searchInput.addEventListener('blur', function() { searchInput.addEventListener('blur', function() {
@@ -120,6 +132,15 @@ function escapeHtml(text) {
return div.innerHTML; 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(); searchInput.focus();
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -1,10 +1,12 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load thumbnail %} {% load thumbnail %}
{% load dict_extras %}
{% block title %}{{ thing.name }} - LabHelper{% endblock %} {% block title %}{{ thing.name }} - LabHelper{% endblock %}
{% block page_header %} {% block page_header %}
<div class="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> <h1><i class="fas fa-cube"></i> {{ thing.name }}</h1>
<p class="breadcrumb"> <p class="breadcrumb">
<a href="/"><i class="fas fa-home"></i> Home</a> / <a href="/"><i class="fas fa-home"></i> Home</a> /
@@ -12,18 +14,22 @@
{{ thing.name }} {{ thing.name }}
</p> </p>
</div> </div>
<a href="{% url 'edit_thing' thing.id %}" class="btn" style="margin-top: 10px;">
<i class="fas fa-edit"></i> Edit
</a>
</div>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="section"> <div class="section">
<div class="thing-card" style="display: flex; gap: 40px; flex-wrap: wrap;"> <div class="thing-card" style="display: flex; gap: 40px; flex-wrap: wrap;">
<div class="thing-image" style="flex-shrink: 0;"> <div class="thing-image" style="flex-shrink: 0; width: 100%; max-width: 400px;">
{% if thing.picture %} {% if thing.picture %}
{% thumbnail thing.picture "400x400" crop="center" as thumb %} {% thumbnail thing.picture "400x400" crop="center" as thumb %}
<img src="{{ thumb.url }}" alt="{{ thing.name }}" style="width: 400px; height: 400px; object-fit: cover; border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.15);"> <img src="{{ thumb.url }}" alt="{{ thing.name }}" style="width: 100%; height: auto; max-height: 400px; object-fit: cover; border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.15);">
{% endthumbnail %} {% endthumbnail %}
{% else %} {% else %}
<div style="width: 400px; height: 400px; background: linear-gradient(135deg, #e0e0e0 0%, #f0f0f0 100%); display: flex; align-items: center; justify-content: center; color: #999; border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.15);"> <div style="width: 100%; aspect-ratio: 1; max-width: 400px; max-height: 400px; background: linear-gradient(135deg, #e0e0e0 0%, #f0f0f0 100%); display: flex; align-items: center; justify-content: center; color: #999; border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.15);">
<div style="text-align: center;"> <div style="text-align: center;">
<i class="fas fa-image" style="font-size: 64px; margin-bottom: 15px; display: block;"></i> <i class="fas fa-image" style="font-size: 64px; margin-bottom: 15px; display: block;"></i>
No image No image
@@ -33,14 +39,30 @@
</div> </div>
<div class="thing-details" style="flex-grow: 1; min-width: 300px;"> <div class="thing-details" style="flex-grow: 1; min-width: 300px;">
{% if thing.tags.all %}
<div class="detail-row" style="margin-bottom: 25px;"> <div class="detail-row" style="margin-bottom: 25px;">
<div style="font-size: 14px; color: #888; font-weight: 600; margin-bottom: 8px;"> <div style="font-size: 14px; color: #888; font-weight: 600; margin-bottom: 8px;">
<i class="fas fa-tag"></i> Type <i class="fas fa-tags"></i> Tags
</div> </div>
<div style="font-size: 18px; color: #333; font-weight: 500;"> <div style="display: flex; flex-direction: column; gap: 12px;">
{{ thing.thing_type.name }} {% 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>
</div> </div>
{% endfor %}
</div>
</div>
{% endif %}
<div class="detail-row" style="margin-bottom: 25px;"> <div class="detail-row" style="margin-bottom: 25px;">
<div style="font-size: 14px; color: #888; font-weight: 600; margin-bottom: 8px;"> <div style="font-size: 14px; color: #888; font-weight: 600; margin-bottom: 8px;">
@@ -57,47 +79,124 @@
<div style="font-size: 14px; color: #888; font-weight: 600; margin-bottom: 8px;"> <div style="font-size: 14px; color: #888; font-weight: 600; margin-bottom: 8px;">
<i class="fas fa-align-left"></i> Description <i class="fas fa-align-left"></i> Description
</div> </div>
<div style="font-size: 16px; color: #555; line-height: 1.6; white-space: pre-wrap;"> <div class="markdown-content" style="font-size: 16px; color: #555; line-height: 1.6;">
{{ thing.description }} {{ 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>
</div> </div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
</div> </div>
<form method="post" class="section">
{% csrf_token %}
<div style="display: flex; align-items: center; gap: 15px; flex-wrap: wrap;">
<div style="flex-grow: 1;">
<label for="new_box" style="font-weight: 600; color: #666; font-size: 14px; margin-bottom: 8px; display: block;">
<i class="fas fa-exchange-alt"></i> Move to:
</label>
<select name="new_box" id="new_box" style="width: 100%; max-width: 400px; padding: 12px 16px; border: 2px solid #e0e0e0; border-radius: 10px; font-size: 15px; background: white; cursor: pointer; transition: all 0.3s;">
<option value="">Select a box...</option>
{% for box in boxes %}
<option value="{{ box.id }}" {% if box.id == thing.box.id %}selected{% endif %}>
Box {{ box.id }} ({{ box.box_type.name }})
</option>
{% endfor %}
</select>
</div>
<button type="submit" class="btn" style="height: 48px; min-width: 120px; margin-top: 24px;">
<i class="fas fa-arrows-alt"></i> Move
</button>
</div>
</form>
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_css %}
<script> <style>
$('#new_box').on('focus', function() { .markdown-content p {
$(this).css('border-color', '#667eea'); margin: 0 0 1em 0;
$(this).css('box-shadow', '0 0 0 3px rgba(102, 126, 234, 0.1)'); }
}).on('blur', function() { .markdown-content p:last-child {
$(this).css('border-color', '#e0e0e0'); margin-bottom: 0;
$(this).css('box-shadow', 'none'); }
}); .markdown-content h1, .markdown-content h2, .markdown-content h3,
</script> .markdown-content h4, .markdown-content h5, .markdown-content h6 {
margin: 1.5em 0 0.5em 0;
color: #333;
font-weight: 600;
}
.markdown-content h1:first-child, .markdown-content h2:first-child,
.markdown-content h3:first-child {
margin-top: 0;
}
.markdown-content ul, .markdown-content ol {
margin: 0.5em 0;
padding-left: 2em;
}
.markdown-content li {
margin: 0.25em 0;
}
.markdown-content code {
background: #f4f4f4;
padding: 2px 6px;
border-radius: 4px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.9em;
}
.markdown-content pre {
background: #f4f4f4;
padding: 15px;
border-radius: 8px;
overflow-x: auto;
margin: 1em 0;
}
.markdown-content pre code {
background: none;
padding: 0;
}
.markdown-content blockquote {
border-left: 4px solid #667eea;
margin: 1em 0;
padding: 0.5em 1em;
background: #f8f9fa;
color: #666;
}
.markdown-content a {
color: #667eea;
text-decoration: none;
}
.markdown-content a:hover {
text-decoration: underline;
}
.markdown-content table {
border-collapse: collapse;
width: 100%;
margin: 1em 0;
}
.markdown-content th, .markdown-content td {
border: 1px solid #e0e0e0;
padding: 8px 12px;
text-align: left;
}
.markdown-content th {
background: #f8f9fa;
font-weight: 600;
}
.markdown-content hr {
border: none;
border-top: 2px solid #e0e0e0;
margin: 1.5em 0;
}
</style>
{% endblock %} {% endblock %}

View File

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,81 +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.http import HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from .forms import ThingFormSet from .forms import (
from .models import Box, Thing, ThingType 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): def index(request):
"""Home page with boxes and thing types.""" """Home page with search and tags."""
boxes = Box.objects.select_related('box_type').all().order_by('id') facets = Facet.objects.all().prefetch_related('tags')
thing_types = ThingType.objects.all()
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', { return render(request, 'boxes/index.html', {
'boxes': boxes, 'facets': facets,
'thing_types': thing_types, 'facet_tag_counts': facet_tag_counts,
}) })
@login_required
def box_detail(request, box_id): def box_detail(request, box_id):
"""Display contents of a box.""" """Display contents of a box."""
box = get_object_or_404(Box, pk=box_id) box = get_object_or_404(Box, pk=box_id)
things = box.things.select_related('thing_type').all() things = box.things.prefetch_related('tags').all()
return render(request, 'boxes/box_detail.html', { return render(request, 'boxes/box_detail.html', {
'box': box, 'box': box,
'things': things, 'things': things,
}) })
@login_required
def thing_detail(request, thing_id): def thing_detail(request, thing_id):
"""Display details of a thing.""" """Display details of a thing (read-only)."""
thing = get_object_or_404( 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})
@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 pk=thing_id
) )
boxes = Box.objects.select_related('box_type').all().order_by('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': 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') new_box_id = request.POST.get('new_box')
if new_box_id: if new_box_id:
new_box = get_object_or_404(Box, pk=new_box_id) new_box = get_object_or_404(Box, pk=new_box_id)
thing.box = new_box thing.box = new_box
thing.save() thing.save()
return redirect('thing_detail', thing_id=thing.id) return redirect('edit_thing', thing_id=thing.id)
return render(request, 'boxes/thing_detail.html', { 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, 'thing': thing,
'boxes': boxes, '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,
}) })
def search(request): @login_required
"""Search page for things."""
return render(request, 'boxes/search.html')
def search_api(request): def search_api(request):
"""AJAX endpoint for searching things.""" """AJAX endpoint for searching things."""
query = request.GET.get('q', '').strip() query = request.GET.get('q', '').strip()
if len(query) < 2: if len(query) < 2:
return JsonResponse({'results': []}) return JsonResponse({'results': []})
# Check for "Facet:Word" format
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( things = Thing.objects.filter(
name__icontains=query Q(tags__facet__name__icontains=facet_name) &
).select_related('thing_type', 'box')[:50] 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 = [ results = [
{ {
'id': thing.id, 'id': thing.id,
'name': thing.name, 'name': thing.name,
'type': thing.thing_type.name,
'box': thing.box.id, '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 for thing in things
] ]
return JsonResponse({'results': results}) return JsonResponse({'results': results})
@login_required
def add_things(request, box_id): def add_things(request, box_id):
"""Add multiple things to a box at once.""" """Add multiple things to a box at once."""
box = get_object_or_404(Box, pk=box_id) box = get_object_or_404(Box, pk=box_id)
@@ -83,13 +270,13 @@ def add_things(request, box_id):
success_message = None success_message = None
if request.method == 'POST': if request.method == 'POST':
formset = ThingFormSet(request.POST, queryset=Thing.objects.filter(box=box)) formset = ThingFormSet(request.POST, request.FILES, queryset=Thing.objects.filter(box=box))
if formset.is_valid(): if formset.is_valid():
things = formset.save(commit=False) things = formset.save(commit=False)
created_count = 0 created_count = 0
for thing in things: 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.box = box
thing.save() thing.save()
created_count += 1 created_count += 1
@@ -106,19 +293,159 @@ def add_things(request, box_id):
}) })
def thing_type_detail(request, type_id): @login_required
"""Display details of a thing type with its hierarchy and things.""" def box_management(request):
thing_type = get_object_or_404(ThingType, pk=type_id) """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()
descendants = thing_type.get_descendants(include_self=True) return render(request, 'boxes/box_management.html', {
things_by_type = {} 'box_types': box_types,
'boxes': boxes,
for descendant in descendants: 'box_type_form': box_type_form,
things = descendant.things.select_related('box', 'box__box_type').all() 'box_form': box_form,
if things: })
things_by_type[descendant] = things
return render(request, 'boxes/thing_type_detail.html', { @login_required
'thing_type': thing_type, def add_box_type(request):
'things_by_type': things_by_type, """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: 775 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

View File

View File

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

View File

@@ -10,6 +10,7 @@ For the full list of settings and their values, see
https://docs.djangoproject.com/en/5.2/ref/settings/ https://docs.djangoproject.com/en/5.2/ref/settings/
""" """
import os
from pathlib import Path from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'. # Build paths inside the project like this: BASE_DIR / 'subdir'.
@@ -20,7 +21,7 @@ BASE_DIR = Path(__file__).resolve().parent.parent
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ # See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret! # SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'f0arjg8q3ut4iuqrguqfjaruf0eripIZZN3t1kymy8ugqnj$li2knhha0@gc5v8f3bge=$+gbybj2$jt28uqm' SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY', 'f0arjg8q3ut4iuqrguqfjaruf0eripIZZN3t1kymy8ugqnj$li2knhha0@gc5v8f3bge=$+gbybj2$jt28uqm')
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True DEBUG = True
@@ -130,3 +131,9 @@ MEDIA_ROOT = BASE_DIR / 'data' / 'media'
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field # https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
CSRF_TRUSTED_ORIGINS=["https://labhelper.adebaumann.com"]
LOGIN_URL = 'login'
LOGIN_REDIRECT_URL = 'index'
LOGOUT_REDIRECT_URL = 'login'

View File

@@ -50,13 +50,31 @@
font-size: 24px; 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 { .navbar-nav {
display: flex; display: flex;
gap: 20px; gap: 20px;
align-items: center; align-items: center;
} }
.navbar-nav a { .navbar-nav a,
.navbar-nav form {
color: #555; color: #555;
text-decoration: none; text-decoration: none;
font-weight: 500; font-weight: 500;
@@ -69,7 +87,8 @@
gap: 8px; gap: 8px;
} }
.navbar-nav a:hover { .navbar-nav a:hover,
.navbar-nav button:hover {
background: #667eea; background: #667eea;
color: white; color: white;
transform: translateY(-2px); transform: translateY(-2px);
@@ -80,6 +99,68 @@
font-size: 14px; 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 { .container {
max-width: 1200px; max-width: 1200px;
margin: 20px auto; margin: 20px auto;
@@ -215,6 +296,97 @@
text-decoration: underline; 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 %} {% block extra_css %}{% endblock %}
</style> </style>
{% block extra_head %}{% endblock %} {% block extra_head %}{% endblock %}
@@ -225,10 +397,33 @@
<i class="fas fa-flask"></i> <i class="fas fa-flask"></i>
LabHelper LabHelper
</a> </a>
<div class="navbar-nav"> <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="/"><i class="fas fa-home"></i> Home</a>
<a href="/search/"><i class="fas fa-search"></i> Search</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> <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> </div>
</nav> </nav>
@@ -243,5 +438,18 @@
</footer> </footer>
{% block extra_js %}{% endblock %} {% 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> </body>
</html> </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,17 +18,47 @@ from django.conf import settings
from django.conf.urls.static import static from django.conf.urls.static import static
from django.contrib import admin from django.contrib import admin
from django.urls import path from django.urls import path
from django.contrib.auth import views as auth_views
from boxes.views import add_things, box_detail, index, search, search_api, thing_detail, thing_type_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 = [ urlpatterns = [
path('login/', auth_views.LoginView.as_view(template_name='login.html'), name='login'),
path('logout/', auth_views.LogoutView.as_view(), name='logout'),
path('', index, name='index'), path('', index, name='index'),
path('box-management/', box_management, name='box_management'),
path('box-type/add/', add_box_type, name='add_box_type'),
path('box-type/<int:type_id>/edit/', edit_box_type, name='edit_box_type'),
path('box-type/<int:type_id>/delete/', delete_box_type, name='delete_box_type'),
path('box/add/', add_box, name='add_box'),
path('box/<str:box_id>/edit/', edit_box, name='edit_box'),
path('box/<str:box_id>/delete/', delete_box, name='delete_box'),
path('box/<str:box_id>/', box_detail, name='box_detail'), path('box/<str:box_id>/', box_detail, name='box_detail'),
path('thing/<int:thing_id>/', thing_detail, name='thing_detail'), path('thing/<int:thing_id>/', thing_detail, name='thing_detail'),
path('thing-type/<int:type_id>/', thing_type_detail, name='thing_type_detail'), path('thing/<int:thing_id>/edit/', edit_thing, name='edit_thing'),
path('box/<str:box_id>/add/', add_things, name='add_things'), path('box/<str:box_id>/add/', add_things, name='add_things'),
path('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('search/api/', search_api, name='search_api'),
path('resources/', resources_list, name='resources_list'),
path('fixme/', fixme, name='fixme'),
path('admin/', admin.site.urls), 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"