update
This commit is contained in:
33
inventory/admin.py
Normal file
33
inventory/admin.py
Normal 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
6
inventory/apps.py
Normal 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
107
inventory/forms.py
Normal 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)
|
||||
96
inventory/migrations/0001_initial.py
Normal file
96
inventory/migrations/0001_initial.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
0
inventory/migrations/__init__.py
Normal file
0
inventory/migrations/__init__.py
Normal file
107
inventory/models.py
Normal file
107
inventory/models.py
Normal 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
43
inventory/services.py
Normal 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
184
inventory/tests.py
Normal 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")
|
||||
44
inventory/tests_template_fields.py
Normal file
44
inventory/tests_template_fields.py
Normal 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
25
inventory/urls.py
Normal 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
199
inventory/views.py
Normal 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")
|
||||
Reference in New Issue
Block a user