19 Commits

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

141
AGENTS.md
View File

@@ -214,12 +214,13 @@ labhelper/
│ │ ├── add_things.html # Form to add multiple things
│ │ ├── box_detail.html # Box contents view
│ │ ├── box_management.html # Box/BoxType CRUD management
│ │ ├── index.html # Home page
│ │ ├── search.html # Search page with AJAX
│ │ ├── thing_detail.html # Thing details view
│ │ ── thing_type_detail.html # Thing type hierarchy view
│ │ ├── 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 filter: get_item
│ │ └── 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
@@ -259,19 +260,24 @@ labhelper/
|-------|-------------|------------|
| **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) |
| **ThingType** | Hierarchical category (MPTT) | `name`, `parent` (TreeForeignKey to self) |
| **Thing** | An item stored in a box | `name`, `thing_type` (FK), `box` (FK), `description`, `picture` |
| **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)
- ThingType -> Thing (1:N via `things` related_name, PROTECT on delete)
- ThingType -> ThingType (self-referential tree via MPTT)
- 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:
@@ -324,7 +330,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
@@ -346,6 +352,7 @@ 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
@@ -371,7 +378,8 @@ The project uses a base template system at `labhelper/templates/base.html`. All
| View Function | URL Pattern | Name | Description |
|---------------|-------------|------|-------------|
| `index` | `/` | `index` | Home page with boxes grid and thing types tree |
| `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 |
@@ -380,11 +388,11 @@ The project uses a base template system at `labhelper/templates/base.html`. All
| `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` | View/edit thing (move, picture, files, links) |
| `thing_type_detail` | `/thing-type/<int:type_id>/` | `thing_type_detail` | View thing type hierarchy |
| `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` | `/search/` | `search` | Search page |
| `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 |
@@ -394,47 +402,99 @@ The project uses a base template system at `labhelper/templates/base.html`. All
### 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 styles
- `extra_js`: Additional JavaScript
3. **Load required template tags**
```django
{% load static %}
{% load mptt_tags %}
{% load thumbnail %}
```
```django
{% load static %}
{% load mptt_tags %}
{% load thumbnail %}
{% load dict_extras %}
```
4. **Use URL names for links**
```django
<a href="{% url 'box_detail' box.id %}">
```
```django
<a href="{% url 'box_detail' box.id %}">
```
5. **Use icons with Font Awesome**
```django
<i class="fas fa-box"></i>
```
```django
<i class="fas fa-box"></i>
```
6. **Add breadcrumbs for navigation**
```django
<p class="breadcrumb">
<a href="/"><i class="fas fa-home"></i> Home</a> /
<a href="/box/{{ box.id }}/"><i class="fas fa-box"></i> Box {{ box.id }}</a>
</p>
```
```django
<p class="breadcrumb">
<a href="/"><i class="fas fa-home"></i> Home</a> /
<a href="/box/{{ box.id }}/"><i class="fas fa-box"></i> Box {{ box.id }}</a>
</p>
```
7. **Icon alignment in lists**: When using icons in list items, use fixed width containers to ensure proper alignment
```django
<a href="{% url 'thing_detail' thing.id %}" style="display: inline-block; width: 20px; text-align: center;">
<i class="fas fa-link"></i>
</a>
```
### Markdown Support
The `Thing.description` field supports Markdown formatting with HTML sanitization for security.
**Available Template Filters:**
- `render_markdown`: Converts Markdown text to sanitized HTML with automatic link handling
- Converts Markdown syntax (headers, lists, bold, italic, links, code, tables, etc.)
- Sanitizes HTML using `bleach` to prevent XSS attacks
- Automatically adds `target="_blank"` and `rel="noopener noreferrer"` to external links
- Use in `thing_detail.html` for full rendered Markdown
- `truncate_markdown`: Converts Markdown to plain text and truncates
- Strips HTML tags after Markdown conversion
- Adds ellipsis (`...`) if text exceeds specified length (default: 100)
- Use in `box_detail.html` or search API previews where space is limited
**Usage Examples:**
```django
<!-- Full Markdown rendering -->
<div class="markdown-content">
{{ thing.description|render_markdown }}
</div>
<!-- Truncated plain text preview -->
{{ thing.description|truncate_markdown:100 }}
```
**Supported Markdown Features:**
- Bold: `**text**` or `__text__`
- Italic: `*text*` or `_text_`
- Headers: `# Header 1`, `## Header 2`, etc.
- Lists: `- item` or `1. item`
- Links: `[text](url)`
- Code: `` `code` `` or ` ```code block```
- Blockquotes: `> quote`
- Tables: `| A | B |\n|---|---|`
**Security:**
- All Markdown is sanitized before rendering
- Dangerous HTML tags (`<script>`, `<iframe>`, etc.) are stripped
- Only safe HTML tags and attributes are allowed
- External links automatically get `target="_blank"` and security attributes
## Forms
| Form | Model | Purpose |
|------|-------|---------|
| `ThingForm` | Thing | Add/edit a thing (name, type, description, picture) |
| `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 |
@@ -499,6 +559,7 @@ Per `.gitignore`:
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

View File

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

View File

@@ -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.047
image: git.baumann.gr/adebaumann/labhelper:0.058
imagePullPolicy: Always
ports:
- containerPort: 8000

View File

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

View File

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

View File

@@ -1,7 +1,41 @@
from django import forms
from django.contrib import admin
from django_mptt_admin.admin import DjangoMpttAdmin
from django.contrib.admin import SimpleListFilter
from django.utils.html import format_html
from .models import Box, BoxType, Thing, ThingFile, ThingLink, ThingType
from .models import Box, BoxType, Facet, Tag, Thing, ThingFile, ThingLink
class BoxFilter(SimpleListFilter):
"""Custom filter for boxes using pk to avoid spaces in aliases."""
title = 'box'
parameter_name = 'box__pk'
def lookups(self, request, model_admin):
boxes = Box.objects.select_related('box_type').order_by('id')
return [(box.pk, str(box)) for box in boxes]
def queryset(self, request, queryset):
if self.value():
return queryset.filter(box__pk=self.value())
return queryset
class TagsFilter(SimpleListFilter):
"""Custom filter for tags using pk to avoid spaces in aliases."""
title = 'tags'
parameter_name = 'tags__pk'
def lookups(self, request, model_admin):
tags = Tag.objects.select_related('facet').order_by('facet__name', 'name')
return [(tag.pk, str(tag)) for tag in tags]
def queryset(self, request, queryset):
if self.value():
return queryset.filter(tags__pk=self.value())
return queryset
@admin.register(BoxType)
@@ -21,13 +55,6 @@ class BoxAdmin(admin.ModelAdmin):
search_fields = ('id',)
@admin.register(ThingType)
class ThingTypeAdmin(DjangoMpttAdmin):
"""Admin configuration for ThingType model."""
search_fields = ('name',)
class ThingFileInline(admin.TabularInline):
"""Inline admin for Thing files."""
@@ -47,9 +74,10 @@ class ThingLinkInline(admin.TabularInline):
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]
@@ -65,6 +93,53 @@ class ThingFileAdmin(admin.ModelAdmin):
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."""

View File

@@ -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}),
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,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):
@@ -50,34 +49,64 @@ class Box(models.Model):
return self.id
class ThingType(MPTTModel):
"""A hierarchical type/category for things stored in boxes."""
class Facet(models.Model):
"""A category of tags (e.g., Priority, Category, Status)."""
name = models.CharField(max_length=255)
parent = TreeForeignKey(
'self',
on_delete=models.CASCADE,
null=True,
blank=True,
related_name='children'
class Cardinality(models.TextChoices):
SINGLE = 'single', 'Single (0..1)'
MULTIPLE = 'multiple', 'Multiple (0..n)'
name = models.CharField(max_length=100, unique=True)
slug = models.SlugField(max_length=100, unique=True)
color = models.CharField(
max_length=7,
default='#667eea',
help_text='Hex color code (e.g., #667eea)'
)
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 +114,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']

View File

@@ -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>

View File

@@ -0,0 +1,74 @@
{% extends "base.html" %}
{% block title %}Boxes - LabHelper{% endblock %}
{% block page_header %}
<div class="page-header">
<h1><i class="fas fa-boxes"></i> Boxes</h1>
<p class="breadcrumb">
<a href="/"><i class="fas fa-home"></i> Home</a> / Boxes
</p>
</div>
{% endblock %}
{% block content %}
{% if boxes %}
<div class="section" style="overflow-x: auto;">
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white;">
<th style="padding: 15px 20px; text-align: left; font-weight: 600;">Box ID</th>
<th style="padding: 15px 20px; text-align: left; font-weight: 600;">Type</th>
<th style="padding: 15px 20px; text-align: left; font-weight: 600;">Dimensions (mm)</th>
<th style="padding: 15px 20px; text-align: left; font-weight: 600;">Contents</th>
<th style="padding: 15px 20px; text-align: left; font-weight: 600;">Item Count</th>
</tr>
</thead>
<tbody>
{% for box in boxes %}
<tr style="border-bottom: 1px solid #e0e0e0; transition: background 0.2s;" class="box-row">
<td style="padding: 15px 20px; font-weight: 700; color: #667eea;">
<a href="{% url 'box_detail' box.id %}" style="text-decoration: none; color: inherit;">Box {{ box.id }}</a>
</td>
<td style="padding: 15px 20px;">{{ box.box_type.name }}</td>
<td style="padding: 15px 20px;">{{ box.box_type.width }} x {{ box.box_type.height }} x {{ box.box_type.length }}</td>
<td style="padding: 15px 20px; color: #555;">
{% if box.things.all %}
<div style="display: flex; flex-wrap: wrap; gap: 5px;">
{% for thing in box.things.all %}
<a href="{% url 'thing_detail' thing.id %}" style="display: inline-block; padding: 4px 10px; background: #f0f0f0; border-radius: 12px; text-decoration: none; color: #333; font-size: 13px; font-weight: 500;">{{ thing.name }}</a>
{% endfor %}
</div>
{% else %}
<span style="color: #999; font-style: italic;">Empty</span>
{% endif %}
</td>
<td style="padding: 15px 20px; font-weight: 600;">{{ box.things.count }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="section" style="text-align: center; padding: 60px 30px;">
<i class="fas fa-box-open" style="font-size: 64px; color: #ddd; margin-bottom: 20px; display: block;"></i>
<h3 style="color: #888; font-size: 20px;">No boxes found</h3>
<p style="color: #999; margin-top: 10px;">Create your first box to get started.</p>
</div>
{% endif %}
{% endblock %}
{% block extra_js %}
<script>
$(document).ready(function() {
$('.box-row').hover(
function() {
$(this).css('background', '#f8f9fa');
},
function() {
$(this).css('background', 'white');
}
);
});
</script>
{% endblock %}

View File

@@ -0,0 +1,324 @@
{% extends "base.html" %}
{% load thumbnail %}
{% load dict_extras %}
{% block title %}Edit {{ thing.name }} - LabHelper{% endblock %}
{% block page_header %}
<div class="page-header">
<h1><i class="fas fa-edit"></i> Edit {{ thing.name }}</h1>
<p class="breadcrumb">
<a href="/"><i class="fas fa-home"></i> Home</a> /
<a href="/box/{{ thing.box.id }}/"><i class="fas fa-box"></i> Box {{ thing.box.id }}</a> /
<a href="{% url 'thing_detail' thing.id %}">{{ thing.name }}</a> /
Edit
</p>
</div>
{% endblock %}
{% block content %}
<div class="section">
<h2 style="color: #667eea; font-size: 20px; font-weight: 700; margin-top: 0; margin-bottom: 20px; display: flex; align-items: center; gap: 10px;">
<i class="fas fa-info-circle"></i> Basic Information
</h2>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
<input type="hidden" name="action" value="save_details">
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px,1fr)); gap: 20px;">
<div>
<label for="id_name" style="font-weight: 600; color: #666; font-size: 14px; margin-bottom: 8px; display: block;">
<i class="fas fa-cube"></i> Name
</label>
{{ thing_form.name }}
</div>
<div>
<label for="id_description" style="font-weight: 600; color: #666; font-size: 14px; margin-bottom: 8px; display: block;">
<i class="fas fa-align-left"></i> Description (Markdown)
</label>
{{ thing_form.description }}
</div>
</div>
<div style="margin-top: 20px;">
<button type="submit" class="btn">
<i class="fas fa-save"></i> Save Changes
</button>
<a href="{% url 'thing_detail' thing.id %}" class="btn" style="background: linear-gradient(135deg, #95a5a6 0%, #7f8c8d 100%);">
<i class="fas fa-times"></i> Cancel
</a>
</div>
</form>
</div>
<div class="section">
<div style="display: flex; gap: 40px; flex-wrap: wrap;">
<div class="thing-image" style="flex-shrink: 0; width: 100%; max-width: 400px;">
{% if thing.picture %}
{% thumbnail thing.picture "400x400" crop="center" as thumb %}
<img src="{{ thumb.url }}" alt="{{ thing.name }}" style="width: 100%; height: auto; max-height: 400px; object-fit: cover; border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.15);">
{% endthumbnail %}
{% else %}
<div style="width: 100%; aspect-ratio: 1; max-width: 400px; max-height: 400px; background: linear-gradient(135deg, #e0e0e0 0%, #f0f0f0 100%); display: flex; align-items: center; justify-content: center; color: #999; border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.15);">
<div style="text-align: center;">
<i class="fas fa-image" style="font-size: 64px; margin-bottom: 15px; display: block;"></i>
No image
</div>
</div>
{% endif %}
<form method="post" enctype="multipart/form-data" style="margin-top: 20px;">
{% csrf_token %}
<input type="hidden" name="action" value="upload_picture">
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
<label class="btn" style="cursor: pointer; display: inline-flex; align-items: center; gap: 8px;">
<i class="fas fa-camera"></i>
<span>{% if thing.picture %}Change picture{% else %}Add picture{% endif %}</span>
<input type="file" id="picture_upload" name="picture" accept="image/*" style="display: none;" onchange="this.form.submit();">
</label>
{% if thing.picture %}
<button type="submit" name="action" value="delete_picture" class="btn" style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); border: none;" onclick="return confirm('Are you sure you want to delete this picture?');">
<i class="fas fa-trash"></i>
<span>Remove</span>
</button>
{% endif %}
</div>
</form>
</div>
<div class="thing-details" style="flex-grow: 1; min-width: 300px;">
<div class="detail-row" style="margin-bottom: 25px;">
<div style="font-size: 14px; color: #888; font-weight: 600; margin-bottom: 8px;">
<i class="fas fa-tags"></i> Tags
</div>
<div style="display: flex; flex-direction: column; gap: 12px;">
{% regroup thing.tags.all by facet as facet_list %}
{% for facet in facet_list %}
<div>
<div style="font-size: 12px; color: {{ facet.grouper.color }}; font-weight: 700; margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.5px;">
{{ facet.grouper.name }}
</div>
<div style="display: flex; flex-wrap: wrap; gap: 8px;">
{% for tag in facet.list %}
<form method="post" style="display: inline;" onsubmit="return confirm('Remove tag {{ tag.facet.name }}:{{ tag.name }}?');">
{% csrf_token %}
<input type="hidden" name="action" value="remove_tag">
<input type="hidden" name="tag_id" value="{{ tag.id }}">
<button type="submit" style="display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; background: {{ facet.grouper.color }}20; color: {{ facet.grouper.color }}; border: 2px solid {{ facet.grouper.color }}; border-radius: 20px; font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.3s;">
{{ tag.name }}
<i class="fas fa-times" style="font-size: 12px;"></i>
</button>
</form>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</div>
<div class="detail-row" style="margin-bottom: 25px;">
<div style="font-size: 14px; color: #888; font-weight: 600; margin-bottom: 8px;">
<i class="fas fa-map-marker-alt"></i> Location
</div>
<form method="post" style="display: inline;">
{% csrf_token %}
<input type="hidden" name="action" value="move">
<div style="display: flex; align-items: center; gap: 15px; flex-wrap: wrap;">
<select name="new_box" style="padding: 10px 15px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 15px; background: white; cursor: pointer; transition: all 0.3s;">
{% for box in boxes %}
<option value="{{ box.id }}" {% if box.id == thing.box.id %}selected{% endif %}>
Box {{ box.id }} ({{ box.box_type.name }})
</option>
{% endfor %}
</select>
<button type="submit" class="btn" style="height: 42px;">
<i class="fas fa-arrows-alt"></i> Move
</button>
</div>
</form>
</div>
{% if thing.files.all %}
<div class="detail-row" style="margin-bottom: 25px;">
<div style="font-size: 14px; color: #888; font-weight: 600; margin-bottom: 8px;">
<i class="fas fa-file-alt"></i> Files
</div>
<div style="display: flex; flex-direction: column; gap: 10px;">
{% for file in thing.files.all %}
<div style="display: flex; align-items: center; justify-content: space-between; padding: 12px 15px; background: #f8f9fa; border-radius: 8px; border: 1px solid #e9ecef;">
<div style="display: flex; align-items: center; gap: 10px;">
<i class="fas fa-paperclip" style="color: #667eea; font-size: 16px;"></i>
<a href="{{ file.file.url }}" target="_blank" style="color: #667eea; text-decoration: none; font-weight: 500; font-size: 15px;">{{ file.title }}</a>
<span style="color: #999; font-size: 12px;">({{ file.filename }})</span>
</div>
<form method="post" style="display: inline;" onsubmit="return confirm('Are you sure you want to delete this file?');">
{% csrf_token %}
<input type="hidden" name="action" value="delete_file">
<input type="hidden" name="file_id" value="{{ file.id }}">
<button type="submit" style="background: none; border: none; cursor: pointer; color: #e74c3c; padding: 5px; transition: all 0.3s;" onmouseover="this.style.color='#c0392b'" onmouseout="this.style.color='#e74c3c'">
<i class="fas fa-times" style="font-size: 14px;"></i>
</button>
</form>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% if thing.links.all %}
<div class="detail-row" style="margin-bottom: 25px;">
<div style="font-size: 14px; color: #888; font-weight: 600; margin-bottom: 8px;">
<i class="fas fa-link"></i> Links
</div>
<div style="display: flex; flex-direction: column; gap: 10px;">
{% for link in thing.links.all %}
<div style="display: flex; align-items: center; justify-content: space-between; padding: 12px 15px; background: #f8f9fa; border-radius: 8px; border: 1px solid #e9ecef;">
<div style="display: flex; align-items: center; gap: 10px;">
<i class="fas fa-external-link-alt" style="color: #667eea; font-size: 16px;"></i>
<a href="{{ link.url }}" target="_blank" style="color: #667eea; text-decoration: none; font-weight: 500; font-size: 15px;">{{ link.title }}</a>
</div>
<form method="post" style="display: inline;" onsubmit="return confirm('Are you sure you want to delete this link?');">
{% csrf_token %}
<input type="hidden" name="action" value="delete_link">
<input type="hidden" name="link_id" value="{{ link.id }}">
<button type="submit" style="background: none; border: none; cursor: pointer; color: #e74c3c; padding: 5px; transition: all 0.3s;" onmouseover="this.style.color='#c0392b'" onmouseout="this.style.color='#e74c3c'">
<i class="fas fa-times" style="font-size: 14px;"></i>
</button>
</form>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<div class="section">
<h2 style="color: #667eea; font-size: 20px; font-weight: 700; margin-top: 0; margin-bottom: 20px; display: flex; align-items: center; gap: 10px;">
<i class="fas fa-plus-circle"></i> Add Tags
</h2>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px,1fr)); gap: 30px;">
<div>
<h3 style="margin: 0 0 15px 0; color: #667eea; font-size: 16px; font-weight: 600;">
<i class="fas fa-tag"></i> Add Tag
</h3>
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="add_tag">
<div style="display: flex; flex-direction: column; gap: 15px;">
<div>
<label for="tag_select" style="font-weight: 600; color: #666; font-size: 14px; margin-bottom: 8px; display: block;">Select Tag</label>
<select name="tag_id" id="tag_select" style="width: 100%; padding: 12px 16px; border: 2px solid #e0e0e0; border-radius: 10px; font-size: 15px; background: white; cursor: pointer; transition: all 0.3s;">
<option value="">-- Select a tag --</option>
{% for facet in facets %}
<optgroup label="{{ facet.name }} ({{ facet.get_cardinality_display }})">
{% for tag in facet.tags.all %}
{% if tag not in thing.tags.all %}
<option value="{{ tag.id }}">
{{ tag.name }}
</option>
{% endif %}
{% endfor %}
</optgroup>
{% endfor %}
</select>
</div>
<button type="submit" class="btn">
<i class="fas fa-plus"></i> Add Tag
</button>
</div>
</form>
</div>
</div>
</div>
<div class="section">
<h2 style="color: #667eea; font-size: 20px; font-weight: 700; margin-top: 0; margin-bottom: 20px; display: flex; align-items: center; gap: 10px;">
<i class="fas fa-plus-circle"></i> Add Attachments
</h2>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px,1fr)); gap: 30px;">
<div>
<h3 style="margin: 0 0 15px 0; color: #667eea; font-size: 16px; font-weight: 600;">
<i class="fas fa-file-upload"></i> Upload File
</h3>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
<input type="hidden" name="action" value="add_file">
<div style="display: flex; flex-direction: column; gap: 15px;">
<div>
<label for="file_title" style="font-weight: 600; color: #666; font-size: 14px; margin-bottom: 8px; display: block;">Title</label>
<input type="text" id="file_title" name="title" style="width: 100%; padding: 10px 15px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 14px;">
</div>
<div>
<label for="file_upload" style="font-weight: 600; color: #666; font-size: 14px; margin-bottom: 8px; display: block;">File</label>
<input type="file" id="file_upload" name="file" style="width: 100%; padding: 10px 15px; border: 2px dashed #e0e0e0; border-radius: 8px; font-size: 14px; background: #f8f9fa;">
</div>
<button type="submit" class="btn">
<i class="fas fa-upload"></i> Upload File
</button>
</div>
</form>
</div>
<div>
<h3 style="margin: 0 0 15px 0; color: #667eea; font-size: 16px; font-weight: 600;">
<i class="fas fa-link"></i> Add Link
</h3>
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="add_link">
<div style="display: flex; flex-direction: column; gap: 15px;">
<div>
<label for="link_title" style="font-weight: 600; color: #666; font-size: 14px; margin-bottom: 8px; display: block;">Title</label>
<input type="text" id="link_title" name="title" style="width: 100%; padding: 10px 15px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 14px;">
</div>
<div>
<label for="link_url" style="font-weight: 600; color: #666; font-size: 14px; margin-bottom: 8px; display: block;">URL</label>
<input type="url" id="link_url" name="url" style="width: 100%; padding: 10px 15px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 14px;">
</div>
<button type="submit" class="btn">
<i class="fas fa-plus"></i> Add Link
</button>
</div>
</form>
</div>
</div>
{% endblock %}
{% block extra_css %}
<style>
#id_name, #id_description {
width: 100%;
padding: 10px 15px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 15px;
background: white;
transition: all 0.3s;
}
#id_name:focus, #id_description:focus {
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
outline: none;
}
#id_description {
min-height: 120px;
font-family: inherit;
}
.detail-row {
margin-bottom: 25px;
}
</style>
{% endblock %}
{% block extra_js %}
<script>
$('#tag_select').on('focus', function() {
$(this).css('border-color', '#667eea');
$(this).css('box-shadow', '0 0 0 3px rgba(102, 126, 234, 0.1)');
}).on('blur', function() {
$(this).css('border-color', '#e0e0e0');
$(this).css('box-shadow', 'none');
});
</script>
{% endblock %}

View File

@@ -0,0 +1,145 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Fixme - LabHelper{% endblock %}
{% block page_header %}
<div class="page-header">
<h1><i class="fas fa-exclamation-triangle"></i> Fixme</h1>
<p class="breadcrumb">Find and fix things missing tags for specific facets</p>
</div>
{% endblock %}
{% block content %}
<div class="section">
<h2><i class="fas fa-tags"></i> Select a Facet</h2>
{% if facets %}
<form method="get" action="{% url 'fixme' %}" class="facet-selector">
<select name="facet_id" id="facet-select" onchange="this.form.submit()" class="form-control">
<option value="">-- Choose a facet --</option>
{% for facet in facets %}
<option value="{{ facet.id }}" {% if selected_facet and selected_facet.id == facet.id %}selected{% endif %}>
{{ facet.name }}
</option>
{% endfor %}
</select>
<noscript>
<button type="submit" class="btn btn-sm">
<i class="fas fa-search"></i> Show Missing Things
</button>
</noscript>
</form>
{% else %}
<p style="color: #888;">No facets found. Please create some facets first.</p>
{% endif %}
</div>
{% if selected_facet %}
<div class="section">
<h2><i class="fas fa-exclamation-circle"></i> Things Missing "{{ selected_facet.name }}" Tags</h2>
{% if missing_things %}
<form method="post" action="{% url 'fixme' %}" id="fixme-form">
{% csrf_token %}
<input type="hidden" name="facet_id" value="{{ selected_facet.id }}">
<div class="tags-selection" style="margin-bottom: 20px;">
<h3><i class="fas fa-plus-circle"></i> Add Tags:</h3>
{% if selected_facet.tags.all %}
{% for tag in selected_facet.tags.all %}
<label style="display: inline-block; margin-right: 15px; margin-bottom: 10px;">
<input type="checkbox" name="tag_ids" value="{{ tag.id }}"
{% if selected_facet.cardinality == 'single' %}onclick="uncheckOtherTags(this)"{% endif %}>
<span style="background: {{ tag.facet.color }}; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px;">
{{ tag.name }}
</span>
</label>
{% endfor %}
{% if selected_facet.cardinality == 'single' %}
<p style="color: #888; font-size: 12px; margin-top: 10px;">
<i class="fas fa-info-circle"></i> This facet allows only one tag per thing.
</p>
{% endif %}
{% else %}
<p style="color: #888;">No tags available for this facet. Please create some tags first.</p>
{% endif %}
</div>
<div class="things-list" style="margin-bottom: 20px;">
<h3><i class="fas fa-box"></i> Things to Update:</h3>
{% for thing in missing_things %}
<div style="background: #f8f9fa; padding: 10px; margin: 5px 0; border-radius: 5px; border-left: 4px solid #667eea;">
<label style="display: block; cursor: pointer; width: 100%;">
<input type="checkbox" name="thing_ids" value="{{ thing.id }}" style="margin-right: 10px;">
<strong>{{ thing.name }}</strong>
<span style="color: #888; margin-left: 10px;">(Box: {{ thing.box.id }})</span>
</label>
</div>
{% endfor %}
</div>
<div class="actions">
<button type="submit" class="btn">
<i class="fas fa-save"></i> Add Selected Tags to Selected Things
</button>
<a href="{% url 'fixme' %}" class="btn btn-secondary">
<i class="fas fa-times"></i> Clear Selection
</a>
</div>
</form>
{% else %}
<p style="color: #28a745;">
<i class="fas fa-check-circle"></i> All things have tags for "{{ selected_facet.name }}" facet!
</p>
<a href="{% url 'fixme' %}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> Back to Facet Selection
</a>
{% endif %}
</div>
{% endif %}
{% block extra_js %}
<script>
function uncheckOtherTags(checkbox) {
// For single cardinality facets, uncheck all other checkboxes
var checkboxes = document.querySelectorAll('input[name="tag_ids"]');
checkboxes.forEach(function(cb) {
if (cb !== checkbox) {
cb.checked = false;
}
});
}
// Add select all/none functionality for things
document.addEventListener('DOMContentLoaded', function() {
var fixmeForm = document.getElementById('fixme-form');
if (fixmeForm) {
var thingsList = fixmeForm.querySelector('.things-list');
if (thingsList) {
var header = document.createElement('div');
header.style.marginBottom = '10px';
header.innerHTML = `
<small>
<a href="#" onclick="selectAllThings(); return false;">Select All</a> |
<a href="#" onclick="deselectAllThings(); return false;">Deselect All</a>
</small>
`;
thingsList.insertBefore(header, thingsList.firstChild.nextSibling);
}
}
});
function selectAllThings() {
var checkboxes = document.querySelectorAll('input[name="thing_ids"]');
checkboxes.forEach(function(cb) { cb.checked = true; });
}
function deselectAllThings() {
var checkboxes = document.querySelectorAll('input[name="thing_ids"]');
checkboxes.forEach(function(cb) { cb.checked = false; });
}
</script>
{% endblock %}
{% endblock %}

View File

@@ -1,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;">&nbsp;</span>
{% endif %}
<a href="{% url 'thing_type_detail' node.pk %}" style="color: #667eea; text-decoration: none; font-size: 16px; font-weight: 500; transition: color 0.2s;">{{ node.name }}</a>
{% with count=type_counts|get_item:node.pk %}
{% if count and count > 0 %}
<span style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 3px 10px; border-radius: 20px; font-size: 12px; font-weight: 600;">{{ count }}</span>
{% endif %}
{% endwith %}
</div>
{% if children %}
<ul style="list-style: none; padding-left: 32px; display: none;">
{{ children }}
</ul>
{% endif %}
</li>
{% endrecursetree %}
</ul>
{% else %}
<p style="text-align: center; color: #888; font-size: 16px; padding: 40px;">
<i class="fas fa-folder-open" style="font-size: 48px; margin-bottom: 15px; display: block;"></i>
No thing types found.
<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 %}

View File

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

View File

@@ -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 %}

View File

@@ -1,16 +1,22 @@
{% extends "base.html" %}
{% 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 %}
@@ -30,35 +36,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 +79,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;">
{% spaceless %}{{ thing.description }}{% endspaceless %}
<div class="markdown-content" style="font-size: 16px; color: #555; line-height: 1.6;">
{{ thing.description|render_markdown }}
</div>
</div>
{% endif %}
@@ -88,20 +92,10 @@
</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 style="display: flex; align-items: center; gap: 10px; padding: 12px 15px; background: #f8f9fa; border-radius: 8px; border: 1px solid #e9ecef;">
<i class="fas fa-paperclip" style="color: #667eea; font-size: 16px;"></i>
<a href="{{ file.file.url }}" target="_blank" style="color: #667eea; text-decoration: none; font-weight: 500; font-size: 15px;">{{ file.title }}</a>
<span style="color: #999; font-size: 12px;">({{ file.filename }})</span>
</div>
{% endfor %}
</div>
@@ -115,19 +109,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>
@@ -136,91 +120,83 @@
</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>
<form method="post" class="section">
{% csrf_token %}
<input type="hidden" name="action" value="move">
<div style="display: flex; align-items: center; gap: 15px; flex-wrap: wrap;">
<div style="flex-grow: 1;">
<label for="new_box" style="font-weight: 600; color: #666; font-size: 14px; margin-bottom: 8px; display: block;">
<i class="fas fa-exchange-alt"></i> Move to:
</label>
<select name="new_box" id="new_box" style="width: 100%; max-width: 400px; padding: 12px 16px; border: 2px solid #e0e0e0; border-radius: 10px; font-size: 15px; background: white; cursor: pointer; transition: all 0.3s;">
<option value="">Select a box...</option>
{% for box in boxes %}
<option value="{{ box.id }}" {% if box.id == thing.box.id %}selected{% endif %}>
Box {{ box.id }} ({{ box.box_type.name }})
</option>
{% endfor %}
</select>
</div>
<button type="submit" class="btn" style="height: 48px; min-width: 120px; margin-top: 24px;">
<i class="fas fa-arrows-alt"></i> Move
</button>
</div>
</form>
{% endblock %}
{% block extra_js %}
<script>
$('#new_box').on('focus', function() {
$(this).css('border-color', '#667eea');
$(this).css('box-shadow', '0 0 0 3px rgba(102, 126, 234, 0.1)');
}).on('blur', function() {
$(this).css('border-color', '#e0e0e0');
$(this).css('box-shadow', 'none');
});
</script>
{% endblock %}
{% block extra_css %}
<style>
.markdown-content p {
margin: 0 0 1em 0;
}
.markdown-content p:last-child {
margin-bottom: 0;
}
.markdown-content h1, .markdown-content h2, .markdown-content h3,
.markdown-content h4, .markdown-content h5, .markdown-content h6 {
margin: 1.5em 0 0.5em 0;
color: #333;
font-weight: 600;
}
.markdown-content h1:first-child, .markdown-content h2:first-child,
.markdown-content h3:first-child {
margin-top: 0;
}
.markdown-content ul, .markdown-content ol {
margin: 0.5em 0;
padding-left: 2em;
}
.markdown-content li {
margin: 0.25em 0;
}
.markdown-content code {
background: #f4f4f4;
padding: 2px 6px;
border-radius: 4px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.9em;
}
.markdown-content pre {
background: #f4f4f4;
padding: 15px;
border-radius: 8px;
overflow-x: auto;
margin: 1em 0;
}
.markdown-content pre code {
background: none;
padding: 0;
}
.markdown-content blockquote {
border-left: 4px solid #667eea;
margin: 1em 0;
padding: 0.5em 1em;
background: #f8f9fa;
color: #666;
}
.markdown-content a {
color: #667eea;
text-decoration: none;
}
.markdown-content a:hover {
text-decoration: underline;
}
.markdown-content table {
border-collapse: collapse;
width: 100%;
margin: 1em 0;
}
.markdown-content th, .markdown-content td {
border: 1px solid #e0e0e0;
padding: 8px 12px;
text-align: left;
}
.markdown-content th {
background: #f8f9fa;
font-weight: 600;
}
.markdown-content hr {
border: none;
border-top: 2px solid #e0e0e0;
margin: 1.5em 0;
}
</style>
{% endblock %}

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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,43 @@ 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 _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 +54,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 +63,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')
facets = Facet.objects.all().prefetch_related('tags')
picture_form = ThingPictureForm(instance=thing)
file_form = ThingFileForm()
link_form = ThingLinkForm()
@@ -60,26 +88,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 +121,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 +129,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 +140,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 +150,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().order_by('id')
return render(request, 'boxes/boxes_list.html', {
'boxes': boxes,
})
@login_required
@@ -140,23 +205,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 +276,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 +293,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 +371,81 @@ def delete_box(request, box_id):
return redirect('box_management')
box.delete()
return redirect('box_management')
@login_required
def resources_list(request):
"""List all links and files from things that have them."""
things_with_files = Thing.objects.filter(files__isnull=False).prefetch_related('files').distinct()
things_with_links = Thing.objects.filter(links__isnull=False).prefetch_related('links').distinct()
all_things = (things_with_files | things_with_links).distinct().order_by('name')
resources = []
for thing in all_things.prefetch_related('files', 'links'):
for file in thing.files.all():
resources.append({
'type': 'file',
'thing_name': thing.name,
'thing_id': thing.id,
'title': file.title,
'url': file.file.url,
})
for link in thing.links.all():
resources.append({
'type': 'link',
'thing_name': thing.name,
'thing_id': thing.id,
'title': link.title,
'url': link.url,
})
return render(request, 'boxes/resources_list.html', {
'resources': resources,
})
@login_required
def fixme(request):
"""Page to find and fix things missing tags for specific facets."""
facets = Facet.objects.all().prefetch_related('tags')
selected_facet = None
missing_things = []
if request.method == 'GET' and 'facet_id' in request.GET:
try:
selected_facet = Facet.objects.get(pk=request.GET['facet_id'])
# Find things that don't have any tag from this facet
missing_things = Thing.objects.exclude(
tags__facet=selected_facet
).select_related('box', 'box__box_type').prefetch_related('tags')
except Facet.DoesNotExist:
selected_facet = None
elif request.method == 'POST':
facet_id = request.POST.get('facet_id')
tag_ids = request.POST.getlist('tag_ids')
thing_ids = request.POST.getlist('thing_ids')
if facet_id and tag_ids and thing_ids:
facet = get_object_or_404(Facet, pk=facet_id)
tags = Tag.objects.filter(id__in=tag_ids, facet=facet)
things = Thing.objects.filter(id__in=thing_ids)
for thing in things:
if facet.cardinality == Facet.Cardinality.SINGLE:
# Remove existing tags from this facet
thing.tags.remove(*thing.tags.filter(facet=facet))
# Add new tags
for tag in tags:
if tag.facet == facet:
thing.tags.add(tag)
return redirect('fixme')
return render(request, 'boxes/fixme.html', {
'facets': facets,
'selected_facet': selected_facet,
'missing_things': missing_things,
})

Binary file not shown.

View File

@@ -50,13 +50,31 @@
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 a,
.navbar-nav form {
color: #555;
text-decoration: none;
font-weight: 500;
@@ -81,6 +99,68 @@
font-size: 14px;
}
.navbar-nav button {
background: none;
border: none;
color: #555;
font: inherit;
cursor: pointer;
padding: 8px 16px;
border-radius: 8px;
transition: all 0.3s ease;
display: inline-flex;
align-items: center;
gap: 8px;
}
@media (max-width: 768px) {
.navbar {
padding: 15px 20px;
}
.navbar-brand {
font-size: 24px;
}
.navbar-toggle {
display: block;
}
.navbar-nav {
display: none;
width: 100%;
flex-direction: column;
gap: 0;
padding-top: 10px;
}
.navbar-nav.active {
display: flex;
}
.navbar-nav a,
.navbar-nav form {
width: 100%;
padding: 12px 16px;
border-radius: 0;
}
.navbar-nav a:first-child,
.navbar-nav form:first-child {
border-radius: 8px 8px 0 0;
}
.navbar-nav a:last-child,
.navbar-nav form:last-child {
border-radius: 0 0 8px 8px;
}
.navbar-nav button {
width: 100%;
justify-content: flex-start;
}
}
.container {
max-width: 1200px;
margin: 20px auto;
@@ -216,6 +296,97 @@
text-decoration: underline;
}
.dropdown {
position: relative;
display: inline-block;
}
.dropdown-content {
display: none;
position: absolute;
right: 0;
background: white;
min-width: 200px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
border-radius: 10px;
padding: 10px 0;
z-index: 1000;
top: 100%;
margin-top: 10px;
}
.dropdown-content a {
display: block;
padding: 12px 20px;
color: #555;
text-decoration: none;
transition: all 0.2s ease;
}
.dropdown-content a:hover {
background: #667eea;
color: white;
}
.dropdown-content a:first-child {
border-radius: 10px 10px 0 0;
}
.dropdown-content a:last-child {
border-radius: 0 0 10px 10px;
}
.dropdown-content button:hover {
background: #667eea;
color: white;
}
.dropdown:hover .dropdown-content,
.dropdown:focus-within .dropdown-content {
display: block;
}
.dropdown-btn {
background: none;
border: none;
color: #667eea;
font-weight: 600;
font-size: 15px;
cursor: pointer;
padding: 8px 16px;
border-radius: 8px;
transition: all 0.3s ease;
display: inline-flex;
align-items: center;
gap: 8px;
}
.dropdown-btn:hover {
background: #667eea;
color: white;
}
@media (max-width: 768px) {
.dropdown-content {
position: static;
box-shadow: none;
border-radius: 0;
margin-top: 0;
padding: 0;
}
.dropdown-content a {
width: 100%;
padding: 12px 16px;
border-radius: 0;
}
.dropdown-btn {
width: 100%;
justify-content: flex-start;
}
}
{% block extra_css %}{% endblock %}
</style>
{% block extra_head %}{% endblock %}
@@ -226,18 +397,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>
{% 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 'logout' %}" style="padding: 0; margin: 0; background: none; border: none;">
{% csrf_token %}
<button type="submit" style="width: 100%; text-align: left; padding: 12px 20px; background: none; border: none; color: #555; font: inherit; cursor: pointer; display: block; transition: all 0.2s ease;">
<i class="fas fa-sign-out-alt"></i> Logout
</button>
</form>
</div>
</div>
{% else %}
<a href="{% url 'login' %}"><i class="fas fa-sign-in-alt"></i> Login</a>
{% endif %}
@@ -255,5 +438,18 @@
</footer>
{% block extra_js %}{% endblock %}
<script>
$(document).ready(function() {
$('#navbar-toggle').on('click', function() {
$('#navbar-nav').toggleClass('active');
const icon = $(this).find('i');
if ($('#navbar-nav').hasClass('active')) {
icon.removeClass('fa-bars').addClass('fa-times');
} else {
icon.removeClass('fa-times').addClass('fa-bars');
}
});
});
</script>
</body>
</html>

View File

@@ -26,15 +26,17 @@ from boxes.views import (
add_things,
box_detail,
box_management,
boxes_list,
delete_box,
delete_box_type,
edit_box,
edit_box_type,
edit_thing,
fixme,
index,
search,
resources_list,
search_api,
thing_detail,
thing_type_detail,
)
urlpatterns = [
@@ -50,10 +52,13 @@ 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('box/<str:box_id>/add/', add_things, name='add_things'),
path('search/', search, name='search'),
path('boxes/', boxes_list, name='boxes_list'),
path('search/', boxes_list, name='search'),
path('search/api/', search_api, name='search_api'),
path('resources/', resources_list, name='resources_list'),
path('fixme/', fixme, name='fixme'),
path('admin/', admin.site.urls),
]