Skip to content

Commit b144670

Browse files
committed
feat: persistance filters multiple sell and auto etiquette
1 parent 0377887 commit b144670

26 files changed

Lines changed: 2388 additions & 456 deletions

api/assets/goupixdex-logo.png

72.6 KB
Loading

api/desktop_vinted_server.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -199,13 +199,14 @@ async def publish_vinted_for_article(
199199
return {
200200
"vinted": {
201201
"status": "running",
202-
"stream_path": f"/articles/{article_id}/vinted-progress",
202+
"stream_path": f"/articles/{article_id}/listing-progress",
203203
},
204204
}
205205

206206

207-
@router.get("/{article_id}/vinted-progress")
208-
async def vinted_progress_stream(
207+
@router.get("/{article_id}/listing-progress")
208+
@router.get("/{article_id}/vinted-progress", include_in_schema=False)
209+
async def article_listing_progress_stream(
209210
article_id: int,
210211
_: Annotated[int, Depends(get_user_id_introspected)],
211212
) -> StreamingResponse:

api/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from routes import pricing_route
2525
from routes import scan as scan_routes
2626
from routes import settings_route
27+
from routes import shipping_route
2728
from routes import stats_route
2829
from routes import users as users_routes
2930

@@ -103,3 +104,4 @@ def health() -> dict[str, str]:
103104
app.include_router(pricing_route.router)
104105
app.include_router(stats_route.router)
105106
app.include_router(scan_routes.router)
107+
app.include_router(shipping_route.router)

api/requirements.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,6 @@ supabase>=2.0.0
2424
requests>=2.31.0
2525
browser-cookie3>=0.19.0
2626
vinted-scraper>=3.0.0
27+
28+
# Shipping label PDF generation (Avery L7173 — 8 labels per A4)
29+
reportlab>=4.0.0

api/routes/articles.py

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,21 @@ def _parse_sold_at_iso(value: str | None) -> dt.datetime | None:
6666
) from exc
6767

6868

69+
def _parse_optional_iso_dt(value: str | None) -> dt.datetime | None:
70+
"""Parse ISO-ish datetime without raising (e.g. wardrobe import)."""
71+
if value is None or not str(value).strip():
72+
return None
73+
raw = str(value).strip()
74+
try:
75+
return dt.datetime.fromisoformat(raw.replace("Z", "+00:00"))
76+
except ValueError:
77+
pass
78+
try:
79+
return dt.datetime.strptime(raw[:10], "%Y-%m-%d").replace(tzinfo=dt.UTC)
80+
except ValueError:
81+
return None
82+
83+
6984
@router.post("/bulk-delete", status_code=status.HTTP_200_OK)
7085
def bulk_delete_articles(
7186
body: BulkIdsBody,
@@ -218,14 +233,16 @@ def list_articles(
218233
return [article_service.article_to_dict(a) for a in rows]
219234

220235

221-
@router.get("/{article_id}/vinted-progress")
222-
async def vinted_progress_stream(
236+
@router.get("/{article_id}/listing-progress")
237+
@router.get("/{article_id}/vinted-progress", include_in_schema=False)
238+
async def article_listing_progress_stream(
223239
article_id: int,
224240
user: Annotated[User, Depends(get_current_user_from_token_str)],
225241
db: Annotated[Session, Depends(get_db)],
226242
) -> StreamingResponse:
227243
"""
228244
SSE stream for Vinted and/or eBay publish steps (JWT via ``Authorization`` or ``?token=``).
245+
Legacy path: ``/vinted-progress`` (alias).
229246
"""
230247
article = article_service.get_article(db, article_id, user.id)
231248
if article is None:
@@ -267,7 +284,7 @@ def publish_vinted_for_article(
267284
) -> dict[str, Any]:
268285
"""
269286
Start Vinted publish for an existing article (images already stored).
270-
Follow SSE ``GET /articles/{id}/vinted-progress``.
287+
Follow SSE ``GET /articles/{id}/listing-progress``.
271288
"""
272289
article = article_service.get_article(db, article_id, user.id)
273290
if article is None:
@@ -300,7 +317,7 @@ def publish_vinted_for_article(
300317
return {
301318
"vinted": {
302319
"status": "running",
303-
"stream_path": f"/articles/{article_id}/vinted-progress",
320+
"stream_path": f"/articles/{article_id}/listing-progress",
304321
},
305322
}
306323

@@ -368,7 +385,7 @@ def publish_ebay_for_article(
368385
return {
369386
"ebay": {
370387
"status": "running",
371-
"stream_path": f"/articles/{article_id}/vinted-progress",
388+
"stream_path": f"/articles/{article_id}/listing-progress",
372389
},
373390
}
374391

@@ -387,10 +404,14 @@ async def create_article(
387404
card_number: str | None = Form(None),
388405
condition: str = Form("Near Mint"),
389406
sell_price: str | None = Form(None),
407+
sold_price: str | None = Form(None),
408+
sale_source: str | None = Form(None),
390409
publish_to_vinted: str | None = Form(None),
391410
publish_to_ebay: str | None = Form(None),
392411
is_sold: str | None = Form(None),
393412
sold_at: str | None = Form(None),
413+
wardrobe_vinted_listed: str | None = Form(None),
414+
vinted_published_at: str | None = Form(None),
394415
images: list[UploadFile] | None = File(None),
395416
) -> dict[str, Any]:
396417
settings = get_settings()
@@ -399,6 +420,19 @@ async def create_article(
399420
if sold_flag and sold_at_dt is None:
400421
sold_at_dt = dt.datetime.now(dt.UTC)
401422

423+
sell_dec = _parse_decimal(sell_price)
424+
proceeds_dec = _parse_decimal(sold_price) if sold_flag and sold_price else None
425+
if sold_flag and proceeds_dec is None:
426+
proceeds_dec = sell_dec
427+
428+
sale_src: str | None = None
429+
if sold_flag:
430+
raw_ss = (sale_source or "").strip().lower()
431+
if raw_ss in ("vinted", "ebay"):
432+
sale_src = raw_ss[:16]
433+
elif _form_bool(wardrobe_vinted_listed):
434+
sale_src = "vinted"
435+
402436
article = Article(
403437
user_id=user.id,
404438
title=title.strip(),
@@ -408,10 +442,20 @@ async def create_article(
408442
card_number=card_number.strip() if card_number else None,
409443
condition=condition.strip() or "Near Mint",
410444
purchase_price=_parse_decimal_required(purchase_price),
411-
sell_price=_parse_decimal(sell_price),
445+
sell_price=sell_dec,
446+
sold_price=proceeds_dec if sold_flag else None,
447+
sale_source=sale_src,
412448
is_sold=sold_flag,
413449
sold_at=sold_at_dt if sold_flag else None,
414450
)
451+
452+
if _form_bool(wardrobe_vinted_listed):
453+
article.published_on_vinted = True
454+
vp = _parse_optional_iso_dt(vinted_published_at)
455+
if vp is not None:
456+
article.vinted_published_at = vp
457+
elif not sold_flag:
458+
article.vinted_published_at = dt.datetime.now(dt.UTC)
415459
db.add(article)
416460
db.flush()
417461

@@ -446,7 +490,7 @@ async def create_article(
446490
want_vinted = raw_want_vinted and ms.vinted_enabled
447491
raw_want_ebay = _form_bool(publish_to_ebay) and not sold_flag
448492

449-
stream_path = f"/articles/{article.id}/vinted-progress"
493+
stream_path = f"/articles/{article.id}/listing-progress"
450494
want_both_server = (
451495
want_vinted
452496
and not vinted_local_desktop

api/routes/shipping_route.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
"""Shipping labels: list eBay buyer orders + render an A4 sheet of labels (Avery L7173)."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Annotated, Any
6+
7+
from fastapi import APIRouter, Depends, HTTPException, status
8+
from fastapi.responses import Response
9+
from pydantic import BaseModel, Field
10+
from sqlalchemy.orm import Session
11+
12+
from core.database import get_db
13+
from core.deps import get_current_user
14+
from core.security import decrypt_ebay_token
15+
from models.user import User
16+
from services.ebay_orders_service import list_unshipped_orders
17+
from services.ebay_publish_service import ensure_ebay_access_token
18+
from services.shipping_label_service import LabelAddress, render_labels_pdf
19+
20+
router = APIRouter(prefix="/shipping", tags=["shipping"])
21+
22+
23+
class ShippingLabelInput(BaseModel):
24+
"""One recipient block. Edits made before printing never touch the eBay order."""
25+
26+
full_name: str = Field(..., min_length=1, max_length=120)
27+
line1: str = Field(..., min_length=1, max_length=180)
28+
line2: str | None = Field(default=None, max_length=180)
29+
postal_code: str = Field(..., min_length=1, max_length=20)
30+
city: str = Field(..., min_length=1, max_length=80)
31+
state: str | None = Field(default=None, max_length=80)
32+
country_code: str | None = Field(default="FR", max_length=3)
33+
34+
35+
class ShippingLabelsBody(BaseModel):
36+
addresses: list[ShippingLabelInput] = Field(..., min_length=1, max_length=200)
37+
38+
39+
@router.get("/ebay-orders")
40+
async def shipping_ebay_orders(
41+
db: Annotated[Session, Depends(get_db)],
42+
user: Annotated[User, Depends(get_current_user)],
43+
) -> dict[str, Any]:
44+
"""List eBay buyer orders that still need shipping (NOT_STARTED / IN_PROGRESS, last 90 days)."""
45+
if not decrypt_ebay_token(user.ebay_refresh_token):
46+
raise HTTPException(
47+
status_code=status.HTTP_400_BAD_REQUEST,
48+
detail="Connect eBay first (OAuth).",
49+
)
50+
try:
51+
token = await ensure_ebay_access_token(db, user)
52+
except RuntimeError as exc:
53+
raise HTTPException(status_code=400, detail=str(exc)) from exc
54+
55+
try:
56+
orders = await list_unshipped_orders(token)
57+
except Exception as exc:
58+
raise HTTPException(
59+
status_code=status.HTTP_502_BAD_GATEWAY,
60+
detail=f"eBay Fulfillment API error: {exc}",
61+
) from exc
62+
return {"orders": orders, "count": len(orders)}
63+
64+
65+
@router.post("/labels.pdf")
66+
async def shipping_labels_pdf(
67+
body: ShippingLabelsBody,
68+
user: Annotated[User, Depends(get_current_user)], # noqa: ARG001 (auth gate)
69+
) -> Response:
70+
"""
71+
Render the supplied recipient blocks as an A4 PDF (Avery L7173 — 99×57 mm, 8 per page).
72+
73+
Same endpoint serves both the in-app preview (iframe) and the final download — guarantees
74+
the on-screen rendering matches the printed output byte-for-byte.
75+
"""
76+
addresses = [
77+
LabelAddress(
78+
full_name=row.full_name.strip(),
79+
line1=row.line1.strip(),
80+
line2=row.line2.strip() if row.line2 else None,
81+
postal_code=row.postal_code.strip(),
82+
city=row.city.strip(),
83+
state=row.state.strip() if row.state else None,
84+
country_code=(row.country_code or "FR").strip().upper() or "FR",
85+
)
86+
for row in body.addresses
87+
]
88+
pdf = render_labels_pdf(addresses)
89+
return Response(
90+
content=pdf,
91+
media_type="application/pdf",
92+
headers={
93+
"Content-Disposition": 'inline; filename="goupixdex-etiquettes.pdf"',
94+
"Cache-Control": "no-store",
95+
},
96+
)

0 commit comments

Comments
 (0)