From cd04a21157c4fe6ed04632b9e9b3f48ece6f43eb Mon Sep 17 00:00:00 2001 From: "Adrian A. Baumann" Date: Sat, 3 Jan 2026 22:23:35 +0100 Subject: [PATCH] Complete replacement of Thing types with tag system --- boxes/admin.py | 64 ++++++++-- boxes/forms.py | 4 +- .../0003_convert_thingtype_to_mptt.py | 53 --------- ...6_tag_alter_thing_thing_type_thing_tags.py | 35 ++++++ boxes/migrations/0007_tag_color.py | 18 +++ ...tions_alter_tag_name_tag_facet_and_more.py | 53 +++++++++ .../migrations/0009_migrate_tags_to_facets.py | 75 ++++++++++++ boxes/migrations/0010_remove_thingtype.py | 35 ++++++ boxes/models.py | 68 ++++++++--- boxes/templates/boxes/box_detail.html | 2 - boxes/templates/boxes/index.html | 83 +++++++------ boxes/templates/boxes/search.html | 52 ++++++--- boxes/templates/boxes/thing_detail.html | 66 ++++++++++- boxes/views.py | 109 +++++++++++------- labhelper/urls.py | 8 -- 15 files changed, 530 insertions(+), 195 deletions(-) create mode 100644 boxes/migrations/0006_tag_alter_thing_thing_type_thing_tags.py create mode 100644 boxes/migrations/0007_tag_color.py create mode 100644 boxes/migrations/0008_facet_alter_tag_options_alter_tag_name_tag_facet_and_more.py create mode 100644 boxes/migrations/0009_migrate_tags_to_facets.py create mode 100644 boxes/migrations/0010_remove_thingtype.py diff --git a/boxes/admin.py b/boxes/admin.py index a77b727..bb8d93f 100644 --- a/boxes/admin.py +++ b/boxes/admin.py @@ -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,13 +22,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 +41,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 = ('box', 'tags') search_fields = ('name', 'description') + filter_horizontal = ('tags',) inlines = [ThingFileInline, ThingLinkInline] @@ -65,6 +60,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('■ {}', 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('{}', 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.""" diff --git a/boxes/forms.py b/boxes/forms.py index ee78a37..a9cde86 100644 --- a/boxes/forms.py +++ b/boxes/forms.py @@ -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'}), } diff --git a/boxes/migrations/0003_convert_thingtype_to_mptt.py b/boxes/migrations/0003_convert_thingtype_to_mptt.py index 673d7a3..7d656c1 100644 --- a/boxes/migrations/0003_convert_thingtype_to_mptt.py +++ b/boxes/migrations/0003_convert_thingtype_to_mptt.py @@ -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), ] diff --git a/boxes/migrations/0006_tag_alter_thing_thing_type_thing_tags.py b/boxes/migrations/0006_tag_alter_thing_thing_type_thing_tags.py new file mode 100644 index 0000000..91dcec8 --- /dev/null +++ b/boxes/migrations/0006_tag_alter_thing_thing_type_thing_tags.py @@ -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'), + ), + ] diff --git a/boxes/migrations/0007_tag_color.py b/boxes/migrations/0007_tag_color.py new file mode 100644 index 0000000..140f942 --- /dev/null +++ b/boxes/migrations/0007_tag_color.py @@ -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), + ), + ] diff --git a/boxes/migrations/0008_facet_alter_tag_options_alter_tag_name_tag_facet_and_more.py b/boxes/migrations/0008_facet_alter_tag_options_alter_tag_name_tag_facet_and_more.py new file mode 100644 index 0000000..16884db --- /dev/null +++ b/boxes/migrations/0008_facet_alter_tag_options_alter_tag_name_tag_facet_and_more.py @@ -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', + ), + ] diff --git a/boxes/migrations/0009_migrate_tags_to_facets.py b/boxes/migrations/0009_migrate_tags_to_facets.py new file mode 100644 index 0000000..c16f09c --- /dev/null +++ b/boxes/migrations/0009_migrate_tags_to_facets.py @@ -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), + ] diff --git a/boxes/migrations/0010_remove_thingtype.py b/boxes/migrations/0010_remove_thingtype.py new file mode 100644 index 0000000..8869050 --- /dev/null +++ b/boxes/migrations/0010_remove_thingtype.py @@ -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', + ), + ] diff --git a/boxes/models.py b/boxes/models.py index c3ee035..8763fc1 100644 --- a/boxes/models.py +++ b/boxes/models.py @@ -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'] diff --git a/boxes/templates/boxes/box_detail.html b/boxes/templates/boxes/box_detail.html index 9f53664..d0095e2 100644 --- a/boxes/templates/boxes/box_detail.html +++ b/boxes/templates/boxes/box_detail.html @@ -37,7 +37,6 @@ Picture Name - Type Description @@ -56,7 +55,6 @@ {{ thing.name }} - {{ thing.thing_type.name }} {{ thing.description|default:"-" }} {% endfor %} diff --git a/boxes/templates/boxes/index.html b/boxes/templates/boxes/index.html index d6f4984..4b62ba1 100644 --- a/boxes/templates/boxes/index.html +++ b/boxes/templates/boxes/index.html @@ -1,6 +1,4 @@ {% extends "base.html" %} -{% load mptt_tags %} -{% load dict_extras %} {% block title %}LabHelper - Home{% endblock %} @@ -44,36 +42,35 @@
-

Thing Types

- {% if thing_types %} - + +
+ {% endfor %} + {% else %}

- - No thing types found. + + No tags found.

{% endif %} @@ -82,15 +79,27 @@ {% block extra_js %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/boxes/templates/boxes/search.html b/boxes/templates/boxes/search.html index 16e4389..e0b66c4 100644 --- a/boxes/templates/boxes/search.html +++ b/boxes/templates/boxes/search.html @@ -13,10 +13,11 @@ {% block content %}
- +

Type at least 2 characters to search

@@ -55,58 +56,62 @@ 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 = + row.innerHTML = '' + escapeHtml(thing.name) + '' + '' + escapeHtml(thing.type) + '' + '' + escapeHtml(thing.box) + '' + '' + escapeHtml(thing.description) + ''; - + 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 +125,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(); {% endblock %} \ No newline at end of file diff --git a/boxes/templates/boxes/thing_detail.html b/boxes/templates/boxes/thing_detail.html index b5b318c..0c5f934 100644 --- a/boxes/templates/boxes/thing_detail.html +++ b/boxes/templates/boxes/thing_detail.html @@ -53,10 +53,30 @@
- Type + Tags
-
- {{ thing.thing_type.name }} +
+ {% regroup thing.tags.all by facet as facet_list %} + {% for facet in facet_list %} +
+
+ {{ facet.grouper.name }} +
+
+ {% for tag in facet.list %} +
+ {% csrf_token %} + + + +
+ {% endfor %} +
+
+ {% endfor %}
@@ -133,6 +153,46 @@
{% endif %} +
+ + + + +
+

+ Add Tags +

+
+
+

+ Add Tag +

+
+ {% csrf_token %} + +
+
+ + +
+ +
+
diff --git a/boxes/views.py b/boxes/views.py index 40a61e6..b283a25 100644 --- a/boxes/views.py +++ b/boxes/views.py @@ -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.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,21 +169,34 @@ 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').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').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 '', 'files': [ @@ -208,25 +250,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.""" diff --git a/labhelper/urls.py b/labhelper/urls.py index b863685..e420228 100644 --- a/labhelper/urls.py +++ b/labhelper/urls.py @@ -22,19 +22,15 @@ from django.contrib.auth import views as auth_views from boxes.views import ( add_box, - add_box_type, add_things, box_detail, box_management, delete_box, - delete_box_type, edit_box, - edit_box_type, index, search, search_api, thing_detail, - thing_type_detail, ) urlpatterns = [ @@ -42,15 +38,11 @@ urlpatterns = [ path('logout/', auth_views.LogoutView.as_view(), name='logout'), path('', index, name='index'), path('box-management/', box_management, name='box_management'), - path('box-type/add/', add_box_type, name='add_box_type'), - path('box-type//edit/', edit_box_type, name='edit_box_type'), - path('box-type//delete/', delete_box_type, name='delete_box_type'), path('box/add/', add_box, name='add_box'), path('box//edit/', edit_box, name='edit_box'), path('box//delete/', delete_box, name='delete_box'), path('box//', box_detail, name='box_detail'), path('thing//', thing_detail, name='thing_detail'), - path('thing-type//', thing_type_detail, name='thing_type_detail'), path('box//add/', add_things, name='add_things'), path('search/', search, name='search'), path('search/api/', search_api, name='search_api'),