Compare commits
70 Commits
improvemen
...
65868c043e
| Author | SHA1 | Date | |
|---|---|---|---|
|
65868c043e
|
|||
|
5c9b45715b
|
|||
|
bbed20813a
|
|||
| 22f5b87a20 | |||
| 1dede761e3 | |||
| 860e80a552 | |||
| a1bc7967c5 | |||
| 2a825646a3 | |||
| 7c990998a4 | |||
| bf62ddcbd7 | |||
| 985460ff84 | |||
| 2705f6c16e | |||
| 887247028a | |||
| 4cbd3e2f87 | |||
| 935392d27d | |||
| 30657be6c2 | |||
| 4bca3ae403 | |||
| db80ddf069 | |||
| 0602347539 | |||
| 384c0d58e6 | |||
| d46f0385c9 | |||
| 7e96fcef8b | |||
| ebf3b9d00a | |||
| be2a0028f4 | |||
| 4efaf17776 | |||
| d02f6d1d1d | |||
| 9599807752 | |||
| 56db405839 | |||
| 074f9263dd | |||
| 35140e9686 | |||
| b756e1b411 | |||
| 11e593f8ce | |||
| da506221f7 | |||
| ca50832b54 | |||
| 5c0b09f78e | |||
| 73b39ec189 | |||
| 232d2270c3 | |||
| 8263afb2a5 | |||
| 68bd013ac9 | |||
| cd04a21157 | |||
| cb3e9d6aec | |||
| bb23f7f574 | |||
| b51bf23726 | |||
| a4783bea2c | |||
| ee9a76dcc8 | |||
| 11d2579c7e | |||
| 7410f8c607 | |||
| b465e7365f | |||
| a4f9274da4 | |||
| acde0cb2f8 | |||
| 10cc24ff03 | |||
| c566e31ab5 | |||
| bd36132946 | |||
| 20e5e0b0c1 | |||
| 0f5011d8f7 | |||
| 88a5c12bbc | |||
| 17e713964c | |||
| e172e2f9dc | |||
| eb8284fdd2 | |||
| 1d1c80a267 | |||
| d28c13d339 | |||
| 0eeedaff97 | |||
| b0b44eeed4 | |||
| 39762037fe | |||
| 150fd1c59d | |||
| 8d23713526 | |||
| 4d3ace5395 | |||
| 2a2d3ead0b | |||
| fe39d2c067 | |||
| 9db47a0ab7 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -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
352
AGENTS.md
@@ -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
272
AGENTS.md.backup
Normal 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.
|
||||||
@@ -34,6 +34,7 @@ WORKDIR /app
|
|||||||
COPY --chown=appuser:appuser . .
|
COPY --chown=appuser:appuser . .
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
ENV IMAGE_TAG=build
|
||||||
USER appuser
|
USER appuser
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
RUN rm -rvf /app/Dockerfile* \
|
RUN rm -rvf /app/Dockerfile* \
|
||||||
@@ -47,5 +48,5 @@ RUN rm -rvf /app/Dockerfile* \
|
|||||||
/app/*.json \
|
/app/*.json \
|
||||||
/app/test_*.py && \
|
/app/test_*.py && \
|
||||||
python3 /app/manage.py collectstatic --noinput
|
python3 /app/manage.py collectstatic --noinput
|
||||||
CMD ["sh", "-c", "python manage.py thumbnail clear && gunicorn --bind 0.0.0.0:8000 --workers 3 labhelper.wsgi:application"]
|
CMD ["sh", "-c", "python manage.py thumbnail clear && gunicorn --bind 0.0.0.0:8000 --workers 3 $GUNICORN_OPTS labhelper.wsgi:application"]
|
||||||
|
|
||||||
|
|||||||
22
argocd/configmap.yaml
Normal file
22
argocd/configmap.yaml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: django-config
|
||||||
|
namespace: labhelper
|
||||||
|
data:
|
||||||
|
DEBUG: "False"
|
||||||
|
ALLOWED_HOSTS: "labhelper.adebaumann.com,*"
|
||||||
|
ALLOWED_CIDR_NETS: "10.0.0.0/16"
|
||||||
|
LANGUAGE_CODE: "en-us"
|
||||||
|
TIME_ZONE: "UTC"
|
||||||
|
USE_I18N: "True"
|
||||||
|
USE_TZ: "True"
|
||||||
|
STATIC_URL: "/static/"
|
||||||
|
MEDIA_URL: "/media/"
|
||||||
|
CSRF_TRUSTED_ORIGINS: "https://labhelper.adebaumann.com"
|
||||||
|
LOGIN_URL: "login"
|
||||||
|
LOGIN_REDIRECT_URL: "index"
|
||||||
|
LOGOUT_REDIRECT_URL: "login"
|
||||||
|
TRUSTED_PROXIES: "192.168.17.44,192.168.17.53"
|
||||||
|
GUNICORN_OPTS: "--access-logfile -"
|
||||||
|
IMAGE_TAG: "0.070"
|
||||||
@@ -18,19 +18,105 @@ 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.070
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 8000
|
- containerPort: 8000
|
||||||
|
env:
|
||||||
|
- name: DJANGO_SECRET_KEY
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: django-secret
|
||||||
|
key: secret-key
|
||||||
|
- name: DEBUG
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: django-config
|
||||||
|
key: DEBUG
|
||||||
|
- name: ALLOWED_HOSTS
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: django-config
|
||||||
|
key: ALLOWED_HOSTS
|
||||||
|
- name: ALLOWED_CIDR_NETS
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: django-config
|
||||||
|
key: ALLOWED_CIDR_NETS
|
||||||
|
- name: LANGUAGE_CODE
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: django-config
|
||||||
|
key: LANGUAGE_CODE
|
||||||
|
- name: TIME_ZONE
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: django-config
|
||||||
|
key: TIME_ZONE
|
||||||
|
- name: USE_I18N
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: django-config
|
||||||
|
key: USE_I18N
|
||||||
|
- name: USE_TZ
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: django-config
|
||||||
|
key: USE_TZ
|
||||||
|
- name: STATIC_URL
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: django-config
|
||||||
|
key: STATIC_URL
|
||||||
|
- name: MEDIA_URL
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: django-config
|
||||||
|
key: MEDIA_URL
|
||||||
|
- name: CSRF_TRUSTED_ORIGINS
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: django-config
|
||||||
|
key: CSRF_TRUSTED_ORIGINS
|
||||||
|
- name: LOGIN_URL
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: django-config
|
||||||
|
key: LOGIN_URL
|
||||||
|
- name: LOGIN_REDIRECT_URL
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: django-config
|
||||||
|
key: LOGIN_REDIRECT_URL
|
||||||
|
- name: LOGOUT_REDIRECT_URL
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: django-config
|
||||||
|
key: LOGOUT_REDIRECT_URL
|
||||||
|
- name: TRUSTED_PROXIES
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: django-config
|
||||||
|
key: TRUSTED_PROXIES
|
||||||
|
- name: GUNICORN_OPTS
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: django-config
|
||||||
|
key: GUNICORN_OPTS
|
||||||
|
- name: IMAGE_TAG
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: django-config
|
||||||
|
key: IMAGE_TAG
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: data
|
- name: data
|
||||||
mountPath: /app/data
|
mountPath: /app/data
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ metadata:
|
|||||||
name: labhelper-data-pv
|
name: labhelper-data-pv
|
||||||
namespace: labhelper
|
namespace: labhelper
|
||||||
spec:
|
spec:
|
||||||
|
claimRef:
|
||||||
|
name: labhelper-data-pvc
|
||||||
|
namespace: labhelper
|
||||||
capacity:
|
capacity:
|
||||||
storage: 2Gi
|
storage: 2Gi
|
||||||
accessModes:
|
accessModes:
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
apiVersion: storage.k8s.io/v1
|
|
||||||
kind: StorageClass
|
|
||||||
metadata:
|
|
||||||
name: nfs
|
|
||||||
provisioner: kubernetes.io/no-provisioner
|
|
||||||
allowVolumeExpansion: true
|
|
||||||
reclaimPolicy: Retain
|
|
||||||
volumeBindingMode: Immediate
|
|
||||||
164
boxes/admin.py
164
boxes/admin.py
@@ -1,7 +1,46 @@
|
|||||||
from django.contrib import admin
|
import json
|
||||||
from django_mptt_admin.admin import DjangoMpttAdmin
|
|
||||||
|
|
||||||
from .models import Box, BoxType, Thing, ThingType
|
from django import forms
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.contrib.admin import SimpleListFilter
|
||||||
|
from django.http import JsonResponse
|
||||||
|
from django.urls import path
|
||||||
|
from django.utils.html import format_html
|
||||||
|
from django.views.decorators.http import require_POST
|
||||||
|
|
||||||
|
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('sort_order')
|
||||||
|
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)
|
||||||
@@ -16,22 +55,125 @@ class BoxTypeAdmin(admin.ModelAdmin):
|
|||||||
class BoxAdmin(admin.ModelAdmin):
|
class BoxAdmin(admin.ModelAdmin):
|
||||||
"""Admin configuration for Box model."""
|
"""Admin configuration for Box model."""
|
||||||
|
|
||||||
list_display = ('id', 'box_type')
|
ordering = ['sort_order']
|
||||||
|
list_display = ('id', 'box_type', 'sort_order')
|
||||||
list_filter = ('box_type',)
|
list_filter = ('box_type',)
|
||||||
search_fields = ('id',)
|
search_fields = ('id',)
|
||||||
|
change_list_template = 'admin/boxes/box/change_list.html'
|
||||||
|
|
||||||
|
def get_urls(self):
|
||||||
|
urls = super().get_urls()
|
||||||
|
custom_urls = [
|
||||||
|
path('reorder/', self.admin_site.admin_view(self.reorder_view), name='boxes_box_reorder'),
|
||||||
|
]
|
||||||
|
return custom_urls + urls
|
||||||
|
|
||||||
|
def reorder_view(self, request):
|
||||||
|
"""Handle AJAX reorder requests."""
|
||||||
|
if request.method != 'POST':
|
||||||
|
return JsonResponse({'error': 'POST required'}, status=405)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(request.body)
|
||||||
|
order = data.get('order', [])
|
||||||
|
|
||||||
|
for index, pk in enumerate(order):
|
||||||
|
Box.objects.filter(pk=pk).update(sort_order=index)
|
||||||
|
|
||||||
|
return JsonResponse({'status': 'ok'})
|
||||||
|
except (json.JSONDecodeError, KeyError) as e:
|
||||||
|
return JsonResponse({'error': str(e)}, status=400)
|
||||||
|
|
||||||
|
|
||||||
@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')
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
0
boxes/management/__init__.py
Normal file
0
boxes/management/__init__.py
Normal file
0
boxes/management/commands/__init__.py
Normal file
0
boxes/management/commands/__init__.py
Normal file
79
boxes/management/commands/clean_orphaned_files.py
Normal file
79
boxes/management/commands/clean_orphaned_files.py
Normal 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')
|
||||||
199
boxes/management/commands/clean_orphaned_images.py
Normal file
199
boxes/management/commands/clean_orphaned_images.py
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
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, ThingFile
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Clean up orphaned images, files, 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))
|
||||||
|
|
||||||
|
for thing_file in ThingFile.objects.all():
|
||||||
|
if thing_file.file:
|
||||||
|
if thing_file.file.name.startswith("things/"):
|
||||||
|
relative_path = thing_file.file.name[7:]
|
||||||
|
valid_paths.add(relative_path)
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
f"Found {len(valid_paths)} valid images and files in database"
|
||||||
|
)
|
||||||
|
|
||||||
|
orphaned_thumbnail_paths = set()
|
||||||
|
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} files 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} files and {thumbnail_deleted_count} thumbnails"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.stdout.write(f"Removed {empty_dirs_removed} empty directories")
|
||||||
25
boxes/management/commands/list_things.py
Normal file
25
boxes/management/commands/list_things.py
Normal 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'))
|
||||||
@@ -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),
|
|
||||||
]
|
]
|
||||||
|
|||||||
19
boxes/migrations/0004_alter_thing_picture.py
Normal file
19
boxes/migrations/0004_alter_thing_picture.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
41
boxes/migrations/0005_thingfile_thinglink.py
Normal file
41
boxes/migrations/0005_thingfile_thinglink.py
Normal 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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
boxes/migrations/0007_tag_color.py
Normal file
18
boxes/migrations/0007_tag_color.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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',
|
||||||
|
),
|
||||||
|
]
|
||||||
75
boxes/migrations/0009_migrate_tags_to_facets.py
Normal file
75
boxes/migrations/0009_migrate_tags_to_facets.py
Normal 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),
|
||||||
|
]
|
||||||
35
boxes/migrations/0010_remove_thingtype.py
Normal file
35
boxes/migrations/0010_remove_thingtype.py
Normal 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',
|
||||||
|
),
|
||||||
|
]
|
||||||
22
boxes/migrations/0011_alter_box_options_box_sort_order.py
Normal file
22
boxes/migrations/0011_alter_box_options_box_sort_order.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Generated by Django 5.2.9 on 2026-01-19 23:00
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('boxes', '0010_remove_thingtype'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='box',
|
||||||
|
options={'ordering': ['sort_order'], 'verbose_name_plural': 'boxes'},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='box',
|
||||||
|
name='sort_order',
|
||||||
|
field=models.PositiveIntegerField(db_index=True, default=0, help_text='Order in which boxes are displayed'),
|
||||||
|
),
|
||||||
|
]
|
||||||
184
boxes/models.py
184
boxes/models.py
@@ -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):
|
||||||
@@ -30,52 +41,159 @@ class Box(models.Model):
|
|||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
related_name='boxes'
|
related_name='boxes'
|
||||||
)
|
)
|
||||||
|
sort_order = models.PositiveIntegerField(
|
||||||
|
default=0,
|
||||||
|
db_index=True,
|
||||||
|
help_text='Order in which boxes are displayed'
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name_plural = 'boxes'
|
verbose_name_plural = 'boxes'
|
||||||
|
ordering = ['sort_order']
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.id
|
return self.id
|
||||||
|
|
||||||
|
|
||||||
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}'
|
||||||
|
|||||||
126
boxes/templates/admin/boxes/box/change_list.html
Normal file
126
boxes/templates/admin/boxes/box/change_list.html
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
{% extends "admin/change_list.html" %}
|
||||||
|
|
||||||
|
{% block extrahead %}
|
||||||
|
{{ block.super }}
|
||||||
|
<style>
|
||||||
|
#result_list tbody tr {
|
||||||
|
cursor: move;
|
||||||
|
}
|
||||||
|
#result_list tbody tr.dragging {
|
||||||
|
opacity: 0.5;
|
||||||
|
background: #ffffd0;
|
||||||
|
}
|
||||||
|
#result_list tbody tr.drag-over {
|
||||||
|
border-top: 2px solid #417690;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block result_list %}
|
||||||
|
{{ block.super }}
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const tbody = document.querySelector('#result_list tbody');
|
||||||
|
if (!tbody) return;
|
||||||
|
|
||||||
|
let draggedRow = null;
|
||||||
|
|
||||||
|
tbody.querySelectorAll('tr').forEach(row => {
|
||||||
|
row.draggable = true;
|
||||||
|
|
||||||
|
row.addEventListener('dragstart', function(e) {
|
||||||
|
draggedRow = this;
|
||||||
|
this.classList.add('dragging');
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
});
|
||||||
|
|
||||||
|
row.addEventListener('dragend', function() {
|
||||||
|
this.classList.remove('dragging');
|
||||||
|
tbody.querySelectorAll('tr').forEach(r => r.classList.remove('drag-over'));
|
||||||
|
draggedRow = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
row.addEventListener('dragover', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
if (this !== draggedRow) {
|
||||||
|
this.classList.add('drag-over');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
row.addEventListener('dragleave', function() {
|
||||||
|
this.classList.remove('drag-over');
|
||||||
|
});
|
||||||
|
|
||||||
|
row.addEventListener('drop', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.classList.remove('drag-over');
|
||||||
|
|
||||||
|
if (draggedRow && this !== draggedRow) {
|
||||||
|
const allRows = Array.from(tbody.querySelectorAll('tr'));
|
||||||
|
const draggedIndex = allRows.indexOf(draggedRow);
|
||||||
|
const targetIndex = allRows.indexOf(this);
|
||||||
|
|
||||||
|
if (draggedIndex < targetIndex) {
|
||||||
|
this.parentNode.insertBefore(draggedRow, this.nextSibling);
|
||||||
|
} else {
|
||||||
|
this.parentNode.insertBefore(draggedRow, this);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveOrder();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function saveOrder() {
|
||||||
|
const rows = tbody.querySelectorAll('tr');
|
||||||
|
const order = [];
|
||||||
|
|
||||||
|
rows.forEach(row => {
|
||||||
|
// Use the action checkbox which contains the PK
|
||||||
|
const checkbox = row.querySelector('input[name="_selected_action"]');
|
||||||
|
if (checkbox) {
|
||||||
|
order.push(checkbox.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fetch('{% url "admin:boxes_box_reorder" %}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]')?.value || getCookie('csrftoken')
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ order: order })
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status === 'ok') {
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
console.error('Reorder failed:', data.error);
|
||||||
|
alert('Failed to save order');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Reorder error:', error);
|
||||||
|
alert('Failed to save order');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCookie(name) {
|
||||||
|
let cookieValue = null;
|
||||||
|
if (document.cookie && document.cookie !== '') {
|
||||||
|
const cookies = document.cookie.split(';');
|
||||||
|
for (let i = 0; i < cookies.length; i++) {
|
||||||
|
const cookie = cookies[i].trim();
|
||||||
|
if (cookie.substring(0, name.length + 1) === (name + '=')) {
|
||||||
|
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cookieValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
216
boxes/templates/boxes/box_management.html
Normal file
216
boxes/templates/boxes/box_management.html
Normal 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 %}
|
||||||
74
boxes/templates/boxes/boxes_list.html
Normal file
74
boxes/templates/boxes/boxes_list.html
Normal 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 %}
|
||||||
324
boxes/templates/boxes/edit_thing.html
Normal file
324
boxes/templates/boxes/edit_thing.html
Normal 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 %}
|
||||||
145
boxes/templates/boxes/fixme.html
Normal file
145
boxes/templates/boxes/fixme.html
Normal 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 %}
|
||||||
@@ -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 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: 15px; color: #555; margin-bottom: 5px;">
|
</div>
|
||||||
{{ box.box_type.name }}
|
|
||||||
|
<div id="no-results" class="section" style="text-align: center; padding: 60px 30px; display: none;">
|
||||||
|
<i class="fas fa-search-minus" style="font-size: 64px; color: #ddd; margin-bottom: 20px; display: block;"></i>
|
||||||
|
<h3 style="color: #888; font-size: 20px;">No results found</h3>
|
||||||
|
<p style="color: #999; margin-top: 10px;">Try different keywords or browse the full inventory.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2><i class="fas fa-tags"></i> Tags</h2>
|
||||||
|
{% if facet_tag_counts %}
|
||||||
|
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px;">
|
||||||
|
{% for facet, tags_with_counts in facet_tag_counts.items %}
|
||||||
|
<div class="facet-card" style="background: white; border-radius: 12px; border: 1px solid #e0e0e0; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.05);">
|
||||||
|
<div class="facet-header" style="padding: 15px 20px; background: linear-gradient(135deg, {{ facet.color }} 0%, {{ facet.color }}dd 100%); color: white; display: flex; align-items: center; justify-content: space-between; cursor: pointer;">
|
||||||
|
<div style="display: flex; align-items: center; gap: 10px;">
|
||||||
|
<i class="fas fa-chevron-right facet-toggle" style="transition: transform 0.3s;"></i>
|
||||||
|
<span style="font-size: 18px; font-weight: 700;">{{ facet.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="box-type" style="font-size: 13px; color: #777; margin-bottom: 5px;">
|
<span style="background: rgba(255,255,255,0.3); padding: 4px 12px; border-radius: 20px; font-size: 13px; font-weight: 600;">{{ facet.cardinality }}</span>
|
||||||
<i class="fas fa-ruler-combined"></i> {{ box.box_type.width }} x {{ box.box_type.height }} x {{ box.box_type.length }} mm
|
|
||||||
</div>
|
|
||||||
<div class="box-type" style="font-size: 13px; color: #777;">
|
|
||||||
<i class="fas fa-layer-group"></i> {{ box.things.count }} item{{ box.things.count|pluralize }}
|
|
||||||
</div>
|
</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;"> </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');
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
41
boxes/templates/boxes/resources_list.html
Normal file
41
boxes/templates/boxes/resources_list.html
Normal 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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -1,29 +1,35 @@
|
|||||||
{% 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> /
|
||||||
<a href="/box/{{ thing.box.id }}/"><i class="fas fa-box"></i> Box {{ thing.box.id }}</a> /
|
<a href="/box/{{ thing.box.id }}/"><i class="fas fa-box"></i> Box {{ thing.box.id }}</a> /
|
||||||
{{ thing.name }}
|
{{ thing.name }}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
<a href="{% url 'edit_thing' thing.id %}" class="btn" style="margin-top: 10px;">
|
||||||
|
<i class="fas fa-edit"></i> Edit
|
||||||
|
</a>
|
||||||
</div>
|
</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 class="lightbox-trigger" data-url="{{ thing.picture.url }}" src="{{ thumb.url }}" alt="{{ thing.name }}" style="width: 100%; height: auto; max-height: 400px; object-fit: cover; border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.15); cursor: pointer; transition: transform 0.2s;" onmouseover="this.style.transform='scale(1.02)'" onmouseout="this.style.transform='scale(1)'">
|
||||||
{% endthumbnail %}
|
{% endthumbnail %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<div style="width: 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,8 +79,54 @@
|
|||||||
<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 %}
|
||||||
|
{% if file.filename|lower|slice:"-4:" == '.jpg' or file.filename|lower|slice:"-5:" == '.jpeg' or file.filename|lower|slice:"-4:" == '.png' or file.filename|lower|slice:"-5:" == '.webp' or file.filename|lower|slice:"-4:" == '.gif' or file.filename|lower|slice:"-4:" == '.svg' or file.filename|lower|slice:"-4:" == '.bmp' or file.filename|lower|slice:"-5:" == '.tiff' or file.filename|lower|slice:"-4:" == '.ico' %}
|
||||||
|
{% thumbnail file.file "200x200" crop="center" as thumb %}
|
||||||
|
<div style="display: flex; align-items: center; gap: 10px; padding: 12px 15px; background: #f8f9fa; border-radius: 8px; border:1px solid #e9ecef;">
|
||||||
|
<img class="lightbox-trigger" data-url="{{ file.file.url }}" src="{{ thumb.url }}" alt="{{ file.title }}" style="width: 60px; height: 60px; object-fit: cover; border-radius: 6px; cursor: pointer; box-shadow: 0 2px 8px rgba(0,0,0,0.1); transition: transform 0.2s;" onmouseover="this.style.transform='scale(1.05)'" onmouseout="this.style.transform='scale(1)'">
|
||||||
|
<div style="flex-grow: 1;">
|
||||||
|
<a href="{{ file.file.url }}" target="_blank" style="color: #667eea; text-decoration: none; font-weight: 500; font-size: 15px;">{{ file.title }}</a>
|
||||||
|
<span style="color: #999; font-size: 12px;">({{ file.filename }})</span>
|
||||||
|
</div>
|
||||||
|
<i class="fas fa-expand lightbox-trigger" data-url="{{ file.file.url }}" style="color: #999; font-size: 14px; cursor: pointer;"></i>
|
||||||
|
</div>
|
||||||
|
{% endthumbnail %}
|
||||||
|
{% else %}
|
||||||
|
<div style="display: flex; align-items: center; gap: 10px; padding: 12px 15px; background: #f8f9fa; border-radius: 8px; border: 1px solid #e9ecef;">
|
||||||
|
<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>
|
||||||
|
{% endif %}
|
||||||
|
{% 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 %}
|
||||||
@@ -66,38 +134,156 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="post" class="section">
|
<div class="lightbox" id="lightbox">
|
||||||
{% csrf_token %}
|
<span class="lightbox-close">×</span>
|
||||||
<div style="display: flex; align-items: center; gap: 15px; flex-wrap: wrap;">
|
<img id="lightbox-image" src="" alt="">
|
||||||
<div style="flex-grow: 1;">
|
</div>
|
||||||
<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_js %}
|
||||||
<script>
|
<script>
|
||||||
$('#new_box').on('focus', function() {
|
$(document).ready(function() {
|
||||||
$(this).css('border-color', '#667eea');
|
$('.lightbox-trigger').on('click', function(e) {
|
||||||
$(this).css('box-shadow', '0 0 0 3px rgba(102, 126, 234, 0.1)');
|
e.preventDefault();
|
||||||
}).on('blur', function() {
|
const imageUrl = $(this).data('url');
|
||||||
$(this).css('border-color', '#e0e0e0');
|
$('#lightbox-image').attr('src', imageUrl);
|
||||||
$(this).css('box-shadow', 'none');
|
$('#lightbox').addClass('active');
|
||||||
|
$('body').css('overflow', 'hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#lightbox').on('click', function(e) {
|
||||||
|
if (e.target === this || e.target.classList.contains('lightbox-close')) {
|
||||||
|
$('#lightbox').removeClass('active');
|
||||||
|
$('body').css('overflow', 'auto');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$(document).on('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
$('#lightbox').removeClass('active');
|
||||||
|
$('body').css('overflow', 'auto');
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
.markdown-content p {
|
||||||
|
margin: 0 0 1em 0;
|
||||||
|
}
|
||||||
|
.markdown-content p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.markdown-content h1, .markdown-content h2, .markdown-content h3,
|
||||||
|
.markdown-content h4, .markdown-content h5, .markdown-content h6 {
|
||||||
|
margin: 1.5em 0 0.5em 0;
|
||||||
|
color: #333;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.markdown-content h1:first-child, .markdown-content h2:first-child,
|
||||||
|
.markdown-content h3:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.markdown-content ul, .markdown-content ol {
|
||||||
|
margin: 0.5em 0;
|
||||||
|
padding-left: 2em;
|
||||||
|
}
|
||||||
|
.markdown-content li {
|
||||||
|
margin: 0.25em 0;
|
||||||
|
}
|
||||||
|
.markdown-content code {
|
||||||
|
background: #f4f4f4;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: 'Consolas', 'Monaco', monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
.markdown-content pre {
|
||||||
|
background: #f4f4f4;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 1em 0;
|
||||||
|
}
|
||||||
|
.markdown-content pre code {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.markdown-content blockquote {
|
||||||
|
border-left: 4px solid #667eea;
|
||||||
|
margin: 1em 0;
|
||||||
|
padding: 0.5em 1em;
|
||||||
|
background: #f8f9fa;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
.markdown-content a {
|
||||||
|
color: #667eea;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.markdown-content a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.markdown-content table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
margin: 1em 0;
|
||||||
|
}
|
||||||
|
.markdown-content th, .markdown-content td {
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
padding: 8px 12px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.markdown-content th {
|
||||||
|
background: #f8f9fa;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.markdown-content hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 2px solid #e0e0e0;
|
||||||
|
margin: 1.5em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.9);
|
||||||
|
z-index: 9999;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
cursor: zoom-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox img {
|
||||||
|
max-width: 90%;
|
||||||
|
max-height: 90%;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 0 30px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
right: 30px;
|
||||||
|
color: white;
|
||||||
|
font-size: 40px;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 10000;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-close:hover {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
0
boxes/templatetags/__init__.py
Normal file
0
boxes/templatetags/__init__.py
Normal file
95
boxes/templatetags/dict_extras.py
Normal file
95
boxes/templatetags/dict_extras.py
Normal 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
|
||||||
1468
boxes/tests.py
1468
boxes/tests.py
File diff suppressed because it is too large
Load Diff
403
boxes/views.py
403
boxes/views.py
@@ -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()
|
||||||
|
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()
|
||||||
|
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 |
78
gunicorn.conf.py
Normal file
78
gunicorn.conf.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
from gunicorn.glogging import Logger
|
||||||
|
|
||||||
|
TRUSTED_PROXIES = {
|
||||||
|
ip.strip()
|
||||||
|
for ip in os.environ.get("TRUSTED_PROXIES", "").split(",")
|
||||||
|
if ip.strip()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class HealthCheckFilter(logging.Filter):
|
||||||
|
def filter(self, record):
|
||||||
|
message = record.getMessage()
|
||||||
|
return "kube-probe" not in message
|
||||||
|
|
||||||
|
|
||||||
|
class CustomLogger(Logger):
|
||||||
|
def atoms(self, resp, req, environ, request_time):
|
||||||
|
atoms = super().atoms(resp, req, environ, request_time)
|
||||||
|
atoms["{client-ip}e"] = self._get_client_ip(environ)
|
||||||
|
return atoms
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_client_ip(environ):
|
||||||
|
remote_addr = environ.get("REMOTE_ADDR", "-")
|
||||||
|
xff = environ.get("HTTP_X_FORWARDED_FOR", "")
|
||||||
|
if not xff:
|
||||||
|
return remote_addr
|
||||||
|
# Walk the chain from right to left, skipping trusted proxies
|
||||||
|
ips = [ip.strip() for ip in xff.split(",")]
|
||||||
|
for ip in reversed(ips):
|
||||||
|
if ip not in TRUSTED_PROXIES:
|
||||||
|
return ip
|
||||||
|
# All IPs in the chain are trusted; fall back to the leftmost
|
||||||
|
return ips[0]
|
||||||
|
|
||||||
|
|
||||||
|
logger_class = CustomLogger
|
||||||
|
access_log_format = '%({client-ip}e)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"'
|
||||||
|
|
||||||
|
logconfig_dict = {
|
||||||
|
"version": 1,
|
||||||
|
"disable_existing_loggers": False,
|
||||||
|
"filters": {
|
||||||
|
"health_check": {
|
||||||
|
"()": HealthCheckFilter,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"handlers": {
|
||||||
|
"console": {
|
||||||
|
"class": "logging.StreamHandler",
|
||||||
|
"stream": "ext://sys.stderr",
|
||||||
|
},
|
||||||
|
"access_console": {
|
||||||
|
"class": "logging.StreamHandler",
|
||||||
|
"filters": ["health_check"],
|
||||||
|
"stream": "ext://sys.stdout",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"level": "INFO",
|
||||||
|
"handlers": ["console"],
|
||||||
|
},
|
||||||
|
"loggers": {
|
||||||
|
"gunicorn.error": {
|
||||||
|
"level": "INFO",
|
||||||
|
"handlers": ["console"],
|
||||||
|
"propagate": False,
|
||||||
|
},
|
||||||
|
"gunicorn.access": {
|
||||||
|
"level": "INFO",
|
||||||
|
"handlers": ["access_console"],
|
||||||
|
"propagate": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
8
k8s-templates/secret.yaml
Normal file
8
k8s-templates/secret.yaml
Normal 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"
|
||||||
8
labhelper/context_processors.py
Normal file
8
labhelper/context_processors.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
def image_tag(request):
|
||||||
|
"""Add the image tag to all template contexts."""
|
||||||
|
return {
|
||||||
|
'image_tag': os.environ.get('IMAGE_TAG', 'dev')
|
||||||
|
}
|
||||||
0
labhelper/management/__init__.py
Normal file
0
labhelper/management/__init__.py
Normal file
0
labhelper/management/commands/__init__.py
Normal file
0
labhelper/management/commands/__init__.py
Normal file
60
labhelper/management/commands/create_default_users.py
Normal file
60
labhelper/management/commands/create_default_users.py
Normal 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!')
|
||||||
@@ -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,13 +21,13 @@ 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 = os.environ.get('DEBUG', 'True').lower() == 'true'
|
||||||
|
|
||||||
ALLOWED_HOSTS = ["*","labhelper.adebaumann.com"]
|
ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '*').split(',')
|
||||||
ALLOWED_CIDR_NETS = ['10.0.0.0/16']
|
ALLOWED_CIDR_NETS = os.environ.get('ALLOWED_CIDR_NETS', '10.0.0.0/16').split(',')
|
||||||
|
|
||||||
|
|
||||||
# Application definition
|
# Application definition
|
||||||
@@ -46,6 +47,7 @@ INSTALLED_APPS = [
|
|||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
'django.middleware.security.SecurityMiddleware',
|
'django.middleware.security.SecurityMiddleware',
|
||||||
|
'whitenoise.middleware.WhiteNoiseMiddleware',
|
||||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||||
'django.middleware.common.CommonMiddleware',
|
'django.middleware.common.CommonMiddleware',
|
||||||
'django.middleware.csrf.CsrfViewMiddleware',
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
@@ -66,6 +68,7 @@ TEMPLATES = [
|
|||||||
'django.template.context_processors.request',
|
'django.template.context_processors.request',
|
||||||
'django.contrib.auth.context_processors.auth',
|
'django.contrib.auth.context_processors.auth',
|
||||||
'django.contrib.messages.context_processors.messages',
|
'django.contrib.messages.context_processors.messages',
|
||||||
|
'labhelper.context_processors.image_tag',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -107,26 +110,35 @@ AUTH_PASSWORD_VALIDATORS = [
|
|||||||
# Internationalization
|
# Internationalization
|
||||||
# https://docs.djangoproject.com/en/5.2/topics/i18n/
|
# https://docs.djangoproject.com/en/5.2/topics/i18n/
|
||||||
|
|
||||||
LANGUAGE_CODE = 'en-us'
|
LANGUAGE_CODE = os.environ.get('LANGUAGE_CODE', 'en-us')
|
||||||
|
|
||||||
TIME_ZONE = 'UTC'
|
TIME_ZONE = os.environ.get('TIME_ZONE', 'UTC')
|
||||||
|
|
||||||
USE_I18N = True
|
USE_I18N = os.environ.get('USE_I18N', 'True').lower() == 'true'
|
||||||
|
|
||||||
USE_TZ = True
|
USE_TZ = os.environ.get('USE_TZ', 'True').lower() == 'true'
|
||||||
|
|
||||||
|
|
||||||
# Static files (CSS, JavaScript, Images)
|
# Static files (CSS, JavaScript, Images)
|
||||||
# https://docs.djangoproject.com/en/5.2/howto/static-files/
|
# https://docs.djangoproject.com/en/5.2/howto/static-files/
|
||||||
|
|
||||||
STATIC_URL = '/static/'
|
STATIC_URL = os.environ.get('STATIC_URL', '/static/')
|
||||||
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
||||||
|
|
||||||
|
# WhiteNoise static file serving configuration
|
||||||
|
STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'
|
||||||
|
|
||||||
# Media files (user uploads)
|
# Media files (user uploads)
|
||||||
MEDIA_URL = '/media/'
|
MEDIA_URL = os.environ.get('MEDIA_URL', '/media/')
|
||||||
MEDIA_ROOT = BASE_DIR / 'data' / 'media'
|
MEDIA_ROOT = BASE_DIR / 'data' / 'media'
|
||||||
|
|
||||||
# Default primary key field type
|
# Default primary key field type
|
||||||
# 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=os.environ.get('CSRF_TRUSTED_ORIGINS', 'https://labhelper.adebaumann.com').split(',')
|
||||||
|
|
||||||
|
LOGIN_URL = os.environ.get('LOGIN_URL', 'login')
|
||||||
|
LOGIN_REDIRECT_URL = os.environ.get('LOGIN_REDIRECT_URL', 'index')
|
||||||
|
LOGOUT_REDIRECT_URL = os.environ.get('LOGOUT_REDIRECT_URL', 'login')
|
||||||
|
|||||||
@@ -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="{% url 'inventory' %}"><i class="fas fa-boxes-stacked"></i> Inventory</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>
|
||||||
|
|
||||||
@@ -240,8 +435,22 @@
|
|||||||
|
|
||||||
<footer class="footer">
|
<footer class="footer">
|
||||||
<p>© 2025 LabHelper. Built with <i class="fas fa-heart"></i> for science.</p>
|
<p>© 2025 LabHelper. Built with <i class="fas fa-heart"></i> for science.</p>
|
||||||
|
<p style="font-size: 12px; opacity: 0.7; margin-top: 10px;">Image: {{ image_tag }}</p>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
{% block extra_js %}{% endblock %}
|
{% block extra_js %}{% endblock %}
|
||||||
|
<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>
|
||||||
78
labhelper/templates/login.html
Normal file
78
labhelper/templates/login.html
Normal 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 %}
|
||||||
@@ -18,19 +18,61 @@ 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('inventory/', boxes_list, name='inventory'),
|
||||||
path('search/api/', search_api, name='search_api'),
|
path('search/api/', search_api, name='search_api'),
|
||||||
|
path('resources/', resources_list, name='resources_list'),
|
||||||
|
path('fixme/', fixme, name='fixme'),
|
||||||
path('admin/', admin.site.urls),
|
path('admin/', admin.site.urls),
|
||||||
]
|
]
|
||||||
|
|
||||||
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
# Static files are served by WhiteNoise middleware in production
|
||||||
|
# Media files need to be served in all environments
|
||||||
|
from django.views.static import serve
|
||||||
|
from django.urls import re_path
|
||||||
|
|
||||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||||
|
|
||||||
|
# Add explicit media serving for production
|
||||||
|
if not settings.DEBUG:
|
||||||
|
urlpatterns += [
|
||||||
|
re_path(r'^media/(?P<path>.*)$', serve, {
|
||||||
|
'document_root': settings.MEDIA_ROOT,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
|||||||
@@ -36,3 +36,4 @@ Pillow==11.1.0
|
|||||||
sorl-thumbnail==12.11.0
|
sorl-thumbnail==12.11.0
|
||||||
bleach==6.1.0
|
bleach==6.1.0
|
||||||
coverage==7.6.1
|
coverage==7.6.1
|
||||||
|
whitenoise==6.8.2
|
||||||
|
|||||||
45
scripts/deploy_secret.sh
Executable file
45
scripts/deploy_secret.sh
Executable file
@@ -0,0 +1,45 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Generate and deploy Django secret key to Kubernetes
|
||||||
|
|
||||||
|
NAMESPACE="labhelper"
|
||||||
|
SECRET_NAME="django-secret"
|
||||||
|
SECRET_FILE="k8s-templates/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"
|
||||||
49
scripts/full_deploy.sh
Executable file
49
scripts/full_deploy.sh
Executable file
@@ -0,0 +1,49 @@
|
|||||||
|
#!/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"
|
||||||
|
|
||||||
|
# Update ConfigMap with new main container version
|
||||||
|
sed -i "s| IMAGE_TAG: \"$MAIN_VERSION\"| IMAGE_TAG: \"$NEW_MAIN_VERSION\"|" "argocd/configmap.yaml"
|
||||||
|
|
||||||
|
# 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 " ConfigMap IMAGE_TAG: $MAIN_VERSION -> $NEW_MAIN_VERSION"
|
||||||
|
echo " Database copied to $DB_DEST"
|
||||||
31
scripts/partial_deploy.sh
Executable file
31
scripts/partial_deploy.sh
Executable file
@@ -0,0 +1,31 @@
|
|||||||
|
#!/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"
|
||||||
|
|
||||||
|
# Update ConfigMap with new main container version
|
||||||
|
sed -i "s| IMAGE_TAG: \"$CURRENT_VERSION\"| IMAGE_TAG: \"$NEW_VERSION\"|" "argocd/configmap.yaml"
|
||||||
|
|
||||||
|
echo "Partial deployment prepared:"
|
||||||
|
echo " Main container: $CURRENT_VERSION -> $NEW_VERSION"
|
||||||
|
echo " ConfigMap IMAGE_TAG: $CURRENT_VERSION -> $NEW_VERSION"
|
||||||
Reference in New Issue
Block a user