9 Commits

Author SHA1 Message Date
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
68bd013ac9 Full deployment with new database
All checks were successful
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/labhelper) (push) Successful in 28s
Build containers when image tags change / build-if-image-changed (data-loader, loader, initContainers, init-container, git.baumann.gr/adebaumann/labhelper-data-loader) (push) Successful in 12s
2026-01-03 22:26:17 +01:00
cd04a21157 Complete replacement of Thing types with tag system 2026-01-03 22:23:35 +01:00
cb3e9d6aec CSV output management command addede 2026-01-02 17:08:23 +01:00
bb23f7f574 Partial deployment
Some checks failed
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/labhelper) (push) Failing after 3m54s
Build containers when image tags change / build-if-image-changed (data-loader, loader, initContainers, init-container, git.baumann.gr/adebaumann/labhelper-data-loader) (push) Successful in 7s
2026-01-01 17:07:43 +01:00
b51bf23726 CSRF trusted hosts added 2026-01-01 17:06:35 +01:00
a4783bea2c Agents file updated 2026-01-01 16:42:17 +01:00
ee9a76dcc8 Partial deployment
All checks were successful
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/labhelper) (push) Successful in 20s
Build containers when image tags change / build-if-image-changed (data-loader, loader, initContainers, init-container, git.baumann.gr/adebaumann/labhelper-data-loader) (push) Successful in 6s
2026-01-01 16:00:56 +01:00
11d2579c7e Things back in admin 2026-01-01 15:45:39 +01:00
21 changed files with 760 additions and 239 deletions

179
AGENTS.md
View File

@@ -4,7 +4,7 @@ This document provides guidelines for AI coding agents working in the labhelper
## Project Overview
- **Type**: Django web application
- **Type**: Django web application (lab inventory management system)
- **Python**: 3.13.7
- **Django**: 5.2.9
- **Database**: SQLite (development)
@@ -66,6 +66,21 @@ python manage.py collectstatic # Collect static files
gunicorn labhelper.wsgi:application # Run with Gunicorn
```
### Custom Management Commands
```bash
# Create default users and groups
python manage.py create_default_users
# Clean up orphaned files from deleted things
python manage.py clean_orphaned_files
python manage.py clean_orphaned_files --dry-run
# Clean up orphaned images and thumbnails
python manage.py clean_orphaned_images
python manage.py clean_orphaned_images --dry-run
```
## Code Style Guidelines
### Python Style
@@ -178,23 +193,85 @@ def get_box(request: HttpRequest, box_id: int) -> HttpResponse:
```
labhelper/
├── manage.py # Django CLI entry point
├── requirements.txt # Python dependencies
├── labhelper/ # Project configuration
│ ├── settings.py # Django settings
│ ├── urls.py # Root URL routing
│ ├── wsgi.py # WSGI application
── asgi.py # ASGI application
└── boxes/ # Django app
├── admin.py # Admin configuration
── apps.py # App configuration
├── models.py # Data models
├── views.py # View functions
├── tests.py # Test cases
├── migrations/ # Database migrations
└── templates/ # HTML templates
├── .gitea/
│ └── workflows/
│ └── build-containers-on-demand.yml # CI/CD workflow
├── argocd/ # Kubernetes deployment manifests
│ ├── 001_pvc.yaml # PersistentVolumeClaim
│ ├── deployment.yaml # Deployment + Service
── ingress.yaml # Traefik ingress
│ ├── nfs-pv.yaml # NFS PersistentVolume
├── nfs-storageclass.yaml # NFS StorageClass
── secret.yaml # Django secret key template
├── boxes/ # Main Django app
├── management/
│ └── commands/
│ │ ├── clean_orphaned_files.py # Cleanup orphaned ThingFile attachments
│ │ └── clean_orphaned_images.py # Cleanup orphaned Thing images
│ ├── migrations/ # Database migrations
│ ├── templates/
│ │ └── boxes/
│ │ ├── add_things.html # Form to add multiple things
│ │ ├── box_detail.html # Box contents view
│ │ ├── box_management.html # Box/BoxType CRUD management
│ │ ├── index.html # Home page
│ │ ├── search.html # Search page with AJAX
│ │ ├── thing_detail.html # Thing details view
│ │ └── thing_type_detail.html # Thing type hierarchy view
│ ├── templatetags/
│ │ └── dict_extras.py # Custom template filter: get_item
│ ├── admin.py # Admin configuration
│ ├── apps.py # App configuration
│ ├── forms.py # All forms and formsets
│ ├── models.py # Data models
│ ├── tests.py # Test cases
│ └── views.py # View functions
├── data-loader/ # Init container for database preload
│ ├── Dockerfile # Alpine-based init container
│ └── preload.sqlite3 # Preloaded database for deployment
├── labhelper/ # Project configuration
│ ├── management/
│ │ └── commands/
│ │ └── create_default_users.py # Create default users/groups
│ ├── templates/
│ │ ├── base.html # Base template with navigation
│ │ └── login.html # Login page
│ ├── asgi.py # ASGI configuration
│ ├── settings.py # Django settings
│ ├── urls.py # Root URL configuration
│ └── wsgi.py # WSGI configuration
├── scripts/
│ ├── deploy_secret.sh # Generate and deploy Django secret
│ ├── full_deploy.sh # Bump both container versions + copy DB
│ └── partial_deploy.sh # Bump main container version only
├── .gitignore
├── AGENTS.md # This file
├── Dockerfile # Multi-stage build for main container
├── manage.py # Django CLI entry point
└── requirements.txt # Python dependencies
```
## Data Models
### boxes app
| Model | Description | Key Fields |
|-------|-------------|------------|
| **BoxType** | Type of storage box with dimensions | `name`, `width`, `height`, `length` (in mm) |
| **Box** | A storage box in the lab | `id` (CharField PK, max 10), `box_type` (FK) |
| **ThingType** | Hierarchical category (MPTT) | `name`, `parent` (TreeForeignKey to self) |
| **Thing** | An item stored in a box | `name`, `thing_type` (FK), `box` (FK), `description`, `picture` |
| **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)
- Thing -> ThingFile (1:N via `files` related_name, CASCADE on delete)
- Thing -> ThingLink (1:N via `links` related_name, CASCADE on delete)
## Available Django Extensions
The project includes these pre-installed packages:
@@ -206,6 +283,11 @@ The project includes these pre-installed packages:
- **django-nested-inline**: Additional nested inline support
- **django-revproxy**: Reverse proxy functionality
- **sorl-thumbnail**: Image thumbnailing
- **Pillow**: Image processing
- **gunicorn**: Production WSGI server
- **Markdown**: Markdown processing
- **bleach**: HTML sanitization
- **coverage**: Test coverage
- **Font Awesome**: Icon library (loaded via CDN)
- **jQuery**: JavaScript library (loaded via CDN)
@@ -287,15 +369,27 @@ The project uses a base template system at `labhelper/templates/base.html`. All
### Available Pages/Views
| View Name | URL Pattern | Description |
|-----------|--------------|-------------|
| `index` | `/` | Home page with boxes grid and thing types tree |
| `box_detail` | `/box/<str:box_id>/` | List items in a specific box |
| `thing_detail` | `/thing/<int:thing_id>/` | Thing details with move-to-box form |
| `thing_type_detail` | `/thing-type/<int:type_id>/` | Thing type hierarchy and items |
| `add_things` | `/box/<str:box_id>/add/` | Form to add/edit items in a box |
| `search` | `/search/` | Search page with AJAX autocomplete |
| `search_api` | `/search/api/` | AJAX endpoint for search results |
| View Function | URL Pattern | Name | Description |
|---------------|-------------|------|-------------|
| `index` | `/` | `index` | Home page with boxes grid and thing types tree |
| `box_management` | `/box-management/` | `box_management` | Manage boxes and box types |
| `add_box_type` | `/box-type/add/` | `add_box_type` | Add new box type |
| `edit_box_type` | `/box-type/<int:type_id>/edit/` | `edit_box_type` | Edit box type |
| `delete_box_type` | `/box-type/<int:type_id>/delete/` | `delete_box_type` | Delete box type |
| `add_box` | `/box/add/` | `add_box` | Add new box |
| `edit_box` | `/box/<str:box_id>/edit/` | `edit_box` | Edit box |
| `delete_box` | `/box/<str:box_id>/delete/` | `delete_box` | Delete box |
| `box_detail` | `/box/<str:box_id>/` | `box_detail` | View box contents |
| `thing_detail` | `/thing/<int:thing_id>/` | `thing_detail` | View/edit thing (move, picture, files, links) |
| `thing_type_detail` | `/thing-type/<int:type_id>/` | `thing_type_detail` | View thing type hierarchy |
| `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 |
| `LoginView` | `/login/` | `login` | Django auth login |
| `LogoutView` | `/logout/` | `logout` | Django auth logout |
| `admin.site` | `/admin/` | - | Django admin |
**All views except login require authentication via `@login_required`.**
### Template Best Practices
@@ -336,6 +430,33 @@ The project uses a base template system at `labhelper/templates/base.html`. All
</p>
```
## Forms
| Form | Model | Purpose |
|------|-------|---------|
| `ThingForm` | Thing | Add/edit a thing (name, type, description, picture) |
| `ThingPictureForm` | Thing | Upload/change thing picture only |
| `ThingFileForm` | ThingFile | Add file attachment |
| `ThingLinkForm` | ThingLink | Add link |
| `BoxTypeForm` | BoxType | Add/edit box type |
| `BoxForm` | Box | Add/edit box |
| `ThingFormSet` | Thing | Formset for adding multiple things |
## Management Commands
### boxes app
| Command | Description | Options |
|---------|-------------|---------|
| `clean_orphaned_files` | Clean up orphaned files from deleted things | `--dry-run` |
| `clean_orphaned_images` | Clean up orphaned images and thumbnails | `--dry-run` |
### labhelper project
| Command | Description |
|---------|-------------|
| `create_default_users` | Create default users and groups (admin/admin123, staff/staff123, viewer/viewer123) |
## Testing Guidelines
- Use `django.test.TestCase` for database tests
@@ -374,10 +495,10 @@ Per `.gitignore`:
1. **NEVER commit or push without explicit permission**: Always ask the user before running `git commit` or `git push`. The user will explicitly say "commit and push" when they want you to do this. Do NOT automatically commit/push after making changes unless instructed to do so.
2. **Always activate venv**: `source .venv/bin/activate`
3. **Run migrations after model changes**: `makemigrations` then `migrate`
3. **Add new apps to INSTALLED_APPS** in `settings.py`
4. **Templates in labhelper/templates/**: The base template and shared templates are in `labhelper/templates/`. App-specific templates remain in `app_name/templates/`.
4. **Use get_object_or_404** instead of bare `.get()` calls
5. **Never commit SECRET_KEY** - use environment variables in production
4. **Add new apps to INSTALLED_APPS** in `settings.py`
5. **Templates in labhelper/templates/**: The base template and shared templates are in `labhelper/templates/`. App-specific templates remain in `app_name/templates/`.
6. **Use get_object_or_404** instead of bare `.get()` calls
7. **Never commit SECRET_KEY** - use environment variables in production
## Deployment Commands

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

View File

@@ -1,7 +1,8 @@
from django import forms
from django.contrib import admin
from django_mptt_admin.admin import DjangoMpttAdmin
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
@admin.register(BoxType)
@@ -21,22 +22,6 @@ class BoxAdmin(admin.ModelAdmin):
search_fields = ('id',)
@admin.register(ThingType)
class ThingTypeAdmin(DjangoMpttAdmin):
"""Admin configuration for ThingType model."""
search_fields = ('name',)
@admin.register(Thing)
class ThingAdmin(admin.ModelAdmin):
"""Admin configuration for Thing model."""
list_display = ('name', 'thing_type', 'box')
list_filter = ('thing_type', 'box')
search_fields = ('name', 'description')
class ThingFileInline(admin.TabularInline):
"""Inline admin for Thing files."""
@@ -53,14 +38,79 @@ class ThingLinkInline(admin.TabularInline):
fields = ('title', 'url')
class ThingAdminWithFiles(admin.ModelAdmin):
"""Admin configuration for Thing model with files and links."""
class ThingAdmin(admin.ModelAdmin):
"""Admin configuration for Thing model."""
list_display = ('name', 'thing_type', 'box')
list_filter = ('thing_type', 'box')
list_display = ('name', 'box')
list_filter = ('box', 'tags')
search_fields = ('name', 'description')
filter_horizontal = ('tags',)
inlines = [ThingFileInline, ThingLinkInline]
admin.site.unregister(Thing)
admin.register(Thing, ThingAdminWithFiles)
admin.site.register(Thing, ThingAdmin)
@admin.register(ThingFile)
class ThingFileAdmin(admin.ModelAdmin):
"""Admin configuration for ThingFile model."""
list_display = ('thing', 'title', 'uploaded_at')
list_filter = ('thing',)
search_fields = ('title',)
class ColorInput(forms.TextInput):
"""Color picker widget using HTML5 color input."""
input_type = 'color'
class FacetAdminForm(forms.ModelForm):
"""Form for Facet model with color picker widget."""
class Meta:
model = Facet
fields = '__all__'
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['color'].widget = ColorInput(attrs={'type': 'color', 'style': 'height: 50px; width: 100px;'})
@admin.register(Facet)
class FacetAdmin(admin.ModelAdmin):
"""Admin configuration for Facet model."""
form = FacetAdminForm
list_display = ('name', 'color_preview', 'cardinality')
search_fields = ('name',)
prepopulated_fields = {'slug': ('name',)}
list_filter = ('cardinality',)
def color_preview(self, obj):
return format_html('<span style="color: {}; font-weight: bold;">■ {}</span>', obj.color, obj.color)
@admin.register(Tag)
class TagAdmin(admin.ModelAdmin):
"""Admin configuration for Tag model."""
list_display = ('__str__', 'facet_with_color')
list_filter = ('facet',)
search_fields = ('name', 'facet__name')
def facet_with_color(self, obj):
if obj.facet:
return format_html('<span style="color: {}; font-weight: bold;">{}</span>', obj.facet.color, obj.facet.name)
return '-'
facet_with_color.short_description = 'Facet'
@admin.register(ThingLink)
class ThingLinkAdmin(admin.ModelAdmin):
"""Admin configuration for ThingLink model."""
list_display = ('thing', 'title', 'url', 'uploaded_at')
list_filter = ('thing',)
search_fields = ('title', 'url')

View File

@@ -8,11 +8,11 @@ class ThingForm(forms.ModelForm):
class Meta:
model = Thing
fields = ('name', 'thing_type', 'description', 'picture')
fields = ('name', 'description', 'picture', 'tags')
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}),
'tags': forms.CheckboxSelectMultiple(attrs={'class': 'tags-checkboxes'}),
}

View File

@@ -0,0 +1,25 @@
from django.core.management.base import BaseCommand
from boxes.models import Thing
class Command(BaseCommand):
help = 'List all things with their full thing type path and box ID'
def get_thing_type_path(self, thing_type):
"""Get the full path of a thing type with underscores instead of spaces."""
ancestors = list(thing_type.get_ancestors(include_self=True))
path_parts = [ancestor.name.replace(' ', '_') for ancestor in ancestors]
return '/'.join(path_parts)
def handle(self, *args, **options):
things = Thing.objects.select_related('thing_type', 'box').all()
if not things.exists():
self.stdout.write(self.style.WARNING('No things found'))
return
for thing in things:
type_path = self.get_thing_type_path(thing.thing_type)
self.stdout.write(f'{thing.name}: {type_path}, box {thing.box.id}')
self.stdout.write(self.style.SUCCESS(f'\nTotal: {things.count()} things'))

View File

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

View File

@@ -0,0 +1,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

@@ -37,7 +37,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,7 +56,15 @@
<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;">
{% 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|default:"-" }}</td>
</tr>
{% endfor %}

View File

@@ -1,6 +1,4 @@
{% extends "base.html" %}
{% load mptt_tags %}
{% load dict_extras %}
{% block title %}LabHelper - Home{% endblock %}
@@ -44,36 +42,35 @@
</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 %}
<h2><i class="fas fa-tags"></i> Tags</h2>
{% if facet_tag_counts %}
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px;">
{% for facet, tags_with_counts in facet_tag_counts.items %}
<div class="facet-card" style="background: white; border-radius: 12px; border: 1px solid #e0e0e0; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.05);">
<div class="facet-header" style="padding: 15px 20px; background: linear-gradient(135deg, {{ facet.color }} 0%, {{ facet.color }}dd 100%); color: white; display: flex; align-items: center; justify-content: space-between; cursor: pointer;">
<div style="display: flex; align-items: center; gap: 10px;">
<i class="fas fa-chevron-right facet-toggle" style="transition: transform 0.3s;"></i>
<span style="font-size: 18px; font-weight: 700;">{{ facet.name }}</span>
</div>
<span style="background: rgba(255,255,255,0.3); padding: 4px 12px; border-radius: 20px; font-size: 13px; font-weight: 600;">{{ facet.cardinality }}</span>
</div>
{% if children %}
<ul style="list-style: none; padding-left: 32px; display: none;">
{{ children }}
</ul>
{% endif %}
</li>
{% endrecursetree %}
</ul>
<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="/search/?q={{ facet.name }}:{{ tag.name }}" style="display: inline-block; padding: 6px 12px; background: {{ facet.color }}20; color: {{ facet.color }}; border: 1px solid {{ facet.color }}; border-radius: 15px; text-decoration: none; font-size: 14px; font-weight: 600; transition: all 0.2s;">
{{ tag.name }}
<span style="background: {{ facet.color }}; color: white; padding: 1px 8px; border-radius: 10px; margin-left: 6px; font-size: 12px;">{{ count }}</span>
</a>
{% endfor %}
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p style="text-align: center; color: #888; font-size: 16px; padding: 40px;">
<i class="fas fa-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,15 +79,27 @@
{% 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') ? '[-]' : '[+]');
$('.facet-header').click(function() {
const $content = $(this).next('.facet-tags');
const $icon = $(this).find('.facet-toggle');
$content.slideToggle(200);
if ($content.is(':visible')) {
$icon.css('transform', 'rotate(90deg)');
} else {
$icon.css('transform', 'rotate(0deg)');
}
});
$('.facet-card a').hover(
function() {
$(this).css('transform', 'scale(1.05)');
},
function() {
$(this).css('transform', 'scale(1)');
}
);
$('.box-card').hover(
function() {
$(this).css('transform', 'translateY(-5px)');
@@ -103,4 +112,4 @@ $(document).ready(function() {
);
});
</script>
{% endblock %}
{% 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

@@ -53,10 +53,30 @@
<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-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 %}
<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>
@@ -76,7 +96,7 @@
<i class="fas fa-align-left"></i> Description
</div>
<div style="font-size: 16px; color: #555; line-height: 1.6; white-space: pre-wrap;">
{{ thing.description }}
{% spaceless %}{{ thing.description }}{% endspaceless %}
</div>
</div>
{% endif %}
@@ -133,6 +153,46 @@
</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>

View File

@@ -706,12 +706,12 @@ class SearchApiTests(AuthTestCase):
results = response.json()['results']
self.assertEqual(len(results), 50)
def test_search_api_includes_type_and_box(self):
"""Search API results should include type and box info."""
def test_search_api_includes_tags_and_box(self):
"""Search API results should include tags and box info."""
response = self.client.get('/search/api/?q=ard')
self.assertEqual(response.status_code, 200)
results = response.json()['results']
self.assertEqual(results[0]['type'], 'Electronics')
self.assertEqual(results[0]['tags'], [])
self.assertEqual(results[0]['box'], 'BOX001')
def test_search_api_requires_login(self):

View File

@@ -11,25 +11,28 @@ from .forms import (
ThingLinkForm,
ThingPictureForm,
)
from .models import Box, BoxType, Thing, ThingFile, ThingLink, ThingType
from .models import Box, BoxType, Facet, Tag, Thing, ThingFile, ThingLink
@login_required
def index(request):
"""Home page with boxes and thing types."""
"""Home page with boxes and tags."""
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
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 +40,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,
@@ -48,11 +51,12 @@ def box_detail(request, box_id):
def thing_detail(request, thing_id):
"""Display details of a thing."""
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
)
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()
@@ -118,9 +122,34 @@ def thing_detail(request, thing_id):
pass
return redirect('thing_detail', thing_id=thing.id)
elif action == 'add_tag':
tag_id = request.POST.get('tag_id')
if tag_id:
try:
tag = Tag.objects.get(pk=tag_id)
if tag.facet.cardinality == Facet.Cardinality.SINGLE:
existing_tags = list(thing.tags.filter(facet=tag.facet))
for existing_tag in existing_tags:
thing.tags.remove(existing_tag)
thing.tags.add(tag)
except Tag.DoesNotExist:
pass
return redirect('thing_detail', 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('thing_detail', thing_id=thing.id)
return render(request, 'boxes/thing_detail.html', {
'thing': thing,
'boxes': boxes,
'facets': facets,
'picture_form': picture_form,
'file_form': file_form,
'link_form': link_form,
@@ -140,23 +169,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 '',
'tags': [
{
'name': tag.name,
'color': tag.facet.color,
}
for tag in thing.tags.all()
],
'files': [
{
'title': f.title,
@@ -208,25 +257,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."""

Binary file not shown.

View File

@@ -132,6 +132,8 @@ MEDIA_ROOT = BASE_DIR / 'data' / 'media'
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
CSRF_TRUSTED_ORIGINS=["https://labhelper.adebaumann.com"]
LOGIN_URL = 'login'
LOGIN_REDIRECT_URL = 'index'
LOGOUT_REDIRECT_URL = 'login'

View File

@@ -34,7 +34,6 @@ from boxes.views import (
search,
search_api,
thing_detail,
thing_type_detail,
)
urlpatterns = [
@@ -50,7 +49,6 @@ 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('box/<str:box_id>/add/', add_things, name='add_things'),
path('search/', search, name='search'),
path('search/api/', search_api, name='search_api'),