feature/tagging #4
@@ -1,7 +1,8 @@
|
|||||||
|
from django import forms
|
||||||
from django.contrib import admin
|
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)
|
@admin.register(BoxType)
|
||||||
@@ -21,13 +22,6 @@ class BoxAdmin(admin.ModelAdmin):
|
|||||||
search_fields = ('id',)
|
search_fields = ('id',)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(ThingType)
|
|
||||||
class ThingTypeAdmin(DjangoMpttAdmin):
|
|
||||||
"""Admin configuration for ThingType model."""
|
|
||||||
|
|
||||||
search_fields = ('name',)
|
|
||||||
|
|
||||||
|
|
||||||
class ThingFileInline(admin.TabularInline):
|
class ThingFileInline(admin.TabularInline):
|
||||||
"""Inline admin for Thing files."""
|
"""Inline admin for Thing files."""
|
||||||
|
|
||||||
@@ -47,9 +41,10 @@ class ThingLinkInline(admin.TabularInline):
|
|||||||
class ThingAdmin(admin.ModelAdmin):
|
class ThingAdmin(admin.ModelAdmin):
|
||||||
"""Admin configuration for Thing model."""
|
"""Admin configuration for Thing model."""
|
||||||
|
|
||||||
list_display = ('name', 'thing_type', 'box')
|
list_display = ('name', 'box')
|
||||||
list_filter = ('thing_type', 'box')
|
list_filter = ('box', 'tags')
|
||||||
search_fields = ('name', 'description')
|
search_fields = ('name', 'description')
|
||||||
|
filter_horizontal = ('tags',)
|
||||||
inlines = [ThingFileInline, ThingLinkInline]
|
inlines = [ThingFileInline, ThingLinkInline]
|
||||||
|
|
||||||
|
|
||||||
@@ -65,6 +60,53 @@ class ThingFileAdmin(admin.ModelAdmin):
|
|||||||
search_fields = ('title',)
|
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)
|
@admin.register(ThingLink)
|
||||||
class ThingLinkAdmin(admin.ModelAdmin):
|
class ThingLinkAdmin(admin.ModelAdmin):
|
||||||
"""Admin configuration for ThingLink model."""
|
"""Admin configuration for ThingLink model."""
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ class ThingForm(forms.ModelForm):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Thing
|
model = Thing
|
||||||
fields = ('name', 'thing_type', 'description', 'picture')
|
fields = ('name', 'description', 'picture', 'tags')
|
||||||
widgets = {
|
widgets = {
|
||||||
'name': forms.TextInput(attrs={'class': 'form-control'}),
|
'name': forms.TextInput(attrs={'class': 'form-control'}),
|
||||||
'thing_type': forms.Select(attrs={'class': 'form-control'}),
|
|
||||||
'description': forms.Textarea(attrs={'class': 'form-control', 'rows': 4}),
|
'description': forms.Textarea(attrs={'class': 'form-control', 'rows': 4}),
|
||||||
|
'tags': forms.CheckboxSelectMultiple(attrs={'class': 'tags-checkboxes'}),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,14 +5,6 @@ import mptt.fields
|
|||||||
from django.db import migrations, models
|
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):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
@@ -20,49 +12,4 @@ class Migration(migrations.Migration):
|
|||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
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),
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
boxes/migrations/0007_tag_color.py
Normal file
18
boxes/migrations/0007_tag_color.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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',
|
||||||
|
),
|
||||||
|
]
|
||||||
75
boxes/migrations/0009_migrate_tags_to_facets.py
Normal file
75
boxes/migrations/0009_migrate_tags_to_facets.py
Normal 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),
|
||||||
|
]
|
||||||
35
boxes/migrations/0010_remove_thingtype.py
Normal file
35
boxes/migrations/0010_remove_thingtype.py
Normal 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',
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
from mptt.models import MPTTModel, TreeForeignKey
|
|
||||||
|
|
||||||
|
|
||||||
def thing_picture_upload_path(instance, filename):
|
def thing_picture_upload_path(instance, filename):
|
||||||
@@ -50,34 +49,64 @@ class Box(models.Model):
|
|||||||
return self.id
|
return self.id
|
||||||
|
|
||||||
|
|
||||||
class ThingType(MPTTModel):
|
class Facet(models.Model):
|
||||||
"""A hierarchical type/category for things stored in boxes."""
|
"""A category of tags (e.g., Priority, Category, Status)."""
|
||||||
|
|
||||||
name = models.CharField(max_length=255)
|
class Cardinality(models.TextChoices):
|
||||||
parent = TreeForeignKey(
|
SINGLE = 'single', 'Single (0..1)'
|
||||||
'self',
|
MULTIPLE = 'multiple', 'Multiple (0..n)'
|
||||||
on_delete=models.CASCADE,
|
|
||||||
null=True,
|
name = models.CharField(max_length=100, unique=True)
|
||||||
blank=True,
|
slug = models.SlugField(max_length=100, unique=True)
|
||||||
related_name='children'
|
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:
|
class Meta:
|
||||||
order_insertion_by = ['name']
|
ordering = ['name']
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
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):
|
class Thing(models.Model):
|
||||||
"""An item stored in a box."""
|
"""An item stored in a box."""
|
||||||
|
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
thing_type = models.ForeignKey(
|
|
||||||
ThingType,
|
|
||||||
on_delete=models.PROTECT,
|
|
||||||
related_name='things'
|
|
||||||
)
|
|
||||||
box = models.ForeignKey(
|
box = models.ForeignKey(
|
||||||
Box,
|
Box,
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
@@ -85,6 +114,11 @@ class Thing(models.Model):
|
|||||||
)
|
)
|
||||||
description = models.TextField(blank=True)
|
description = models.TextField(blank=True)
|
||||||
picture = models.ImageField(upload_to=thing_picture_upload_path, blank=True)
|
picture = models.ImageField(upload_to=thing_picture_upload_path, blank=True)
|
||||||
|
tags = models.ManyToManyField(
|
||||||
|
Tag,
|
||||||
|
blank=True,
|
||||||
|
related_name='things'
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['name']
|
ordering = ['name']
|
||||||
|
|||||||
@@ -37,7 +37,6 @@
|
|||||||
<tr style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white;">
|
<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;">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;">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;">Description</th>
|
<th style="padding: 15px 20px; text-align: left; font-weight: 600;">Description</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -56,7 +55,6 @@
|
|||||||
<td style="padding: 15px 20px;">
|
<td style="padding: 15px 20px;">
|
||||||
<a href="{% url 'thing_detail' thing.id %}" style="color: #667eea; text-decoration: none; font-weight: 500;">{{ thing.name }}</a>
|
<a href="{% url 'thing_detail' thing.id %}" style="color: #667eea; text-decoration: none; font-weight: 500;">{{ thing.name }}</a>
|
||||||
</td>
|
</td>
|
||||||
<td style="padding: 15px 20px; color: #555;">{{ thing.thing_type.name }}</td>
|
|
||||||
<td style="padding: 15px 20px; color: #777;">{{ thing.description|default:"-" }}</td>
|
<td style="padding: 15px 20px; color: #777;">{{ thing.description|default:"-" }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% load mptt_tags %}
|
|
||||||
{% load dict_extras %}
|
|
||||||
|
|
||||||
{% block title %}LabHelper - Home{% endblock %}
|
{% block title %}LabHelper - Home{% endblock %}
|
||||||
|
|
||||||
@@ -44,36 +42,35 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h2><i class="fas fa-folder-tree"></i> Thing Types</h2>
|
<h2><i class="fas fa-tags"></i> Tags</h2>
|
||||||
{% if thing_types %}
|
{% if facet_tag_counts %}
|
||||||
<ul class="tree" style="list-style: none; padding-left: 0;">
|
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px;">
|
||||||
{% recursetree thing_types %}
|
{% for facet, tags_with_counts in facet_tag_counts.items %}
|
||||||
<li style="padding: 8px 0;">
|
<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="tree-item" style="display: flex; align-items: center; gap: 8px;">
|
<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;">
|
||||||
{% if children %}
|
<div style="display: flex; align-items: center; gap: 10px;">
|
||||||
<span class="toggle-handle" style="display: inline-block; width: 24px; color: #667eea; font-weight: bold; cursor: pointer; transition: transform 0.2s;">[+]</span>
|
<i class="fas fa-chevron-right facet-toggle" style="transition: transform 0.3s;"></i>
|
||||||
{% else %}
|
<span style="font-size: 18px; font-weight: 700;">{{ facet.name }}</span>
|
||||||
<span class="toggle-handle" style="display: inline-block; width: 24px; color: #ccc;"> </span>
|
</div>
|
||||||
{% endif %}
|
<span style="background: rgba(255,255,255,0.3); padding: 4px 12px; border-radius: 20px; font-size: 13px; font-weight: 600;">{{ facet.cardinality }}</span>
|
||||||
<a href="{% url 'thing_type_detail' node.pk %}" style="color: #667eea; text-decoration: none; font-size: 16px; font-weight: 500; transition: color 0.2s;">{{ node.name }}</a>
|
|
||||||
{% with count=type_counts|get_item:node.pk %}
|
|
||||||
{% if count and count > 0 %}
|
|
||||||
<span style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 3px 10px; border-radius: 20px; font-size: 12px; font-weight: 600;">{{ count }}</span>
|
|
||||||
{% endif %}
|
|
||||||
{% endwith %}
|
|
||||||
</div>
|
</div>
|
||||||
{% if children %}
|
<div class="facet-tags" style="padding: 15px 20px; display: none;">
|
||||||
<ul style="list-style: none; padding-left: 32px; display: none;">
|
<div style="display: flex; flex-wrap: wrap; gap: 8px;">
|
||||||
{{ children }}
|
{% for tag, count in tags_with_counts %}
|
||||||
</ul>
|
<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;">
|
||||||
{% endif %}
|
{{ tag.name }}
|
||||||
</li>
|
<span style="background: {{ facet.color }}; color: white; padding: 1px 8px; border-radius: 10px; margin-left: 6px; font-size: 12px;">{{ count }}</span>
|
||||||
{% endrecursetree %}
|
</a>
|
||||||
</ul>
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p style="text-align: center; color: #888; font-size: 16px; padding: 40px;">
|
<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>
|
<i class="fas fa-tag" style="font-size: 48px; margin-bottom: 15px; display: block;"></i>
|
||||||
No thing types found.
|
No tags found.
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -82,15 +79,27 @@
|
|||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
<script>
|
<script>
|
||||||
$(document).ready(function() {
|
$(document).ready(function() {
|
||||||
$('.toggle-handle').click(function(e) {
|
$('.facet-header').click(function() {
|
||||||
e.stopPropagation();
|
const $content = $(this).next('.facet-tags');
|
||||||
var $ul = $(this).closest('li').children('ul');
|
const $icon = $(this).find('.facet-toggle');
|
||||||
if ($ul.length) {
|
|
||||||
$ul.slideToggle(200);
|
$content.slideToggle(200);
|
||||||
$(this).text($ul.is(':visible') ? '[-]' : '[+]');
|
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(
|
$('.box-card').hover(
|
||||||
function() {
|
function() {
|
||||||
$(this).css('transform', 'translateY(-5px)');
|
$(this).css('transform', 'translateY(-5px)');
|
||||||
@@ -103,4 +112,4 @@ $(document).ready(function() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -13,10 +13,11 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<input type="text"
|
<input type="text"
|
||||||
id="search-input"
|
id="search-input"
|
||||||
placeholder="Search for things..."
|
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;">
|
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;">
|
<p style="color: #888; font-size: 14px; margin-top: 10px;">
|
||||||
<i class="fas fa-info-circle"></i> Type at least 2 characters to search
|
<i class="fas fa-info-circle"></i> Type at least 2 characters to search
|
||||||
</p>
|
</p>
|
||||||
@@ -55,58 +56,62 @@ const noResults = document.getElementById('no-results');
|
|||||||
|
|
||||||
let searchTimeout = null;
|
let searchTimeout = null;
|
||||||
|
|
||||||
searchInput.addEventListener('input', function() {
|
function performSearch(query) {
|
||||||
const query = this.value.trim();
|
|
||||||
|
|
||||||
if (searchTimeout) {
|
|
||||||
clearTimeout(searchTimeout);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (query.length < 2) {
|
if (query.length < 2) {
|
||||||
resultsContainer.style.display = 'none';
|
resultsContainer.style.display = 'none';
|
||||||
noResults.style.display = 'none';
|
noResults.style.display = 'none';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
searchInput.style.borderColor = '#667eea';
|
searchInput.style.borderColor = '#667eea';
|
||||||
searchInput.style.boxShadow = '0 0 0 3px rgba(102, 126, 234, 0.1)';
|
searchInput.style.boxShadow = '0 0 0 3px rgba(102, 126, 234, 0.1)';
|
||||||
|
|
||||||
searchTimeout = setTimeout(function() {
|
searchTimeout = setTimeout(function() {
|
||||||
fetch('/search/api/?q=' + encodeURIComponent(query))
|
fetch('/search/api/?q=' + encodeURIComponent(query))
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
resultsBody.innerHTML = '';
|
resultsBody.innerHTML = '';
|
||||||
|
|
||||||
if (data.results.length === 0) {
|
if (data.results.length === 0) {
|
||||||
resultsContainer.style.display = 'none';
|
resultsContainer.style.display = 'none';
|
||||||
noResults.style.display = 'block';
|
noResults.style.display = 'block';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
noResults.style.display = 'none';
|
noResults.style.display = 'none';
|
||||||
resultsContainer.style.display = 'block';
|
resultsContainer.style.display = 'block';
|
||||||
|
|
||||||
data.results.forEach(function(thing) {
|
data.results.forEach(function(thing) {
|
||||||
const row = document.createElement('tr');
|
const row = document.createElement('tr');
|
||||||
row.style.borderBottom = '1px solid #e0e0e0';
|
row.style.borderBottom = '1px solid #e0e0e0';
|
||||||
row.style.transition = 'background 0.2s';
|
row.style.transition = 'background 0.2s';
|
||||||
row.innerHTML =
|
row.innerHTML =
|
||||||
'<td style="padding: 15px 20px;"><a href="/thing/' + thing.id + '/">' + escapeHtml(thing.name) + '</a></td>' +
|
'<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; color: #555;">' + escapeHtml(thing.type) + '</td>' +
|
||||||
'<td style="padding: 15px 20px;"><a href="/box/' + escapeHtml(thing.box) + '/">' + escapeHtml(thing.box) + '</a></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>';
|
'<td style="padding: 15px 20px; color: #777;" class="description">' + escapeHtml(thing.description) + '</td>';
|
||||||
|
|
||||||
row.addEventListener('mouseenter', function() {
|
row.addEventListener('mouseenter', function() {
|
||||||
this.style.background = '#f8f9fa';
|
this.style.background = '#f8f9fa';
|
||||||
});
|
});
|
||||||
row.addEventListener('mouseleave', function() {
|
row.addEventListener('mouseleave', function() {
|
||||||
this.style.background = 'white';
|
this.style.background = 'white';
|
||||||
});
|
});
|
||||||
|
|
||||||
resultsBody.appendChild(row);
|
resultsBody.appendChild(row);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}, 200);
|
}, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
searchInput.addEventListener('input', function() {
|
||||||
|
const query = this.value.trim();
|
||||||
|
|
||||||
|
if (searchTimeout) {
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
performSearch(query);
|
||||||
});
|
});
|
||||||
|
|
||||||
searchInput.addEventListener('blur', function() {
|
searchInput.addEventListener('blur', function() {
|
||||||
@@ -120,6 +125,15 @@ function escapeHtml(text) {
|
|||||||
return div.innerHTML;
|
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();
|
searchInput.focus();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -53,10 +53,30 @@
|
|||||||
<div class="thing-details" style="flex-grow: 1; min-width: 300px;">
|
<div class="thing-details" style="flex-grow: 1; min-width: 300px;">
|
||||||
<div class="detail-row" style="margin-bottom: 25px;">
|
<div class="detail-row" style="margin-bottom: 25px;">
|
||||||
<div style="font-size: 14px; color: #888; font-weight: 600; margin-bottom: 8px;">
|
<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>
|
||||||
<div style="font-size: 18px; color: #333; font-weight: 500;">
|
<div style="display: flex; flex-direction: column; gap: 12px;">
|
||||||
{{ thing.thing_type.name }}
|
{% regroup thing.tags.all by facet as facet_list %}
|
||||||
|
{% for facet in facet_list %}
|
||||||
|
<div>
|
||||||
|
<div style="font-size: 12px; color: {{ facet.grouper.color }}; font-weight: 700; margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.5px;">
|
||||||
|
{{ facet.grouper.name }}
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; flex-wrap: wrap; gap: 8px;">
|
||||||
|
{% for tag in facet.list %}
|
||||||
|
<form method="post" style="display: inline;" onsubmit="return confirm('Remove tag {{ tag.facet.name }}:{{ tag.name }}?');">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="remove_tag">
|
||||||
|
<input type="hidden" name="tag_id" value="{{ tag.id }}">
|
||||||
|
<button type="submit" style="display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; background: {{ facet.grouper.color }}20; color: {{ facet.grouper.color }}; border: 2px solid {{ facet.grouper.color }}; border-radius: 20px; font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.3s;">
|
||||||
|
{{ tag.name }}
|
||||||
|
<i class="fas fa-times" style="font-size: 12px;"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -133,6 +153,46 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2 style="color: #667eea; font-size: 20px; font-weight: 700; margin-top: 0; margin-bottom: 20px; display: flex; align-items: center; gap: 10px;">
|
||||||
|
<i class="fas fa-plus-circle"></i> Add Tags
|
||||||
|
</h2>
|
||||||
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px,1fr)); gap: 30px;">
|
||||||
|
<div>
|
||||||
|
<h3 style="margin: 0 0 15px 0; color: #667eea; font-size: 16px; font-weight: 600;">
|
||||||
|
<i class="fas fa-tag"></i> Add Tag
|
||||||
|
</h3>
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="add_tag">
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 15px;">
|
||||||
|
<div>
|
||||||
|
<label for="tag_select" style="font-weight: 600; color: #666; font-size: 14px; margin-bottom: 8px; display: block;">Select Tag</label>
|
||||||
|
<select name="tag_id" id="tag_select" style="width: 100%; padding: 12px 16px; border: 2px solid #e0e0e0; border-radius: 10px; font-size: 15px; background: white; cursor: pointer; transition: all 0.3s;">
|
||||||
|
<option value="">-- Select a tag --</option>
|
||||||
|
{% for facet in facets %}
|
||||||
|
<optgroup label="{{ facet.name }} ({{ facet.get_cardinality_display }})">
|
||||||
|
{% for tag in facet.tags.all %}
|
||||||
|
{% if tag not in thing.tags.all %}
|
||||||
|
<option value="{{ tag.id }}">
|
||||||
|
{{ tag.name }}
|
||||||
|
</option>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</optgroup>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn">
|
||||||
|
<i class="fas fa-plus"></i> Add Tag
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
109
boxes/views.py
109
boxes/views.py
@@ -11,25 +11,28 @@ from .forms import (
|
|||||||
ThingLinkForm,
|
ThingLinkForm,
|
||||||
ThingPictureForm,
|
ThingPictureForm,
|
||||||
)
|
)
|
||||||
from .models import Box, BoxType, Thing, ThingFile, ThingLink, ThingType
|
from .models import Box, BoxType, Facet, Tag, Thing, ThingFile, ThingLink
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def index(request):
|
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')
|
boxes = Box.objects.select_related('box_type').all().order_by('id')
|
||||||
thing_types = ThingType.objects.all()
|
facets = Facet.objects.all().prefetch_related('tags')
|
||||||
|
|
||||||
type_counts = {}
|
facet_tag_counts = {}
|
||||||
for thing_type in thing_types:
|
for facet in facets:
|
||||||
descendants = thing_type.get_descendants(include_self=True)
|
for tag in facet.tags.all():
|
||||||
count = Thing.objects.filter(thing_type__in=descendants).count()
|
count = tag.things.count()
|
||||||
type_counts[thing_type.pk] = 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', {
|
return render(request, 'boxes/index.html', {
|
||||||
'boxes': boxes,
|
'boxes': boxes,
|
||||||
'thing_types': thing_types,
|
'facets': facets,
|
||||||
'type_counts': type_counts,
|
'facet_tag_counts': facet_tag_counts,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -37,7 +40,7 @@ def index(request):
|
|||||||
def box_detail(request, box_id):
|
def box_detail(request, box_id):
|
||||||
"""Display contents of a box."""
|
"""Display contents of a box."""
|
||||||
box = get_object_or_404(Box, pk=box_id)
|
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', {
|
return render(request, 'boxes/box_detail.html', {
|
||||||
'box': box,
|
'box': box,
|
||||||
'things': things,
|
'things': things,
|
||||||
@@ -48,11 +51,12 @@ def box_detail(request, box_id):
|
|||||||
def thing_detail(request, thing_id):
|
def thing_detail(request, thing_id):
|
||||||
"""Display details of a thing."""
|
"""Display details of a thing."""
|
||||||
thing = get_object_or_404(
|
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
|
pk=thing_id
|
||||||
)
|
)
|
||||||
|
|
||||||
boxes = Box.objects.select_related('box_type').all().order_by('id')
|
boxes = Box.objects.select_related('box_type').all().order_by('id')
|
||||||
|
facets = Facet.objects.all().prefetch_related('tags')
|
||||||
picture_form = ThingPictureForm(instance=thing)
|
picture_form = ThingPictureForm(instance=thing)
|
||||||
file_form = ThingFileForm()
|
file_form = ThingFileForm()
|
||||||
link_form = ThingLinkForm()
|
link_form = ThingLinkForm()
|
||||||
@@ -118,9 +122,34 @@ def thing_detail(request, thing_id):
|
|||||||
pass
|
pass
|
||||||
return redirect('thing_detail', thing_id=thing.id)
|
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', {
|
return render(request, 'boxes/thing_detail.html', {
|
||||||
'thing': thing,
|
'thing': thing,
|
||||||
'boxes': boxes,
|
'boxes': boxes,
|
||||||
|
'facets': facets,
|
||||||
'picture_form': picture_form,
|
'picture_form': picture_form,
|
||||||
'file_form': file_form,
|
'file_form': file_form,
|
||||||
'link_form': link_form,
|
'link_form': link_form,
|
||||||
@@ -140,21 +169,34 @@ def search_api(request):
|
|||||||
if len(query) < 2:
|
if len(query) < 2:
|
||||||
return JsonResponse({'results': []})
|
return JsonResponse({'results': []})
|
||||||
|
|
||||||
things = Thing.objects.filter(
|
# Check for "Facet:Word" format
|
||||||
Q(name__icontains=query) |
|
if ':' in query:
|
||||||
Q(description__icontains=query) |
|
parts = query.split(':',1)
|
||||||
Q(thing_type__name__icontains=query) |
|
facet_name = parts[0].strip()
|
||||||
Q(files__title__icontains=query) |
|
tag_name = parts[1].strip()
|
||||||
Q(files__file__icontains=query) |
|
|
||||||
Q(links__title__icontains=query) |
|
# Search for things with specific facet and tag
|
||||||
Q(links__url__icontains=query)
|
things = Thing.objects.filter(
|
||||||
).prefetch_related('files', 'links').select_related('thing_type', 'box').distinct()[:50]
|
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 = [
|
results = [
|
||||||
{
|
{
|
||||||
'id': thing.id,
|
'id': thing.id,
|
||||||
'name': thing.name,
|
'name': thing.name,
|
||||||
'type': thing.thing_type.name,
|
|
||||||
'box': thing.box.id,
|
'box': thing.box.id,
|
||||||
'description': thing.description[:100] if thing.description else '',
|
'description': thing.description[:100] if thing.description else '',
|
||||||
'files': [
|
'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
|
@login_required
|
||||||
def box_management(request):
|
def box_management(request):
|
||||||
"""Main page for managing boxes and box types."""
|
"""Main page for managing boxes and box types."""
|
||||||
|
|||||||
@@ -22,19 +22,15 @@ from django.contrib.auth import views as auth_views
|
|||||||
|
|
||||||
from boxes.views import (
|
from boxes.views import (
|
||||||
add_box,
|
add_box,
|
||||||
add_box_type,
|
|
||||||
add_things,
|
add_things,
|
||||||
box_detail,
|
box_detail,
|
||||||
box_management,
|
box_management,
|
||||||
delete_box,
|
delete_box,
|
||||||
delete_box_type,
|
|
||||||
edit_box,
|
edit_box,
|
||||||
edit_box_type,
|
|
||||||
index,
|
index,
|
||||||
search,
|
search,
|
||||||
search_api,
|
search_api,
|
||||||
thing_detail,
|
thing_detail,
|
||||||
thing_type_detail,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
@@ -42,15 +38,11 @@ urlpatterns = [
|
|||||||
path('logout/', auth_views.LogoutView.as_view(), name='logout'),
|
path('logout/', auth_views.LogoutView.as_view(), name='logout'),
|
||||||
path('', index, name='index'),
|
path('', index, name='index'),
|
||||||
path('box-management/', box_management, name='box_management'),
|
path('box-management/', box_management, name='box_management'),
|
||||||
path('box-type/add/', add_box_type, name='add_box_type'),
|
|
||||||
path('box-type/<int:type_id>/edit/', edit_box_type, name='edit_box_type'),
|
|
||||||
path('box-type/<int:type_id>/delete/', delete_box_type, name='delete_box_type'),
|
|
||||||
path('box/add/', add_box, name='add_box'),
|
path('box/add/', add_box, name='add_box'),
|
||||||
path('box/<str:box_id>/edit/', edit_box, name='edit_box'),
|
path('box/<str:box_id>/edit/', edit_box, name='edit_box'),
|
||||||
path('box/<str:box_id>/delete/', delete_box, name='delete_box'),
|
path('box/<str:box_id>/delete/', delete_box, name='delete_box'),
|
||||||
path('box/<str:box_id>/', box_detail, name='box_detail'),
|
path('box/<str:box_id>/', box_detail, name='box_detail'),
|
||||||
path('thing/<int:thing_id>/', thing_detail, name='thing_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('box/<str:box_id>/add/', add_things, name='add_things'),
|
||||||
path('search/', search, name='search'),
|
path('search/', search, name='search'),
|
||||||
path('search/api/', search_api, name='search_api'),
|
path('search/api/', search_api, name='search_api'),
|
||||||
|
|||||||
Reference in New Issue
Block a user