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 @@
- - No thing types found. + + No tags found.
{% endif %}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 = '