Skip to content

Commit 3fe3dc7

Browse files
committed
Fix slugs
1 parent 284e06a commit 3fe3dc7

6 files changed

Lines changed: 187 additions & 50 deletions

File tree

landolfio/inventory/admin.py

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,9 @@ def get_queryset(self, request):
5757

5858
def formfield_for_foreignkey(self, db_field, request, **kwargs):
5959
if db_field.name == "property" and hasattr(self, "parent_obj"):
60-
# Filter properties by the asset's category
60+
# Filter properties that apply to the asset's category
6161
kwargs["queryset"] = AssetProperty.objects.filter(
62-
category=self.parent_obj.category
62+
categories=self.parent_obj.category
6363
)
6464
return super().formfield_for_foreignkey(db_field, request, **kwargs)
6565

@@ -669,18 +669,23 @@ def has_add_permission(self, request, obj=None):
669669
class AssetPropertyAdmin(admin.ModelAdmin):
670670
"""Admin interface for managing asset properties."""
671671

672-
list_display = ["name", "category", "property_type", "unit", "order"]
672+
list_display = ["name", "get_categories", "property_type", "unit", "order"]
673673
list_filter = [
674-
"category",
674+
"categories",
675675
"property_type",
676676
]
677-
search_fields = ["name", "category__name"]
678-
ordering = ["category", "order", "name"]
677+
search_fields = ["name", "categories__name"]
678+
ordering = ["name", "order"]
679+
680+
def get_categories(self, obj):
681+
return ", ".join([cat.name for cat in obj.categories.all()])
682+
683+
get_categories.short_description = _("Categories")
679684

680685
fieldsets = (
681686
(
682687
_("Basic Information"),
683-
{"fields": ("category", "name", "property_type", "order")},
688+
{"fields": ("categories", "name", "property_type", "order")},
684689
),
685690
(
686691
_("Type-Specific Settings"),
@@ -728,8 +733,8 @@ def get_queryset(self, request):
728733

729734
def formfield_for_foreignkey(self, db_field, request, **kwargs):
730735
if db_field.name == "property" and hasattr(request, "_asset_category"):
731-
# Filter properties by category if we know the asset's category
736+
# Filter properties that apply to the category if we know the asset's category
732737
kwargs["queryset"] = AssetProperty.objects.filter(
733-
category=request._asset_category
738+
categories=request._asset_category
734739
)
735740
return super().formfield_for_foreignkey(db_field, request, **kwargs)
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Generated by Django 5.1.6 on 2025-07-20 21:32
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("inventory", "0006_assetproperty_assetpropertyvalue"),
10+
]
11+
12+
operations = [
13+
migrations.AlterModelOptions(
14+
name="assetproperty",
15+
options={
16+
"ordering": ["name", "order"],
17+
"verbose_name": "asset property",
18+
"verbose_name_plural": "asset properties",
19+
},
20+
),
21+
migrations.AlterUniqueTogether(
22+
name="assetproperty",
23+
unique_together=set(),
24+
),
25+
migrations.AddField(
26+
model_name="assetproperty",
27+
name="categories",
28+
field=models.ManyToManyField(
29+
help_text="Categories this property applies to",
30+
related_name="properties",
31+
to="inventory.category",
32+
verbose_name="categories",
33+
),
34+
),
35+
migrations.AlterField(
36+
model_name="assetproperty",
37+
name="slug",
38+
field=models.SlugField(
39+
blank=True,
40+
help_text="URL-friendly version of property name (e.g., 'color', 'weight')",
41+
max_length=100,
42+
null=True,
43+
unique=True,
44+
verbose_name="slug",
45+
),
46+
),
47+
migrations.RemoveField(
48+
model_name="assetproperty",
49+
name="category",
50+
),
51+
]
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Generated by Django 5.1.6 on 2025-07-20 21:43
2+
3+
from django.db import migrations
4+
from django.utils.text import slugify
5+
6+
7+
def update_property_slugs(apps, schema_editor):
8+
"""Update existing property slugs to remove category prefixes."""
9+
AssetProperty = apps.get_model("inventory", "AssetProperty")
10+
11+
for prop in AssetProperty.objects.all():
12+
# Generate new slug from just the property name
13+
new_slug = slugify(prop.name)
14+
15+
# Ensure uniqueness
16+
counter = 1
17+
original_slug = new_slug
18+
while AssetProperty.objects.filter(slug=new_slug).exclude(id=prop.id).exists():
19+
new_slug = f"{original_slug}-{counter}"
20+
counter += 1
21+
22+
prop.slug = new_slug
23+
prop.save()
24+
25+
26+
def reverse_update_property_slugs(apps, schema_editor):
27+
"""Reverse operation - not implemented as old slugs can't be reconstructed."""
28+
pass
29+
30+
31+
class Migration(migrations.Migration):
32+
33+
dependencies = [
34+
("inventory", "0007_alter_assetproperty_options_and_more"),
35+
]
36+
37+
operations = [
38+
migrations.RunPython(update_property_slugs, reverse_update_property_slugs),
39+
]

landolfio/inventory/models/asset_property.py

Lines changed: 15 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,14 @@ class AssetPropertyType(models.TextChoices):
1313

1414
class AssetProperty(models.Model):
1515
"""
16-
Defines a property that can be assigned to assets of a specific category.
16+
Defines a property that can be assigned to assets of multiple categories.
1717
"""
1818

19-
category = models.ForeignKey(
19+
categories = models.ManyToManyField(
2020
"Category",
21-
on_delete=models.CASCADE,
2221
related_name="properties",
23-
verbose_name=_("category"),
22+
verbose_name=_("categories"),
23+
help_text=_("Categories this property applies to"),
2424
)
2525
name = models.CharField(
2626
max_length=100,
@@ -33,9 +33,7 @@ class AssetProperty(models.Model):
3333
blank=True,
3434
null=True,
3535
verbose_name=_("slug"),
36-
help_text=_(
37-
"URL-friendly version with category prefix (e.g., 'electronics-color', 'furniture-weight')"
38-
),
36+
help_text=_("URL-friendly version of property name (e.g., 'color', 'weight')"),
3937
)
4038
property_type = models.CharField(
4139
max_length=20,
@@ -65,11 +63,13 @@ class AssetProperty(models.Model):
6563
class Meta:
6664
verbose_name = _("asset property")
6765
verbose_name_plural = _("asset properties")
68-
unique_together = [("category", "name")]
69-
ordering = ["category", "order", "name"]
66+
ordering = ["name", "order"]
7067

7168
def __str__(self):
72-
return f"{self.category.name} - {self.name}"
69+
category_names = ", ".join([cat.name for cat in self.categories.all()[:3]])
70+
if self.categories.count() > 3:
71+
category_names += "..."
72+
return f"{self.name} ({category_names})"
7373

7474
def clean(self):
7575
super().clean()
@@ -116,15 +116,9 @@ def get_dropdown_options(self):
116116
return []
117117

118118
def _generate_slug(self):
119-
"""Generate a category-prefixed slug from the property name."""
120-
if not self.category:
121-
raise ValidationError(
122-
{"category": _("Category is required to generate slug")}
123-
)
124-
125-
category_slug = slugify(self.category.name_singular)
119+
"""Generate a slug from the property name."""
126120
property_slug = slugify(self.name)
127-
return f"{category_slug}-{property_slug}"
121+
return property_slug
128122

129123
def save(self, *args, **kwargs):
130124
# Auto-generate slug if not provided
@@ -168,14 +162,10 @@ def __str__(self):
168162
def clean(self):
169163
super().clean()
170164

171-
# Validate that the property belongs to the asset's category
172-
if self.asset.category != self.property.category:
165+
# Validate that the property applies to the asset's category
166+
if self.asset.category not in self.property.categories.all():
173167
raise ValidationError(
174-
{
175-
"property": _(
176-
"Property must belong to the same category as the asset"
177-
)
178-
}
168+
{"property": _("Property must apply to the asset's category")}
179169
)
180170

181171
# Validate value based on property type

landolfio/inventory_frontend/templates/list.html

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -212,9 +212,7 @@
212212
{% for property in all_properties %}
213213
<div class="mb-3">
214214
<div class="d-flex justify-content-between align-items-center">
215-
<label class="form-label fw-bold small mb-0">{{ property.name }}
216-
<span class="text-muted">({{ property.category.name }})</span>
217-
</label>
215+
<label class="form-label fw-bold small mb-0">{{ property.name }}</label>
218216
<i class="fas fa-eye property-visibility-toggle property-eye-icon"
219217
style="cursor: pointer; color: #6c757d; font-size: 0.8rem;"
220218
data-property-slug="{{ property.slug }}"

landolfio/inventory_frontend/views.py

Lines changed: 67 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -136,8 +136,56 @@ def get_queryset(self):
136136
for property_filter in property_filters:
137137
queryset = queryset.filter(property_filter)
138138

139+
# Apply numeric range filters separately
140+
queryset = self._apply_numeric_range_filters(queryset)
141+
139142
return queryset.select_related("category", "location", "collection", "size")
140143

144+
def _apply_numeric_range_filters(self, queryset):
145+
"""Apply numeric range filters using proper numeric comparison."""
146+
from django.db.models import DecimalField
147+
from django.db.models.functions import Cast
148+
149+
for param_name, param_values in self.request.GET.lists():
150+
if param_name.endswith(("_min", "_max")):
151+
try:
152+
property_slug = param_name.replace("_min", "").replace("_max", "")
153+
property_obj = AssetProperty.objects.get(slug=property_slug)
154+
155+
if property_obj.property_type == "number":
156+
range_type = "min" if param_name.endswith("_min") else "max"
157+
158+
for param_value in param_values:
159+
if param_value.strip():
160+
numeric_value = float(param_value)
161+
162+
# Apply range filter using Cast for proper numeric comparison
163+
if range_type == "min":
164+
queryset = queryset.filter(
165+
property_values__property=property_obj,
166+
property_values__value__regex=r"^[0-9]+\.?[0-9]*$",
167+
).extra(
168+
where=[
169+
"CAST(inventory_assetpropertyvalue.value AS DECIMAL(10,2)) >= %s"
170+
],
171+
params=[numeric_value],
172+
)
173+
else: # max
174+
queryset = queryset.filter(
175+
property_values__property=property_obj,
176+
property_values__value__regex=r"^[0-9]+\.?[0-9]*$",
177+
).extra(
178+
where=[
179+
"CAST(inventory_assetpropertyvalue.value AS DECIMAL(10,2)) <= %s"
180+
],
181+
params=[numeric_value],
182+
)
183+
break
184+
except (ValueError, TypeError, AssetProperty.DoesNotExist):
185+
continue
186+
187+
return queryset
188+
141189
def _parse_property_parameters(self):
142190
"""Parse property-related parameters from the request."""
143191
property_params = {}
@@ -210,13 +258,19 @@ def _build_numeric_filter(self, property_id, params):
210258
has_min = "min" in params
211259
has_max = "max" in params
212260

213-
if has_min or has_max:
214-
return (
215-
Q(property_values__property_id=property_id)
216-
& Q(property_values__value__regex=r"^[0-9]+\.?[0-9]*$")
217-
& ~Q(property_values__value="")
218-
)
219-
return None
261+
if not (has_min or has_max):
262+
return None
263+
264+
# Base filter for valid numeric values
265+
base_filter = (
266+
Q(property_values__property_id=property_id)
267+
& Q(property_values__value__regex=r"^[0-9]+\.?[0-9]*$")
268+
& ~Q(property_values__value="")
269+
)
270+
271+
# For range filtering, we need to do post-processing since we store numeric values as strings
272+
# We'll return the base filter and let the get_queryset method handle the range logic
273+
return base_filter
220274

221275
def _build_dropdown_filter(self, property_id, params):
222276
"""Build filter for dropdown properties."""
@@ -247,8 +301,8 @@ def _get_property_filters(self):
247301

248302
def _get_properties_with_current_values(self):
249303
"""Get all properties with their current filter values attached."""
250-
properties = AssetProperty.objects.select_related("category").order_by(
251-
"category__name", "order", "name"
304+
properties = AssetProperty.objects.prefetch_related("categories").order_by(
305+
"name", "order"
252306
)
253307

254308
for prop in properties:
@@ -481,7 +535,7 @@ def get_context_data(self, **kwargs):
481535

482536
# Add asset properties
483537
category_properties = AssetProperty.objects.filter(
484-
category=asset.category
538+
categories=asset.category
485539
).order_by("order", "name")
486540

487541
# Get existing property values for this asset
@@ -600,7 +654,7 @@ def post(self, request, *args, **kwargs):
600654
def _update_asset_properties(self, asset, data):
601655
"""Update asset property values from form data."""
602656
updated_properties = []
603-
category_properties = AssetProperty.objects.filter(category=asset.category)
657+
category_properties = AssetProperty.objects.filter(categories=asset.category)
604658

605659
for prop in category_properties:
606660
field_name = f"property_{prop.slug}"
@@ -767,11 +821,11 @@ def get(self, request, *args, **kwargs):
767821
except AssetProperty.DoesNotExist:
768822
return JsonResponse([], safe=False)
769823

770-
# Get distinct values for this property from assets in the same category that match the query
824+
# Get distinct values for this property from assets in categories that use this property
771825
values = (
772826
AssetPropertyValue.objects.filter(
773827
property=property_obj,
774-
asset__category=property_obj.category, # Only from same category
828+
asset__category__in=property_obj.categories.all(), # Only from categories that use this property
775829
value__icontains=query,
776830
)
777831
.values_list("value", flat=True)

0 commit comments

Comments
 (0)