update
This commit is contained in:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
db.sqlite3
|
||||||
|
*.egg-info/
|
||||||
|
staticfiles/
|
||||||
|
media/
|
||||||
12
README.md
Normal file
12
README.md
Normal 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
10
config/asgi.py
Normal 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
73
config/settings.py
Normal 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
11
config/urls.py
Normal 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
10
config/wsgi.py
Normal 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
15
docs/agent-guide.md
Normal 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
30
docs/architecture.md
Normal 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
22
docs/domain-model.md
Normal 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
|
||||||
24
docs/implementation-checklist.md
Normal file
24
docs/implementation-checklist.md
Normal 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
14
docs/pricing.md
Normal 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
19
docs/workflows.md
Normal 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
33
inventory/admin.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from .models import Item, ItemNote, ItemPhoto, ItemTemplate, PriceEstimate
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ItemTemplate)
|
||||||
|
class ItemTemplateAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("name", "category", "is_active", "updated_at")
|
||||||
|
search_fields = ("name", "category", "description")
|
||||||
|
list_filter = ("is_active", "category")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Item)
|
||||||
|
class ItemAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("title", "category", "status", "purchase_price", "sold_price", "created_by", "created_at")
|
||||||
|
search_fields = ("title", "brand", "category", "notes", "created_by__username")
|
||||||
|
list_filter = ("status", "category", "created_by")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(PriceEstimate)
|
||||||
|
class PriceEstimateAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("item", "source", "estimated_price", "retrieved_at")
|
||||||
|
search_fields = ("item__title", "source", "notes")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ItemNote)
|
||||||
|
class ItemNoteAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("item", "created_by", "created_at")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ItemPhoto)
|
||||||
|
class ItemPhotoAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("item", "uploaded_at")
|
||||||
6
inventory/apps.py
Normal file
6
inventory/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "inventory"
|
||||||
107
inventory/forms.py
Normal file
107
inventory/forms.py
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
from django import forms
|
||||||
|
|
||||||
|
from .models import Item, ItemNote, ItemTemplate
|
||||||
|
|
||||||
|
|
||||||
|
class ItemTemplateForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = ItemTemplate
|
||||||
|
fields = [
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"category",
|
||||||
|
"field_definitions",
|
||||||
|
"default_purchase_price",
|
||||||
|
"default_estimated_resale_price",
|
||||||
|
"default_notes",
|
||||||
|
"is_active",
|
||||||
|
]
|
||||||
|
widgets = {
|
||||||
|
"field_definitions": forms.Textarea(attrs={"rows": 4, "placeholder": '[{"name":"rewind","label":"Rewind?","type":"boolean"}]'}),
|
||||||
|
}
|
||||||
|
|
||||||
|
def clean_field_definitions(self):
|
||||||
|
raw = self.cleaned_data.get("field_definitions")
|
||||||
|
# Allow users to provide a JSON string or a python list
|
||||||
|
if raw in (None, ""):
|
||||||
|
return []
|
||||||
|
|
||||||
|
if isinstance(raw, str):
|
||||||
|
import json
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw)
|
||||||
|
except Exception as e:
|
||||||
|
raise forms.ValidationError("Invalid JSON for field_definitions: %s" % e)
|
||||||
|
else:
|
||||||
|
parsed = raw
|
||||||
|
|
||||||
|
if not isinstance(parsed, list):
|
||||||
|
raise forms.ValidationError("field_definitions must be a JSON list of field descriptors")
|
||||||
|
|
||||||
|
# Basic validation for each field descriptor
|
||||||
|
for entry in parsed:
|
||||||
|
if not isinstance(entry, dict) or "name" not in entry or "type" not in entry:
|
||||||
|
raise forms.ValidationError("Each field definition must be an object with at least 'name' and 'type'")
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
class ItemForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Item
|
||||||
|
fields = [
|
||||||
|
"template",
|
||||||
|
"title",
|
||||||
|
"brand",
|
||||||
|
"category",
|
||||||
|
"condition",
|
||||||
|
"size",
|
||||||
|
"color",
|
||||||
|
"purchase_price",
|
||||||
|
"estimated_resale_price",
|
||||||
|
"notes",
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.fields["category"].required = False
|
||||||
|
self.fields["purchase_price"].required = False
|
||||||
|
self.fields["estimated_resale_price"].required = False
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned_data = super().clean()
|
||||||
|
template = cleaned_data.get("template")
|
||||||
|
|
||||||
|
if not cleaned_data.get("category") and not template:
|
||||||
|
self.add_error("category", "Category is required when no template is selected.")
|
||||||
|
if cleaned_data.get("purchase_price") in (None, "") and not template:
|
||||||
|
self.add_error("purchase_price", "Purchase price is required when no template is selected.")
|
||||||
|
|
||||||
|
return cleaned_data
|
||||||
|
|
||||||
|
|
||||||
|
class ItemSoldForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Item
|
||||||
|
fields = ["sold_price", "ebay_fee", "shipping_cost", "sold_at"]
|
||||||
|
|
||||||
|
|
||||||
|
class ItemNoteForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = ItemNote
|
||||||
|
fields = ["body"]
|
||||||
|
|
||||||
|
|
||||||
|
class ItemFilterForm(forms.Form):
|
||||||
|
query = forms.CharField(required=False)
|
||||||
|
status = forms.ChoiceField(required=False, choices=[("", "All")])
|
||||||
|
category = forms.CharField(required=False)
|
||||||
|
created_by = forms.CharField(required=False)
|
||||||
|
min_purchase_price = forms.DecimalField(required=False, min_value=0)
|
||||||
|
max_purchase_price = forms.DecimalField(required=False, min_value=0)
|
||||||
|
min_profit = forms.DecimalField(required=False)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.fields["status"].choices = [("", "All")] + list(Item.Status.choices)
|
||||||
96
inventory/migrations/0001_initial.py
Normal file
96
inventory/migrations/0001_initial.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
# Generated by Django 4.2.30 on 2026-05-18 17:55
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
import django.core.validators
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Item',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('title', models.CharField(max_length=200)),
|
||||||
|
('brand', models.CharField(blank=True, max_length=120)),
|
||||||
|
('category', models.CharField(max_length=80)),
|
||||||
|
('condition', models.CharField(blank=True, max_length=80)),
|
||||||
|
('size', models.CharField(blank=True, max_length=50)),
|
||||||
|
('color', models.CharField(blank=True, max_length=80)),
|
||||||
|
('purchase_price', models.DecimalField(decimal_places=2, max_digits=10, validators=[django.core.validators.MinValueValidator(0)])),
|
||||||
|
('estimated_resale_price', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)),
|
||||||
|
('sold_price', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)),
|
||||||
|
('ebay_fee', models.DecimalField(decimal_places=2, default=0, max_digits=10)),
|
||||||
|
('shipping_cost', models.DecimalField(decimal_places=2, default=0, max_digits=10)),
|
||||||
|
('status', models.CharField(choices=[('in_stock', 'In stock'), ('listed', 'Listed'), ('sold', 'Sold'), ('donated', 'Donated')], default='in_stock', max_length=20)),
|
||||||
|
('notes', models.TextField(blank=True)),
|
||||||
|
('acquisition_date', models.DateField(auto_now_add=True)),
|
||||||
|
('sold_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='items_created', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ItemTemplate',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=120, unique=True)),
|
||||||
|
('description', models.TextField(blank=True)),
|
||||||
|
('category', models.CharField(max_length=80)),
|
||||||
|
('default_purchase_price', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)),
|
||||||
|
('default_estimated_resale_price', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)),
|
||||||
|
('default_notes', models.TextField(blank=True)),
|
||||||
|
('is_active', models.BooleanField(default=True)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='PriceEstimate',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('source', models.CharField(max_length=80)),
|
||||||
|
('source_url', models.URLField(blank=True)),
|
||||||
|
('estimated_price', models.DecimalField(decimal_places=2, max_digits=10)),
|
||||||
|
('notes', models.TextField(blank=True)),
|
||||||
|
('retrieved_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='price_estimates', to='inventory.item')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-retrieved_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ItemPhoto',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('file', models.FileField(upload_to='item-photos/')),
|
||||||
|
('uploaded_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='photos', to='inventory.item')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ItemNote',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('body', models.TextField()),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
|
||||||
|
('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notes_history', to='inventory.item')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='item',
|
||||||
|
name='template',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='items', to='inventory.itemtemplate'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 4.2.30 on 2026-05-18 18:02
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('inventory', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='item',
|
||||||
|
name='properties',
|
||||||
|
field=models.JSONField(blank=True, default=dict),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='itemtemplate',
|
||||||
|
name='field_definitions',
|
||||||
|
field=models.JSONField(blank=True, default=list),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
inventory/migrations/__init__.py
Normal file
0
inventory/migrations/__init__.py
Normal file
107
inventory/models.py
Normal file
107
inventory/models.py
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
from django.conf import settings
|
||||||
|
from django.core.validators import MinValueValidator
|
||||||
|
from django.db import models
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
|
||||||
|
class ItemTemplate(models.Model):
|
||||||
|
name = models.CharField(max_length=120, unique=True)
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
category = models.CharField(max_length=80)
|
||||||
|
# field_definitions is a list of objects describing extra fields for items of this template.
|
||||||
|
# Example: [{"name": "rewind", "label": "Rewind?", "type": "boolean"}, {"name":"output_count","label":"Outputs","type":"number"}]
|
||||||
|
field_definitions = models.JSONField(default=list, blank=True)
|
||||||
|
default_purchase_price = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
|
||||||
|
default_estimated_resale_price = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
|
||||||
|
default_notes = models.TextField(blank=True)
|
||||||
|
is_active = models.BooleanField(default=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class Item(models.Model):
|
||||||
|
class Status(models.TextChoices):
|
||||||
|
IN_STOCK = "in_stock", "In stock"
|
||||||
|
LISTED = "listed", "Listed"
|
||||||
|
SOLD = "sold", "Sold"
|
||||||
|
DONATED = "donated", "Donated"
|
||||||
|
|
||||||
|
template = models.ForeignKey(ItemTemplate, null=True, blank=True, on_delete=models.SET_NULL, related_name="items")
|
||||||
|
created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="items_created")
|
||||||
|
title = models.CharField(max_length=200)
|
||||||
|
brand = models.CharField(max_length=120, blank=True)
|
||||||
|
category = models.CharField(max_length=80)
|
||||||
|
condition = models.CharField(max_length=80, blank=True)
|
||||||
|
size = models.CharField(max_length=50, blank=True)
|
||||||
|
color = models.CharField(max_length=80, blank=True)
|
||||||
|
purchase_price = models.DecimalField(max_digits=10, decimal_places=2, validators=[MinValueValidator(0)])
|
||||||
|
estimated_resale_price = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
|
||||||
|
sold_price = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
|
||||||
|
ebay_fee = models.DecimalField(max_digits=10, decimal_places=2, default=0)
|
||||||
|
shipping_cost = models.DecimalField(max_digits=10, decimal_places=2, default=0)
|
||||||
|
status = models.CharField(max_length=20, choices=Status.choices, default=Status.IN_STOCK)
|
||||||
|
notes = models.TextField(blank=True)
|
||||||
|
# properties stores template-specific values keyed by field name
|
||||||
|
properties = models.JSONField(default=dict, blank=True)
|
||||||
|
acquisition_date = models.DateField(auto_now_add=True)
|
||||||
|
sold_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_cost(self):
|
||||||
|
return self.purchase_price + self.ebay_fee + self.shipping_cost
|
||||||
|
|
||||||
|
@property
|
||||||
|
def profit(self):
|
||||||
|
if self.sold_price is None:
|
||||||
|
return None
|
||||||
|
return self.sold_price - self.total_cost
|
||||||
|
|
||||||
|
@property
|
||||||
|
def margin(self):
|
||||||
|
if self.profit is None or self.sold_price in (None, 0):
|
||||||
|
return None
|
||||||
|
return self.profit / self.sold_price
|
||||||
|
|
||||||
|
def mark_sold(self, sold_price, sold_at=None):
|
||||||
|
self.sold_price = sold_price
|
||||||
|
self.status = self.Status.SOLD
|
||||||
|
self.sold_at = sold_at or timezone.now()
|
||||||
|
self.save(update_fields=["sold_price", "status", "sold_at", "updated_at"])
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
return reverse("item-detail", kwargs={"pk": self.pk})
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.title
|
||||||
|
|
||||||
|
|
||||||
|
class PriceEstimate(models.Model):
|
||||||
|
item = models.ForeignKey(Item, on_delete=models.CASCADE, related_name="price_estimates")
|
||||||
|
source = models.CharField(max_length=80)
|
||||||
|
source_url = models.URLField(blank=True)
|
||||||
|
estimated_price = models.DecimalField(max_digits=10, decimal_places=2)
|
||||||
|
notes = models.TextField(blank=True)
|
||||||
|
retrieved_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-retrieved_at"]
|
||||||
|
|
||||||
|
|
||||||
|
class ItemNote(models.Model):
|
||||||
|
item = models.ForeignKey(Item, on_delete=models.CASCADE, related_name="notes_history")
|
||||||
|
created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT)
|
||||||
|
body = models.TextField()
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ItemPhoto(models.Model):
|
||||||
|
item = models.ForeignKey(Item, on_delete=models.CASCADE, related_name="photos")
|
||||||
|
file = models.FileField(upload_to="item-photos/")
|
||||||
|
uploaded_at = models.DateTimeField(auto_now_add=True)
|
||||||
43
inventory/services.py
Normal file
43
inventory/services.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from .models import Item
|
||||||
|
|
||||||
|
|
||||||
|
class PricingSuggestionService:
|
||||||
|
def suggest_for_item(self, item: Item):
|
||||||
|
recent_comp = (
|
||||||
|
Item.objects.filter(status=Item.Status.SOLD, category=item.category)
|
||||||
|
.exclude(sold_price__isnull=True)
|
||||||
|
.order_by("-sold_at", "-created_at")
|
||||||
|
.values_list("sold_price", flat=True)[:5]
|
||||||
|
)
|
||||||
|
recent_prices = list(recent_comp)
|
||||||
|
|
||||||
|
if recent_prices:
|
||||||
|
average_price = sum(recent_prices) / len(recent_prices)
|
||||||
|
return {
|
||||||
|
"price": average_price.quantize(Decimal("0.01")),
|
||||||
|
"source": "Recent sold comps",
|
||||||
|
"notes": f"Based on {len(recent_prices)} recent sold items in {item.category}.",
|
||||||
|
}
|
||||||
|
|
||||||
|
if item.estimated_resale_price is not None:
|
||||||
|
return {
|
||||||
|
"price": item.estimated_resale_price,
|
||||||
|
"source": "Item estimate",
|
||||||
|
"notes": "Using the item's existing estimated resale price.",
|
||||||
|
}
|
||||||
|
|
||||||
|
if item.template and item.template.default_estimated_resale_price is not None:
|
||||||
|
return {
|
||||||
|
"price": item.template.default_estimated_resale_price,
|
||||||
|
"source": "Template default",
|
||||||
|
"notes": "Using the template's default resale estimate.",
|
||||||
|
}
|
||||||
|
|
||||||
|
fallback = (item.purchase_price * Decimal("1.75")).quantize(Decimal("0.01"))
|
||||||
|
return {
|
||||||
|
"price": fallback,
|
||||||
|
"source": "Purchase-price fallback",
|
||||||
|
"notes": "No sold comps yet; using a purchase-price multiple as a starting point.",
|
||||||
|
}
|
||||||
184
inventory/tests.py
Normal file
184
inventory/tests.py
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from inventory.models import Item, ItemTemplate
|
||||||
|
from inventory.services import PricingSuggestionService
|
||||||
|
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class ItemModelTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(username="alice", password="password123")
|
||||||
|
|
||||||
|
def test_profit_uses_costs_and_sale_price(self):
|
||||||
|
item = Item.objects.create(
|
||||||
|
created_by=self.user,
|
||||||
|
title="Vintage jacket",
|
||||||
|
category="Apparel",
|
||||||
|
purchase_price=Decimal("10.00"),
|
||||||
|
ebay_fee=Decimal("2.00"),
|
||||||
|
shipping_cost=Decimal("4.00"),
|
||||||
|
sold_price=Decimal("30.00"),
|
||||||
|
status=Item.Status.SOLD,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(item.total_cost, Decimal("16.00"))
|
||||||
|
self.assertEqual(item.profit, Decimal("14.00"))
|
||||||
|
|
||||||
|
|
||||||
|
class ItemCreateViewTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(username="alice", password="password123")
|
||||||
|
self.template = ItemTemplate.objects.create(
|
||||||
|
name="Blanket",
|
||||||
|
category="Home",
|
||||||
|
default_purchase_price=Decimal("8.00"),
|
||||||
|
default_estimated_resale_price=Decimal("24.00"),
|
||||||
|
default_notes="Check for stains.",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_template_defaults_are_applied_on_create(self):
|
||||||
|
self.client.login(username="alice", password="password123")
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("item-create"),
|
||||||
|
{
|
||||||
|
"template": self.template.id,
|
||||||
|
"title": "Wool blanket",
|
||||||
|
"brand": "Pendleton",
|
||||||
|
"category": "",
|
||||||
|
"condition": "Good",
|
||||||
|
"size": "Queen",
|
||||||
|
"color": "Red",
|
||||||
|
"purchase_price": "",
|
||||||
|
"estimated_resale_price": "",
|
||||||
|
"notes": "",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
item = Item.objects.get(title="Wool blanket")
|
||||||
|
self.assertEqual(item.category, "Home")
|
||||||
|
self.assertEqual(item.purchase_price, Decimal("8.00"))
|
||||||
|
self.assertEqual(item.estimated_resale_price, Decimal("24.00"))
|
||||||
|
self.assertEqual(item.notes, "Check for stains.")
|
||||||
|
self.assertEqual(item.created_by, self.user)
|
||||||
|
|
||||||
|
|
||||||
|
class ItemWorkflowTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(username="alice", password="password123")
|
||||||
|
self.other_user = User.objects.create_user(username="bob", password="password123")
|
||||||
|
self.item = Item.objects.create(
|
||||||
|
created_by=self.user,
|
||||||
|
title="Leather boots",
|
||||||
|
category="Shoes",
|
||||||
|
purchase_price=Decimal("20.00"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_dashboard_filters_by_query(self):
|
||||||
|
self.client.login(username="alice", password="password123")
|
||||||
|
response = self.client.get(reverse("dashboard"), {"query": "boots"})
|
||||||
|
|
||||||
|
self.assertContains(response, "Leather boots")
|
||||||
|
|
||||||
|
def test_mark_item_sold_sets_status_and_sold_date(self):
|
||||||
|
self.client.login(username="alice", password="password123")
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("item-mark-sold", kwargs={"pk": self.item.pk}),
|
||||||
|
{
|
||||||
|
"sold_price": "55.00",
|
||||||
|
"ebay_fee": "5.00",
|
||||||
|
"shipping_cost": "7.00",
|
||||||
|
"sold_at": "",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.item.refresh_from_db()
|
||||||
|
self.assertEqual(self.item.status, Item.Status.SOLD)
|
||||||
|
self.assertEqual(self.item.sold_price, Decimal("55.00"))
|
||||||
|
self.assertIsNotNone(self.item.sold_at)
|
||||||
|
|
||||||
|
def test_editing_item_does_not_change_creator(self):
|
||||||
|
self.client.login(username="bob", password="password123")
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("item-edit", kwargs={"pk": self.item.pk}),
|
||||||
|
{
|
||||||
|
"template": "",
|
||||||
|
"title": "Leather boots updated",
|
||||||
|
"brand": "",
|
||||||
|
"category": "Shoes",
|
||||||
|
"condition": "",
|
||||||
|
"size": "",
|
||||||
|
"color": "",
|
||||||
|
"purchase_price": "20.00",
|
||||||
|
"estimated_resale_price": "",
|
||||||
|
"notes": "Updated notes",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.item.refresh_from_db()
|
||||||
|
self.assertEqual(self.item.title, "Leather boots updated")
|
||||||
|
self.assertEqual(self.item.created_by, self.user)
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateAndPricingTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(username="alice", password="password123")
|
||||||
|
|
||||||
|
def test_template_edit_updates_existing_template(self):
|
||||||
|
template = ItemTemplate.objects.create(name="Mugs", category="Kitchen")
|
||||||
|
|
||||||
|
self.client.login(username="alice", password="password123")
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("template-edit", kwargs={"pk": template.pk}),
|
||||||
|
{
|
||||||
|
"name": "Mugs and cups",
|
||||||
|
"description": "Glassware",
|
||||||
|
"category": "Kitchen",
|
||||||
|
"default_purchase_price": "3.00",
|
||||||
|
"default_estimated_resale_price": "12.00",
|
||||||
|
"default_notes": "Check for chips.",
|
||||||
|
"is_active": "on",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
template.refresh_from_db()
|
||||||
|
self.assertEqual(template.name, "Mugs and cups")
|
||||||
|
self.assertEqual(template.default_purchase_price, Decimal("3.00"))
|
||||||
|
|
||||||
|
def test_pricing_suggestion_uses_recent_sold_comps(self):
|
||||||
|
sold_one = Item.objects.create(
|
||||||
|
created_by=self.user,
|
||||||
|
title="Blue sweater",
|
||||||
|
category="Apparel",
|
||||||
|
purchase_price=Decimal("10.00"),
|
||||||
|
)
|
||||||
|
sold_one.mark_sold(Decimal("40.00"))
|
||||||
|
|
||||||
|
sold_two = Item.objects.create(
|
||||||
|
created_by=self.user,
|
||||||
|
title="Green sweater",
|
||||||
|
category="Apparel",
|
||||||
|
purchase_price=Decimal("12.00"),
|
||||||
|
)
|
||||||
|
sold_two.mark_sold(Decimal("60.00"))
|
||||||
|
|
||||||
|
unsold = Item.objects.create(
|
||||||
|
created_by=self.user,
|
||||||
|
title="Striped sweater",
|
||||||
|
category="Apparel",
|
||||||
|
purchase_price=Decimal("15.00"),
|
||||||
|
)
|
||||||
|
|
||||||
|
suggestion = PricingSuggestionService().suggest_for_item(unsold)
|
||||||
|
|
||||||
|
self.assertEqual(suggestion["price"], Decimal("50.00"))
|
||||||
|
self.assertEqual(suggestion["source"], "Recent sold comps")
|
||||||
44
inventory/tests_template_fields.py
Normal file
44
inventory/tests_template_fields.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from inventory.models import Item, ItemTemplate
|
||||||
|
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateFieldsIntegrationTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(username="alice", password="password123")
|
||||||
|
|
||||||
|
def test_create_item_with_template_fields(self):
|
||||||
|
tmpl = ItemTemplate.objects.create(
|
||||||
|
name="VHS Player",
|
||||||
|
category="Electronics",
|
||||||
|
field_definitions=[
|
||||||
|
{"name": "rewind", "label": "Has Rewind", "type": "boolean"},
|
||||||
|
{"name": "outputs", "label": "Output Count", "type": "number"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.client.login(username="alice", password="password123")
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("item-create"),
|
||||||
|
{
|
||||||
|
"template": tmpl.id,
|
||||||
|
"title": "Panasonic VHS",
|
||||||
|
"brand": "Panasonic",
|
||||||
|
"category": "",
|
||||||
|
"purchase_price": "5.00",
|
||||||
|
"prop_rewind": "on",
|
||||||
|
"prop_outputs": "2",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
item = Item.objects.get(title="Panasonic VHS")
|
||||||
|
self.assertTrue(item.properties.get("rewind"))
|
||||||
|
self.assertEqual(item.properties.get("outputs"), "2")
|
||||||
25
inventory/urls.py
Normal file
25
inventory/urls.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from .views import (
|
||||||
|
DashboardView,
|
||||||
|
ItemCreateView,
|
||||||
|
ItemDetailView,
|
||||||
|
ItemMarkSoldView,
|
||||||
|
ItemNoteCreateView,
|
||||||
|
ItemTemplateCreateView,
|
||||||
|
ItemTemplateUpdateView,
|
||||||
|
ItemUpdateView,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("", DashboardView.as_view(), name="dashboard"),
|
||||||
|
path("items/new/", ItemCreateView.as_view(), name="item-create"),
|
||||||
|
path("items/<int:pk>/", ItemDetailView.as_view(), name="item-detail"),
|
||||||
|
path("items/<int:pk>/edit/", ItemUpdateView.as_view(), name="item-edit"),
|
||||||
|
path("items/<int:pk>/sold/", ItemMarkSoldView.as_view(), name="item-mark-sold"),
|
||||||
|
path("items/<int:pk>/notes/new/", ItemNoteCreateView.as_view(), name="item-note-create"),
|
||||||
|
path("templates/new/", ItemTemplateCreateView.as_view(), name="template-create"),
|
||||||
|
path("templates/<int:pk>/edit/", ItemTemplateUpdateView.as_view(), name="template-edit"),
|
||||||
|
path("add/", DashboardView.as_view(), name="add-hub"),
|
||||||
|
]
|
||||||
199
inventory/views.py
Normal file
199
inventory/views.py
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
|
from django.db.models import DecimalField, ExpressionWrapper, F, Q, Sum
|
||||||
|
from django.http import HttpResponseRedirect
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from django.urls import reverse_lazy
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.views.generic import CreateView, DetailView, TemplateView, UpdateView
|
||||||
|
|
||||||
|
from .forms import ItemForm, ItemFilterForm, ItemNoteForm, ItemSoldForm, ItemTemplateForm
|
||||||
|
from .models import Item, ItemNote, ItemTemplate
|
||||||
|
from .services import PricingSuggestionService
|
||||||
|
import decimal
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardView(LoginRequiredMixin, TemplateView):
|
||||||
|
template_name = "inventory/dashboard.html"
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
item_filter = ItemFilterForm(self.request.GET or None)
|
||||||
|
items = Item.objects.select_related("created_by", "template").order_by("-created_at")
|
||||||
|
|
||||||
|
if item_filter.is_valid():
|
||||||
|
query = item_filter.cleaned_data.get("query")
|
||||||
|
status = item_filter.cleaned_data.get("status")
|
||||||
|
category = item_filter.cleaned_data.get("category")
|
||||||
|
created_by = item_filter.cleaned_data.get("created_by")
|
||||||
|
min_purchase_price = item_filter.cleaned_data.get("min_purchase_price")
|
||||||
|
max_purchase_price = item_filter.cleaned_data.get("max_purchase_price")
|
||||||
|
min_profit = item_filter.cleaned_data.get("min_profit")
|
||||||
|
|
||||||
|
if query:
|
||||||
|
items = items.filter(
|
||||||
|
Q(title__icontains=query)
|
||||||
|
| Q(brand__icontains=query)
|
||||||
|
| Q(notes__icontains=query)
|
||||||
|
| Q(category__icontains=query)
|
||||||
|
)
|
||||||
|
if status:
|
||||||
|
items = items.filter(status=status)
|
||||||
|
if category:
|
||||||
|
items = items.filter(category__icontains=category)
|
||||||
|
if min_purchase_price is not None:
|
||||||
|
items = items.filter(purchase_price__gte=min_purchase_price)
|
||||||
|
if max_purchase_price is not None:
|
||||||
|
items = items.filter(purchase_price__lte=max_purchase_price)
|
||||||
|
if min_profit is not None:
|
||||||
|
items = items.annotate(
|
||||||
|
calculated_profit=ExpressionWrapper(
|
||||||
|
F("sold_price") - F("purchase_price") - F("ebay_fee") - F("shipping_cost"),
|
||||||
|
output_field=DecimalField(max_digits=10, decimal_places=2),
|
||||||
|
)
|
||||||
|
).filter(
|
||||||
|
sold_price__isnull=False,
|
||||||
|
calculated_profit__gte=min_profit,
|
||||||
|
)
|
||||||
|
if created_by:
|
||||||
|
items = items.filter(created_by__username__icontains=created_by)
|
||||||
|
|
||||||
|
context["items"] = items[:100]
|
||||||
|
context["templates"] = ItemTemplate.objects.filter(is_active=True).order_by("name")
|
||||||
|
context["item_filter"] = item_filter
|
||||||
|
context["item_form"] = ItemForm()
|
||||||
|
context["template_form"] = ItemTemplateForm()
|
||||||
|
inventory_cost = Item.objects.aggregate(total=Sum("purchase_price"))["total"] or 0
|
||||||
|
context["stats"] = {
|
||||||
|
"item_count": Item.objects.count(),
|
||||||
|
"profit_total": sum((item.profit or 0) for item in Item.objects.all()),
|
||||||
|
"sold_count": Item.objects.filter(status=Item.Status.SOLD).count(),
|
||||||
|
"inventory_value": inventory_cost,
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class ItemCreateView(LoginRequiredMixin, CreateView):
|
||||||
|
model = Item
|
||||||
|
form_class = ItemForm
|
||||||
|
template_name = "inventory/item_form.html"
|
||||||
|
success_url = reverse_lazy("dashboard")
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
template_id = self.request.GET.get("template") or self.request.POST.get("template")
|
||||||
|
template_obj = None
|
||||||
|
if template_id:
|
||||||
|
try:
|
||||||
|
template_obj = ItemTemplate.objects.get(pk=int(template_id))
|
||||||
|
except Exception:
|
||||||
|
template_obj = None
|
||||||
|
context["template_obj"] = template_obj
|
||||||
|
return context
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
form.instance.created_by = self.request.user
|
||||||
|
template = form.cleaned_data.get("template")
|
||||||
|
properties = {}
|
||||||
|
# If template defines extra fields, read them from POST and coerce types
|
||||||
|
if template and template.field_definitions:
|
||||||
|
for fd in template.field_definitions:
|
||||||
|
key = fd.get("name")
|
||||||
|
ftype = fd.get("type", "text")
|
||||||
|
raw = self.request.POST.get(f"prop_{key}")
|
||||||
|
if raw is None or raw == "":
|
||||||
|
value = None
|
||||||
|
else:
|
||||||
|
if ftype == "number":
|
||||||
|
try:
|
||||||
|
value = decimal.Decimal(raw)
|
||||||
|
# JSONField cannot store Decimal directly; store as string to preserve precision
|
||||||
|
value = str(value)
|
||||||
|
except Exception:
|
||||||
|
value = raw
|
||||||
|
elif ftype == "boolean":
|
||||||
|
value = raw in ("on", "true", "1", "True")
|
||||||
|
else:
|
||||||
|
value = raw
|
||||||
|
properties[key] = value
|
||||||
|
form.instance.properties = properties
|
||||||
|
if template:
|
||||||
|
if not form.cleaned_data.get("category"):
|
||||||
|
form.instance.category = template.category
|
||||||
|
if not form.cleaned_data.get("purchase_price") and template.default_purchase_price is not None:
|
||||||
|
form.instance.purchase_price = template.default_purchase_price
|
||||||
|
if not form.cleaned_data.get("estimated_resale_price") and template.default_estimated_resale_price is not None:
|
||||||
|
form.instance.estimated_resale_price = template.default_estimated_resale_price
|
||||||
|
if not form.cleaned_data.get("notes") and template.default_notes:
|
||||||
|
form.instance.notes = template.default_notes
|
||||||
|
return super().form_valid(form)
|
||||||
|
|
||||||
|
|
||||||
|
class ItemDetailView(LoginRequiredMixin, DetailView):
|
||||||
|
model = Item
|
||||||
|
template_name = "inventory/item_detail.html"
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
context["edit_form"] = ItemForm(instance=self.object)
|
||||||
|
context["sold_form"] = ItemSoldForm(instance=self.object)
|
||||||
|
context["note_form"] = ItemNoteForm()
|
||||||
|
context["notes"] = self.object.notes_history.select_related("created_by").order_by("-created_at")
|
||||||
|
context["price_estimates"] = self.object.price_estimates.all()
|
||||||
|
context["pricing_suggestion"] = PricingSuggestionService().suggest_for_item(self.object)
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class ItemUpdateView(LoginRequiredMixin, UpdateView):
|
||||||
|
model = Item
|
||||||
|
form_class = ItemForm
|
||||||
|
template_name = "inventory/item_form.html"
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return self.object.get_absolute_url()
|
||||||
|
|
||||||
|
|
||||||
|
class ItemMarkSoldView(LoginRequiredMixin, UpdateView):
|
||||||
|
model = Item
|
||||||
|
form_class = ItemSoldForm
|
||||||
|
template_name = "inventory/item_sold_form.html"
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
self.object = form.save(commit=False)
|
||||||
|
self.object.status = Item.Status.SOLD
|
||||||
|
if self.object.sold_at is None:
|
||||||
|
self.object.sold_at = timezone.now()
|
||||||
|
self.object.save()
|
||||||
|
return HttpResponseRedirect(self.get_success_url())
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return self.object.get_absolute_url()
|
||||||
|
|
||||||
|
|
||||||
|
class ItemNoteCreateView(LoginRequiredMixin, CreateView):
|
||||||
|
model = ItemNote
|
||||||
|
form_class = ItemNoteForm
|
||||||
|
template_name = "inventory/note_form.html"
|
||||||
|
success_url = reverse_lazy("dashboard")
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
form.instance.item = get_object_or_404(Item, pk=self.kwargs["pk"])
|
||||||
|
form.instance.created_by = self.request.user
|
||||||
|
response = super().form_valid(form)
|
||||||
|
return HttpResponseRedirect(self.object.item.get_absolute_url())
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return self.object.item.get_absolute_url()
|
||||||
|
|
||||||
|
|
||||||
|
class ItemTemplateCreateView(LoginRequiredMixin, CreateView):
|
||||||
|
model = ItemTemplate
|
||||||
|
form_class = ItemTemplateForm
|
||||||
|
template_name = "inventory/template_form.html"
|
||||||
|
success_url = reverse_lazy("dashboard")
|
||||||
|
|
||||||
|
|
||||||
|
class ItemTemplateUpdateView(LoginRequiredMixin, UpdateView):
|
||||||
|
model = ItemTemplate
|
||||||
|
form_class = ItemTemplateForm
|
||||||
|
template_name = "inventory/template_form.html"
|
||||||
|
success_url = reverse_lazy("dashboard")
|
||||||
14
manage.py
Normal file
14
manage.py
Normal 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
41
plan.md
Normal 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
17
pyproject.toml
Normal 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
46
templates/base.html
Normal 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>
|
||||||
11
templates/inventory/add_hub.html
Normal file
11
templates/inventory/add_hub.html
Normal 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 %}
|
||||||
62
templates/inventory/dashboard.html
Normal file
62
templates/inventory/dashboard.html
Normal 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 %}
|
||||||
74
templates/inventory/item_detail.html
Normal file
74
templates/inventory/item_detail.html
Normal 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 %}
|
||||||
49
templates/inventory/item_form.html
Normal file
49
templates/inventory/item_form.html
Normal 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 %}
|
||||||
11
templates/inventory/item_sold_form.html
Normal file
11
templates/inventory/item_sold_form.html
Normal 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 %}
|
||||||
11
templates/inventory/note_form.html
Normal file
11
templates/inventory/note_form.html
Normal 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 %}
|
||||||
11
templates/inventory/template_form.html
Normal file
11
templates/inventory/template_form.html
Normal 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 %}
|
||||||
12
templates/registration/login.html
Normal file
12
templates/registration/login.html
Normal 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 %}
|
||||||
Reference in New Issue
Block a user