Skip to content

Commit 468cfe6

Browse files
committed
feat: stock page and graded cards
1 parent a533495 commit 468cfe6

21 files changed

Lines changed: 1294 additions & 521 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
-- Cartes gradées (slab) : société + note eBay + certificat optionnel ; Vinted « Neuf avec étiquette ».
2+
3+
ALTER TABLE articles ADD COLUMN IF NOT EXISTS is_graded TINYINT(1) NOT NULL DEFAULT 0;
4+
ALTER TABLE articles ADD COLUMN IF NOT EXISTS graded_grader_value_id VARCHAR(16) NULL;
5+
ALTER TABLE articles ADD COLUMN IF NOT EXISTS graded_grade_value_id VARCHAR(16) NULL;
6+
ALTER TABLE articles ADD COLUMN IF NOT EXISTS graded_cert_number VARCHAR(32) NULL;

api/models/article.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,15 @@ class Article(Base):
2323
set_code: Mapped[str | None] = mapped_column(String(64), nullable=True)
2424
card_number: Mapped[str | None] = mapped_column(String(64), nullable=True)
2525
condition: Mapped[str] = mapped_column(String(64), default="Near Mint")
26+
is_graded: Mapped[bool] = mapped_column(
27+
Boolean(),
28+
default=False,
29+
server_default="0",
30+
nullable=False,
31+
)
32+
graded_grader_value_id: Mapped[str | None] = mapped_column(String(16), nullable=True)
33+
graded_grade_value_id: Mapped[str | None] = mapped_column(String(16), nullable=True)
34+
graded_cert_number: Mapped[str | None] = mapped_column(String(32), nullable=True)
2635
purchase_price: Mapped[Decimal] = mapped_column(Numeric(12, 2))
2736
sell_price: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True)
2837
# Actual proceeds; sale_source is vinted | ebay

api/routes/articles.py

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@
2727
from services.vinted_batch_session_service import VintedBatchSessionService as vinted_batch_hub
2828
from services.vinted_progress_session_service import VintedProgressSessionService as vinted_progress_hub
2929
from services import supabase_storage_service
30+
from services.ebay_trading_card_grading import (
31+
is_valid_grade_value_id,
32+
is_valid_grader_value_id,
33+
)
3034
from services.vinted_background_service import VintedBackgroundService
3135
from services.vinted_batch_background_service import VintedBatchBackgroundService
3236

@@ -66,6 +70,18 @@ def _parse_sold_at_iso(value: str | None) -> dt.datetime | None:
6670
) from exc
6771

6872

73+
def _validate_graded_article_or_raise(article: Article) -> None:
74+
if not article.is_graded:
75+
return
76+
if not is_valid_grader_value_id(article.graded_grader_value_id) or not is_valid_grade_value_id(
77+
article.graded_grade_value_id
78+
):
79+
raise HTTPException(
80+
status_code=status.HTTP_400_BAD_REQUEST,
81+
detail="Carte gradée : choisissez une société de grading et une note (identifiants eBay).",
82+
)
83+
84+
6985
def _parse_optional_iso_dt(value: str | None) -> dt.datetime | None:
7086
"""Parse ISO-ish datetime without raising (e.g. wardrobe import)."""
7187
if value is None or not str(value).strip():
@@ -421,6 +437,10 @@ async def create_article(
421437
sold_at: str | None = Form(None),
422438
wardrobe_vinted_listed: str | None = Form(None),
423439
vinted_published_at: str | None = Form(None),
440+
is_graded: str | None = Form(None),
441+
graded_grader_value_id: str | None = Form(None),
442+
graded_grade_value_id: str | None = Form(None),
443+
graded_cert_number: str | None = Form(None),
424444
images: list[UploadFile] | None = File(None),
425445
) -> dict[str, Any]:
426446
settings = get_settings()
@@ -442,6 +462,16 @@ async def create_article(
442462
elif _form_bool(wardrobe_vinted_listed):
443463
sale_src = "vinted"
444464

465+
graded_flag = _form_bool(is_graded)
466+
grader_vid = (graded_grader_value_id or "").strip() or None
467+
grade_vid = (graded_grade_value_id or "").strip() or None
468+
cert_raw = (graded_cert_number or "").strip()
469+
cert_norm = cert_raw[:30] if cert_raw else None
470+
if not graded_flag:
471+
grader_vid = None
472+
grade_vid = None
473+
cert_norm = None
474+
445475
article = Article(
446476
user_id=user.id,
447477
title=title.strip(),
@@ -450,13 +480,18 @@ async def create_article(
450480
set_code=set_code.strip() if set_code else None,
451481
card_number=card_number.strip() if card_number else None,
452482
condition=condition.strip() or "Near Mint",
483+
is_graded=graded_flag,
484+
graded_grader_value_id=grader_vid,
485+
graded_grade_value_id=grade_vid,
486+
graded_cert_number=cert_norm,
453487
purchase_price=_parse_decimal_required(purchase_price),
454488
sell_price=sell_dec,
455489
sold_price=proceeds_dec if sold_flag else None,
456490
sale_source=sale_src,
457491
is_sold=sold_flag,
458492
sold_at=sold_at_dt if sold_flag else None,
459493
)
494+
_validate_graded_article_or_raise(article)
460495

461496
if _form_bool(wardrobe_vinted_listed):
462497
article.published_on_vinted = True
@@ -580,17 +615,8 @@ def update_article(
580615
article = article_service.get_article(db, article_id, user.id)
581616
if article is None:
582617
raise HTTPException(status_code=404, detail="Article not found")
583-
article_service.update_article_fields(
584-
article,
585-
title=body.title,
586-
description=body.description,
587-
pokemon_name=body.pokemon_name,
588-
set_code=body.set_code,
589-
card_number=body.card_number,
590-
condition=body.condition,
591-
purchase_price=body.purchase_price,
592-
sell_price=body.sell_price,
593-
)
618+
article_service.update_article_from_body(article, body)
619+
_validate_graded_article_or_raise(article)
594620
db.commit()
595621
db.refresh(article)
596622
return article_service.article_to_dict(article)

api/schemas/articles.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ class ArticleUpdate(BaseModel):
1111
set_code: str | None = None
1212
card_number: str | None = None
1313
condition: str | None = None
14+
is_graded: bool | None = None
15+
graded_grader_value_id: str | None = None
16+
graded_grade_value_id: str | None = None
17+
graded_cert_number: str | None = None
1418
purchase_price: Decimal | None = None
1519
sell_price: Decimal | None = None
1620

api/services/article_service.py

Lines changed: 43 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
from core.database import SessionLocal
1212
from models.article import Article
13+
from schemas.articles import ArticleUpdate
1314

1415

1516
def list_articles_for_user(db: Session, user_id: int) -> list[Article]:
@@ -40,6 +41,10 @@ def article_to_dict(article: Article) -> dict[str, Any]:
4041
"set_code": article.set_code,
4142
"card_number": article.card_number,
4243
"condition": article.condition,
44+
"is_graded": bool(article.is_graded),
45+
"graded_grader_value_id": article.graded_grader_value_id,
46+
"graded_grade_value_id": article.graded_grade_value_id,
47+
"graded_cert_number": article.graded_cert_number,
4348
"purchase_price": float(article.purchase_price),
4449
"sell_price": float(article.sell_price) if article.sell_price is not None else None,
4550
"sold_price": float(article.sold_price) if article.sold_price is not None else None,
@@ -105,31 +110,41 @@ def delete_articles_by_ids(db: Session, user_id: int, ids: list[int]) -> int:
105110
return int(deleted)
106111

107112

108-
def update_article_fields(
109-
article: Article,
110-
*,
111-
title: str | None = None,
112-
description: str | None = None,
113-
pokemon_name: str | None = None,
114-
set_code: str | None = None,
115-
card_number: str | None = None,
116-
condition: str | None = None,
117-
purchase_price: Decimal | None = None,
118-
sell_price: Decimal | None = None,
119-
) -> None:
120-
if title is not None:
121-
article.title = title
122-
if description is not None:
123-
article.description = description
124-
if pokemon_name is not None:
125-
article.pokemon_name = pokemon_name
126-
if set_code is not None:
127-
article.set_code = set_code
128-
if card_number is not None:
129-
article.card_number = card_number
130-
if condition is not None:
131-
article.condition = condition
132-
if purchase_price is not None:
133-
article.purchase_price = purchase_price
134-
if sell_price is not None:
135-
article.sell_price = sell_price
113+
def update_article_from_body(article: Article, body: ArticleUpdate) -> None:
114+
"""Apply a partial update from ``ArticleUpdate`` (only set fields are changed)."""
115+
data = body.model_dump(exclude_unset=True)
116+
if "is_graded" in data:
117+
article.is_graded = bool(data["is_graded"])
118+
if not article.is_graded:
119+
article.graded_grader_value_id = None
120+
article.graded_grade_value_id = None
121+
article.graded_cert_number = None
122+
if "title" in data and data["title"] is not None:
123+
article.title = data["title"]
124+
if "description" in data and data["description"] is not None:
125+
article.description = data["description"]
126+
if "pokemon_name" in data:
127+
article.pokemon_name = data["pokemon_name"]
128+
if "set_code" in data:
129+
article.set_code = data["set_code"]
130+
if "card_number" in data:
131+
article.card_number = data["card_number"]
132+
if "condition" in data and data["condition"] is not None:
133+
article.condition = data["condition"]
134+
if "purchase_price" in data and data["purchase_price"] is not None:
135+
article.purchase_price = data["purchase_price"]
136+
if "sell_price" in data:
137+
article.sell_price = data["sell_price"]
138+
if "graded_grader_value_id" in data:
139+
gid = data["graded_grader_value_id"]
140+
article.graded_grader_value_id = (gid.strip() if isinstance(gid, str) else None) or None
141+
if "graded_grade_value_id" in data:
142+
tid = data["graded_grade_value_id"]
143+
article.graded_grade_value_id = (tid.strip() if isinstance(tid, str) else None) or None
144+
if "graded_cert_number" in data:
145+
cert = data["graded_cert_number"]
146+
if cert is None:
147+
article.graded_cert_number = None
148+
else:
149+
c = str(cert).strip()[:30]
150+
article.graded_cert_number = c or None

api/services/desktop_stubs_service.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ def article_from_api_dict(d: dict[str, Any]) -> Article:
2323
a.set_code = d.get("set_code")
2424
a.card_number = d.get("card_number")
2525
a.condition = d.get("condition") or "Near Mint"
26+
a.is_graded = bool(d.get("is_graded", False))
27+
a.graded_grader_value_id = d.get("graded_grader_value_id")
28+
a.graded_grade_value_id = d.get("graded_grade_value_id")
29+
a.graded_cert_number = d.get("graded_cert_number")
2630
a.purchase_price = Decimal(str(d["purchase_price"]))
2731
sp = d.get("sell_price")
2832
a.sell_price = Decimal(str(sp)) if sp is not None else None

api/services/ebay_publish_service.py

Lines changed: 50 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@
1919
from models.margin_settings import MarginSettings
2020
from models.user import User
2121
from services.ebay_oauth_service import _api_base_url, refresh_user_access_token
22+
from services.ebay_trading_card_grading import (
23+
EBAY_CONDITION_GRADED,
24+
graded_condition_descriptor_payloads,
25+
is_valid_grade_value_id,
26+
is_valid_grader_value_id,
27+
)
2228
from services.user_settings_service import ebay_listing_config_complete, effective_ebay_category_id
2329

2430
logger = logging.getLogger(__name__)
@@ -321,15 +327,55 @@ async def publish_article_to_ebay(
321327
price_str = f"{price.quantize(Decimal('0.01'))}"
322328

323329
category_id = effective_ebay_category_id(ms).strip()
330+
if article.is_graded:
331+
if category_id not in _TCG_LEAF_CATEGORY_IDS:
332+
await _emit_ebay(
333+
progress,
334+
"error",
335+
"Carte gradée : la catégorie eBay doit être une feuille « cartes à jouer / TCG » (183050, 183454 ou 261328).",
336+
detail="ebay_graded_requires_tcg_leaf_category",
337+
)
338+
return {"ok": False, "detail": "ebay_graded_requires_tcg_leaf_category"}
339+
if not is_valid_grader_value_id(article.graded_grader_value_id) or not is_valid_grade_value_id(
340+
article.graded_grade_value_id
341+
):
342+
await _emit_ebay(
343+
progress,
344+
"error",
345+
"Carte gradée incomplète : société et note requis.",
346+
detail="ebay_graded_incomplete",
347+
)
348+
return {"ok": False, "detail": "ebay_graded_incomplete"}
349+
324350
core_aspects = _product_aspects_core(article, category_id=category_id, marketplace_id=marketplace_id)
325351
optional_fr_aspects = _product_aspects_optional_fr(
326352
article, category_id=category_id, marketplace_id=marketplace_id
327353
)
328354

355+
if article.is_graded and category_id in _TCG_LEAF_CATEGORY_IDS:
356+
list_condition = EBAY_CONDITION_GRADED
357+
list_desc = "Carte professionnellement gradée (slab)."
358+
cond_descriptors: list[dict[str, Any]] = graded_condition_descriptor_payloads(
359+
grader_value_id=str(article.graded_grader_value_id),
360+
grade_value_id=str(article.graded_grade_value_id),
361+
certification_text=article.graded_cert_number,
362+
)
363+
else:
364+
list_condition = _ebay_condition_for_category(category_id, article.condition)
365+
list_desc = (article.condition or "")[:1000]
366+
cond_descriptors = []
367+
if category_id in _TCG_LEAF_CATEGORY_IDS:
368+
cond_descriptors = [
369+
{
370+
"name": _CARD_CONDITION_DESCRIPTOR_NAME,
371+
"values": [_card_condition_descriptor_value_id(article.condition)],
372+
}
373+
]
374+
329375
inv_payload: dict[str, Any] = {
330376
"availability": {"shipToLocationAvailability": {"quantity": 1}},
331-
"condition": _ebay_condition_for_category(category_id, article.condition),
332-
"conditionDescription": html.escape((article.condition or "")[:1000]),
377+
"condition": list_condition,
378+
"conditionDescription": html.escape(list_desc),
333379
"product": {
334380
"title": title,
335381
"description": _description_html(article),
@@ -338,13 +384,8 @@ async def publish_article_to_ebay(
338384
},
339385
}
340386

341-
if category_id in _TCG_LEAF_CATEGORY_IDS:
342-
inv_payload["conditionDescriptors"] = [
343-
{
344-
"name": _CARD_CONDITION_DESCRIPTOR_NAME,
345-
"values": [_card_condition_descriptor_value_id(article.condition)],
346-
}
347-
]
387+
if cond_descriptors:
388+
inv_payload["conditionDescriptors"] = cond_descriptors
348389

349390
headers = {
350391
"Authorization": f"Bearer {token}",

0 commit comments

Comments
 (0)