Compare commits
63 Commits
7410f8c607
...
feature/ss
| Author | SHA1 | Date | |
|---|---|---|---|
| 4569fec82c | |||
|
b507f961cb
|
|||
|
41ec7bdc08
|
|||
|
da6a73e357
|
|||
|
4ad03403aa
|
|||
|
88ff6ddae5
|
|||
| 450ff488ea | |||
| 97ce26fb51 | |||
|
ed47530c3c
|
|||
|
e537ec2ac0
|
|||
|
ec102dd1cc
|
|||
|
7bae0d12de
|
|||
|
dbfb38bb8a
|
|||
|
20239242ce
|
|||
|
60e13822ee
|
|||
|
4d492ded4e
|
|||
|
3b53967c40
|
|||
|
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 |
324
AGENTS.md
324
AGENTS.md
@@ -4,7 +4,7 @@ This document provides guidelines for AI coding agents working in the labhelper
|
||||
|
||||
## Project Overview
|
||||
|
||||
- **Type**: Django web application
|
||||
- **Type**: Django web application (lab inventory management system)
|
||||
- **Python**: 3.13.7
|
||||
- **Django**: 5.2.9
|
||||
- **Database**: SQLite (development)
|
||||
@@ -66,6 +66,21 @@ python manage.py collectstatic # Collect static files
|
||||
gunicorn labhelper.wsgi:application # Run with Gunicorn
|
||||
```
|
||||
|
||||
### Custom Management Commands
|
||||
|
||||
```bash
|
||||
# Create default users and groups
|
||||
python manage.py create_default_users
|
||||
|
||||
# Clean up orphaned files from deleted things
|
||||
python manage.py clean_orphaned_files
|
||||
python manage.py clean_orphaned_files --dry-run
|
||||
|
||||
# Clean up orphaned images and thumbnails
|
||||
python manage.py clean_orphaned_images
|
||||
python manage.py clean_orphaned_images --dry-run
|
||||
```
|
||||
|
||||
## Code Style Guidelines
|
||||
|
||||
### Python Style
|
||||
@@ -178,23 +193,96 @@ def get_box(request: HttpRequest, box_id: int) -> HttpResponse:
|
||||
|
||||
```
|
||||
labhelper/
|
||||
├── manage.py # Django CLI entry point
|
||||
├── requirements.txt # Python dependencies
|
||||
├── labhelper/ # Project configuration
|
||||
│ ├── settings.py # Django settings
|
||||
│ ├── urls.py # Root URL routing
|
||||
│ ├── wsgi.py # WSGI application
|
||||
│ └── asgi.py # ASGI application
|
||||
└── boxes/ # Django app
|
||||
├── admin.py # Admin configuration
|
||||
├── apps.py # App configuration
|
||||
├── models.py # Data models
|
||||
├── views.py # View functions
|
||||
├── tests.py # Test cases
|
||||
├── migrations/ # Database migrations
|
||||
└── templates/ # HTML templates
|
||||
├── .gitea/
|
||||
│ └── workflows/
|
||||
│ └── build-containers-on-demand.yml # CI/CD workflow
|
||||
├── argocd/ # Kubernetes deployment manifests
|
||||
│ ├── 001_pvc.yaml # PersistentVolumeClaim
|
||||
│ ├── deployment.yaml # Deployment + Service
|
||||
│ ├── ingress.yaml # Traefik ingress
|
||||
│ ├── nfs-pv.yaml # NFS PersistentVolume
|
||||
│ ├── nfs-storageclass.yaml # NFS StorageClass
|
||||
│ └── secret.yaml # Django secret key template
|
||||
├── boxes/ # Main Django app
|
||||
│ ├── management/
|
||||
│ │ └── commands/
|
||||
│ │ ├── clean_orphaned_files.py # Cleanup orphaned ThingFile attachments
|
||||
│ │ └── clean_orphaned_images.py # Cleanup orphaned Thing images
|
||||
│ ├── migrations/ # Database migrations
|
||||
│ ├── static/
|
||||
│ │ └── css/
|
||||
│ │ ├── base.css # Base styles (layout, navbar, buttons, alerts, etc.)
|
||||
│ │ ├── edit_thing.css # Edit thing form styles
|
||||
│ │ └── thing_detail.css # Markdown content + lightbox styles
|
||||
│ ├── templates/
|
||||
│ │ └── boxes/
|
||||
│ │ ├── add_things.html # Form to add multiple things
|
||||
│ │ ├── box_detail.html # Box contents view
|
||||
│ │ ├── box_management.html # Box/BoxType CRUD management
|
||||
│ │ ├── boxes_list.html # Boxes list page with tabular view
|
||||
│ │ ├── edit_thing.html # Edit thing page (name, description, picture, tags, files, links)
|
||||
│ │ ├── index.html # Home page with search and tags
|
||||
│ │ ├── resources_list.html # List all links and files from things
|
||||
│ │ └── thing_detail.html # Read-only thing details view
|
||||
│ ├── templatetags/
|
||||
│ │ └── dict_extras.py # Custom template filters: get_item, render_markdown, truncate_markdown
|
||||
│ ├── admin.py # Admin configuration
|
||||
│ ├── apps.py # App configuration
|
||||
│ ├── forms.py # All forms and formsets
|
||||
│ ├── models.py # Data models
|
||||
│ ├── tests.py # Test cases
|
||||
│ └── views.py # View functions
|
||||
├── data-loader/ # Init container for database preload
|
||||
│ ├── Dockerfile # Alpine-based init container
|
||||
│ └── preload.sqlite3 # Preloaded database for deployment
|
||||
├── labhelper/ # Project configuration
|
||||
│ ├── management/
|
||||
│ │ └── commands/
|
||||
│ │ └── create_default_users.py # Create default users/groups
|
||||
│ ├── templates/
|
||||
│ │ ├── base.html # Base template with navigation
|
||||
│ │ └── login.html # Login page
|
||||
│ ├── asgi.py # ASGI configuration
|
||||
│ ├── settings.py # Django settings
|
||||
│ ├── urls.py # Root URL configuration
|
||||
│ └── wsgi.py # WSGI configuration
|
||||
├── scripts/
|
||||
│ ├── deploy_secret.sh # Generate and deploy Django secret
|
||||
│ ├── full_deploy.sh # Bump both container versions + copy DB
|
||||
│ └── partial_deploy.sh # Bump main container version only
|
||||
├── .gitignore
|
||||
├── AGENTS.md # This file
|
||||
├── Dockerfile # Multi-stage build for main container
|
||||
├── manage.py # Django CLI entry point
|
||||
└── requirements.txt # Python dependencies
|
||||
```
|
||||
|
||||
## Data Models
|
||||
|
||||
### boxes app
|
||||
|
||||
| Model | Description | Key Fields |
|
||||
|-------|-------------|------------|
|
||||
| **BoxType** | Type of storage box with dimensions | `name`, `width`, `height`, `length` (in mm) |
|
||||
| **Box** | A storage box in the lab | `id` (CharField PK, max 10), `box_type` (FK) |
|
||||
| **Facet** | A category of tags (e.g., Priority, Category) | `name`, `slug`, `color`, `cardinality` (single/multiple) |
|
||||
| **Tag** | A tag value for a specific facet | `facet` (FK), `name` |
|
||||
| **Thing** | An item stored in a box | `name`, `box` (FK), `description` (Markdown), `picture`, `tags` (M2M) |
|
||||
| **ThingFile** | File attachment for a Thing | `thing` (FK), `file`, `title`, `uploaded_at` |
|
||||
| **ThingLink** | Hyperlink for a Thing | `thing` (FK), `url`, `title`, `uploaded_at` |
|
||||
|
||||
**Model Relationships:**
|
||||
- BoxType -> Box (1:N via `boxes` related_name, PROTECT on delete)
|
||||
- Box -> Thing (1:N via `things` related_name, PROTECT on delete)
|
||||
- Facet -> Tag (1:N via `tags` related_name, CASCADE on delete)
|
||||
- Thing <-> Tag (M2M via `tags` related_name on Thing, `things` related_name on Tag)
|
||||
- Thing -> ThingFile (1:N via `files` related_name, CASCADE on delete)
|
||||
- Thing -> ThingLink (1:N via `links` related_name, CASCADE on delete)
|
||||
|
||||
**Facet Cardinality:**
|
||||
- `single`: A thing can have at most one tag from this facet (e.g., Priority: High/Medium/Low)
|
||||
- `multiple`: A thing can have multiple tags from this facet (e.g., Category: Electronics, Tools)
|
||||
|
||||
## Available Django Extensions
|
||||
|
||||
The project includes these pre-installed packages:
|
||||
@@ -206,6 +294,11 @@ The project includes these pre-installed packages:
|
||||
- **django-nested-inline**: Additional nested inline support
|
||||
- **django-revproxy**: Reverse proxy functionality
|
||||
- **sorl-thumbnail**: Image thumbnailing
|
||||
- **Pillow**: Image processing
|
||||
- **gunicorn**: Production WSGI server
|
||||
- **Markdown**: Markdown processing
|
||||
- **bleach**: HTML sanitization
|
||||
- **coverage**: Test coverage
|
||||
- **Font Awesome**: Icon library (loaded via CDN)
|
||||
- **jQuery**: JavaScript library (loaded via CDN)
|
||||
|
||||
@@ -242,7 +335,7 @@ The project uses a base template system at `labhelper/templates/base.html`. All
|
||||
- Cards: White with subtle shadows
|
||||
|
||||
**Components:**
|
||||
- **Navigation**: Glassmorphism effect with blur backdrop
|
||||
- **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
|
||||
@@ -264,17 +357,25 @@ The project uses a base template system at `labhelper/templates/base.html`. All
|
||||
- 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
|
||||
|
||||
**Static CSS Files:**
|
||||
- Base styles live in `boxes/static/css/base.css` (loaded by `base.html` via `{% static %}`)
|
||||
- Page-specific styles live in separate static CSS files (e.g., `thing_detail.css`, `edit_thing.css`)
|
||||
- Child templates load their CSS via `{% block extra_css %}` with `<link>` tags
|
||||
- WhiteNoise serves and cache-busts static files via `CompressedManifestStaticFilesStorage`
|
||||
- Run `python manage.py collectstatic` after adding or modifying static CSS files
|
||||
|
||||
**Naming:**
|
||||
- Use descriptive class names
|
||||
- BEM pattern encouraged for complex components
|
||||
- Inline styles allowed for template-specific styling
|
||||
- Inline styles allowed for template-specific one-off styling
|
||||
|
||||
**Styles:**
|
||||
- Use base template styles when possible
|
||||
- Template-specific styles in `{% block extra_css %}`
|
||||
- Use base CSS classes when possible
|
||||
- Page-specific styles in dedicated static CSS files loaded via `{% block extra_css %}`
|
||||
- JavaScript in `{% block extra_js %}`
|
||||
- Smooth transitions (0.2s - 0.3s)
|
||||
- Hover effects with transform and box-shadow
|
||||
@@ -287,54 +388,154 @@ The project uses a base template system at `labhelper/templates/base.html`. All
|
||||
|
||||
### Available Pages/Views
|
||||
|
||||
| View Name | URL Pattern | Description |
|
||||
|-----------|--------------|-------------|
|
||||
| `index` | `/` | Home page with boxes grid and thing types tree |
|
||||
| `box_detail` | `/box/<str:box_id>/` | List items in a specific box |
|
||||
| `thing_detail` | `/thing/<int:thing_id>/` | Thing details with move-to-box form |
|
||||
| `thing_type_detail` | `/thing-type/<int:type_id>/` | Thing type hierarchy and items |
|
||||
| `add_things` | `/box/<str:box_id>/add/` | Form to add/edit items in a box |
|
||||
| `search` | `/search/` | Search page with AJAX autocomplete |
|
||||
| `search_api` | `/search/api/` | AJAX endpoint for search results |
|
||||
| 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" %}
|
||||
```
|
||||
```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
|
||||
- `title`: Page title tag
|
||||
- `page_header`: Page header with breadcrumbs
|
||||
- `content`: Main page content
|
||||
- `extra_css`: Additional CSS via `<link>` tags to static files
|
||||
- `extra_head`: Additional head elements
|
||||
- `extra_js`: Additional JavaScript
|
||||
|
||||
3. **Load required template tags**
|
||||
```django
|
||||
{% load static %}
|
||||
{% load mptt_tags %}
|
||||
{% load thumbnail %}
|
||||
```
|
||||
```django
|
||||
{% load static %} {# Required when using {% static %} for CSS/asset links #}
|
||||
{% load mptt_tags %}
|
||||
{% load thumbnail %}
|
||||
{% load dict_extras %}
|
||||
```
|
||||
|
||||
4. **Use URL names for links**
|
||||
```django
|
||||
<a href="{% url 'box_detail' box.id %}">
|
||||
```
|
||||
4. **Link page-specific CSS from static files**
|
||||
```django
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" href="{% static 'css/thing_detail.css' %}">
|
||||
{% endblock %}
|
||||
```
|
||||
|
||||
5. **Use icons with Font Awesome**
|
||||
```django
|
||||
<i class="fas fa-box"></i>
|
||||
```
|
||||
5. **Use URL names for links**
|
||||
```django
|
||||
<a href="{% url 'box_detail' box.id %}">
|
||||
```
|
||||
|
||||
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>
|
||||
```
|
||||
6. **Use icons with Font Awesome**
|
||||
```django
|
||||
<i class="fas fa-box"></i>
|
||||
```
|
||||
|
||||
7. **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>
|
||||
```
|
||||
|
||||
8. **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
|
||||
|
||||
@@ -374,10 +575,11 @@ Per `.gitignore`:
|
||||
1. **NEVER commit or push without explicit permission**: Always ask the user before running `git commit` or `git push`. The user will explicitly say "commit and push" when they want you to do this. Do NOT automatically commit/push after making changes unless instructed to do so.
|
||||
2. **Always activate venv**: `source .venv/bin/activate`
|
||||
3. **Run migrations after model changes**: `makemigrations` then `migrate`
|
||||
3. **Add new apps to INSTALLED_APPS** in `settings.py`
|
||||
4. **Templates in labhelper/templates/**: The base template and shared templates are in `labhelper/templates/`. App-specific templates remain in `app_name/templates/`.
|
||||
4. **Use get_object_or_404** instead of bare `.get()` calls
|
||||
5. **Never commit SECRET_KEY** - use environment variables in production
|
||||
4. **Add new apps to INSTALLED_APPS** in `settings.py`
|
||||
5. **Templates in labhelper/templates/**: The base template and shared templates are in `labhelper/templates/`. App-specific templates remain in `app_name/templates/`.
|
||||
6. **Use get_object_or_404** instead of bare `.get()` calls
|
||||
7. **Never commit SECRET_KEY** - use environment variables in production
|
||||
8. **Be careful with process management**: Avoid blanket kills on ports (e.g., `lsof -ti:8000 | xargs kill -9`) as they can kill unintended processes like web browsers. Use specific process kills instead: `pkill -f "process_name"`
|
||||
|
||||
## Deployment Commands
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ WORKDIR /app
|
||||
COPY --chown=appuser:appuser . .
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV IMAGE_TAG=build
|
||||
USER appuser
|
||||
EXPOSE 8000
|
||||
RUN rm -rvf /app/Dockerfile* \
|
||||
@@ -45,7 +46,10 @@ RUN rm -rvf /app/Dockerfile* \
|
||||
/app/requirements.txt \
|
||||
/app/node_modules \
|
||||
/app/*.json \
|
||||
/app/AGENTS* \
|
||||
/app/*.md \
|
||||
/app/k8s-templates \
|
||||
/app/test_*.py && \
|
||||
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"]
|
||||
|
||||
|
||||
207
Keycloak-installation.md
Normal file
207
Keycloak-installation.md
Normal file
@@ -0,0 +1,207 @@
|
||||
# Keycloak SSO Integration
|
||||
|
||||
This document describes how Keycloak SSO was integrated into labhelper, replacing the built-in Django username/password authentication.
|
||||
|
||||
## Overview
|
||||
|
||||
Authentication is handled via OpenID Connect (OIDC) using the `mozilla-django-oidc` library. When a user visits any protected page, they are redirected to Keycloak to authenticate. On return, Keycloak group memberships are synced to Django groups, controlling what the user can do in the app.
|
||||
|
||||
---
|
||||
|
||||
## Keycloak Setup
|
||||
|
||||
### 1. Create a client
|
||||
|
||||
In the Keycloak admin console, go to **Clients → Create client**.
|
||||
|
||||
| Field | Value | Why |
|
||||
|---|---|---|
|
||||
| Client type | OpenID Connect | The protocol mozilla-django-oidc speaks |
|
||||
| Client ID | `labhelper` | Must match `OIDC_RP_CLIENT_ID` in the app |
|
||||
| Client authentication | On (confidential) | Server-side apps use a client secret; this is more secure than a public client |
|
||||
| Authentication flow | Standard flow only | labhelper uses the standard authorisation code flow |
|
||||
|
||||
After saving, go to the **Credentials** tab and copy the **Client secret** — this is `OIDC_RP_CLIENT_SECRET`.
|
||||
|
||||
### 2. Set the redirect URI
|
||||
|
||||
In the client **Settings** tab:
|
||||
|
||||
| Field | Value | Why |
|
||||
|---|---|---|
|
||||
| Valid redirect URIs | `https://your-app/oidc/callback/` | Keycloak will only redirect back to whitelisted URIs after authentication. The trailing slash is required — without it Keycloak rejects the request. For local dev also add `http://127.0.0.1:8000/oidc/callback/` |
|
||||
|
||||
### 3. Set the PKCE code challenge method
|
||||
|
||||
In the client **Advanced** tab, find **"Proof Key for Code Exchange Code Challenge Method"** and set it to **S256**.
|
||||
|
||||
Why: Keycloak 26 configures new clients with PKCE enforced. `mozilla-django-oidc` sends `S256` as the challenge method (the more secure option). If Keycloak is set to `plain`, the two sides don't agree and authentication fails with `code challenge method is not matching the configured one`.
|
||||
|
||||
### 4. Add a Groups mapper
|
||||
|
||||
This makes Keycloak include the user's group memberships in the token so the app can sync them to Django groups.
|
||||
|
||||
Go to **Clients → labhelper → Client scopes** tab → click the dedicated scope (named `labhelper-dedicated`) → **Add mapper → By configuration → Group Membership**.
|
||||
|
||||
| Field | Value | Why |
|
||||
|---|---|---|
|
||||
| Name | `groups` | Label for this mapper |
|
||||
| Token Claim Name | `groups` | The claim name the app reads from the token |
|
||||
| Full group path | Off | Sends `LabHelper Administrators` instead of `/LabHelper Administrators`. The app strips leading slashes anyway, but this is cleaner |
|
||||
| Add to ID token | On | |
|
||||
| Add to access token | On | |
|
||||
| Add to userinfo | On | The app fetches userinfo after the token exchange |
|
||||
|
||||
### 5. Create groups
|
||||
|
||||
Go to **Groups** (left sidebar) and create these three groups with exactly these names — they map to the existing Django groups:
|
||||
|
||||
- `LabHelper Administrators` — gets `is_staff=True` in Django (admin access)
|
||||
- `LabHelper Staff`
|
||||
- `LabHelper Viewers`
|
||||
|
||||
### 6. Ensure users have an email address
|
||||
|
||||
`mozilla-django-oidc` requires the `email` claim to be present in the token. Every Keycloak user who will log into labhelper must have:
|
||||
|
||||
- An **Email** address set (Users → select user → Details tab)
|
||||
- **Email verified** ticked
|
||||
|
||||
Without an email, authentication fails silently with `Claims verification failed` in the Django logs.
|
||||
|
||||
---
|
||||
|
||||
## App Configuration
|
||||
|
||||
The app is configured entirely via environment variables.
|
||||
|
||||
### Required variables
|
||||
|
||||
```bash
|
||||
OIDC_OP_BASE_URL=https://keycloak.example.com/realms/your-realm
|
||||
OIDC_RP_CLIENT_ID=labhelper
|
||||
OIDC_RP_CLIENT_SECRET=<client-secret-from-keycloak-credentials-tab>
|
||||
```
|
||||
|
||||
`OIDC_OP_BASE_URL` is the realm URL. All OIDC endpoints (authorisation, token, userinfo, JWKS, logout) are derived from it automatically in `settings.py`.
|
||||
|
||||
### Other relevant variables
|
||||
|
||||
```bash
|
||||
ALLOWED_HOSTS=your-app-hostname
|
||||
CSRF_TRUSTED_ORIGINS=https://your-app
|
||||
```
|
||||
|
||||
`CSRF_TRUSTED_ORIGINS` must include the app's origin. The OIDC callback goes through Django's CSRF middleware, and requests from untrusted origins are rejected.
|
||||
|
||||
---
|
||||
|
||||
## How the App Side Works
|
||||
|
||||
### Library
|
||||
|
||||
`mozilla-django-oidc` handles the full OIDC flow: redirecting to Keycloak, validating the returned token (RS256 signature verified against Keycloak's JWKS endpoint), exchanging the authorisation code, and fetching userinfo.
|
||||
|
||||
### Key settings
|
||||
|
||||
| Setting | Value | Why |
|
||||
|---|---|---|
|
||||
| `OIDC_RP_SIGN_ALGO` | `RS256` | Keycloak signs tokens with RS256 by default |
|
||||
| `OIDC_RP_SCOPES` | `openid email profile` | `profile` is needed to get `preferred_username`, `given_name`, `family_name` from Keycloak |
|
||||
| `OIDC_USE_PKCE` | `True` | Required because Keycloak enforces PKCE on this client |
|
||||
| `OIDC_STORE_ID_TOKEN` | `True` | The ID token is stored in the session and passed as `id_token_hint` when logging out, so Keycloak also ends its own session |
|
||||
| `OIDC_EXEMPT_URLS` | `['search_api']` | The search endpoint is called via AJAX. The `SessionRefresh` middleware would return a redirect instead of JSON for unauthenticated AJAX calls, breaking the UI |
|
||||
| `LOGIN_URL` | `oidc_authentication_init` | When `@login_required` intercepts an unauthenticated request, it redirects directly to the OIDC flow rather than a local login form |
|
||||
|
||||
### Authentication backend (`labhelper/auth_backend.py`)
|
||||
|
||||
Overrides `OIDCAuthenticationBackend` to:
|
||||
|
||||
- Use `preferred_username` from Keycloak as the Django username
|
||||
- Set `first_name` and `last_name` from `given_name` / `family_name` claims
|
||||
- Sync group memberships on every login — if a user is added to or removed from a Keycloak group, it takes effect at their next login
|
||||
- Set `is_staff=True` for members of `LabHelper Administrators` (grants Django admin access)
|
||||
|
||||
`django.contrib.auth.backends.ModelBackend` is kept as a fallback so the Django admin login form still works with a local username/password (useful for emergency superuser access without Keycloak).
|
||||
|
||||
### Session refresh middleware
|
||||
|
||||
`mozilla_django_oidc.middleware.SessionRefresh` is added after `AuthenticationMiddleware`. It periodically checks whether the user's OIDC session is still valid and forces re-authentication if the token has expired, rather than keeping a stale Django session alive indefinitely.
|
||||
|
||||
### URLs
|
||||
|
||||
All OIDC routes are mounted under `/oidc/`:
|
||||
|
||||
| URL | Purpose |
|
||||
|---|---|
|
||||
| `/oidc/authenticate/` | Initiates the OIDC flow, redirects to Keycloak |
|
||||
| `/oidc/callback/` | Keycloak redirects here after authentication |
|
||||
| `/oidc/logout/` | Logs out of Django and ends the Keycloak session |
|
||||
|
||||
`/login/` is kept as a static landing page with a "Login with SSO" button, used when users navigate to it manually or are redirected there after logout.
|
||||
|
||||
---
|
||||
|
||||
## Auth Flow
|
||||
|
||||
```
|
||||
User visits protected page
|
||||
↓
|
||||
@login_required → redirect to /oidc/authenticate/?next=/original/url/
|
||||
↓
|
||||
Redirect to Keycloak (with code_challenge for PKCE)
|
||||
↓
|
||||
User authenticates in Keycloak
|
||||
↓
|
||||
Keycloak redirects to /oidc/callback/?code=...
|
||||
↓
|
||||
App exchanges code for tokens, verifies RS256 signature
|
||||
↓
|
||||
App fetches userinfo, syncs groups and attributes
|
||||
↓
|
||||
User redirected to original URL
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Error | Cause | Fix |
|
||||
|---|---|---|
|
||||
| `Invalid parameter: redirect_uri` | Redirect URI not in Keycloak whitelist, or trailing slash missing | Add exact URI including trailing slash to Keycloak client Valid Redirect URIs |
|
||||
| `Missing parameter: code_challenge_method` | Keycloak requires PKCE but app wasn't sending it | Set `OIDC_USE_PKCE = True` in settings |
|
||||
| `code challenge method is not matching the configured one` | Keycloak client set to `plain`, app sends `S256` | Set PKCE method to `S256` in Keycloak client Advanced settings |
|
||||
| `Claims verification failed` | User has no email set in Keycloak | Set email address and tick Email Verified on the Keycloak user |
|
||||
| `NoReverseMatch` for `OIDC_EXEMPT_URLS` | Regex pattern used instead of URL name | Use the Django URL name (`'search_api'`), not a regex |
|
||||
| Login loops without showing Keycloak | Existing Keycloak session auto-authenticates | Expected behaviour — Keycloak reuses its session. Log out of Keycloak admin console to test a clean login |
|
||||
|
||||
---
|
||||
|
||||
## Kubernetes Deployment
|
||||
|
||||
Split the configuration across a ConfigMap and a Secret. The client secret must not go in a ConfigMap as the contents are visible in plain text to anyone with cluster access.
|
||||
|
||||
**ConfigMap**
|
||||
```yaml
|
||||
data:
|
||||
OIDC_OP_BASE_URL: https://keycloak.example.com/realms/your-realm
|
||||
OIDC_RP_CLIENT_ID: labhelper
|
||||
CSRF_TRUSTED_ORIGINS: https://labhelper.adebaumann.com
|
||||
ALLOWED_HOSTS: labhelper.adebaumann.com
|
||||
```
|
||||
|
||||
**Secret**
|
||||
```yaml
|
||||
stringData:
|
||||
OIDC_RP_CLIENT_SECRET: <client-secret-from-keycloak-credentials-tab>
|
||||
DJANGO_SECRET_KEY: <random-secret-key>
|
||||
```
|
||||
|
||||
Reference both in the deployment:
|
||||
```yaml
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: labhelper-config
|
||||
- secretRef:
|
||||
name: labhelper-secret
|
||||
```
|
||||
24
argocd/configmap.yaml
Normal file
24
argocd/configmap.yaml
Normal file
@@ -0,0 +1,24 @@
|
||||
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: "oidc_authentication_init"
|
||||
OIDC_OP_BASE_URL: "https://sso.baumann.gr/realms/homelab"
|
||||
OIDC_RP_CLIENT_ID: "labhelper"
|
||||
LOGIN_REDIRECT_URL: "index"
|
||||
LOGOUT_REDIRECT_URL: "/login/"
|
||||
OIDC_AUTHENTICATION_FAILURE_REDIRECT_URL: "/login/"
|
||||
GUNICORN_OPTS: "--access-logfile -"
|
||||
IMAGE_TAG: "0.079"
|
||||
@@ -18,7 +18,7 @@ spec:
|
||||
fsGroupChangePolicy: "OnRootMismatch"
|
||||
initContainers:
|
||||
- name: loader
|
||||
image: git.baumann.gr/adebaumann/labhelper-data-loader:0.012
|
||||
image: git.baumann.gr/adebaumann/labhelper-data-loader:0.014
|
||||
securityContext:
|
||||
runAsUser: 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" ]
|
||||
@@ -27,7 +27,7 @@ spec:
|
||||
mountPath: /data
|
||||
containers:
|
||||
- name: web
|
||||
image: git.baumann.gr/adebaumann/labhelper:0.045
|
||||
image: git.baumann.gr/adebaumann/labhelper:0.082
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 8000
|
||||
@@ -37,12 +37,107 @@ spec:
|
||||
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: OIDC_OP_BASE_URL
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: django-config
|
||||
key: OIDC_OP_BASE_URL
|
||||
- name: OIDC_RP_CLIENT_ID
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: django-config
|
||||
key: OIDC_RP_CLIENT_ID
|
||||
- name: OIDC_RP_CLIENT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: django-secret
|
||||
key: oidc-client-secret
|
||||
- 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: OIDC_AUTHENTICATION_FAILURE_REDIRECT_URL
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: django-config
|
||||
key: OIDC_AUTHENTICATION_FAILURE_REDIRECT_URL
|
||||
- name: GUNICORN_OPTS
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: django-config
|
||||
key: GUNICORN_OPTS
|
||||
- name: IMAGE_TAG
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: django-config
|
||||
key: IMAGE_TAG
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /app/data
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
path: /health/
|
||||
port: 8000
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
@@ -50,7 +145,7 @@ spec:
|
||||
failureThreshold: 6
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
path: /health/
|
||||
port: 8000
|
||||
initialDelaySeconds: 20
|
||||
periodSeconds: 20
|
||||
|
||||
@@ -4,6 +4,9 @@ metadata:
|
||||
name: labhelper-data-pv
|
||||
namespace: labhelper
|
||||
spec:
|
||||
claimRef:
|
||||
name: labhelper-data-pvc
|
||||
namespace: labhelper
|
||||
capacity:
|
||||
storage: 2Gi
|
||||
accessModes:
|
||||
@@ -12,4 +15,4 @@ spec:
|
||||
storageClassName: nfs
|
||||
nfs:
|
||||
server: 192.168.17.199
|
||||
path: /mnt/user/labhelper
|
||||
path: /mnt/user/kubernetesdata/labhelper
|
||||
|
||||
@@ -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
|
||||
155
boxes/admin.py
155
boxes/admin.py
@@ -1,7 +1,46 @@
|
||||
from django.contrib import admin
|
||||
from django_mptt_admin.admin import DjangoMpttAdmin
|
||||
import json
|
||||
|
||||
from .models import Box, BoxType, Thing, ThingFile, ThingLink, 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)
|
||||
@@ -16,25 +55,34 @@ class BoxTypeAdmin(admin.ModelAdmin):
|
||||
class BoxAdmin(admin.ModelAdmin):
|
||||
"""Admin configuration for Box model."""
|
||||
|
||||
list_display = ('id', 'box_type')
|
||||
ordering = ['sort_order']
|
||||
list_display = ('id', 'box_type', 'sort_order')
|
||||
list_filter = ('box_type',)
|
||||
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
|
||||
|
||||
@admin.register(ThingType)
|
||||
class ThingTypeAdmin(DjangoMpttAdmin):
|
||||
"""Admin configuration for ThingType model."""
|
||||
def reorder_view(self, request):
|
||||
"""Handle AJAX reorder requests."""
|
||||
if request.method != 'POST':
|
||||
return JsonResponse({'error': 'POST required'}, status=405)
|
||||
|
||||
search_fields = ('name',)
|
||||
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)
|
||||
|
||||
@admin.register(Thing)
|
||||
class ThingAdmin(admin.ModelAdmin):
|
||||
"""Admin configuration for Thing model."""
|
||||
|
||||
list_display = ('name', 'thing_type', 'box')
|
||||
list_filter = ('thing_type', 'box')
|
||||
search_fields = ('name', 'description')
|
||||
return JsonResponse({'status': 'ok'})
|
||||
except (json.JSONDecodeError, KeyError) as e:
|
||||
return JsonResponse({'error': str(e)}, status=400)
|
||||
|
||||
|
||||
class ThingFileInline(admin.TabularInline):
|
||||
@@ -53,14 +101,79 @@ class ThingLinkInline(admin.TabularInline):
|
||||
fields = ('title', 'url')
|
||||
|
||||
|
||||
class ThingAdminWithFiles(admin.ModelAdmin):
|
||||
"""Admin configuration for Thing model with files and links."""
|
||||
class ThingAdmin(admin.ModelAdmin):
|
||||
"""Admin configuration for Thing model."""
|
||||
|
||||
list_display = ('name', 'thing_type', 'box')
|
||||
list_filter = ('thing_type', 'box')
|
||||
list_display = ('name', 'box')
|
||||
list_filter = (BoxFilter, TagsFilter)
|
||||
search_fields = ('name', 'description')
|
||||
filter_horizontal = ('tags',)
|
||||
inlines = [ThingFileInline, ThingLinkInline]
|
||||
|
||||
|
||||
admin.site.unregister(Thing)
|
||||
admin.register(Thing, ThingAdminWithFiles)
|
||||
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')
|
||||
|
||||
@@ -4,14 +4,13 @@ from .models import Box, BoxType, Thing, ThingFile, ThingLink
|
||||
|
||||
|
||||
class ThingForm(forms.ModelForm):
|
||||
"""Form for adding a Thing."""
|
||||
"""Form for adding/editing a Thing."""
|
||||
|
||||
class Meta:
|
||||
model = Thing
|
||||
fields = ('name', 'thing_type', 'description', 'picture')
|
||||
fields = ('name', 'description', 'picture')
|
||||
widgets = {
|
||||
'name': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'thing_type': forms.Select(attrs={'class': 'form-control'}),
|
||||
'description': forms.Textarea(attrs={'class': 'form-control', 'rows': 4}),
|
||||
}
|
||||
|
||||
|
||||
@@ -4,144 +4,196 @@ 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
|
||||
from boxes.models import Thing, ThingFile
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Clean up orphaned images and thumbnails from deleted things'
|
||||
help = "Clean up orphaned images, files, and thumbnails from deleted things"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--dry-run',
|
||||
action='store_true',
|
||||
dest='dry_run',
|
||||
help='Show what would be deleted without actually deleting',
|
||||
"--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)
|
||||
|
||||
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...')
|
||||
|
||||
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')
|
||||
|
||||
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'))
|
||||
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):
|
||||
for thing in Thing.objects.exclude(picture__exact="").exclude(
|
||||
picture__isnull=True
|
||||
):
|
||||
if thing.picture:
|
||||
valid_paths.add(os.path.basename(thing.picture.name))
|
||||
|
||||
self.stdout.write(f'Found {len(valid_paths)} valid images in database')
|
||||
|
||||
|
||||
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||'):
|
||||
|
||||
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/'):
|
||||
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()
|
||||
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()
|
||||
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/'):
|
||||
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/'):
|
||||
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}')
|
||||
self.stdout.write(f"Would delete: {file_path}")
|
||||
else:
|
||||
try:
|
||||
os.remove(file_path)
|
||||
self.stdout.write(f'Deleted: {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}'))
|
||||
|
||||
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}')
|
||||
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}')
|
||||
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}'))
|
||||
|
||||
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}')
|
||||
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}')
|
||||
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}'))
|
||||
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}')
|
||||
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}')
|
||||
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}'))
|
||||
|
||||
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}')
|
||||
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}'))
|
||||
|
||||
self.stdout.write(
|
||||
self.style.ERROR(
|
||||
f"Failed to remove {dir_path}: {e}"
|
||||
)
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
self.stdout.write(self.style.WARNING(f'\nDry run complete. Would delete {deleted_count} images and {thumbnail_deleted_count} thumbnails'))
|
||||
self.stdout.write(f'Would remove {empty_dirs_removed} empty directories')
|
||||
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} images and {thumbnail_deleted_count} thumbnails'))
|
||||
self.stdout.write(f'Removed {empty_dirs_removed} empty directories')
|
||||
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
|
||||
|
||||
|
||||
def rebuild_tree(apps, schema_editor):
|
||||
"""Rebuild MPTT tree after adding fields."""
|
||||
ThingType = apps.get_model('boxes', 'ThingType')
|
||||
# Import the actual model to use rebuild
|
||||
from boxes.models import ThingType as RealThingType
|
||||
RealThingType.objects.rebuild()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
@@ -20,49 +12,4 @@ class Migration(migrations.Migration):
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='thingtype',
|
||||
name='parent',
|
||||
field=mptt.fields.TreeForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='children',
|
||||
to='boxes.thingtype'
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='thingtype',
|
||||
name='level',
|
||||
field=models.PositiveIntegerField(default=0, editable=False),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='thingtype',
|
||||
name='lft',
|
||||
field=models.PositiveIntegerField(default=0, editable=False),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='thingtype',
|
||||
name='rght',
|
||||
field=models.PositiveIntegerField(default=0, editable=False),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='thingtype',
|
||||
name='tree_id',
|
||||
field=models.PositiveIntegerField(db_index=True, default=0, editable=False),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='thingtype',
|
||||
options={},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='thingtype',
|
||||
name='name',
|
||||
field=models.CharField(max_length=255),
|
||||
),
|
||||
migrations.RunPython(rebuild_tree, migrations.RunPython.noop),
|
||||
]
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -1,7 +1,6 @@
|
||||
import os
|
||||
from django.db import models
|
||||
from django.utils.text import slugify
|
||||
from mptt.models import MPTTModel, TreeForeignKey
|
||||
|
||||
|
||||
def thing_picture_upload_path(instance, filename):
|
||||
@@ -42,42 +41,78 @@ class Box(models.Model):
|
||||
on_delete=models.PROTECT,
|
||||
related_name='boxes'
|
||||
)
|
||||
sort_order = models.PositiveIntegerField(
|
||||
default=0,
|
||||
db_index=True,
|
||||
help_text='Order in which boxes are displayed'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name_plural = 'boxes'
|
||||
ordering = ['sort_order']
|
||||
|
||||
def __str__(self):
|
||||
return self.id
|
||||
|
||||
|
||||
class ThingType(MPTTModel):
|
||||
"""A hierarchical type/category for things stored in boxes."""
|
||||
class Facet(models.Model):
|
||||
"""A category of tags (e.g., Priority, Category, Status)."""
|
||||
|
||||
name = models.CharField(max_length=255)
|
||||
parent = TreeForeignKey(
|
||||
'self',
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='children'
|
||||
class Cardinality(models.TextChoices):
|
||||
SINGLE = 'single', 'Single (0..1)'
|
||||
MULTIPLE = 'multiple', 'Multiple (0..n)'
|
||||
|
||||
name = models.CharField(max_length=100, unique=True)
|
||||
slug = models.SlugField(max_length=100, unique=True)
|
||||
color = models.CharField(
|
||||
max_length=7,
|
||||
default='#667eea',
|
||||
help_text='Hex color code (e.g., #667eea)'
|
||||
)
|
||||
cardinality = models.CharField(
|
||||
max_length=10,
|
||||
choices=Cardinality.choices,
|
||||
default=Cardinality.MULTIPLE,
|
||||
help_text='Can a thing have multiple tags of this facet?'
|
||||
)
|
||||
|
||||
class MPTTMeta:
|
||||
order_insertion_by = ['name']
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.slug:
|
||||
self.slug = slugify(self.name)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class Tag(models.Model):
|
||||
"""A tag value for a specific facet."""
|
||||
|
||||
facet = models.ForeignKey(
|
||||
Facet,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='tags'
|
||||
)
|
||||
name = models.CharField(
|
||||
max_length=100,
|
||||
help_text='Tag description (e.g., "High", "Electronics")'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['facet', 'name']
|
||||
unique_together = [['facet', 'name']]
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.facet.name}:{self.name}'
|
||||
|
||||
|
||||
class Thing(models.Model):
|
||||
"""An item stored in a box."""
|
||||
|
||||
name = models.CharField(max_length=255)
|
||||
thing_type = models.ForeignKey(
|
||||
ThingType,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='things'
|
||||
)
|
||||
box = models.ForeignKey(
|
||||
Box,
|
||||
on_delete=models.PROTECT,
|
||||
@@ -85,6 +120,11 @@ class Thing(models.Model):
|
||||
)
|
||||
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']
|
||||
|
||||
383
boxes/static/css/base.css
Normal file
383
boxes/static/css/base.css
Normal file
@@ -0,0 +1,383 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 15px;
|
||||
padding: 15px 30px;
|
||||
margin: 20px auto;
|
||||
max-width: 1200px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.navbar-brand i {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.navbar-toggle {
|
||||
display: none;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #555;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.navbar-toggle:hover {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.navbar-nav {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.navbar-nav a,
|
||||
.navbar-nav form {
|
||||
color: #555;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
font-size: 15px;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.navbar-nav a:hover,
|
||||
.navbar-nav button:hover {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.navbar-nav a i {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.navbar-nav button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #555;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.navbar {
|
||||
padding: 15px 20px;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.navbar-toggle {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.navbar-nav {
|
||||
display: none;
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.navbar-nav.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.navbar-nav a,
|
||||
.navbar-nav form {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.navbar-nav a:first-child,
|
||||
.navbar-nav form:first-child {
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
|
||||
.navbar-nav > a:last-child {
|
||||
border-radius: 0 0 8px 8px;
|
||||
}
|
||||
|
||||
.dropdown-content a:last-child {
|
||||
border-radius: 0 0 8px 8px;
|
||||
}
|
||||
|
||||
.navbar-nav button {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 20px auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 15px;
|
||||
margin-bottom: 30px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
color: #333;
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.page-header .breadcrumb {
|
||||
color: #888;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.page-header .breadcrumb a {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.page-header .breadcrumb a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.section {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 15px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.section h2 {
|
||||
color: #667eea;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin-top: 0;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 3px solid #667eea;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.section h2 i {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.6);
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: linear-gradient(135deg, #7f8c8d 0%, #95a5a6 100%);
|
||||
box-shadow: 0 4px 15px rgba(127, 140, 141, 0.4);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
box-shadow: 0 6px 20px rgba(127, 140, 141, 0.6);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 15px 20px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 20px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background: linear-gradient(135deg, #00b894 0%, #00cec9 100%);
|
||||
color: white;
|
||||
box-shadow: 0 4px 15px rgba(0, 184, 148, 0.3);
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%);
|
||||
color: white;
|
||||
box-shadow: 0 4px 15px rgba(231, 76, 60, 0.3);
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
color: white;
|
||||
padding: 30px;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.dropdown-content {
|
||||
display: none;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
background: white;
|
||||
min-width: 200px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
||||
border-radius: 10px;
|
||||
padding: 10px 0;
|
||||
z-index: 1000;
|
||||
top: 100%;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.dropdown-content a {
|
||||
display: block;
|
||||
padding: 12px 20px;
|
||||
color: #555;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.dropdown-content a:hover {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.dropdown-content a:first-child {
|
||||
border-radius: 10px 10px 0 0;
|
||||
}
|
||||
|
||||
.dropdown-content a:last-child {
|
||||
border-radius: 0 0 10px 10px;
|
||||
}
|
||||
|
||||
#logout-form {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dropdown:hover .dropdown-content,
|
||||
.dropdown:focus-within .dropdown-content {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dropdown-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #667eea;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dropdown-btn:hover {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.dropdown {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.dropdown-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dropdown-content,
|
||||
.dropdown:hover .dropdown-content,
|
||||
.dropdown:focus-within .dropdown-content {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.dropdown-content a {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
21
boxes/static/css/edit_thing.css
Normal file
21
boxes/static/css/edit_thing.css
Normal file
@@ -0,0 +1,21 @@
|
||||
#id_name, #id_description {
|
||||
width: 100%;
|
||||
padding: 10px 15px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
background: white;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
#id_name:focus, #id_description:focus {
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
outline: none;
|
||||
}
|
||||
#id_description {
|
||||
min-height: 120px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.detail-row {
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
115
boxes/static/css/thing_detail.css
Normal file
115
boxes/static/css/thing_detail.css
Normal file
@@ -0,0 +1,115 @@
|
||||
.markdown-content p {
|
||||
margin: 0 0 1em 0;
|
||||
}
|
||||
.markdown-content p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.markdown-content h1, .markdown-content h2, .markdown-content h3,
|
||||
.markdown-content h4, .markdown-content h5, .markdown-content h6 {
|
||||
margin: 1.5em 0 0.5em 0;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
.markdown-content h1:first-child, .markdown-content h2:first-child,
|
||||
.markdown-content h3:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.markdown-content ul, .markdown-content ol {
|
||||
margin: 0.5em 0;
|
||||
padding-left: 2em;
|
||||
}
|
||||
.markdown-content li {
|
||||
margin: 0.25em 0;
|
||||
}
|
||||
.markdown-content code {
|
||||
background: #f4f4f4;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.markdown-content pre {
|
||||
background: #f4f4f4;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
margin: 1em 0;
|
||||
}
|
||||
.markdown-content pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
.markdown-content blockquote {
|
||||
border-left: 4px solid #667eea;
|
||||
margin: 1em 0;
|
||||
padding: 0.5em 1em;
|
||||
background: #f8f9fa;
|
||||
color: #666;
|
||||
}
|
||||
.markdown-content a {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
}
|
||||
.markdown-content a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.markdown-content table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 1em 0;
|
||||
}
|
||||
.markdown-content th, .markdown-content td {
|
||||
border: 1px solid #e0e0e0;
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
}
|
||||
.markdown-content th {
|
||||
background: #f8f9fa;
|
||||
font-weight: 600;
|
||||
}
|
||||
.markdown-content hr {
|
||||
border: none;
|
||||
border-top: 2px solid #e0e0e0;
|
||||
margin: 1.5em 0;
|
||||
}
|
||||
|
||||
.lightbox {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
z-index: 9999;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: zoom-out;
|
||||
}
|
||||
|
||||
.lightbox.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.lightbox img {
|
||||
max-width: 90%;
|
||||
max-height: 90%;
|
||||
object-fit: contain;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 30px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.lightbox-close {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 30px;
|
||||
color: white;
|
||||
font-size: 40px;
|
||||
cursor: pointer;
|
||||
z-index: 10000;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.lightbox-close:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
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 %}
|
||||
@@ -1,5 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% load thumbnail %}
|
||||
{% load dict_extras %}
|
||||
|
||||
{% block title %}Box {{ box.id }} - LabHelper{% endblock %}
|
||||
|
||||
@@ -37,7 +38,7 @@
|
||||
<tr style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white;">
|
||||
<th style="padding: 15px 20px; text-align: left; font-weight: 600;">Picture</th>
|
||||
<th style="padding: 15px 20px; text-align: left; font-weight: 600;">Name</th>
|
||||
<th style="padding: 15px 20px; text-align: left; font-weight: 600;">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>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -56,8 +57,16 @@
|
||||
<td style="padding: 15px 20px;">
|
||||
<a href="{% url 'thing_detail' thing.id %}" style="color: #667eea; text-decoration: none; font-weight: 500;">{{ thing.name }}</a>
|
||||
</td>
|
||||
<td style="padding: 15px 20px; color: #555;">{{ thing.thing_type.name }}</td>
|
||||
<td style="padding: 15px 20px; color: #777;">{{ thing.description|default:"-" }}</td>
|
||||
<td style="padding: 15px 20px;">
|
||||
{% if thing.tags.all %}
|
||||
{% for tag in thing.tags.all %}
|
||||
<span style="display: inline-block; padding: 3px 8px; margin: 2px; border-radius: 12px; font-size: 11px; background: {{ tag.facet.color }}20; color: {{ tag.facet.color }}; border: 1px solid {{ tag.facet.color }}40;">{{ tag.name }}</span>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<span style="color: #999; font-style: italic; font-size: 13px;">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="padding: 15px 20px; color: #777;">{{ thing.description|truncate_markdown:100|default:"-" }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
||||
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 %}
|
||||
313
boxes/templates/boxes/edit_thing.html
Normal file
313
boxes/templates/boxes/edit_thing.html
Normal file
@@ -0,0 +1,313 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
{% 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>
|
||||
|
||||
<div class="section">
|
||||
<form method="post" action="{% url 'delete_thing' thing.id %}"
|
||||
onsubmit="return confirm('Are you sure you want to delete {{ thing.name }}? This cannot be undone.');">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<i class="fas fa-trash"></i> Delete Thing
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" href="{% static 'css/edit_thing.css' %}">
|
||||
{% 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,6 +1,4 @@
|
||||
{% extends "base.html" %}
|
||||
{% load mptt_tags %}
|
||||
{% load dict_extras %}
|
||||
|
||||
{% block title %}LabHelper - Home{% endblock %}
|
||||
|
||||
@@ -13,67 +11,69 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="section">
|
||||
<h2><i class="fas fa-box"></i> Boxes</h2>
|
||||
{% if boxes %}
|
||||
<div class="box-grid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 20px;">
|
||||
{% for box in boxes %}
|
||||
<div class="box-card" style="background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); padding: 20px; border-radius: 12px; border: 1px solid #e0e0e0; transition: all 0.3s ease; cursor: pointer;">
|
||||
<a href="{% url 'box_detail' box.id %}" style="text-decoration: none; color: #333; display: block;">
|
||||
<div class="box-id" style="font-size: 20px; font-weight: 700; color: #667eea; margin-bottom: 8px;">
|
||||
<i class="fas fa-cube"></i> Box {{ box.id }}
|
||||
<input type="text"
|
||||
id="search-input"
|
||||
placeholder="Search for things..."
|
||||
style="width: 100%; padding: 16px 20px; font-size: 18px; border: 2px solid #e0e0e0; border-radius: 12px; box-sizing: border-box; transition: all 0.3s;"
|
||||
{% if request.GET.q %}value="{{ request.GET.q }}"{% endif %}>
|
||||
<p style="color: #888; font-size: 14px; margin-top: 10px;">
|
||||
<i class="fas fa-info-circle"></i> Type at least 2 characters to search
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="results-container" style="display: none;">
|
||||
<div class="section" style="overflow-x: auto; padding: 0;">
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<thead>
|
||||
<tr style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white;">
|
||||
<th style="padding: 15px 20px; text-align: left; font-weight: 600;">Name</th>
|
||||
<th style="padding: 15px 20px; text-align: left; font-weight: 600;">Tags</th>
|
||||
<th style="padding: 15px 20px; text-align: left; font-weight: 600;">Box</th>
|
||||
<th style="padding: 15px 20px; text-align: left; font-weight: 600;">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="results-body">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="no-results" class="section" style="text-align: center; padding: 60px 30px; display: none;">
|
||||
<i class="fas fa-search-minus" style="font-size: 64px; color: #ddd; margin-bottom: 20px; display: block;"></i>
|
||||
<h3 style="color: #888; font-size: 20px;">No results found</h3>
|
||||
<p style="color: #999; margin-top: 10px;">Try different keywords or browse the full inventory.</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2><i class="fas fa-tags"></i> Tags</h2>
|
||||
{% if facet_tag_counts %}
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px;">
|
||||
{% for facet, tags_with_counts in facet_tag_counts.items %}
|
||||
<div class="facet-card" style="background: white; border-radius: 12px; border: 1px solid #e0e0e0; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.05);">
|
||||
<div class="facet-header" style="padding: 15px 20px; background: linear-gradient(135deg, {{ facet.color }} 0%, {{ facet.color }}dd 100%); color: white; display: flex; align-items: center; justify-content: space-between; cursor: pointer;">
|
||||
<div style="display: flex; align-items: center; gap: 10px;">
|
||||
<i class="fas fa-chevron-right facet-toggle" style="transition: transform 0.3s;"></i>
|
||||
<span style="font-size: 18px; font-weight: 700;">{{ facet.name }}</span>
|
||||
</div>
|
||||
<div class="box-type" style="font-size: 15px; color: #555; margin-bottom: 5px;">
|
||||
{{ box.box_type.name }}
|
||||
<span style="background: rgba(255,255,255,0.3); padding: 4px 12px; border-radius: 20px; font-size: 13px; font-weight: 600;">{{ facet.cardinality }}</span>
|
||||
</div>
|
||||
<div class="facet-tags" style="padding: 15px 20px; display: none;">
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 8px;">
|
||||
{% for tag, count in tags_with_counts %}
|
||||
<a href="/?q={{ facet.name }}:{{ tag.name }}" style="display: inline-block; padding: 6px 12px; background: {{ facet.color }}20; color: {{ facet.color }}; border: 1px solid {{ facet.color }}; border-radius: 15px; text-decoration: none; font-size: 14px; font-weight: 600; transition: all 0.2s;">
|
||||
{{ tag.name }}
|
||||
<span style="background: {{ facet.color }}; color: white; padding: 1px 8px; border-radius: 10px; margin-left: 6px; font-size: 12px;">{{ count }}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="box-type" style="font-size: 13px; color: #777; margin-bottom: 5px;">
|
||||
<i class="fas fa-ruler-combined"></i> {{ box.box_type.width }} x {{ box.box_type.height }} x {{ box.box_type.length }} mm
|
||||
</div>
|
||||
<div class="box-type" style="font-size: 13px; color: #777;">
|
||||
<i class="fas fa-layer-group"></i> {{ box.things.count }} item{{ box.things.count|pluralize }}
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p style="text-align: center; color: #888; font-size: 16px; padding: 40px;">
|
||||
<i class="fas fa-box-open" style="font-size: 48px; margin-bottom: 15px; display: block;"></i>
|
||||
No boxes found.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2><i class="fas fa-folder-tree"></i> Thing Types</h2>
|
||||
{% if thing_types %}
|
||||
<ul class="tree" style="list-style: none; padding-left: 0;">
|
||||
{% recursetree thing_types %}
|
||||
<li style="padding: 8px 0;">
|
||||
<div class="tree-item" style="display: flex; align-items: center; gap: 8px;">
|
||||
{% if children %}
|
||||
<span class="toggle-handle" style="display: inline-block; width: 24px; color: #667eea; font-weight: bold; cursor: pointer; transition: transform 0.2s;">[+]</span>
|
||||
{% else %}
|
||||
<span class="toggle-handle" style="display: inline-block; width: 24px; color: #ccc;"> </span>
|
||||
{% endif %}
|
||||
<a href="{% url 'thing_type_detail' node.pk %}" style="color: #667eea; text-decoration: none; font-size: 16px; font-weight: 500; transition: color 0.2s;">{{ node.name }}</a>
|
||||
{% with count=type_counts|get_item:node.pk %}
|
||||
{% if count and count > 0 %}
|
||||
<span style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 3px 10px; border-radius: 20px; font-size: 12px; font-weight: 600;">{{ count }}</span>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
{% if children %}
|
||||
<ul style="list-style: none; padding-left: 32px; display: none;">
|
||||
{{ children }}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endrecursetree %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p style="text-align: center; color: #888; font-size: 16px; padding: 40px;">
|
||||
<i class="fas fa-folder-open" style="font-size: 48px; margin-bottom: 15px; display: block;"></i>
|
||||
No thing types found.
|
||||
<i class="fas fa-tag" style="font-size: 48px; margin-bottom: 15px; display: block;"></i>
|
||||
No tags found.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -82,25 +82,117 @@
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
$('.toggle-handle').click(function(e) {
|
||||
e.stopPropagation();
|
||||
var $ul = $(this).closest('li').children('ul');
|
||||
if ($ul.length) {
|
||||
$ul.slideToggle(200);
|
||||
$(this).text($ul.is(':visible') ? '[-]' : '[+]');
|
||||
const searchInput = document.getElementById('search-input');
|
||||
const resultsContainer = document.getElementById('results-container');
|
||||
const resultsBody = document.getElementById('results-body');
|
||||
const noResults = document.getElementById('no-results');
|
||||
|
||||
let searchTimeout = null;
|
||||
|
||||
function performSearch(query) {
|
||||
if (query.length < 2) {
|
||||
resultsContainer.style.display = 'none';
|
||||
noResults.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
searchInput.style.borderColor = '#667eea';
|
||||
searchInput.style.boxShadow = '0 0 0 3px rgba(102, 126, 234, 0.1)';
|
||||
|
||||
searchTimeout = setTimeout(function() {
|
||||
fetch('/search/api/?q=' + encodeURIComponent(query))
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
resultsBody.innerHTML = '';
|
||||
|
||||
if (data.results.length === 0) {
|
||||
resultsContainer.style.display = 'none';
|
||||
noResults.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
noResults.style.display = 'none';
|
||||
resultsContainer.style.display = 'block';
|
||||
|
||||
data.results.forEach(function(thing) {
|
||||
const row = document.createElement('tr');
|
||||
row.style.borderBottom = '1px solid #e0e0e0';
|
||||
row.style.transition = 'background 0.2s';
|
||||
|
||||
let tagsHtml = thing.tags.length > 0
|
||||
? thing.tags.map(tag =>
|
||||
'<span style="display: inline-block; padding: 3px 8px; margin: 2px; border-radius: 12px; font-size: 11px; background: ' + escapeHtml(tag.color) + '20; color: ' + escapeHtml(tag.color) + '; border: 1px solid ' + escapeHtml(tag.color) + '40;">' + escapeHtml(tag.name) + '</span>'
|
||||
).join('')
|
||||
: '<span style="color: #999; font-style: italic; font-size: 13px;">-</span>';
|
||||
|
||||
row.innerHTML =
|
||||
'<td style="padding: 15px 20px;"><a href="/thing/' + thing.id + '/">' + escapeHtml(thing.name) + '</a></td>' +
|
||||
'<td style="padding: 15px 20px;">' + tagsHtml + '</td>' +
|
||||
'<td style="padding: 15px 20px;"><a href="/box/' + escapeHtml(thing.box) + '/">' + escapeHtml(thing.box) + '</a></td>' +
|
||||
'<td style="padding: 15px 20px; color: #777;" class="description">' + escapeHtml(thing.description) + '</td>';
|
||||
|
||||
row.addEventListener('mouseenter', function() {
|
||||
this.style.background = '#f8f9fa';
|
||||
});
|
||||
row.addEventListener('mouseleave', function() {
|
||||
this.style.background = 'white';
|
||||
});
|
||||
|
||||
resultsBody.appendChild(row);
|
||||
});
|
||||
});
|
||||
}, 200);
|
||||
}
|
||||
|
||||
searchInput.addEventListener('input', function() {
|
||||
const query = this.value.trim();
|
||||
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout);
|
||||
}
|
||||
|
||||
performSearch(query);
|
||||
});
|
||||
|
||||
searchInput.addEventListener('blur', function() {
|
||||
searchInput.style.borderColor = '#e0e0e0';
|
||||
searchInput.style.boxShadow = 'none';
|
||||
});
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const initialQuery = urlParams.get('q');
|
||||
|
||||
if (initialQuery) {
|
||||
searchInput.value = initialQuery;
|
||||
performSearch(initialQuery.trim());
|
||||
}
|
||||
|
||||
$('.facet-header').click(function() {
|
||||
const $content = $(this).next('.facet-tags');
|
||||
const $icon = $(this).find('.facet-toggle');
|
||||
|
||||
$content.slideToggle(200);
|
||||
if ($content.is(':visible')) {
|
||||
$icon.css('transform', 'rotate(90deg)');
|
||||
} else {
|
||||
$icon.css('transform', 'rotate(0deg)');
|
||||
}
|
||||
});
|
||||
|
||||
$('.box-card').hover(
|
||||
|
||||
$('.facet-card a').hover(
|
||||
function() {
|
||||
$(this).css('transform', 'translateY(-5px)');
|
||||
$(this).css('box-shadow', '0 12px 24px rgba(102, 126, 234, 0.2)');
|
||||
$(this).css('transform', 'scale(1.05)');
|
||||
},
|
||||
function() {
|
||||
$(this).css('transform', 'translateY(0)');
|
||||
$(this).css('box-shadow', 'none');
|
||||
$(this).css('transform', 'scale(1)');
|
||||
}
|
||||
);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
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 %}
|
||||
@@ -13,10 +13,11 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="section">
|
||||
<input type="text"
|
||||
id="search-input"
|
||||
placeholder="Search for things..."
|
||||
style="width: 100%; padding: 16px 20px; font-size: 18px; border: 2px solid #e0e0e0; border-radius: 12px; box-sizing: border-box; transition: all 0.3s;">
|
||||
<input type="text"
|
||||
id="search-input"
|
||||
placeholder="Search for things..."
|
||||
style="width: 100%; padding: 16px 20px; font-size: 18px; border: 2px solid #e0e0e0; border-radius: 12px; box-sizing: border-box; transition: all 0.3s;"
|
||||
{% if request.GET.q %}value="{{ request.GET.q }}"{% endif %}>
|
||||
<p style="color: #888; font-size: 14px; margin-top: 10px;">
|
||||
<i class="fas fa-info-circle"></i> Type at least 2 characters to search
|
||||
</p>
|
||||
@@ -28,7 +29,7 @@
|
||||
<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;">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;">Description</th>
|
||||
</tr>
|
||||
@@ -55,58 +56,69 @@ const noResults = document.getElementById('no-results');
|
||||
|
||||
let searchTimeout = null;
|
||||
|
||||
searchInput.addEventListener('input', function() {
|
||||
const query = this.value.trim();
|
||||
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout);
|
||||
}
|
||||
|
||||
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';
|
||||
row.innerHTML =
|
||||
|
||||
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; 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; 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() {
|
||||
@@ -120,6 +132,15 @@ function escapeHtml(text) {
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Check for query parameter on page load
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const initialQuery = urlParams.get('q');
|
||||
|
||||
if (initialQuery) {
|
||||
searchInput.value = initialQuery;
|
||||
performSearch(initialQuery.trim());
|
||||
}
|
||||
|
||||
searchInput.focus();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,16 +1,23 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
{% load thumbnail %}
|
||||
{% load dict_extras %}
|
||||
|
||||
{% block title %}{{ thing.name }} - LabHelper{% endblock %}
|
||||
|
||||
{% block page_header %}
|
||||
<div class="page-header">
|
||||
<h1><i class="fas fa-cube"></i> {{ thing.name }}</h1>
|
||||
<p class="breadcrumb">
|
||||
<a href="/"><i class="fas fa-home"></i> Home</a> /
|
||||
<a href="/box/{{ thing.box.id }}/"><i class="fas fa-box"></i> Box {{ thing.box.id }}</a> /
|
||||
{{ thing.name }}
|
||||
</p>
|
||||
<div class="page-header" style="display: flex; justify-content: space-between; align-items: start;">
|
||||
<div>
|
||||
<h1><i class="fas fa-cube"></i> {{ thing.name }}</h1>
|
||||
<p class="breadcrumb">
|
||||
<a href="/"><i class="fas fa-home"></i> Home</a> /
|
||||
<a href="/box/{{ thing.box.id }}/"><i class="fas fa-box"></i> Box {{ thing.box.id }}</a> /
|
||||
{{ thing.name }}
|
||||
</p>
|
||||
</div>
|
||||
<a href="{% url 'edit_thing' thing.id %}" class="btn" style="margin-top: 10px;">
|
||||
<i class="fas fa-edit"></i> Edit
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -20,7 +27,7 @@
|
||||
<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);">
|
||||
<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 %}
|
||||
{% 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);">
|
||||
@@ -30,35 +37,33 @@
|
||||
</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;">
|
||||
{% if thing.tags.all %}
|
||||
<div class="detail-row" style="margin-bottom: 25px;">
|
||||
<div style="font-size: 14px; color: #888; font-weight: 600; margin-bottom: 8px;">
|
||||
<i class="fas fa-tag"></i> Type
|
||||
<i class="fas fa-tags"></i> Tags
|
||||
</div>
|
||||
<div style="font-size: 18px; color: #333; font-weight: 500;">
|
||||
{{ thing.thing_type.name }}
|
||||
<div style="display: flex; flex-direction: column; gap: 12px;">
|
||||
{% regroup thing.tags.all by facet as facet_list %}
|
||||
{% for facet in facet_list %}
|
||||
<div>
|
||||
<div style="font-size: 12px; color: {{ facet.grouper.color }}; font-weight: 700; margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.5px;">
|
||||
{{ facet.grouper.name }}
|
||||
</div>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 8px;">
|
||||
{% for tag in facet.list %}
|
||||
<span style="display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; background: {{ facet.grouper.color }}20; color: {{ facet.grouper.color }}; border: 2px solid {{ facet.grouper.color }}; border-radius: 20px; font-size: 14px; font-weight: 600;">
|
||||
{{ tag.name }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="detail-row" style="margin-bottom: 25px;">
|
||||
<div style="font-size: 14px; color: #888; font-weight: 600; margin-bottom: 8px;">
|
||||
@@ -75,8 +80,8 @@
|
||||
<div style="font-size: 14px; color: #888; font-weight: 600; margin-bottom: 8px;">
|
||||
<i class="fas fa-align-left"></i> Description
|
||||
</div>
|
||||
<div style="font-size: 16px; color: #555; line-height: 1.6; white-space: pre-wrap;">
|
||||
{{ thing.description }}
|
||||
<div class="markdown-content" style="font-size: 16px; color: #555; line-height: 1.6;">
|
||||
{{ thing.description|render_markdown }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -88,21 +93,24 @@
|
||||
</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>
|
||||
{% 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="#" class="lightbox-trigger" data-url="{{ file.file.url }}" 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>
|
||||
<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>
|
||||
@@ -115,19 +123,9 @@
|
||||
</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 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>
|
||||
@@ -137,90 +135,40 @@
|
||||
</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 class="lightbox" id="lightbox">
|
||||
<span class="lightbox-close">×</span>
|
||||
<img id="lightbox-image" src="" alt="">
|
||||
</div>
|
||||
|
||||
<form method="post" class="section">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="move">
|
||||
<div style="display: flex; align-items: center; gap: 15px; flex-wrap: wrap;">
|
||||
<div style="flex-grow: 1;">
|
||||
<label for="new_box" style="font-weight: 600; color: #666; font-size: 14px; margin-bottom: 8px; display: block;">
|
||||
<i class="fas fa-exchange-alt"></i> Move to:
|
||||
</label>
|
||||
<select name="new_box" id="new_box" style="width: 100%; max-width: 400px; padding: 12px 16px; border: 2px solid #e0e0e0; border-radius: 10px; font-size: 15px; background: white; cursor: pointer; transition: all 0.3s;">
|
||||
<option value="">Select a box...</option>
|
||||
{% for box in boxes %}
|
||||
<option value="{{ box.id }}" {% if box.id == thing.box.id %}selected{% endif %}>
|
||||
Box {{ box.id }} ({{ box.box_type.name }})
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn" style="height: 48px; min-width: 120px; margin-top: 24px;">
|
||||
<i class="fas fa-arrows-alt"></i> Move
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
$('#new_box').on('focus', function() {
|
||||
$(this).css('border-color', '#667eea');
|
||||
$(this).css('box-shadow', '0 0 0 3px rgba(102, 126, 234, 0.1)');
|
||||
}).on('blur', function() {
|
||||
$(this).css('border-color', '#e0e0e0');
|
||||
$(this).css('box-shadow', 'none');
|
||||
$(document).ready(function() {
|
||||
$('.lightbox-trigger').on('click', function(e) {
|
||||
e.preventDefault();
|
||||
const imageUrl = $(this).data('url');
|
||||
$('#lightbox-image').attr('src', imageUrl);
|
||||
$('#lightbox').addClass('active');
|
||||
$('body').css('overflow', 'hidden');
|
||||
});
|
||||
|
||||
$('#lightbox').on('click', function(e) {
|
||||
if (e.target === this || e.target.classList.contains('lightbox-close')) {
|
||||
$('#lightbox').removeClass('active');
|
||||
$('body').css('overflow', 'auto');
|
||||
}
|
||||
});
|
||||
|
||||
$(document).on('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
$('#lightbox').removeClass('active');
|
||||
$('body').css('overflow', 'auto');
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" href="{% static 'css/thing_detail.css' %}">
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,7 +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)
|
||||
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
|
||||
794
boxes/tests.py
794
boxes/tests.py
File diff suppressed because it is too large
Load Diff
288
boxes/views.py
288
boxes/views.py
@@ -1,5 +1,8 @@
|
||||
import bleach
|
||||
import markdown
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db.models import Q
|
||||
from django.db.models import Q, Prefetch
|
||||
from django.http import HttpResponse, JsonResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
|
||||
@@ -7,29 +10,48 @@ from .forms import (
|
||||
BoxForm,
|
||||
BoxTypeForm,
|
||||
ThingFileForm,
|
||||
ThingForm,
|
||||
ThingFormSet,
|
||||
ThingLinkForm,
|
||||
ThingPictureForm,
|
||||
)
|
||||
from .models import Box, BoxType, Thing, ThingFile, ThingLink, ThingType
|
||||
from .models import Box, BoxType, Facet, Tag, Thing, ThingFile, ThingLink
|
||||
|
||||
|
||||
def health_check(request):
|
||||
"""Health check endpoint for Kubernetes liveness/readiness probes."""
|
||||
return HttpResponse('OK', status=200)
|
||||
|
||||
|
||||
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):
|
||||
"""Home page with boxes and thing types."""
|
||||
boxes = Box.objects.select_related('box_type').all().order_by('id')
|
||||
thing_types = ThingType.objects.all()
|
||||
|
||||
type_counts = {}
|
||||
for thing_type in thing_types:
|
||||
descendants = thing_type.get_descendants(include_self=True)
|
||||
count = Thing.objects.filter(thing_type__in=descendants).count()
|
||||
type_counts[thing_type.pk] = count
|
||||
|
||||
"""Home page with search and tags."""
|
||||
facets = Facet.objects.all().prefetch_related('tags')
|
||||
|
||||
facet_tag_counts = {}
|
||||
for facet in facets:
|
||||
for tag in facet.tags.all():
|
||||
count = tag.things.count()
|
||||
if count > 0:
|
||||
if facet not in facet_tag_counts:
|
||||
facet_tag_counts[facet] = []
|
||||
facet_tag_counts[facet].append((tag, count))
|
||||
|
||||
return render(request, 'boxes/index.html', {
|
||||
'boxes': boxes,
|
||||
'thing_types': thing_types,
|
||||
'type_counts': type_counts,
|
||||
'facets': facets,
|
||||
'facet_tag_counts': facet_tag_counts,
|
||||
})
|
||||
|
||||
|
||||
@@ -37,7 +59,7 @@ def index(request):
|
||||
def box_detail(request, box_id):
|
||||
"""Display contents of a box."""
|
||||
box = get_object_or_404(Box, pk=box_id)
|
||||
things = box.things.select_related('thing_type').all()
|
||||
things = box.things.prefetch_related('tags').all()
|
||||
return render(request, 'boxes/box_detail.html', {
|
||||
'box': box,
|
||||
'things': things,
|
||||
@@ -46,13 +68,24 @@ def box_detail(request, box_id):
|
||||
|
||||
@login_required
|
||||
def thing_detail(request, thing_id):
|
||||
"""Display details of a thing."""
|
||||
"""Display details of a thing (read-only)."""
|
||||
thing = get_object_or_404(
|
||||
Thing.objects.select_related('thing_type', 'box', 'box__box_type').prefetch_related('files', 'links'),
|
||||
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
|
||||
)
|
||||
|
||||
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()
|
||||
@@ -60,26 +93,32 @@ def thing_detail(request, thing_id):
|
||||
if request.method == 'POST':
|
||||
action = request.POST.get('action')
|
||||
|
||||
if action == 'move':
|
||||
if action == 'save_details':
|
||||
form = ThingForm(request.POST, request.FILES, instance=thing)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
return redirect('thing_detail', thing_id=thing.id)
|
||||
|
||||
elif action == 'move':
|
||||
new_box_id = request.POST.get('new_box')
|
||||
if new_box_id:
|
||||
new_box = get_object_or_404(Box, pk=new_box_id)
|
||||
thing.box = new_box
|
||||
thing.save()
|
||||
return redirect('thing_detail', thing_id=thing.id)
|
||||
return redirect('edit_thing', thing_id=thing.id)
|
||||
|
||||
elif action == 'upload_picture':
|
||||
picture_form = ThingPictureForm(request.POST, request.FILES, instance=thing)
|
||||
if picture_form.is_valid():
|
||||
picture_form.save()
|
||||
return redirect('thing_detail', thing_id=thing.id)
|
||||
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('thing_detail', thing_id=thing.id)
|
||||
return redirect('edit_thing', thing_id=thing.id)
|
||||
|
||||
elif action == 'add_file':
|
||||
file_form = ThingFileForm(request.POST, request.FILES)
|
||||
@@ -87,7 +126,7 @@ def thing_detail(request, thing_id):
|
||||
thing_file = file_form.save(commit=False)
|
||||
thing_file.thing = thing
|
||||
thing_file.save()
|
||||
return redirect('thing_detail', thing_id=thing.id)
|
||||
return redirect('edit_thing', thing_id=thing.id)
|
||||
|
||||
elif action == 'add_link':
|
||||
link_form = ThingLinkForm(request.POST)
|
||||
@@ -95,7 +134,7 @@ def thing_detail(request, thing_id):
|
||||
thing_link = link_form.save(commit=False)
|
||||
thing_link.thing = thing
|
||||
thing_link.save()
|
||||
return redirect('thing_detail', thing_id=thing.id)
|
||||
return redirect('edit_thing', thing_id=thing.id)
|
||||
|
||||
elif action == 'delete_file':
|
||||
file_id = request.POST.get('file_id')
|
||||
@@ -106,7 +145,7 @@ def thing_detail(request, thing_id):
|
||||
thing_file.delete()
|
||||
except ThingFile.DoesNotExist:
|
||||
pass
|
||||
return redirect('thing_detail', thing_id=thing.id)
|
||||
return redirect('edit_thing', thing_id=thing.id)
|
||||
|
||||
elif action == 'delete_link':
|
||||
link_id = request.POST.get('link_id')
|
||||
@@ -116,21 +155,52 @@ def thing_detail(request, thing_id):
|
||||
thing_link.delete()
|
||||
except ThingLink.DoesNotExist:
|
||||
pass
|
||||
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 == 'add_tag':
|
||||
tag_id = request.POST.get('tag_id')
|
||||
if tag_id:
|
||||
try:
|
||||
tag = Tag.objects.get(pk=tag_id)
|
||||
if tag.facet.cardinality == Facet.Cardinality.SINGLE:
|
||||
existing_tags = list(thing.tags.filter(facet=tag.facet))
|
||||
for existing_tag in existing_tags:
|
||||
thing.tags.remove(existing_tag)
|
||||
thing.tags.add(tag)
|
||||
except Tag.DoesNotExist:
|
||||
pass
|
||||
return redirect('edit_thing', thing_id=thing.id)
|
||||
|
||||
elif action == 'remove_tag':
|
||||
tag_id = request.POST.get('tag_id')
|
||||
if tag_id:
|
||||
try:
|
||||
tag = Tag.objects.get(pk=tag_id)
|
||||
thing.tags.remove(tag)
|
||||
except Tag.DoesNotExist:
|
||||
pass
|
||||
return redirect('edit_thing', thing_id=thing.id)
|
||||
|
||||
thing_form = ThingForm(instance=thing)
|
||||
|
||||
return render(request, 'boxes/edit_thing.html', {
|
||||
'thing': thing,
|
||||
'boxes': boxes,
|
||||
'facets': facets,
|
||||
'picture_form': picture_form,
|
||||
'file_form': file_form,
|
||||
'link_form': link_form,
|
||||
'thing_form': thing_form,
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
def search(request):
|
||||
"""Search page for things."""
|
||||
return render(request, 'boxes/search.html')
|
||||
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,
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -140,23 +210,43 @@ def search_api(request):
|
||||
if len(query) < 2:
|
||||
return JsonResponse({'results': []})
|
||||
|
||||
things = Thing.objects.filter(
|
||||
Q(name__icontains=query) |
|
||||
Q(description__icontains=query) |
|
||||
Q(thing_type__name__icontains=query) |
|
||||
Q(files__title__icontains=query) |
|
||||
Q(files__file__icontains=query) |
|
||||
Q(links__title__icontains=query) |
|
||||
Q(links__url__icontains=query)
|
||||
).prefetch_related('files', 'links').select_related('thing_type', 'box').distinct()[:50]
|
||||
# Check for "Facet:Word" format
|
||||
if ':' in query:
|
||||
parts = query.split(':',1)
|
||||
facet_name = parts[0].strip()
|
||||
tag_name = parts[1].strip()
|
||||
|
||||
# Search for things with specific facet and tag
|
||||
things = Thing.objects.filter(
|
||||
Q(tags__facet__name__icontains=facet_name) &
|
||||
Q(tags__name__icontains=tag_name)
|
||||
).prefetch_related('files', 'links', 'tags').select_related('box').distinct()[:50]
|
||||
else:
|
||||
# Normal search
|
||||
things = Thing.objects.filter(
|
||||
Q(name__icontains=query) |
|
||||
Q(description__icontains=query) |
|
||||
Q(files__title__icontains=query) |
|
||||
Q(files__file__icontains=query) |
|
||||
Q(links__title__icontains=query) |
|
||||
Q(links__url__icontains=query) |
|
||||
Q(tags__name__icontains=query) |
|
||||
Q(tags__facet__name__icontains=query)
|
||||
).prefetch_related('files', 'links', 'tags').select_related('box').distinct()[:50]
|
||||
|
||||
results = [
|
||||
{
|
||||
'id': thing.id,
|
||||
'name': thing.name,
|
||||
'type': thing.thing_type.name,
|
||||
'box': thing.box.id,
|
||||
'description': thing.description[:100] if thing.description else '',
|
||||
'description': _strip_markdown(thing.description),
|
||||
'tags': [
|
||||
{
|
||||
'name': tag.name,
|
||||
'color': tag.facet.color,
|
||||
}
|
||||
for tag in thing.tags.all()
|
||||
],
|
||||
'files': [
|
||||
{
|
||||
'title': f.title,
|
||||
@@ -191,7 +281,7 @@ def add_things(request, box_id):
|
||||
things = formset.save(commit=False)
|
||||
created_count = 0
|
||||
for thing in things:
|
||||
if thing.name or thing.thing_type or thing.description or thing.picture:
|
||||
if thing.name or thing.description or thing.picture:
|
||||
thing.box = box
|
||||
thing.save()
|
||||
created_count += 1
|
||||
@@ -208,25 +298,6 @@ def add_things(request, box_id):
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
def thing_type_detail(request, type_id):
|
||||
"""Display details of a thing type with its hierarchy and things."""
|
||||
thing_type = get_object_or_404(ThingType, pk=type_id)
|
||||
|
||||
descendants = thing_type.get_descendants(include_self=True)
|
||||
things_by_type = {}
|
||||
|
||||
for descendant in descendants:
|
||||
things = descendant.things.select_related('box', 'box__box_type').all()
|
||||
if things:
|
||||
things_by_type[descendant] = things
|
||||
|
||||
return render(request, 'boxes/thing_type_detail.html', {
|
||||
'thing_type': thing_type,
|
||||
'things_by_type': things_by_type,
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
def box_management(request):
|
||||
"""Main page for managing boxes and box types."""
|
||||
@@ -305,3 +376,96 @@ def delete_box(request, box_id):
|
||||
return redirect('box_management')
|
||||
box.delete()
|
||||
return redirect('box_management')
|
||||
|
||||
|
||||
@login_required
|
||||
def delete_thing(request, thing_id):
|
||||
"""Delete a thing and its associated files."""
|
||||
thing = get_object_or_404(Thing, pk=thing_id)
|
||||
if request.method == 'POST':
|
||||
box_id = thing.box.id
|
||||
if thing.picture:
|
||||
thing.picture.delete(save=False)
|
||||
for thing_file in thing.files.all():
|
||||
thing_file.file.delete(save=False)
|
||||
thing.delete()
|
||||
return redirect('box_detail', box_id=box_id)
|
||||
return redirect('edit_thing', thing_id=thing_id)
|
||||
|
||||
|
||||
@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.
18
gunicorn.conf.py
Normal file
18
gunicorn.conf.py
Normal file
@@ -0,0 +1,18 @@
|
||||
import logging
|
||||
|
||||
from gunicorn.glogging import Logger
|
||||
|
||||
|
||||
class HealthCheckFilter(logging.Filter):
|
||||
def filter(self, record):
|
||||
message = record.getMessage()
|
||||
return "kube-probe" not in message
|
||||
|
||||
|
||||
class CustomLogger(Logger):
|
||||
def setup(self, cfg):
|
||||
super().setup(cfg)
|
||||
self.access_log.addFilter(HealthCheckFilter())
|
||||
|
||||
|
||||
logger_class = CustomLogger
|
||||
54
labhelper/auth_backend.py
Normal file
54
labhelper/auth_backend.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from mozilla_django_oidc.auth import OIDCAuthenticationBackend
|
||||
from django.contrib.auth.models import Group
|
||||
|
||||
# Keycloak group name → Django group name mapping.
|
||||
# Keycloak may send group paths with a leading slash (e.g. "/LabHelper Administrators");
|
||||
# these are stripped before comparison.
|
||||
KEYCLOAK_GROUP_MAP = {
|
||||
'LabHelper Administrators': 'LabHelper Administrators',
|
||||
'LabHelper Staff': 'LabHelper Staff',
|
||||
'LabHelper Viewers': 'LabHelper Viewers',
|
||||
}
|
||||
|
||||
# Members of these groups receive is_staff=True (Django admin access)
|
||||
STAFF_GROUPS = {'LabHelper Administrators'}
|
||||
|
||||
|
||||
class KeycloakOIDCBackend(OIDCAuthenticationBackend):
|
||||
"""OIDC backend that maps Keycloak groups to Django groups on every login."""
|
||||
|
||||
def get_username(self, claims):
|
||||
return claims.get('preferred_username') or super().get_username(claims)
|
||||
|
||||
def create_user(self, claims):
|
||||
user = super().create_user(claims)
|
||||
self._sync_from_claims(user, claims)
|
||||
return user
|
||||
|
||||
def update_user(self, user, claims):
|
||||
user = super().update_user(user, claims)
|
||||
self._sync_from_claims(user, claims)
|
||||
return user
|
||||
|
||||
def _sync_from_claims(self, user, claims):
|
||||
"""Sync user attributes and group memberships from Keycloak token claims."""
|
||||
user.first_name = claims.get('given_name', user.first_name)
|
||||
user.last_name = claims.get('family_name', user.last_name)
|
||||
|
||||
# Keycloak sends group paths like "/LabHelper Administrators"; normalise them.
|
||||
raw_groups = claims.get('groups', [])
|
||||
keycloak_groups = {g.lstrip('/') for g in raw_groups}
|
||||
|
||||
user.is_staff = bool(keycloak_groups & STAFF_GROUPS)
|
||||
user.save()
|
||||
|
||||
# Add/remove the user from each managed Django group to match Keycloak.
|
||||
for kc_group, django_group_name in KEYCLOAK_GROUP_MAP.items():
|
||||
try:
|
||||
group = Group.objects.get(name=django_group_name)
|
||||
except Group.DoesNotExist:
|
||||
continue
|
||||
if kc_group in keycloak_groups:
|
||||
user.groups.add(group)
|
||||
else:
|
||||
user.groups.remove(group)
|
||||
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')
|
||||
}
|
||||
@@ -9,9 +9,9 @@ class Command(BaseCommand):
|
||||
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',
|
||||
'LabHelper Administrators': 'Full access to all lab functions',
|
||||
'LabHelper Staff': 'Can view and search items, add things to boxes',
|
||||
'LabHelper Viewers': 'Read-only access to view and search',
|
||||
}
|
||||
|
||||
for group_name, description in groups.items():
|
||||
@@ -22,9 +22,9 @@ class Command(BaseCommand):
|
||||
self.stdout.write(f'Group already exists: {group_name}')
|
||||
|
||||
users = {
|
||||
'admin': ('Lab Administrators', True),
|
||||
'staff': ('Lab Staff', False),
|
||||
'viewer': ('Lab Viewers', False),
|
||||
'admin': ('LabHelper Administrators', True),
|
||||
'staff': ('LabHelper Staff', False),
|
||||
'viewer': ('LabHelper Viewers', False),
|
||||
}
|
||||
|
||||
for username, (group_name, is_superuser) in users.items():
|
||||
|
||||
@@ -24,10 +24,10 @@ BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY', 'f0arjg8q3ut4iuqrguqfjaruf0eripIZZN3t1kymy8ugqnj$li2knhha0@gc5v8f3bge=$+gbybj2$jt28uqm')
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = True
|
||||
DEBUG = os.environ.get('DEBUG', 'True').lower() == 'true'
|
||||
|
||||
ALLOWED_HOSTS = ["*","labhelper.adebaumann.com"]
|
||||
ALLOWED_CIDR_NETS = ['10.0.0.0/16']
|
||||
ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '*').split(',')
|
||||
ALLOWED_CIDR_NETS = os.environ.get('ALLOWED_CIDR_NETS', '10.0.0.0/16').split(',')
|
||||
|
||||
|
||||
# Application definition
|
||||
@@ -39,6 +39,7 @@ INSTALLED_APPS = [
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'mozilla_django_oidc',
|
||||
'mptt',
|
||||
'django_mptt_admin',
|
||||
'sorl.thumbnail',
|
||||
@@ -47,10 +48,12 @@ INSTALLED_APPS = [
|
||||
|
||||
MIDDLEWARE = [
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'whitenoise.middleware.WhiteNoiseMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'mozilla_django_oidc.middleware.SessionRefresh',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
]
|
||||
@@ -67,6 +70,7 @@ TEMPLATES = [
|
||||
'django.template.context_processors.request',
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
'labhelper.context_processors.image_tag',
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -108,23 +112,26 @@ AUTH_PASSWORD_VALIDATORS = [
|
||||
# Internationalization
|
||||
# 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)
|
||||
# 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'
|
||||
|
||||
# WhiteNoise static file serving configuration
|
||||
STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'
|
||||
|
||||
# Media files (user uploads)
|
||||
MEDIA_URL = '/media/'
|
||||
MEDIA_URL = os.environ.get('MEDIA_URL', '/media/')
|
||||
MEDIA_ROOT = BASE_DIR / 'data' / 'media'
|
||||
|
||||
# Default primary key field type
|
||||
@@ -132,6 +139,47 @@ MEDIA_ROOT = BASE_DIR / 'data' / 'media'
|
||||
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||
|
||||
LOGIN_URL = 'login'
|
||||
LOGIN_REDIRECT_URL = 'index'
|
||||
LOGOUT_REDIRECT_URL = 'login'
|
||||
CSRF_TRUSTED_ORIGINS=os.environ.get('CSRF_TRUSTED_ORIGINS', 'https://labhelper.adebaumann.com,http://127.0.0.1:8000').split(',')
|
||||
|
||||
LOGIN_URL = os.environ.get('LOGIN_URL', 'oidc_authentication_init')
|
||||
LOGIN_REDIRECT_URL = os.environ.get('LOGIN_REDIRECT_URL', 'index')
|
||||
LOGOUT_REDIRECT_URL = os.environ.get('LOGOUT_REDIRECT_URL', 'login')
|
||||
|
||||
AUTHENTICATION_BACKENDS = [
|
||||
'labhelper.auth_backend.KeycloakOIDCBackend',
|
||||
# ModelBackend kept as fallback for Django admin emergency access
|
||||
'django.contrib.auth.backends.ModelBackend',
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Keycloak / OIDC configuration
|
||||
#
|
||||
# Set OIDC_OP_BASE_URL to your realm URL, e.g.:
|
||||
# https://keycloak.example.com/realms/myrealm
|
||||
#
|
||||
# All individual endpoints are derived from OIDC_OP_BASE_URL automatically.
|
||||
# You can override any individual endpoint with its own env var.
|
||||
# ---------------------------------------------------------------------------
|
||||
_oidc_base = os.environ.get('OIDC_OP_BASE_URL', '').rstrip('/')
|
||||
_oidc_connect = f'{_oidc_base}/protocol/openid-connect' if _oidc_base else ''
|
||||
|
||||
OIDC_RP_CLIENT_ID = os.environ.get('OIDC_RP_CLIENT_ID', '')
|
||||
OIDC_RP_CLIENT_SECRET = os.environ.get('OIDC_RP_CLIENT_SECRET', '')
|
||||
OIDC_RP_SIGN_ALGO = 'RS256'
|
||||
OIDC_RP_SCOPES = 'openid email profile'
|
||||
OIDC_USE_PKCE = True
|
||||
|
||||
OIDC_OP_AUTHORIZATION_ENDPOINT = os.environ.get('OIDC_OP_AUTHORIZATION_ENDPOINT', f'{_oidc_connect}/auth')
|
||||
OIDC_OP_TOKEN_ENDPOINT = os.environ.get('OIDC_OP_TOKEN_ENDPOINT', f'{_oidc_connect}/token')
|
||||
OIDC_OP_USER_ENDPOINT = os.environ.get('OIDC_OP_USER_ENDPOINT', f'{_oidc_connect}/userinfo')
|
||||
OIDC_OP_JWKS_ENDPOINT = os.environ.get('OIDC_OP_JWKS_ENDPOINT', f'{_oidc_connect}/certs')
|
||||
OIDC_OP_LOGOUT_ENDPOINT = os.environ.get('OIDC_OP_LOGOUT_ENDPOINT', f'{_oidc_connect}/logout')
|
||||
|
||||
# Store the ID token in the session so Keycloak logout can use id_token_hint
|
||||
OIDC_STORE_ID_TOKEN = True
|
||||
|
||||
# Redirect to the static login page on auth failure instead of looping back into OIDC
|
||||
OIDC_AUTHENTICATION_FAILURE_REDIRECT_URL = os.environ.get('OIDC_AUTHENTICATION_FAILURE_REDIRECT_URL', '/login/')
|
||||
|
||||
# Exempt AJAX endpoints from the session-refresh middleware redirect
|
||||
OIDC_EXEMPT_URLS = ['search_api']
|
||||
|
||||
@@ -7,217 +7,8 @@
|
||||
<title>{% block title %}LabHelper{% endblock %}</title>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 15px;
|
||||
padding: 15px 30px;
|
||||
margin: 20px auto;
|
||||
max-width: 1200px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.navbar-brand i {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.navbar-nav {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.navbar-nav a {
|
||||
color: #555;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
font-size: 15px;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.navbar-nav a:hover,
|
||||
.navbar-nav button:hover {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.navbar-nav a i {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 20px auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 15px;
|
||||
margin-bottom: 30px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
color: #333;
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.page-header .breadcrumb {
|
||||
color: #888;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.page-header .breadcrumb a {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.page-header .breadcrumb a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.section {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 15px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.section h2 {
|
||||
color: #667eea;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin-top: 0;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 3px solid #667eea;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.section h2 i {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.6);
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: linear-gradient(135deg, #7f8c8d 0%, #95a5a6 100%);
|
||||
box-shadow: 0 4px 15px rgba(127, 140, 141, 0.4);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
box-shadow: 0 6px 20px rgba(127, 140, 141, 0.6);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 15px 20px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 20px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background: linear-gradient(135deg, #00b894 0%, #00cec9 100%);
|
||||
color: white;
|
||||
box-shadow: 0 4px 15px rgba(0, 184, 148, 0.3);
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%);
|
||||
color: white;
|
||||
box-shadow: 0 4px 15px rgba(231, 76, 60, 0.3);
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
color: white;
|
||||
padding: 30px;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
{% block extra_css %}{% endblock %}
|
||||
</style>
|
||||
<link rel="stylesheet" href="{% static 'css/base.css' %}">
|
||||
{% block extra_css %}{% endblock %}
|
||||
{% block extra_head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
@@ -226,18 +17,30 @@
|
||||
<i class="fas fa-flask"></i>
|
||||
LabHelper
|
||||
</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="/box-management/"><i class="fas fa-boxes"></i> Box Management</a>
|
||||
<a href="/search/"><i class="fas fa-search"></i> Search</a>
|
||||
<a href="/admin/"><i class="fas fa-cog"></i> Admin</a>
|
||||
<a href="{% url 'inventory' %}"><i class="fas fa-boxes-stacked"></i> Inventory</a>
|
||||
{% if user.is_authenticated %}
|
||||
<form method="post" action="{% url 'logout' %}" style="display: inline;">
|
||||
{% csrf_token %}
|
||||
<button type="submit" style="background: none; border: none; color: #555; font: inherit; cursor: pointer; padding: 8px 16px; border-radius: 8px; transition: all 0.3s ease; display: inline-flex; align-items: center; gap: 8px;">
|
||||
<i class="fas fa-sign-out-alt"></i> Logout ({{ user.username }})
|
||||
<div class="dropdown">
|
||||
<button class="dropdown-btn">
|
||||
<i class="fas fa-user"></i> {{ user.username }} <i class="fas fa-chevron-down"></i>
|
||||
</button>
|
||||
</form>
|
||||
<div class="dropdown-content">
|
||||
<a href="/box-management/"><i class="fas fa-boxes"></i> Box Management</a>
|
||||
<a href="/resources/"><i class="fas fa-folder-open"></i> Resources</a>
|
||||
<a href="/fixme/"><i class="fas fa-exclamation-triangle"></i> Fixme</a>
|
||||
<a href="/admin/"><i class="fas fa-cog"></i> Admin</a>
|
||||
<form method="post" action="{% url 'oidc_logout' %}" id="logout-form">
|
||||
{% csrf_token %}
|
||||
</form>
|
||||
<a href="#" onclick="document.getElementById('logout-form').submit(); return false;">
|
||||
<i class="fas fa-sign-out-alt"></i> Logout
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<a href="{% url 'login' %}"><i class="fas fa-sign-in-alt"></i> Login</a>
|
||||
{% endif %}
|
||||
@@ -252,8 +55,22 @@
|
||||
|
||||
<footer class="footer">
|
||||
<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>
|
||||
|
||||
{% block extra_js %}{% endblock %}
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
$('#navbar-toggle').on('click', function() {
|
||||
$('#navbar-nav').toggleClass('active');
|
||||
const icon = $(this).find('i');
|
||||
if ($('#navbar-nav').hasClass('active')) {
|
||||
icon.removeClass('fa-bars').addClass('fa-times');
|
||||
} else {
|
||||
icon.removeClass('fa-times').addClass('fa-bars');
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -9,70 +9,24 @@
|
||||
{% 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 class="section" style="max-width: 500px; margin: 0 auto; text-align: center;">
|
||||
{% if request.GET.next and user.is_authenticated %}
|
||||
<div class="alert alert-error" style="margin-bottom: 24px;">
|
||||
<i class="fas fa-info-circle"></i> Your account doesn't have access to this page.
|
||||
</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">
|
||||
{% elif request.GET.next %}
|
||||
<div class="alert alert-error" style="margin-bottom: 24px;">
|
||||
<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>
|
||||
<p style="color: #555; margin-bottom: 28px;">
|
||||
Authentication is handled via Single Sign-On. Click below to continue to the login page.
|
||||
</p>
|
||||
|
||||
<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>
|
||||
<a href="{% url 'oidc_authentication_init' %}{% if request.GET.next %}?next={{ request.GET.next }}{% endif %}"
|
||||
class="btn" style="justify-content: center; display: inline-flex;">
|
||||
<i class="fas fa-sign-in-alt"></i> Login with SSO
|
||||
</a>
|
||||
</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 %}
|
||||
|
||||
@@ -17,8 +17,8 @@ Including another URLconf
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from django.contrib import admin
|
||||
from django.urls import path
|
||||
from django.contrib.auth import views as auth_views
|
||||
from django.urls import include, path
|
||||
from django.views.generic import TemplateView
|
||||
|
||||
from boxes.views import (
|
||||
add_box,
|
||||
@@ -26,20 +26,25 @@ from boxes.views import (
|
||||
add_things,
|
||||
box_detail,
|
||||
box_management,
|
||||
boxes_list,
|
||||
delete_box,
|
||||
delete_box_type,
|
||||
delete_thing,
|
||||
edit_box,
|
||||
edit_box_type,
|
||||
edit_thing,
|
||||
fixme,
|
||||
health_check,
|
||||
index,
|
||||
search,
|
||||
resources_list,
|
||||
search_api,
|
||||
thing_detail,
|
||||
thing_type_detail,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path('login/', auth_views.LoginView.as_view(template_name='login.html'), name='login'),
|
||||
path('logout/', auth_views.LogoutView.as_view(), name='logout'),
|
||||
path('oidc/', include('mozilla_django_oidc.urls')),
|
||||
path('login/', TemplateView.as_view(template_name='login.html'), name='login'),
|
||||
path('health/', health_check, name='health_check'),
|
||||
path('', index, name='index'),
|
||||
path('box-management/', box_management, name='box_management'),
|
||||
path('box-type/add/', add_box_type, name='add_box_type'),
|
||||
@@ -50,12 +55,28 @@ urlpatterns = [
|
||||
path('box/<str:box_id>/delete/', delete_box, name='delete_box'),
|
||||
path('box/<str:box_id>/', box_detail, name='box_detail'),
|
||||
path('thing/<int:thing_id>/', thing_detail, name='thing_detail'),
|
||||
path('thing-type/<int:type_id>/', thing_type_detail, name='thing_type_detail'),
|
||||
path('thing/<int:thing_id>/edit/', edit_thing, name='edit_thing'),
|
||||
path('thing/<int:thing_id>/delete/', delete_thing, name='delete_thing'),
|
||||
path('box/<str:box_id>/add/', add_things, name='add_things'),
|
||||
path('search/', search, name='search'),
|
||||
path('boxes/', boxes_list, name='boxes_list'),
|
||||
path('inventory/', boxes_list, name='inventory'),
|
||||
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),
|
||||
]
|
||||
|
||||
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)
|
||||
|
||||
# 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,5 @@ Pillow==11.1.0
|
||||
sorl-thumbnail==12.11.0
|
||||
bleach==6.1.0
|
||||
coverage==7.6.1
|
||||
whitenoise==6.8.2
|
||||
mozilla-django-oidc==4.0.1
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
NAMESPACE="labhelper"
|
||||
SECRET_NAME="django-secret"
|
||||
SECRET_FILE="argocd/secret.yaml"
|
||||
SECRET_FILE="k8s-templates/secret.yaml"
|
||||
|
||||
# Check if secret file exists
|
||||
if [ ! -f "$SECRET_FILE" ]; then
|
||||
|
||||
@@ -36,10 +36,14 @@ NEW_MAIN_VERSION=$(echo "$MAIN_VERSION + 0.001" | bc | sed 's/^\./0./')
|
||||
sed -i "s|image: git.baumann.gr/adebaumann/labhelper-data-loader:$LOADER_VERSION|image: git.baumann.gr/adebaumann/labhelper-data-loader:$NEW_LOADER_VERSION|" "$DEPLOYMENT_FILE"
|
||||
sed -i "s|image: git.baumann.gr/adebaumann/labhelper:$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"
|
||||
|
||||
@@ -23,5 +23,9 @@ NEW_VERSION=$(echo "$CURRENT_VERSION + 0.001" | bc | sed 's/^\./0./')
|
||||
# Update the deployment file (only the main container, not the data-loader)
|
||||
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