From cd04a21157c4fe6ed04632b9e9b3f48ece6f43eb Mon Sep 17 00:00:00 2001 From: "Adrian A. Baumann" Date: Sat, 3 Jan 2026 22:23:35 +0100 Subject: [PATCH 1/2] 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'), -- 2.51.0 From 68bd013ac9bceaa87098f70796350b2b9b3af768 Mon Sep 17 00:00:00 2001 From: "Adrian A. Baumann" Date: Sat, 3 Jan 2026 22:26:17 +0100 Subject: [PATCH 2/2] Full deployment with new database --- argocd/deployment.yaml | 4 ++-- data-loader/preload.sqlite3 | Bin 208896 -> 266240 bytes 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/argocd/deployment.yaml b/argocd/deployment.yaml index 7cb5703..5c7896f 100644 --- a/argocd/deployment.yaml +++ b/argocd/deployment.yaml @@ -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.013 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.048 imagePullPolicy: Always ports: - containerPort: 8000 diff --git a/data-loader/preload.sqlite3 b/data-loader/preload.sqlite3 index ca9b6a2e798aba5b3ecab724daac2e502d3d0a65..12b932d1390af92e23eaac508eff0d5498231543 100644 GIT binary patch delta 21981 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 delta 6007 zcmai23v?XSd7jtq?Cj3oyQ}wV^%%V@327zmLr>S(vYvkEWjzcwjXc_&U9Gj+S?{cD z*{99ons82l91Lohh9uwu4NXpXMF9m097-vW5C@Y0CC;NxLO8Tg4sbYWP9aJEdw1ok zCTTTCpXT2GKK}3j|NGB>b^STl^_S$c+gArtmTu8gqy7R-|!Lq5BNB~8Q+PU@OJ$7xM1C#l5y7U;2YTm zeN{8&0W&94nx(LSZ^?IM7URhn>8!-BGOo?N$=@Q5_pyO895;-(o(wr9zQ#s7V`+UQCENLGi8faZ&7a1FeBIXF<`|}w=YyMThxKGe zOJ?SY5`>|9?Tz-v;xaqDIAM_jdQ($0_hkS(w#?uxHZ zxt&*}SMBzi|{F-=yZ2aeF{ru0nRUWf1nBp$>uhx&au4Xr0=x6(?+6g1e zMaH^pxrjq3?(CPB9M3sA>>o?xl2`n(SZ4XUB_X`VGQu6aox9cSIKp0p`4@zZ&+lSk zm9iN7ZVw-K-fW8J*e{rOo@0k$$4BIin;&3Lav=D~6dz`P1byYsjgOvUU0jtD+Zg;G z_{aEr_(^;>z6r15qxcXW#vQl<+knAa$h5u8eqv|w$Dkh5jz8Y`U?=xJ=G%7K*V$)y z7qGqB6yImN?{%lJdG#OI=bvJXpD(gje% zE^SnOmrwIN!>}gX&eh&~#KmqrdY<3LX6snX*qPb>L}wtGK9$fGyF&g`X?-COTwY!d zg!K5acw{AUvZqbkyE=Dt%$PV4@1Hsl^$lmHhk6bzrPCehV03EF?#0xI9^0n{_xUE~ z)l~oSNH94$5uI9!X42P$(pq%*)XABNDgF3N_v)cRZDqQvzx&vzwrpfiW)}37aOZUQ zp4hzFxiEJing~tw`h2m);VxhA!GS|FBYR?d2j|qK!0PO&iP#Bsk6$028i-vRhz^|C zd+k_ke6Bw|9+(Z!PW2u;bacpfV)B4C7#{2MA3v13#_02>BCE%a_RbAX9t*> zW5IO{-oSNu8IR(h;-7-yF*fVkIh|veJKDJ>^Xc<^buU-Pk4gWZJ;|}M;3$KCiT??I z4oC5Q^mrV=LKWZ7no}?F<>n1Lxz8M_eF==)MA`h+!2U2GX7!>2gZCoRTj03OvJ7&JN0%qntU)nWLO#%GpghbCk2YfJ|}| zpp4~DG3I0>-YksnCBo(KZYLy*Z(bih1T#b5MiIiCvXbK@d7>!t{=lA zct<~co;qv<@g?@(q1-*h^LjR1OMjPm1^mTL(`ZD@! z^aXSy%0gPX$!VU^x%mwx$%WZn7Ra@JAP)%c1_XBlg1Z47ITmwUSZvYuX>=F*8&i3h zuRVZXML$3Qu;Dj=VAmcMfaf$ zN}@UR7P^FPN4J2A7t!_T6uJ%+ea1-xykw3T*gH#5o}oBRuw;tjBth>4#c_gVV-)vO z+(%H@OK}gul2L;42*qK7g+ugxkm3Nv-4y#RSuzw%#Im041j{-JmbMZs2~iAE+(xmQ zU~v^i8lXiqKnrO+7Si)opb%*RjYJnccesP};C6~_6dNf*TxMZ=A8E)f6q_m10G8?D zlxciAx=FtyK+#X}DvC`6?eqXkG{7Z#j4gen-_lF5hal=ADAZG|qgV^*V}%;ppm{~0 zXH3ZDl}}|9zTsOB+gyqxxb@+Gd*uTK!W($E6Tiq*I=8Wq(k|e?#OEOH`|-QD6&GO! zy@|e$oNsp*PUN<-r&@n!_I9GA8*Rps!AIpu`KIn1&cVg zlE}nUE9sPOXi7%c6GnMuMNZz%T1D}ou18Mj=_M%HHpSjeF~^HT2`!vS>&bZ7$cu5g zjj_SMj)|XRy5=u9ND=EoIYgn5k4Cu)uD59qguCmz106B02#|y$(Je<}^ zxtG=x2`z2Ng+)qko)_e{5j7IiGKRaUFxNT678DAm{fAsdmh%+k`WnGZw&~1DGzttA zLr-ViZg-BOm$BOB)O1{5F^Zw;A=_3$CbtUHC*qm#qGreiDA!%ea!w?S$5-Q;;Bw~r z3Rw=jgt_5Cp+w%~!im6zaLiJY<&NA&K1Q&PY2if`46P=VNn*JIdv214Eh-kqG_WWb zMWTID9k^~%OUF}-T3SsQ)ExF!eiQ~DE#EJ$vpRo}r=Qhcf3qxa<11lFN1!9hY7rZf`E+jw~t5bTLaq|x_ z_&nZ=ZbLK9r)-7JHu+294)KD+X}{eLRuEnhcJdEE!G4C{IQ|58mN)GxyJqd*Z!Mi| zH*4Z-nOXld*U)z6z^GW-(!$<&I0LI|xy#Gto5~f<7V{U6aUOwOA57N>+i6K?$F+p{ z&>~y!rmwZi)jO1q=2oRP5j7uNWE)T-n)#p3wE4|Nwy`)JTinu_No(4CJVIAN`9^+< zIl0KzxZ%8zOG`Xbt1QH0aE8ot8=wWbO_>kI*cJh7=((-Gc*kipA(l?nv8OxZ$%wYP zMfrKkh~9f(o%!D0-X1j+P`8X|GVgkn^A?juKQ+wkc$_PC|F0!#n@^6LuYQL+*>FZ0 z6HDvr*w0;ir2)6BZeDtfD>rX>oJ*ONPjJ`Rz(904GaF-D%_AF}#r*v;>oY%joU7X1 ze7ayS@M!rXJc#AzFWX~28thVmMemlK$cSx(izoXh2G=SR<4WxnQ)-o_+RN+KHlLRF zh^1qof$vI7(na%LnqLTpIwPH3kt-H!7&tK0KV#neC|A_|N5}t36*k@9Y>E-qv~CYs zODCFh(fWLpt=_mR!u}b{`Tb^PoUJgk>zvh;Vk~YVr3blEqbaCdE22vn=>cg?T9ppk584K7)#3-@OX3~kvN$5TtUoJp#I65m{hIaj)+Ott zwcA=^`PlMv%RQi>Y?1orM8-c-$U4m$s}RVRcxOe%=Y;lTJgI~e>T*g+!M#p1{2|rr znGuE%8i>Omkk4C6Z}9yaXQ-BCD7XnRX(hpVnoPm^al4s$TDSUe_(sgr-KwRIg_Y z_H)D3X+5Em4`fnH7@c$efLd0*Uu0V7jw|6M_zotIYiW|j{Q-ws=HDwa)qC7vkgtkD zvt8)iBQjwpY5zbJd>umlC`_({_U!a-<%F6@z-Nr;JDk>+l@Z80VXa3kX&e!m5jv~U zCRE$y;IPQ-A+yYJo6usH>xV?9pUO|$gbI6M>!8SNr}DqE3H5flc0goyQ28r1p}bkH z*bQzX?NPF$7LCFeI&X;{RW@2}jQ{j@49kDUAsqFT_5_Ks-Ru-e@0t=zsL6=Ysg{@{ zcA>T;6aqJbbnIaN$OJe!o}x0hT~M<2A;wJt|ME}uE|KXbJNJirdwP`VjFy6b9VwA? z9IR%*=AP}u3$S-^5H#9Sj&ys<`iP-HG&?%0q_vb9PlGNiT9?Jso1ZkMN3_JUN^2qz zT?4YJ95&sUHW(LaaE3xT-QY6 zjVRYH3*J#jU1PpII5{{2wOA|$VPfPfEyY-dC=21zZRBLaq}^&dt!)On5h`hD5SamD zGxHW%sI>F-^_$WBhz!vzG}npDJ}Q4r7HaH5U9HIMr0p7~P~`S^t@^uy3qnl|1pQ{W zXP;27d9iIA}=2rm>wXmj%rW_gf(S>)UHNQEvohA&t1O8YE@>2XTZVwN;1-_gu=@~ zRcI*RWN$;jUTE-awtdj{3k_xYHpFK{qb^JGoMsr30 -- 2.51.0