From 377326ec2cead8a80cd264358aa3b5d89ac5142e Mon Sep 17 00:00:00 2001 From: lockeshor Date: Mon, 18 May 2026 14:08:13 -0400 Subject: [PATCH] update --- .gitignore | 7 + README.md | 12 ++ config/asgi.py | 10 + config/settings.py | 73 +++++++ config/urls.py | 11 + config/wsgi.py | 10 + docs/agent-guide.md | 15 ++ docs/architecture.md | 30 +++ docs/domain-model.md | 22 ++ docs/implementation-checklist.md | 24 +++ docs/pricing.md | 14 ++ docs/workflows.md | 19 ++ inventory/admin.py | 33 +++ inventory/apps.py | 6 + inventory/forms.py | 107 ++++++++++ inventory/migrations/0001_initial.py | 96 +++++++++ ...operties_itemtemplate_field_definitions.py | 23 ++ inventory/migrations/__init__.py | 0 inventory/models.py | 107 ++++++++++ inventory/services.py | 43 ++++ inventory/tests.py | 184 ++++++++++++++++ inventory/tests_template_fields.py | 44 ++++ inventory/urls.py | 25 +++ inventory/views.py | 199 ++++++++++++++++++ manage.py | 14 ++ plan.md | 41 ++++ pyproject.toml | 17 ++ templates/base.html | 46 ++++ templates/inventory/add_hub.html | 11 + templates/inventory/dashboard.html | 62 ++++++ templates/inventory/item_detail.html | 74 +++++++ templates/inventory/item_form.html | 49 +++++ templates/inventory/item_sold_form.html | 11 + templates/inventory/note_form.html | 11 + templates/inventory/template_form.html | 11 + templates/registration/login.html | 12 ++ 36 files changed, 1473 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config/asgi.py create mode 100644 config/settings.py create mode 100644 config/urls.py create mode 100644 config/wsgi.py create mode 100644 docs/agent-guide.md create mode 100644 docs/architecture.md create mode 100644 docs/domain-model.md create mode 100644 docs/implementation-checklist.md create mode 100644 docs/pricing.md create mode 100644 docs/workflows.md create mode 100644 inventory/admin.py create mode 100644 inventory/apps.py create mode 100644 inventory/forms.py create mode 100644 inventory/migrations/0001_initial.py create mode 100644 inventory/migrations/0002_item_properties_itemtemplate_field_definitions.py create mode 100644 inventory/migrations/__init__.py create mode 100644 inventory/models.py create mode 100644 inventory/services.py create mode 100644 inventory/tests.py create mode 100644 inventory/tests_template_fields.py create mode 100644 inventory/urls.py create mode 100644 inventory/views.py create mode 100644 manage.py create mode 100644 plan.md create mode 100644 pyproject.toml create mode 100644 templates/base.html create mode 100644 templates/inventory/add_hub.html create mode 100644 templates/inventory/dashboard.html create mode 100644 templates/inventory/item_detail.html create mode 100644 templates/inventory/item_form.html create mode 100644 templates/inventory/item_sold_form.html create mode 100644 templates/inventory/note_form.html create mode 100644 templates/inventory/template_form.html create mode 100644 templates/registration/login.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b0de5e3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.venv/ +__pycache__/ +*.pyc +db.sqlite3 +*.egg-info/ +staticfiles/ +media/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..4f6ed33 --- /dev/null +++ b/README.md @@ -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`. diff --git a/config/asgi.py b/config/asgi.py new file mode 100644 index 0000000..ba2c72b --- /dev/null +++ b/config/asgi.py @@ -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() diff --git a/config/settings.py b/config/settings.py new file mode 100644 index 0000000..ef4a248 --- /dev/null +++ b/config/settings.py @@ -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/" diff --git a/config/urls.py b/config/urls.py new file mode 100644 index 0000000..52349f0 --- /dev/null +++ b/config/urls.py @@ -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")), +] diff --git a/config/wsgi.py b/config/wsgi.py new file mode 100644 index 0000000..352cb55 --- /dev/null +++ b/config/wsgi.py @@ -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() diff --git a/docs/agent-guide.md b/docs/agent-guide.md new file mode 100644 index 0000000..68497c0 --- /dev/null +++ b/docs/agent-guide.md @@ -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. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..78ca9bf --- /dev/null +++ b/docs/architecture.md @@ -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 diff --git a/docs/domain-model.md b/docs/domain-model.md new file mode 100644 index 0000000..8a4d46b --- /dev/null +++ b/docs/domain-model.md @@ -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 diff --git a/docs/implementation-checklist.md b/docs/implementation-checklist.md new file mode 100644 index 0000000..788c8b7 --- /dev/null +++ b/docs/implementation-checklist.md @@ -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 diff --git a/docs/pricing.md b/docs/pricing.md new file mode 100644 index 0000000..ebfd317 --- /dev/null +++ b/docs/pricing.md @@ -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. diff --git a/docs/workflows.md b/docs/workflows.md new file mode 100644 index 0000000..3ea810b --- /dev/null +++ b/docs/workflows.md @@ -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. diff --git a/inventory/admin.py b/inventory/admin.py new file mode 100644 index 0000000..77c48e8 --- /dev/null +++ b/inventory/admin.py @@ -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") diff --git a/inventory/apps.py b/inventory/apps.py new file mode 100644 index 0000000..7bd447f --- /dev/null +++ b/inventory/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class InventoryConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "inventory" diff --git a/inventory/forms.py b/inventory/forms.py new file mode 100644 index 0000000..8fd22ab --- /dev/null +++ b/inventory/forms.py @@ -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) diff --git a/inventory/migrations/0001_initial.py b/inventory/migrations/0001_initial.py new file mode 100644 index 0000000..b296cb0 --- /dev/null +++ b/inventory/migrations/0001_initial.py @@ -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'), + ), + ] diff --git a/inventory/migrations/0002_item_properties_itemtemplate_field_definitions.py b/inventory/migrations/0002_item_properties_itemtemplate_field_definitions.py new file mode 100644 index 0000000..0d9ad7c --- /dev/null +++ b/inventory/migrations/0002_item_properties_itemtemplate_field_definitions.py @@ -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), + ), + ] diff --git a/inventory/migrations/__init__.py b/inventory/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/inventory/models.py b/inventory/models.py new file mode 100644 index 0000000..966374d --- /dev/null +++ b/inventory/models.py @@ -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) diff --git a/inventory/services.py b/inventory/services.py new file mode 100644 index 0000000..7a5aa5d --- /dev/null +++ b/inventory/services.py @@ -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.", + } diff --git a/inventory/tests.py b/inventory/tests.py new file mode 100644 index 0000000..7c3d136 --- /dev/null +++ b/inventory/tests.py @@ -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") diff --git a/inventory/tests_template_fields.py b/inventory/tests_template_fields.py new file mode 100644 index 0000000..7a48af7 --- /dev/null +++ b/inventory/tests_template_fields.py @@ -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") \ No newline at end of file diff --git a/inventory/urls.py b/inventory/urls.py new file mode 100644 index 0000000..34abe98 --- /dev/null +++ b/inventory/urls.py @@ -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//", ItemDetailView.as_view(), name="item-detail"), + path("items//edit/", ItemUpdateView.as_view(), name="item-edit"), + path("items//sold/", ItemMarkSoldView.as_view(), name="item-mark-sold"), + path("items//notes/new/", ItemNoteCreateView.as_view(), name="item-note-create"), + path("templates/new/", ItemTemplateCreateView.as_view(), name="template-create"), + path("templates//edit/", ItemTemplateUpdateView.as_view(), name="template-edit"), + path("add/", DashboardView.as_view(), name="add-hub"), +] diff --git a/inventory/views.py b/inventory/views.py new file mode 100644 index 0000000..4a67503 --- /dev/null +++ b/inventory/views.py @@ -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") diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..7f082c5 --- /dev/null +++ b/manage.py @@ -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() diff --git a/plan.md b/plan.md new file mode 100644 index 0000000..2a6fc20 --- /dev/null +++ b/plan.md @@ -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. \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9ff9912 --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..93a823f --- /dev/null +++ b/templates/base.html @@ -0,0 +1,46 @@ + + + + + + {% block title %}Arbit{% endblock %} + + + +
+
+ Arbit +
Thrift inventory and resale tracking
+
+ +
+
+ {% block content %}{% endblock %} +
+ + diff --git a/templates/inventory/add_hub.html b/templates/inventory/add_hub.html new file mode 100644 index 0000000..40b712b --- /dev/null +++ b/templates/inventory/add_hub.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} +{% block title %}Add | Arbit{% endblock %} +{% block content %} +
+

Add

+ +
+{% endblock %} \ No newline at end of file diff --git a/templates/inventory/dashboard.html b/templates/inventory/dashboard.html new file mode 100644 index 0000000..6aa42f0 --- /dev/null +++ b/templates/inventory/dashboard.html @@ -0,0 +1,62 @@ +{% extends "base.html" %} +{% block title %}Dashboard | Arbit{% endblock %} +{% block content %} +
+
Items

{{ stats.item_count }}

+
Sold

{{ stats.sold_count }}

+
Inventory cost

${{ stats.inventory_value }}

+
+ +
+
+

Current items

+
Focus on inventory, suggested resale pricing, and quick actions.
+
+
+ Add item +
+
+ +
+

Search inventory

+
+ {{ 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 }} +
+
+
+ +
+

Latest items

+ + + + + + {% for item in items %} + + + + + + + + + {% empty %} + + {% endfor %} + +
TitleCategoryStatusCostCreatorProfit
{{ item.title }}{{ item.category }}{{ item.get_status_display }}${{ item.purchase_price }}{{ item.created_by.username }}{% if item.profit is not None %}${{ item.profit }}{% else %}Pending sale{% endif %}
No items yet.
+
+ +
+
Inventory cost

${{ stats.inventory_value }}

+
Profit total

${{ stats.profit_total }}

+
Sold count

{{ stats.sold_count }}

+
+{% endblock %} diff --git a/templates/inventory/item_detail.html b/templates/inventory/item_detail.html new file mode 100644 index 0000000..a220a14 --- /dev/null +++ b/templates/inventory/item_detail.html @@ -0,0 +1,74 @@ +{% extends "base.html" %} +{% block title %}{{ object.title }} | Arbit{% endblock %} +{% block content %} +
+
+
+
+

{{ object.title }}

+
Added by {{ object.created_by.username }} on {{ object.created_at }}
+
+
+ Edit + Mark sold +
+
+ +
+
Category
{{ object.category }}
+
Status
{{ object.get_status_display }}
+
Purchase price
${{ object.purchase_price }}
+
Estimated resale
{% if object.estimated_resale_price %}${{ object.estimated_resale_price }}{% else %}Not set{% endif %}
+
Sold price
{% if object.sold_price %}${{ object.sold_price }}{% else %}Not sold{% endif %}
+
Profit
{% if object.profit is not None %}${{ object.profit }}{% else %}Pending sale{% endif %}
+
+ +

{{ object.notes|linebreaksbr }}

+
+ +
+
+

Sold details

+
Suggested price: ${{ pricing_suggestion.price }} from {{ pricing_suggestion.source }}
+

{{ pricing_suggestion.notes }}

+
+ {% csrf_token %} + {{ sold_form.as_p }} + +
+
+ +
+

Add note

+
+ {% csrf_token %} + {{ note_form.as_p }} + +
+
+
+
+ +
+
+

Price history

+
    + {% for estimate in price_estimates %} +
  • {{ estimate.source }}: ${{ estimate.estimated_price }} on {{ estimate.retrieved_at }}
  • + {% empty %} +
  • No price estimates yet.
  • + {% endfor %} +
+
+
+

Notes

+
    + {% for note in notes %} +
  • {{ note.created_by.username }} - {{ note.body }} ({{ note.created_at }})
  • + {% empty %} +
  • No notes yet.
  • + {% endfor %} +
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/inventory/item_form.html b/templates/inventory/item_form.html new file mode 100644 index 0000000..b5555c9 --- /dev/null +++ b/templates/inventory/item_form.html @@ -0,0 +1,49 @@ +{% extends "base.html" %} +{% block content %} +
+

{% if object %}Edit item{% else %}New item{% endif %}

+
+ {% csrf_token %} +
+ {{ form.template.label_tag }} + {{ form.template }} +
+
+ {{ form.title.label_tag }} + {{ form.title }} +
+
+
{{ form.brand.label_tag }}{{ form.brand }}
+
{{ form.category.label_tag }}{{ form.category }}
+
{{ form.condition.label_tag }}{{ form.condition }}
+
+
+
{{ form.size.label_tag }}{{ form.size }}
+
{{ form.color.label_tag }}{{ form.color }}
+
{{ form.purchase_price.label_tag }}{{ form.purchase_price }}
+
+
{{ form.estimated_resale_price.label_tag }}{{ form.estimated_resale_price }}
+ + {% if template_obj and template_obj.field_definitions %} +
+ Template properties ({{ template_obj.name }}) + {% for fd in template_obj.field_definitions %} +
+ + {% if fd.type == 'boolean' %} + + {% elif fd.type == 'number' %} + + {% else %} + + {% endif %} +
+ {% endfor %} +
+ {% endif %} + +
{{ form.notes.label_tag }}{{ form.notes }}
+ +
+
+{% endblock %} diff --git a/templates/inventory/item_sold_form.html b/templates/inventory/item_sold_form.html new file mode 100644 index 0000000..3a97d20 --- /dev/null +++ b/templates/inventory/item_sold_form.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} +{% block content %} +
+

Sold details

+
+ {% csrf_token %} + {{ form.as_p }} + +
+
+{% endblock %} \ No newline at end of file diff --git a/templates/inventory/note_form.html b/templates/inventory/note_form.html new file mode 100644 index 0000000..0ba349d --- /dev/null +++ b/templates/inventory/note_form.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} +{% block content %} +
+

New note

+
+ {% csrf_token %} + {{ form.as_p }} + +
+
+{% endblock %} \ No newline at end of file diff --git a/templates/inventory/template_form.html b/templates/inventory/template_form.html new file mode 100644 index 0000000..7248f6e --- /dev/null +++ b/templates/inventory/template_form.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} +{% block content %} +
+

{% if object %}Edit template{% else %}New template{% endif %}

+
+ {% csrf_token %} + {{ form.as_p }} + +
+
+{% endblock %} diff --git a/templates/registration/login.html b/templates/registration/login.html new file mode 100644 index 0000000..1a287b3 --- /dev/null +++ b/templates/registration/login.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} +{% block title %}Log in | Arbit{% endblock %} +{% block content %} +
+

Log in

+
+ {% csrf_token %} + {{ form.as_p }} + +
+
+{% endblock %}