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

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
.venv/
__pycache__/
*.pyc
db.sqlite3
*.egg-info/
staticfiles/
media/

12
README.md Normal file
View File

@@ -0,0 +1,12 @@
# Arbit
Internal dashboard for tracking thrifted inventory, resale pricing, and profitability.
## Local setup
1. Create a virtual environment.
2. Use Python 3.9 or newer.
3. Install dependencies with `pip install -e .`.
4. Run migrations with `python manage.py migrate`.
5. Create a superuser with `python manage.py createsuperuser`.
6. Start the server with `python manage.py runserver`.

10
config/asgi.py Normal file
View File

@@ -0,0 +1,10 @@
"""ASGI config for arbit."""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
application = get_asgi_application()

73
config/settings.py Normal file
View File

@@ -0,0 +1,73 @@
from pathlib import Path
import os
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "django-insecure-arbit-dev-key")
DEBUG = os.environ.get("DJANGO_DEBUG", "true").lower() == "true"
ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", "127.0.0.1,localhost").split(",")
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"inventory",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "config.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [BASE_DIR / "templates"],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
}
]
WSGI_APPLICATION = "config.wsgi.application"
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
}
AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]
LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC"
USE_I18N = True
USE_TZ = True
STATIC_URL = "static/"
STATICFILES_DIRS = [BASE_DIR / "static"]
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
LOGIN_REDIRECT_URL = "/"
LOGOUT_REDIRECT_URL = "/accounts/login/"

11
config/urls.py Normal file
View File

@@ -0,0 +1,11 @@
from django.contrib import admin
from django.contrib.auth import views as auth_views
from django.urls import include, path
urlpatterns = [
path("admin/", admin.site.urls),
path("accounts/login/", auth_views.LoginView.as_view(template_name="registration/login.html"), name="login"),
path("accounts/logout/", auth_views.LogoutView.as_view(), name="logout"),
path("", include("inventory.urls")),
]

10
config/wsgi.py Normal file
View File

@@ -0,0 +1,10 @@
"""WSGI config for arbit."""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
application = get_wsgi_application()

15
docs/agent-guide.md Normal file
View File

@@ -0,0 +1,15 @@
# Agent Guide
## Implementation priorities
1. Keep the app simple for a 3-person internal team.
2. Prefer server-rendered Django flows over heavy frontend complexity.
3. Optimize for fast item creation and quick search.
4. Preserve creator attribution on every item.
5. Treat pricing integrations as advisory.
## Working conventions
- Update the docs whenever a workflow or model meaningfully changes.
- Keep new fields aligned with the existing thrift-to-sale lifecycle.
- Add focused tests for item creation, filters, and profit math before expanding the feature set.

30
docs/architecture.md Normal file
View File

@@ -0,0 +1,30 @@
# Architecture
Arbit is a small internal Django app for tracking thrift acquisitions through resale.
## Goals
- Fast item entry
- Editable templates for recurring item types
- Searchable inventory with practical filters
- Profit tracking and resale pricing guidance
- Simple per-item attribution to the user who created the item
## Stack
- Django for server-rendered forms, auth, and admin
- SQLite for local development, PostgreSQL for production
- HTMX later if the team wants faster inline editing without a full SPA
## Components
- `inventory` app for domain models and dashboard views
- Django auth for login/logout
- Django admin for internal management and emergency data editing
- Templates for the main dashboard and item forms
## Phase 1 Boundaries
- No role-based permissions beyond standard authenticated access
- No marketplace posting automation
- Pricing suggestions are advisory and can be overridden manually

22
docs/domain-model.md Normal file
View File

@@ -0,0 +1,22 @@
# Domain Model
## Core entities
- ItemTemplate: reusable defaults for common purchase types
- Item: individual acquired object being tracked for resale
- PriceEstimate: a resale suggestion from comps or manual input
- ItemNote: internal notes and condition updates
- ItemPhoto: images attached to the item
## Item lifecycle
1. Created from a template or from scratch
2. Marked in stock
3. Optionally listed
4. Sold or removed from inventory
## Tracking rules
- The creator of the item is stored on the record and shown in item views
- Profit is based on sold price minus purchase price, fees, and shipping cost
- Estimated resale price is only a suggestion until sold data exists

View File

@@ -0,0 +1,24 @@
# Implementation Checklist
## Phase 1
- [x] Scaffold Django project and auth wiring
- [x] Create core inventory models
- [x] Add template-driven item entry
- [x] Add a searchable dashboard view
- [x] Add starter tests for profit math and template defaults
## Phase 2
- [ ] Add editable item detail pages
- [ ] Add sold-price and fee entry workflows
- [ ] Add photo upload handling
- [ ] Add price estimate service integration
- [ ] Add richer search filters and saved views
## Phase 3
- [ ] Add summary charts for profit and inventory velocity
- [ ] Add bulk updates for item status and pricing
- [ ] Add local notes history and item activity timeline
- [ ] Add deployment configuration for PostgreSQL and media storage

14
docs/pricing.md Normal file
View File

@@ -0,0 +1,14 @@
# Pricing
Pricing recommendations should be advisory, not blocking.
## Rules
- Prefer recently sold listings over active listing prices
- Store the source and retrieval time for every estimate
- Keep manual overrides available for every item
- Fall back to the last known local estimate if the external source fails
## Later integration
The first implementation can use a service wrapper for eBay sold comps or another source without coupling the rest of the app to a single marketplace.

19
docs/workflows.md Normal file
View File

@@ -0,0 +1,19 @@
# Workflows
## Add item
1. Choose a template or start from scratch.
2. Fill in item properties.
3. Save the item and keep the creator attribution.
## Update pricing
1. Pull recent sold comps from an external marketplace source when available.
2. Store the estimate with a source and timestamp.
3. Allow manual override when comps are stale or unavailable.
## Sell item
1. Set the sold price.
2. Mark the item sold.
3. Use the stored costs to show profit and margin.

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")

14
manage.py Normal file
View File

@@ -0,0 +1,14 @@
#!/usr/bin/env python
import os
import sys
def main() -> None:
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
from django.core.management import execute_from_command_line
execute_from_command_line(sys.argv)
if __name__ == "__main__":
main()

41
plan.md Normal file
View File

@@ -0,0 +1,41 @@
## Plan: Thrift Resale Dashboard
Build a greenfield internal dashboard for a 3-person reselling workflow, optimized for fast item intake, flexible templates, searchable inventory, and profit visibility. Recommended default stack for implementation is a Python-first web app, ideally Django + PostgreSQL + server-rendered forms with a small amount of HTMX for fast interactions. That fits the user's readability preference, keeps auth/data modeling simple, and reduces complexity for an internal tool.
**Steps**
1. Define the domain model and workflow boundaries first: items, templates, users, status history, sold listings, pricing estimates, and profit calculation rules. Capture the minimum viable lifecycle from acquisition to sale so implementation does not drift into marketplace-management scope.
2. Scaffold the app foundation: authentication, database, local dev setup, environment config, and a simple layout shell for the dashboard. Use built-in auth patterns where possible so user attribution is straightforward from the start.
3. Build item intake and templates: create editable item templates for common thrifting categories, allow one-click creation from templates, and make properties customizable per template so the team can adapt as buying patterns change.
4. Build inventory views and filters: implement item search across multiple fields and filters such as category, status, owner, acquisition date, price range, estimated value, and sale channel. Prioritize fast filtering and low-friction navigation over complex reporting.
5. Build pricing and profit estimation: support manual resale price suggestions at first, then integrate an external sold-comps source as a pluggable service. Cache retrieved comps, show the source and date, and always allow manual override when the estimate is wrong or unavailable.
6. Add sales tracking and profitability: track sold price, fees, shipping, net profit, time-to-sell, and item-level margin. Make it easy to mark items as sold and associate them with the person who added them.
7. Add operational quality-of-life features that fit the internal scope: photo uploads, quick notes, condition grading, bulk status updates, and a simple dashboard summary for inventory value, sales velocity, and profit by user/category.
8. Write the supporting markdown docs for future code agents and maintainers: architecture overview, data model, workflow notes, pricing integration notes, and an implementation checklist so later agents can work without re-discovering decisions.
9. Verify each slice with focused tests and a small end-to-end pass: auth, item/template CRUD, filtering, profit math, and pricing suggestion fallback behavior. Keep manual review focused on usability from intake to sale.
**Relevant files**
- `docs/architecture.md` - stack choice, system boundaries, and major components.
- `docs/domain-model.md` - entities, relationships, and lifecycle states.
- `docs/workflows.md` - intake, templating, selling, and edit flows.
- `docs/pricing.md` - comp sourcing, caching, overrides, and fallback rules.
- `docs/agent-guide.md` - conventions and instructions for future code agents.
- `app/` or equivalent project root - implementation of auth, inventory, search, and reporting once the repo is scaffolded.
**Verification**
1. Confirm the initial schema supports the full item lifecycle without migrations that force redesign.
2. Validate that a new item can be created from a template in a small number of steps.
3. Validate multi-filter search returns expected results and stays responsive on realistic inventory sizes.
4. Validate pricing suggestions fall back cleanly when external comps are unavailable and never block item entry.
5. Validate profit calculations against known sample sales and ensure item attribution always shows the creator.
**Decisions**
- Scope is internal-only for a 3-person company; prioritize usability and speed of entry over permissions complexity.
- Use simple email/password auth for the first release; no role-based permission system unless a later need appears.
- Marketplace pricing should be advisory, not blocking, with manual overrides always available.
- Start with a Python/Django-style implementation unless you decide to force a different stack later.
- Keep public marketplace posting automation out of the first phase; the first version is about inventory, pricing, and profitability tracking.
**Further Considerations**
1. If you want the plan to assume a different stack than Django/PostgreSQL, tell me before implementation and I will revise the architecture section.
2. If you want eBay sold-comps integration to be authoritative rather than advisory, the plan should add API credential, caching, and failure-handling requirements up front.
3. If you expect to manage many item photos or barcode scans, the plan should add storage and capture workflow details now rather than later.

17
pyproject.toml Normal file
View File

@@ -0,0 +1,17 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "arbit"
version = "0.1.0"
description = "Internal thrift resale dashboard"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"Django>=4.2,<5.0",
"python-dotenv>=1.0,<2.0",
]
[tool.setuptools]
packages = ["config", "inventory"]

46
templates/base.html Normal file
View File

@@ -0,0 +1,46 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Arbit{% endblock %}</title>
<style>
:root { color-scheme: light; --bg: #f6f2ea; --panel: #fffaf2; --ink: #1f2937; --muted: #6b7280; --accent: #1f6f78; --border: #d6cbbd; }
body { margin: 0; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: linear-gradient(180deg, #f8f3ea 0%, #f2ece1 100%); color: var(--ink); }
header, main { max-width: 1200px; margin: 0 auto; padding: 1.25rem; }
.shell { display: grid; gap: 1rem; }
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 16px; padding: 1rem; box-shadow: 0 10px 30px rgba(31, 41, 55, 0.05); }
.grid { display: grid; gap: 1rem; }
.grid-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.grid-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
input, select, textarea, button { font: inherit; padding: 0.75rem 0.85rem; border-radius: 10px; border: 1px solid var(--border); width: 100%; box-sizing: border-box; }
button { background: var(--accent); color: white; border: none; cursor: pointer; }
a { color: var(--accent); text-decoration: none; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; border-bottom: 1px solid var(--border); padding: 0.75rem 0.5rem; vertical-align: top; }
.muted { color: var(--muted); }
.row { display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: center; }
.spaced { display: flex; justify-content: space-between; gap: 1rem; align-items: center; }
@media (max-width: 820px) { .grid-2, .grid-3 { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<header class="spaced">
<div>
<strong>Arbit</strong>
<div class="muted">Thrift inventory and resale tracking</div>
</div>
<nav class="row">
{% if user.is_authenticated %}
<span class="muted">Signed in as {{ user.username }}</span>
<a href="{% url 'logout' %}">Log out</a>
{% else %}
<a href="{% url 'login' %}">Log in</a>
{% endif %}
</nav>
</header>
<main>
{% block content %}{% endblock %}
</main>
</body>
</html>

View File

@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block title %}Add | Arbit{% endblock %}
{% block content %}
<div class="card" style="max-width:720px; margin:2rem auto;">
<h1>Add</h1>
<div class="grid">
<a href="{% url 'item-create' %}" class="card" style="display:block; padding:1rem; text-align:center;">New item</a>
<a href="{% url 'template-create' %}" class="card" style="display:block; padding:1rem; text-align:center;">New template</a>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,62 @@
{% extends "base.html" %}
{% block title %}Dashboard | Arbit{% endblock %}
{% block content %}
<section class="grid grid-3">
<div class="card"><div class="muted">Items</div><h2>{{ stats.item_count }}</h2></div>
<div class="card"><div class="muted">Sold</div><h2>{{ stats.sold_count }}</h2></div>
<div class="card"><div class="muted">Inventory cost</div><h2>${{ stats.inventory_value }}</h2></div>
</section>
<section style="margin-top: 1rem; display:flex; justify-content:space-between; align-items:center; gap:1rem;">
<div style="flex:1">
<h2>Current items</h2>
<div class="muted">Focus on inventory, suggested resale pricing, and quick actions.</div>
</div>
<div>
<a href="{% url 'item-create' %}" style="background:var(--accent); color:white; padding:0.6rem 1rem; border-radius:10px;">Add item</a>
</div>
</section>
<section class="card" style="margin-top: 1rem;">
<h3>Search inventory</h3>
<form method="get" class="grid grid-3">
{{ item_filter.query.label_tag }}{{ item_filter.query }}
{{ item_filter.status.label_tag }}{{ item_filter.status }}
{{ item_filter.category.label_tag }}{{ item_filter.category }}
{{ item_filter.created_by.label_tag }}{{ item_filter.created_by }}
{{ item_filter.min_purchase_price.label_tag }}{{ item_filter.min_purchase_price }}
{{ item_filter.max_purchase_price.label_tag }}{{ item_filter.max_purchase_price }}
{{ item_filter.min_profit.label_tag }}{{ item_filter.min_profit }}
<div style="grid-column: 1 / -1;"><button type="submit">Filter</button></div>
</form>
</section>
<section class="card" style="margin-top: 1rem; overflow-x: auto;">
<h3>Latest items</h3>
<table>
<thead>
<tr><th>Title</th><th>Category</th><th>Status</th><th>Cost</th><th>Creator</th><th>Profit</th></tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td><a href="{{ item.get_absolute_url }}">{{ item.title }}</a></td>
<td>{{ item.category }}</td>
<td>{{ item.get_status_display }}</td>
<td>${{ item.purchase_price }}</td>
<td>{{ item.created_by.username }}</td>
<td>{% if item.profit is not None %}${{ item.profit }}{% else %}<span class="muted">Pending sale</span>{% endif %}</td>
</tr>
{% empty %}
<tr><td colspan="6" class="muted">No items yet.</td></tr>
{% endfor %}
</tbody>
</table>
</section>
<section class="grid grid-3" style="margin-top: 1rem;">
<div class="card"><div class="muted">Inventory cost</div><h2>${{ stats.inventory_value }}</h2></div>
<div class="card"><div class="muted">Profit total</div><h2>${{ stats.profit_total }}</h2></div>
<div class="card"><div class="muted">Sold count</div><h2>{{ stats.sold_count }}</h2></div>
</section>
{% endblock %}

View File

@@ -0,0 +1,74 @@
{% extends "base.html" %}
{% block title %}{{ object.title }} | Arbit{% endblock %}
{% block content %}
<section class="grid grid-2">
<div class="card">
<div class="spaced">
<div>
<h1>{{ object.title }}</h1>
<div class="muted">Added by {{ object.created_by.username }} on {{ object.created_at }}</div>
</div>
<div class="row">
<a href="{% url 'item-edit' object.pk %}">Edit</a>
<a href="{% url 'item-mark-sold' object.pk %}">Mark sold</a>
</div>
</div>
<dl class="grid grid-2">
<div><dt class="muted">Category</dt><dd>{{ object.category }}</dd></div>
<div><dt class="muted">Status</dt><dd>{{ object.get_status_display }}</dd></div>
<div><dt class="muted">Purchase price</dt><dd>${{ object.purchase_price }}</dd></div>
<div><dt class="muted">Estimated resale</dt><dd>{% if object.estimated_resale_price %}${{ object.estimated_resale_price }}{% else %}<span class="muted">Not set</span>{% endif %}</dd></div>
<div><dt class="muted">Sold price</dt><dd>{% if object.sold_price %}${{ object.sold_price }}{% else %}<span class="muted">Not sold</span>{% endif %}</dd></div>
<div><dt class="muted">Profit</dt><dd>{% if object.profit is not None %}${{ object.profit }}{% else %}<span class="muted">Pending sale</span>{% endif %}</dd></div>
</dl>
<p>{{ object.notes|linebreaksbr }}</p>
</div>
<div class="grid">
<div class="card">
<h2>Sold details</h2>
<div class="muted">Suggested price: ${{ pricing_suggestion.price }} from {{ pricing_suggestion.source }}</div>
<p class="muted">{{ pricing_suggestion.notes }}</p>
<form method="post" action="{% url 'item-mark-sold' object.pk %}" class="grid">
{% csrf_token %}
{{ sold_form.as_p }}
<button type="submit">Save sold details</button>
</form>
</div>
<div class="card">
<h2>Add note</h2>
<form method="post" action="{% url 'item-note-create' object.pk %}" class="grid">
{% csrf_token %}
{{ note_form.as_p }}
<button type="submit">Save note</button>
</form>
</div>
</div>
</section>
<section class="grid grid-2" style="margin-top: 1rem;">
<div class="card">
<h2>Price history</h2>
<ul>
{% for estimate in price_estimates %}
<li>{{ estimate.source }}: ${{ estimate.estimated_price }} on {{ estimate.retrieved_at }}</li>
{% empty %}
<li class="muted">No price estimates yet.</li>
{% endfor %}
</ul>
</div>
<div class="card">
<h2>Notes</h2>
<ul>
{% for note in notes %}
<li><strong>{{ note.created_by.username }}</strong> - {{ note.body }} <span class="muted">({{ note.created_at }})</span></li>
{% empty %}
<li class="muted">No notes yet.</li>
{% endfor %}
</ul>
</div>
</section>
{% endblock %}

View File

@@ -0,0 +1,49 @@
{% extends "base.html" %}
{% block content %}
<div class="card">
<h1>{% if object %}Edit item{% else %}New item{% endif %}</h1>
<form method="post" class="grid">
{% csrf_token %}
<div>
{{ form.template.label_tag }}
{{ form.template }}
</div>
<div>
{{ form.title.label_tag }}
{{ form.title }}
</div>
<div class="grid grid-3">
<div>{{ form.brand.label_tag }}{{ form.brand }}</div>
<div>{{ form.category.label_tag }}{{ form.category }}</div>
<div>{{ form.condition.label_tag }}{{ form.condition }}</div>
</div>
<div class="grid grid-3">
<div>{{ form.size.label_tag }}{{ form.size }}</div>
<div>{{ form.color.label_tag }}{{ form.color }}</div>
<div>{{ form.purchase_price.label_tag }}{{ form.purchase_price }}</div>
</div>
<div>{{ form.estimated_resale_price.label_tag }}{{ form.estimated_resale_price }}</div>
{% if template_obj and template_obj.field_definitions %}
<fieldset class="card">
<legend>Template properties ({{ template_obj.name }})</legend>
{% for fd in template_obj.field_definitions %}
<div>
<label for="id_prop_{{ fd.name }}">{{ fd.label|default:fd.name }}</label>
{% if fd.type == 'boolean' %}
<input type="checkbox" name="prop_{{ fd.name }}" id="id_prop_{{ fd.name }}" {% if fd.default %}checked{% endif %} />
{% elif fd.type == 'number' %}
<input type="number" step="0.01" name="prop_{{ fd.name }}" id="id_prop_{{ fd.name }}" value="{{ fd.default|default:'' }}" />
{% else %}
<input type="text" name="prop_{{ fd.name }}" id="id_prop_{{ fd.name }}" value="{{ fd.default|default:'' }}" />
{% endif %}
</div>
{% endfor %}
</fieldset>
{% endif %}
<div>{{ form.notes.label_tag }}{{ form.notes }}</div>
<button type="submit">{% if object %}Update item{% else %}Create item{% endif %}</button>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block content %}
<div class="card">
<h1>Sold details</h1>
<form method="post" class="grid">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">Save</button>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block content %}
<div class="card">
<h1>New note</h1>
<form method="post" class="grid">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">Save note</button>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block content %}
<div class="card">
<h1>{% if object %}Edit template{% else %}New template{% endif %}</h1>
<form method="post" class="grid">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">{% if object %}Update template{% else %}Save template{% endif %}</button>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block title %}Log in | Arbit{% endblock %}
{% block content %}
<div class="card" style="max-width: 420px; margin: 8vh auto;">
<h1>Log in</h1>
<form method="post" class="grid">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">Log in</button>
</form>
</div>
{% endblock %}