This commit is contained in:
2026-05-18 14:08:13 -04:00
commit 377326ec2c
36 changed files with 1473 additions and 0 deletions

33
inventory/admin.py Normal file
View File

@@ -0,0 +1,33 @@
from django.contrib import admin
from .models import Item, ItemNote, ItemPhoto, ItemTemplate, PriceEstimate
@admin.register(ItemTemplate)
class ItemTemplateAdmin(admin.ModelAdmin):
list_display = ("name", "category", "is_active", "updated_at")
search_fields = ("name", "category", "description")
list_filter = ("is_active", "category")
@admin.register(Item)
class ItemAdmin(admin.ModelAdmin):
list_display = ("title", "category", "status", "purchase_price", "sold_price", "created_by", "created_at")
search_fields = ("title", "brand", "category", "notes", "created_by__username")
list_filter = ("status", "category", "created_by")
@admin.register(PriceEstimate)
class PriceEstimateAdmin(admin.ModelAdmin):
list_display = ("item", "source", "estimated_price", "retrieved_at")
search_fields = ("item__title", "source", "notes")
@admin.register(ItemNote)
class ItemNoteAdmin(admin.ModelAdmin):
list_display = ("item", "created_by", "created_at")
@admin.register(ItemPhoto)
class ItemPhotoAdmin(admin.ModelAdmin):
list_display = ("item", "uploaded_at")

6
inventory/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class InventoryConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "inventory"

107
inventory/forms.py Normal file
View File

@@ -0,0 +1,107 @@
from django import forms
from .models import Item, ItemNote, ItemTemplate
class ItemTemplateForm(forms.ModelForm):
class Meta:
model = ItemTemplate
fields = [
"name",
"description",
"category",
"field_definitions",
"default_purchase_price",
"default_estimated_resale_price",
"default_notes",
"is_active",
]
widgets = {
"field_definitions": forms.Textarea(attrs={"rows": 4, "placeholder": '[{"name":"rewind","label":"Rewind?","type":"boolean"}]'}),
}
def clean_field_definitions(self):
raw = self.cleaned_data.get("field_definitions")
# Allow users to provide a JSON string or a python list
if raw in (None, ""):
return []
if isinstance(raw, str):
import json
try:
parsed = json.loads(raw)
except Exception as e:
raise forms.ValidationError("Invalid JSON for field_definitions: %s" % e)
else:
parsed = raw
if not isinstance(parsed, list):
raise forms.ValidationError("field_definitions must be a JSON list of field descriptors")
# Basic validation for each field descriptor
for entry in parsed:
if not isinstance(entry, dict) or "name" not in entry or "type" not in entry:
raise forms.ValidationError("Each field definition must be an object with at least 'name' and 'type'")
return parsed
class ItemForm(forms.ModelForm):
class Meta:
model = Item
fields = [
"template",
"title",
"brand",
"category",
"condition",
"size",
"color",
"purchase_price",
"estimated_resale_price",
"notes",
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["category"].required = False
self.fields["purchase_price"].required = False
self.fields["estimated_resale_price"].required = False
def clean(self):
cleaned_data = super().clean()
template = cleaned_data.get("template")
if not cleaned_data.get("category") and not template:
self.add_error("category", "Category is required when no template is selected.")
if cleaned_data.get("purchase_price") in (None, "") and not template:
self.add_error("purchase_price", "Purchase price is required when no template is selected.")
return cleaned_data
class ItemSoldForm(forms.ModelForm):
class Meta:
model = Item
fields = ["sold_price", "ebay_fee", "shipping_cost", "sold_at"]
class ItemNoteForm(forms.ModelForm):
class Meta:
model = ItemNote
fields = ["body"]
class ItemFilterForm(forms.Form):
query = forms.CharField(required=False)
status = forms.ChoiceField(required=False, choices=[("", "All")])
category = forms.CharField(required=False)
created_by = forms.CharField(required=False)
min_purchase_price = forms.DecimalField(required=False, min_value=0)
max_purchase_price = forms.DecimalField(required=False, min_value=0)
min_profit = forms.DecimalField(required=False)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["status"].choices = [("", "All")] + list(Item.Status.choices)

View File

@@ -0,0 +1,96 @@
# Generated by Django 4.2.30 on 2026-05-18 17:55
from django.conf import settings
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Item',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=200)),
('brand', models.CharField(blank=True, max_length=120)),
('category', models.CharField(max_length=80)),
('condition', models.CharField(blank=True, max_length=80)),
('size', models.CharField(blank=True, max_length=50)),
('color', models.CharField(blank=True, max_length=80)),
('purchase_price', models.DecimalField(decimal_places=2, max_digits=10, validators=[django.core.validators.MinValueValidator(0)])),
('estimated_resale_price', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)),
('sold_price', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)),
('ebay_fee', models.DecimalField(decimal_places=2, default=0, max_digits=10)),
('shipping_cost', models.DecimalField(decimal_places=2, default=0, max_digits=10)),
('status', models.CharField(choices=[('in_stock', 'In stock'), ('listed', 'Listed'), ('sold', 'Sold'), ('donated', 'Donated')], default='in_stock', max_length=20)),
('notes', models.TextField(blank=True)),
('acquisition_date', models.DateField(auto_now_add=True)),
('sold_at', models.DateTimeField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='items_created', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='ItemTemplate',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=120, unique=True)),
('description', models.TextField(blank=True)),
('category', models.CharField(max_length=80)),
('default_purchase_price', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)),
('default_estimated_resale_price', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)),
('default_notes', models.TextField(blank=True)),
('is_active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
),
migrations.CreateModel(
name='PriceEstimate',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('source', models.CharField(max_length=80)),
('source_url', models.URLField(blank=True)),
('estimated_price', models.DecimalField(decimal_places=2, max_digits=10)),
('notes', models.TextField(blank=True)),
('retrieved_at', models.DateTimeField(auto_now_add=True)),
('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='price_estimates', to='inventory.item')),
],
options={
'ordering': ['-retrieved_at'],
},
),
migrations.CreateModel(
name='ItemPhoto',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('file', models.FileField(upload_to='item-photos/')),
('uploaded_at', models.DateTimeField(auto_now_add=True)),
('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='photos', to='inventory.item')),
],
),
migrations.CreateModel(
name='ItemNote',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('body', models.TextField()),
('created_at', models.DateTimeField(auto_now_add=True)),
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notes_history', to='inventory.item')),
],
),
migrations.AddField(
model_name='item',
name='template',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='items', to='inventory.itemtemplate'),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 4.2.30 on 2026-05-18 18:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('inventory', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='item',
name='properties',
field=models.JSONField(blank=True, default=dict),
),
migrations.AddField(
model_name='itemtemplate',
name='field_definitions',
field=models.JSONField(blank=True, default=list),
),
]

View File

107
inventory/models.py Normal file
View File

@@ -0,0 +1,107 @@
from django.conf import settings
from django.core.validators import MinValueValidator
from django.db import models
from django.utils import timezone
class ItemTemplate(models.Model):
name = models.CharField(max_length=120, unique=True)
description = models.TextField(blank=True)
category = models.CharField(max_length=80)
# field_definitions is a list of objects describing extra fields for items of this template.
# Example: [{"name": "rewind", "label": "Rewind?", "type": "boolean"}, {"name":"output_count","label":"Outputs","type":"number"}]
field_definitions = models.JSONField(default=list, blank=True)
default_purchase_price = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
default_estimated_resale_price = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
default_notes = models.TextField(blank=True)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self) -> str:
return self.name
class Item(models.Model):
class Status(models.TextChoices):
IN_STOCK = "in_stock", "In stock"
LISTED = "listed", "Listed"
SOLD = "sold", "Sold"
DONATED = "donated", "Donated"
template = models.ForeignKey(ItemTemplate, null=True, blank=True, on_delete=models.SET_NULL, related_name="items")
created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="items_created")
title = models.CharField(max_length=200)
brand = models.CharField(max_length=120, blank=True)
category = models.CharField(max_length=80)
condition = models.CharField(max_length=80, blank=True)
size = models.CharField(max_length=50, blank=True)
color = models.CharField(max_length=80, blank=True)
purchase_price = models.DecimalField(max_digits=10, decimal_places=2, validators=[MinValueValidator(0)])
estimated_resale_price = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
sold_price = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
ebay_fee = models.DecimalField(max_digits=10, decimal_places=2, default=0)
shipping_cost = models.DecimalField(max_digits=10, decimal_places=2, default=0)
status = models.CharField(max_length=20, choices=Status.choices, default=Status.IN_STOCK)
notes = models.TextField(blank=True)
# properties stores template-specific values keyed by field name
properties = models.JSONField(default=dict, blank=True)
acquisition_date = models.DateField(auto_now_add=True)
sold_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
@property
def total_cost(self):
return self.purchase_price + self.ebay_fee + self.shipping_cost
@property
def profit(self):
if self.sold_price is None:
return None
return self.sold_price - self.total_cost
@property
def margin(self):
if self.profit is None or self.sold_price in (None, 0):
return None
return self.profit / self.sold_price
def mark_sold(self, sold_price, sold_at=None):
self.sold_price = sold_price
self.status = self.Status.SOLD
self.sold_at = sold_at or timezone.now()
self.save(update_fields=["sold_price", "status", "sold_at", "updated_at"])
def get_absolute_url(self):
from django.urls import reverse
return reverse("item-detail", kwargs={"pk": self.pk})
def __str__(self) -> str:
return self.title
class PriceEstimate(models.Model):
item = models.ForeignKey(Item, on_delete=models.CASCADE, related_name="price_estimates")
source = models.CharField(max_length=80)
source_url = models.URLField(blank=True)
estimated_price = models.DecimalField(max_digits=10, decimal_places=2)
notes = models.TextField(blank=True)
retrieved_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-retrieved_at"]
class ItemNote(models.Model):
item = models.ForeignKey(Item, on_delete=models.CASCADE, related_name="notes_history")
created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT)
body = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
class ItemPhoto(models.Model):
item = models.ForeignKey(Item, on_delete=models.CASCADE, related_name="photos")
file = models.FileField(upload_to="item-photos/")
uploaded_at = models.DateTimeField(auto_now_add=True)

43
inventory/services.py Normal file
View File

@@ -0,0 +1,43 @@
from decimal import Decimal
from .models import Item
class PricingSuggestionService:
def suggest_for_item(self, item: Item):
recent_comp = (
Item.objects.filter(status=Item.Status.SOLD, category=item.category)
.exclude(sold_price__isnull=True)
.order_by("-sold_at", "-created_at")
.values_list("sold_price", flat=True)[:5]
)
recent_prices = list(recent_comp)
if recent_prices:
average_price = sum(recent_prices) / len(recent_prices)
return {
"price": average_price.quantize(Decimal("0.01")),
"source": "Recent sold comps",
"notes": f"Based on {len(recent_prices)} recent sold items in {item.category}.",
}
if item.estimated_resale_price is not None:
return {
"price": item.estimated_resale_price,
"source": "Item estimate",
"notes": "Using the item's existing estimated resale price.",
}
if item.template and item.template.default_estimated_resale_price is not None:
return {
"price": item.template.default_estimated_resale_price,
"source": "Template default",
"notes": "Using the template's default resale estimate.",
}
fallback = (item.purchase_price * Decimal("1.75")).quantize(Decimal("0.01"))
return {
"price": fallback,
"source": "Purchase-price fallback",
"notes": "No sold comps yet; using a purchase-price multiple as a starting point.",
}

184
inventory/tests.py Normal file
View File

@@ -0,0 +1,184 @@
from decimal import Decimal
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse
from inventory.models import Item, ItemTemplate
from inventory.services import PricingSuggestionService
User = get_user_model()
class ItemModelTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username="alice", password="password123")
def test_profit_uses_costs_and_sale_price(self):
item = Item.objects.create(
created_by=self.user,
title="Vintage jacket",
category="Apparel",
purchase_price=Decimal("10.00"),
ebay_fee=Decimal("2.00"),
shipping_cost=Decimal("4.00"),
sold_price=Decimal("30.00"),
status=Item.Status.SOLD,
)
self.assertEqual(item.total_cost, Decimal("16.00"))
self.assertEqual(item.profit, Decimal("14.00"))
class ItemCreateViewTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username="alice", password="password123")
self.template = ItemTemplate.objects.create(
name="Blanket",
category="Home",
default_purchase_price=Decimal("8.00"),
default_estimated_resale_price=Decimal("24.00"),
default_notes="Check for stains.",
)
def test_template_defaults_are_applied_on_create(self):
self.client.login(username="alice", password="password123")
response = self.client.post(
reverse("item-create"),
{
"template": self.template.id,
"title": "Wool blanket",
"brand": "Pendleton",
"category": "",
"condition": "Good",
"size": "Queen",
"color": "Red",
"purchase_price": "",
"estimated_resale_price": "",
"notes": "",
},
)
self.assertEqual(response.status_code, 302)
item = Item.objects.get(title="Wool blanket")
self.assertEqual(item.category, "Home")
self.assertEqual(item.purchase_price, Decimal("8.00"))
self.assertEqual(item.estimated_resale_price, Decimal("24.00"))
self.assertEqual(item.notes, "Check for stains.")
self.assertEqual(item.created_by, self.user)
class ItemWorkflowTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username="alice", password="password123")
self.other_user = User.objects.create_user(username="bob", password="password123")
self.item = Item.objects.create(
created_by=self.user,
title="Leather boots",
category="Shoes",
purchase_price=Decimal("20.00"),
)
def test_dashboard_filters_by_query(self):
self.client.login(username="alice", password="password123")
response = self.client.get(reverse("dashboard"), {"query": "boots"})
self.assertContains(response, "Leather boots")
def test_mark_item_sold_sets_status_and_sold_date(self):
self.client.login(username="alice", password="password123")
response = self.client.post(
reverse("item-mark-sold", kwargs={"pk": self.item.pk}),
{
"sold_price": "55.00",
"ebay_fee": "5.00",
"shipping_cost": "7.00",
"sold_at": "",
},
)
self.assertEqual(response.status_code, 302)
self.item.refresh_from_db()
self.assertEqual(self.item.status, Item.Status.SOLD)
self.assertEqual(self.item.sold_price, Decimal("55.00"))
self.assertIsNotNone(self.item.sold_at)
def test_editing_item_does_not_change_creator(self):
self.client.login(username="bob", password="password123")
response = self.client.post(
reverse("item-edit", kwargs={"pk": self.item.pk}),
{
"template": "",
"title": "Leather boots updated",
"brand": "",
"category": "Shoes",
"condition": "",
"size": "",
"color": "",
"purchase_price": "20.00",
"estimated_resale_price": "",
"notes": "Updated notes",
},
)
self.assertEqual(response.status_code, 302)
self.item.refresh_from_db()
self.assertEqual(self.item.title, "Leather boots updated")
self.assertEqual(self.item.created_by, self.user)
class TemplateAndPricingTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username="alice", password="password123")
def test_template_edit_updates_existing_template(self):
template = ItemTemplate.objects.create(name="Mugs", category="Kitchen")
self.client.login(username="alice", password="password123")
response = self.client.post(
reverse("template-edit", kwargs={"pk": template.pk}),
{
"name": "Mugs and cups",
"description": "Glassware",
"category": "Kitchen",
"default_purchase_price": "3.00",
"default_estimated_resale_price": "12.00",
"default_notes": "Check for chips.",
"is_active": "on",
},
)
self.assertEqual(response.status_code, 302)
template.refresh_from_db()
self.assertEqual(template.name, "Mugs and cups")
self.assertEqual(template.default_purchase_price, Decimal("3.00"))
def test_pricing_suggestion_uses_recent_sold_comps(self):
sold_one = Item.objects.create(
created_by=self.user,
title="Blue sweater",
category="Apparel",
purchase_price=Decimal("10.00"),
)
sold_one.mark_sold(Decimal("40.00"))
sold_two = Item.objects.create(
created_by=self.user,
title="Green sweater",
category="Apparel",
purchase_price=Decimal("12.00"),
)
sold_two.mark_sold(Decimal("60.00"))
unsold = Item.objects.create(
created_by=self.user,
title="Striped sweater",
category="Apparel",
purchase_price=Decimal("15.00"),
)
suggestion = PricingSuggestionService().suggest_for_item(unsold)
self.assertEqual(suggestion["price"], Decimal("50.00"))
self.assertEqual(suggestion["source"], "Recent sold comps")

View File

@@ -0,0 +1,44 @@
from decimal import Decimal
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse
from inventory.models import Item, ItemTemplate
User = get_user_model()
class TemplateFieldsIntegrationTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username="alice", password="password123")
def test_create_item_with_template_fields(self):
tmpl = ItemTemplate.objects.create(
name="VHS Player",
category="Electronics",
field_definitions=[
{"name": "rewind", "label": "Has Rewind", "type": "boolean"},
{"name": "outputs", "label": "Output Count", "type": "number"},
],
)
self.client.login(username="alice", password="password123")
response = self.client.post(
reverse("item-create"),
{
"template": tmpl.id,
"title": "Panasonic VHS",
"brand": "Panasonic",
"category": "",
"purchase_price": "5.00",
"prop_rewind": "on",
"prop_outputs": "2",
},
)
self.assertEqual(response.status_code, 302)
item = Item.objects.get(title="Panasonic VHS")
self.assertTrue(item.properties.get("rewind"))
self.assertEqual(item.properties.get("outputs"), "2")

25
inventory/urls.py Normal file
View File

@@ -0,0 +1,25 @@
from django.urls import path
from .views import (
DashboardView,
ItemCreateView,
ItemDetailView,
ItemMarkSoldView,
ItemNoteCreateView,
ItemTemplateCreateView,
ItemTemplateUpdateView,
ItemUpdateView,
)
urlpatterns = [
path("", DashboardView.as_view(), name="dashboard"),
path("items/new/", ItemCreateView.as_view(), name="item-create"),
path("items/<int:pk>/", ItemDetailView.as_view(), name="item-detail"),
path("items/<int:pk>/edit/", ItemUpdateView.as_view(), name="item-edit"),
path("items/<int:pk>/sold/", ItemMarkSoldView.as_view(), name="item-mark-sold"),
path("items/<int:pk>/notes/new/", ItemNoteCreateView.as_view(), name="item-note-create"),
path("templates/new/", ItemTemplateCreateView.as_view(), name="template-create"),
path("templates/<int:pk>/edit/", ItemTemplateUpdateView.as_view(), name="template-edit"),
path("add/", DashboardView.as_view(), name="add-hub"),
]

199
inventory/views.py Normal file
View File

@@ -0,0 +1,199 @@
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import DecimalField, ExpressionWrapper, F, Q, Sum
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404
from django.urls import reverse_lazy
from django.utils import timezone
from django.views.generic import CreateView, DetailView, TemplateView, UpdateView
from .forms import ItemForm, ItemFilterForm, ItemNoteForm, ItemSoldForm, ItemTemplateForm
from .models import Item, ItemNote, ItemTemplate
from .services import PricingSuggestionService
import decimal
class DashboardView(LoginRequiredMixin, TemplateView):
template_name = "inventory/dashboard.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
item_filter = ItemFilterForm(self.request.GET or None)
items = Item.objects.select_related("created_by", "template").order_by("-created_at")
if item_filter.is_valid():
query = item_filter.cleaned_data.get("query")
status = item_filter.cleaned_data.get("status")
category = item_filter.cleaned_data.get("category")
created_by = item_filter.cleaned_data.get("created_by")
min_purchase_price = item_filter.cleaned_data.get("min_purchase_price")
max_purchase_price = item_filter.cleaned_data.get("max_purchase_price")
min_profit = item_filter.cleaned_data.get("min_profit")
if query:
items = items.filter(
Q(title__icontains=query)
| Q(brand__icontains=query)
| Q(notes__icontains=query)
| Q(category__icontains=query)
)
if status:
items = items.filter(status=status)
if category:
items = items.filter(category__icontains=category)
if min_purchase_price is not None:
items = items.filter(purchase_price__gte=min_purchase_price)
if max_purchase_price is not None:
items = items.filter(purchase_price__lte=max_purchase_price)
if min_profit is not None:
items = items.annotate(
calculated_profit=ExpressionWrapper(
F("sold_price") - F("purchase_price") - F("ebay_fee") - F("shipping_cost"),
output_field=DecimalField(max_digits=10, decimal_places=2),
)
).filter(
sold_price__isnull=False,
calculated_profit__gte=min_profit,
)
if created_by:
items = items.filter(created_by__username__icontains=created_by)
context["items"] = items[:100]
context["templates"] = ItemTemplate.objects.filter(is_active=True).order_by("name")
context["item_filter"] = item_filter
context["item_form"] = ItemForm()
context["template_form"] = ItemTemplateForm()
inventory_cost = Item.objects.aggregate(total=Sum("purchase_price"))["total"] or 0
context["stats"] = {
"item_count": Item.objects.count(),
"profit_total": sum((item.profit or 0) for item in Item.objects.all()),
"sold_count": Item.objects.filter(status=Item.Status.SOLD).count(),
"inventory_value": inventory_cost,
}
return context
class ItemCreateView(LoginRequiredMixin, CreateView):
model = Item
form_class = ItemForm
template_name = "inventory/item_form.html"
success_url = reverse_lazy("dashboard")
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
template_id = self.request.GET.get("template") or self.request.POST.get("template")
template_obj = None
if template_id:
try:
template_obj = ItemTemplate.objects.get(pk=int(template_id))
except Exception:
template_obj = None
context["template_obj"] = template_obj
return context
def form_valid(self, form):
form.instance.created_by = self.request.user
template = form.cleaned_data.get("template")
properties = {}
# If template defines extra fields, read them from POST and coerce types
if template and template.field_definitions:
for fd in template.field_definitions:
key = fd.get("name")
ftype = fd.get("type", "text")
raw = self.request.POST.get(f"prop_{key}")
if raw is None or raw == "":
value = None
else:
if ftype == "number":
try:
value = decimal.Decimal(raw)
# JSONField cannot store Decimal directly; store as string to preserve precision
value = str(value)
except Exception:
value = raw
elif ftype == "boolean":
value = raw in ("on", "true", "1", "True")
else:
value = raw
properties[key] = value
form.instance.properties = properties
if template:
if not form.cleaned_data.get("category"):
form.instance.category = template.category
if not form.cleaned_data.get("purchase_price") and template.default_purchase_price is not None:
form.instance.purchase_price = template.default_purchase_price
if not form.cleaned_data.get("estimated_resale_price") and template.default_estimated_resale_price is not None:
form.instance.estimated_resale_price = template.default_estimated_resale_price
if not form.cleaned_data.get("notes") and template.default_notes:
form.instance.notes = template.default_notes
return super().form_valid(form)
class ItemDetailView(LoginRequiredMixin, DetailView):
model = Item
template_name = "inventory/item_detail.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["edit_form"] = ItemForm(instance=self.object)
context["sold_form"] = ItemSoldForm(instance=self.object)
context["note_form"] = ItemNoteForm()
context["notes"] = self.object.notes_history.select_related("created_by").order_by("-created_at")
context["price_estimates"] = self.object.price_estimates.all()
context["pricing_suggestion"] = PricingSuggestionService().suggest_for_item(self.object)
return context
class ItemUpdateView(LoginRequiredMixin, UpdateView):
model = Item
form_class = ItemForm
template_name = "inventory/item_form.html"
def get_success_url(self):
return self.object.get_absolute_url()
class ItemMarkSoldView(LoginRequiredMixin, UpdateView):
model = Item
form_class = ItemSoldForm
template_name = "inventory/item_sold_form.html"
def form_valid(self, form):
self.object = form.save(commit=False)
self.object.status = Item.Status.SOLD
if self.object.sold_at is None:
self.object.sold_at = timezone.now()
self.object.save()
return HttpResponseRedirect(self.get_success_url())
def get_success_url(self):
return self.object.get_absolute_url()
class ItemNoteCreateView(LoginRequiredMixin, CreateView):
model = ItemNote
form_class = ItemNoteForm
template_name = "inventory/note_form.html"
success_url = reverse_lazy("dashboard")
def form_valid(self, form):
form.instance.item = get_object_or_404(Item, pk=self.kwargs["pk"])
form.instance.created_by = self.request.user
response = super().form_valid(form)
return HttpResponseRedirect(self.object.item.get_absolute_url())
def get_success_url(self):
return self.object.item.get_absolute_url()
class ItemTemplateCreateView(LoginRequiredMixin, CreateView):
model = ItemTemplate
form_class = ItemTemplateForm
template_name = "inventory/template_form.html"
success_url = reverse_lazy("dashboard")
class ItemTemplateUpdateView(LoginRequiredMixin, UpdateView):
model = ItemTemplate
form_class = ItemTemplateForm
template_name = "inventory/template_form.html"
success_url = reverse_lazy("dashboard")