Skip to content

Commit 8bf1979

Browse files
committed
Update models dependency to v2.1.0 and enhance item lifecycle and benchmark APIs
- Updated the models dependency in requirements.txt to version 2.1.0. - Added new item lifecycle and benchmark routes in main.py to expand API functionality. - Enhanced item creation logic in item.py to link existing items to catalog products based on brand and product IDs. - Improved product enrichment task in enrich_product.py to update linked items with catalog product IDs.
1 parent 5034b7b commit 8bf1979

8 files changed

Lines changed: 436 additions & 3 deletions

File tree

app/api/benchmark.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import logging
2+
3+
from fastapi import APIRouter, Depends, HTTPException
4+
from fastapi_sqlalchemy import db
5+
from pydantic import BaseModel
6+
from typing import Optional
7+
8+
from models.base import User, CategoryBenchmark
9+
from utils.auth import authenticate
10+
from utils.gear_lifecycle import get_all_benchmarks, DEFAULT_BENCHMARKS
11+
12+
logger = logging.getLogger(__name__)
13+
14+
route = APIRouter(dependencies=[Depends(authenticate)])
15+
16+
17+
@route.get("")
18+
def fetch_benchmarks(user: User = Depends(authenticate)):
19+
return get_all_benchmarks(db.session, user.id)
20+
21+
22+
class BenchmarkUpdate(BaseModel):
23+
lifespan_years: Optional[float] = None
24+
expected_nights: Optional[float] = None
25+
expected_distance: Optional[float] = None
26+
distance_unit: Optional[str] = None
27+
28+
29+
@route.put("/{category_name}")
30+
def upsert_benchmark(category_name: str, payload: BenchmarkUpdate, user: User = Depends(authenticate)):
31+
if category_name not in DEFAULT_BENCHMARKS:
32+
raise HTTPException(400, f"Unknown category: {category_name}")
33+
34+
override = db.session.query(CategoryBenchmark).filter_by(
35+
user_id=user.id, category_name=category_name
36+
).first()
37+
38+
if not override:
39+
override = CategoryBenchmark(user_id=user.id, category_name=category_name)
40+
db.session.add(override)
41+
42+
fields = payload.dict(exclude_none=True)
43+
for key, value in fields.items():
44+
setattr(override, key, value)
45+
46+
try:
47+
db.session.commit()
48+
db.session.refresh(override)
49+
except Exception:
50+
logger.exception("Failed to update benchmark")
51+
raise HTTPException(400, "Unable to update benchmark.")
52+
53+
return get_all_benchmarks(db.session, user.id)
54+
55+
56+
@route.delete("/{category_name}", status_code=204)
57+
def reset_benchmark(category_name: str, user: User = Depends(authenticate)):
58+
override = db.session.query(CategoryBenchmark).filter_by(
59+
user_id=user.id, category_name=category_name
60+
).first()
61+
62+
if override:
63+
db.session.delete(override)
64+
db.session.commit()

app/api/item.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from io import StringIO
99
from sqlalchemy import or_, func
1010

11-
from models.base import User, Item, ItemCategory, Category, Brand, Product, ProductVariant
11+
from models.base import User, Item, ItemCategory, Category, Brand, Product, ProductVariant, CatalogProduct
1212
from utils.auth import authenticate
1313
from utils.weight import standardize_weight_unit
1414
from utils.item_category import get_or_create_item_category
@@ -39,6 +39,28 @@ class ItemType(BaseModel):
3939
wishlist: bool = None
4040
notes: str = None
4141

42+
acquired_date: str = None
43+
acquisition_type: str = None
44+
purchase_retailer: str = None
45+
condition: str = None
46+
status: str = None
47+
retired_date: str = None
48+
retired_reason: str = None
49+
replaced_by_id: int = None
50+
51+
52+
def _find_catalog_product(session, brand_id: int, product_id: int, product_variant_id: int | None):
53+
q = session.query(CatalogProduct).filter(
54+
CatalogProduct.brand_id == brand_id,
55+
CatalogProduct.product_id == product_id,
56+
CatalogProduct.status == "approved",
57+
)
58+
if product_variant_id:
59+
q = q.filter(CatalogProduct.product_variant_id == product_variant_id)
60+
else:
61+
q = q.filter(CatalogProduct.product_variant_id.is_(None))
62+
return q.first()
63+
4264

4365
@route.post("", status_code=201)
4466
def create(payload: ItemType, user: User = Depends(authenticate)):
@@ -56,6 +78,14 @@ def create(payload: ItemType, user: User = Depends(authenticate)):
5678

5779
new_item = Item(user_id=user.id, **item_data)
5880

81+
if new_item.brand_id and new_item.product_id:
82+
catalog_match = _find_catalog_product(
83+
db.session, new_item.brand_id, new_item.product_id,
84+
new_item.product_variant_id
85+
)
86+
if catalog_match:
87+
new_item.catalog_product_id = catalog_match.id
88+
5989
try:
6090
db.session.add(new_item)
6191
db.session.commit()

app/api/item_lifecycle.py

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import datetime
2+
import logging
3+
4+
from fastapi import APIRouter, Depends, HTTPException
5+
from fastapi_sqlalchemy import db
6+
from pydantic import BaseModel
7+
from typing import Optional
8+
9+
from models.base import User, Item, ItemLog
10+
from utils.auth import authenticate
11+
from utils.gear_lifecycle import replacement_score, get_benchmark
12+
13+
logger = logging.getLogger(__name__)
14+
15+
route = APIRouter(dependencies=[Depends(authenticate)])
16+
17+
VALID_CONDITIONS = {"new", "good", "fair", "worn", "retired"}
18+
VALID_STATUSES = {"active", "wishlist", "retired", "sold", "lost"}
19+
VALID_ACQUISITION_TYPES = {"purchased", "gifted", "traded", "diy"}
20+
VALID_RETIRED_REASONS = {"worn_out", "upgraded", "lost", "sold", "gifted"}
21+
22+
23+
class LifecycleUpdate(BaseModel):
24+
acquired_date: str = None
25+
acquisition_type: str = None
26+
purchase_retailer: str = None
27+
condition: str = None
28+
status: str = None
29+
retired_date: str = None
30+
retired_reason: str = None
31+
replaced_by_id: int = None
32+
33+
34+
@route.put("/{item_id}/lifecycle")
35+
def update_lifecycle(item_id: int, payload: LifecycleUpdate, user: User = Depends(authenticate)):
36+
item = db.session.query(Item).filter_by(id=item_id, user_id=user.id).first()
37+
if not item:
38+
raise HTTPException(404, "Item not found.")
39+
40+
if payload.condition and payload.condition not in VALID_CONDITIONS:
41+
raise HTTPException(400, f"Invalid condition. Must be one of: {', '.join(VALID_CONDITIONS)}")
42+
43+
if payload.status and payload.status not in VALID_STATUSES:
44+
raise HTTPException(400, f"Invalid status. Must be one of: {', '.join(VALID_STATUSES)}")
45+
46+
if payload.acquisition_type and payload.acquisition_type not in VALID_ACQUISITION_TYPES:
47+
raise HTTPException(400, f"Invalid acquisition_type. Must be one of: {', '.join(VALID_ACQUISITION_TYPES)}")
48+
49+
if payload.retired_reason and payload.retired_reason not in VALID_RETIRED_REASONS:
50+
raise HTTPException(400, f"Invalid retired_reason. Must be one of: {', '.join(VALID_RETIRED_REASONS)}")
51+
52+
if payload.replaced_by_id:
53+
replacement = db.session.query(Item).filter_by(
54+
id=payload.replaced_by_id, user_id=user.id
55+
).first()
56+
if not replacement:
57+
raise HTTPException(400, "Replacement item not found.")
58+
59+
old_condition = item.condition
60+
61+
fields = payload.dict(exclude_none=True)
62+
for key, value in fields.items():
63+
setattr(item, key, value)
64+
65+
if payload.condition and payload.condition != old_condition:
66+
log_entry = ItemLog(
67+
item_id=item.id,
68+
user_id=user.id,
69+
event_type="condition_change",
70+
event_date=datetime.date.today(),
71+
old_condition=old_condition,
72+
new_condition=payload.condition,
73+
)
74+
db.session.add(log_entry)
75+
76+
try:
77+
db.session.commit()
78+
db.session.refresh(item)
79+
except Exception:
80+
logger.exception("Failed to update item lifecycle")
81+
raise HTTPException(400, "Unable to update item lifecycle.")
82+
83+
return item
84+
85+
86+
VALID_EVENT_TYPES = {
87+
"acquired", "condition_change", "repair", "maintenance",
88+
"weight_check", "retired", "sold", "note",
89+
}
90+
91+
92+
class ItemLogCreate(BaseModel):
93+
event_type: str
94+
event_date: str
95+
note: Optional[str] = None
96+
old_condition: Optional[str] = None
97+
new_condition: Optional[str] = None
98+
old_weight: Optional[float] = None
99+
new_weight: Optional[float] = None
100+
cost: Optional[float] = None
101+
102+
103+
@route.post("/{item_id}/log", status_code=201)
104+
def create_log(item_id: int, payload: ItemLogCreate, user: User = Depends(authenticate)):
105+
item = db.session.query(Item).filter_by(id=item_id, user_id=user.id).first()
106+
if not item:
107+
raise HTTPException(404, "Item not found.")
108+
109+
if payload.event_type not in VALID_EVENT_TYPES:
110+
raise HTTPException(400, f"Invalid event_type. Must be one of: {', '.join(VALID_EVENT_TYPES)}")
111+
112+
log_entry = ItemLog(
113+
item_id=item.id,
114+
user_id=user.id,
115+
**payload.dict(),
116+
)
117+
118+
try:
119+
db.session.add(log_entry)
120+
db.session.commit()
121+
db.session.refresh(log_entry)
122+
except Exception:
123+
logger.exception("Failed to create item log")
124+
raise HTTPException(400, "Unable to create log entry.")
125+
126+
return log_entry
127+
128+
129+
@route.get("/{item_id}/log")
130+
def fetch_logs(item_id: int, user: User = Depends(authenticate)):
131+
item = db.session.query(Item).filter_by(id=item_id, user_id=user.id).first()
132+
if not item:
133+
raise HTTPException(404, "Item not found.")
134+
135+
logs = db.session.query(ItemLog).filter_by(
136+
item_id=item.id
137+
).order_by(ItemLog.event_date.desc(), ItemLog.created_at.desc()).all()
138+
139+
return logs
140+
141+
142+
@route.get("/{item_id}/replacement-score")
143+
def get_replacement_score(item_id: int, user: User = Depends(authenticate)):
144+
item = db.session.query(Item).filter_by(id=item_id, user_id=user.id).first()
145+
if not item:
146+
raise HTTPException(404, "Item not found.")
147+
148+
category_name = (
149+
item.category.category.name
150+
if item.category and item.category.category
151+
else "Miscellaneous"
152+
)
153+
154+
benchmark = get_benchmark(db.session, user.id, category_name)
155+
score = replacement_score(item.acquired_date, item.condition, benchmark)
156+
157+
return {
158+
"item_id": item.id,
159+
"score": score,
160+
"category": category_name,
161+
"benchmark": benchmark,
162+
"acquired_date": str(item.acquired_date) if item.acquired_date else None,
163+
"condition": item.condition,
164+
}

app/main.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from sqlalchemy import create_engine
66

77
from utils.consts import DATABASE_URL, DEVELOPMENT, APP_HOST
8-
from api import user, resources, item, trip, category, pack, kit, hiker_profile, webhook
8+
from api import user, resources, item, item_lifecycle, benchmark, trip, category, pack, kit, hiker_profile, webhook
99

1010
ENGINE_KWARGS = dict(
1111
pool_size=5,
@@ -65,6 +65,20 @@
6565
responses={404: {"description": "Not found"}}
6666
)
6767

68+
app.include_router(
69+
item_lifecycle.route,
70+
prefix="/item",
71+
tags=["item-lifecycle"],
72+
responses={404: {"description": "Not found"}}
73+
)
74+
75+
app.include_router(
76+
benchmark.route,
77+
prefix="/benchmark",
78+
tags=["benchmark"],
79+
responses={404: {"description": "Not found"}}
80+
)
81+
6882

6983
app.include_router(
7084
trip.route,

app/tasks/enrich_product.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -585,4 +585,19 @@ def enrich_product(self, brand_id: int, product_id: int, product_variant_id: int
585585
session.commit()
586586
logger.info("Inserted %s (confidence=%.3f)", display_name, confidence)
587587

588+
variant_filter = (
589+
Item.product_variant_id == variant.id
590+
if variant
591+
else Item.product_variant_id.is_(None)
592+
)
593+
linked = session.query(Item).filter(
594+
Item.brand_id == brand.id,
595+
Item.product_id == product.id,
596+
variant_filter,
597+
Item.catalog_product_id.is_(None),
598+
).update({"catalog_product_id": entry.id}, synchronize_session=False)
599+
if linked:
600+
session.commit()
601+
logger.info("Linked %d existing items to catalog product %d", linked, entry.id)
602+
588603
find_product_image.delay(entry.id)

0 commit comments

Comments
 (0)