Skip to content

Commit 58e90cc

Browse files
committed
feat: amx article lenght and update upload status
1 parent 468cfe6 commit 58e90cc

7 files changed

Lines changed: 170 additions & 11 deletions

File tree

api/routes/articles.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,13 @@
1919
from models.article import Article
2020
from models.image import Image as ImageModel
2121
from models.user import User
22-
from schemas.articles import ArticleUpdate, BulkIdsBody, SoldPatch, VintedBatchStartBody
22+
from schemas.articles import (
23+
ARTICLE_TITLE_MAX_LEN_VINTED,
24+
ArticleUpdate,
25+
BulkIdsBody,
26+
SoldPatch,
27+
VintedBatchStartBody,
28+
)
2329
from services import article_service
2430
from services.combined_marketplace_service import CombinedMarketplaceService
2531
from services.ebay_background_service import EbayBackgroundService
@@ -70,6 +76,25 @@ def _parse_sold_at_iso(value: str | None) -> dt.datetime | None:
7076
) from exc
7177

7278

79+
def _validate_create_title_or_raise(raw_title: str) -> str:
80+
"""Titre requis, longueur max alignée sur Vinted (espaces inclus après trim côté annonce)."""
81+
t = raw_title.strip()
82+
if not t:
83+
raise HTTPException(
84+
status_code=status.HTTP_400_BAD_REQUEST,
85+
detail="Le titre est requis.",
86+
)
87+
if len(t) > ARTICLE_TITLE_MAX_LEN_VINTED:
88+
raise HTTPException(
89+
status_code=status.HTTP_400_BAD_REQUEST,
90+
detail=(
91+
f"Le titre ne doit pas dépasser {ARTICLE_TITLE_MAX_LEN_VINTED} caractères "
92+
f"(limite Vinted, espaces inclus). Longueur : {len(t)}."
93+
),
94+
)
95+
return t
96+
97+
7398
def _validate_graded_article_or_raise(article: Article) -> None:
7499
if not article.is_graded:
75100
return
@@ -472,9 +497,11 @@ async def create_article(
472497
grade_vid = None
473498
cert_norm = None
474499

500+
title_clean = _validate_create_title_or_raise(title)
501+
475502
article = Article(
476503
user_id=user.id,
477-
title=title.strip(),
504+
title=title_clean,
478505
description=description,
479506
pokemon_name=pokemon_name.strip() if pokemon_name else None,
480507
set_code=set_code.strip() if set_code else None,

api/schemas/articles.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
from decimal import Decimal
22
from typing import Literal
33

4-
from pydantic import BaseModel, Field
4+
from pydantic import BaseModel, Field, field_validator
5+
6+
# Limite champ titre Vinted (espaces comptés comme un caractère).
7+
ARTICLE_TITLE_MAX_LEN_VINTED = 100
58

69

710
class ArticleUpdate(BaseModel):
@@ -17,6 +20,23 @@ class ArticleUpdate(BaseModel):
1720
graded_cert_number: str | None = None
1821
purchase_price: Decimal | None = None
1922
sell_price: Decimal | None = None
23+
#: Remet l’article en « non publié » côté GoupixDex (ne supprime pas l’annonce sur Vinted).
24+
clear_vinted_publication: bool | None = None
25+
#: Idem eBay : efface le suivi local et l’identifiant d’annonce enregistré.
26+
clear_ebay_publication: bool | None = None
27+
28+
@field_validator("title")
29+
@classmethod
30+
def title_vinted_max_length(cls, v: str | None) -> str | None:
31+
if v is None:
32+
return None
33+
s = v.strip()
34+
if len(s) > ARTICLE_TITLE_MAX_LEN_VINTED:
35+
raise ValueError(
36+
f"Le titre ne doit pas dépasser {ARTICLE_TITLE_MAX_LEN_VINTED} caractères "
37+
f"(limite Vinted, espaces inclus). Longueur : {len(s)}."
38+
)
39+
return s
2040

2141

2242
class SoldPatch(BaseModel):

api/services/article_service.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,3 +148,10 @@ def update_article_from_body(article: Article, body: ArticleUpdate) -> None:
148148
else:
149149
c = str(cert).strip()[:30]
150150
article.graded_cert_number = c or None
151+
if data.get("clear_vinted_publication") is True:
152+
article.published_on_vinted = False
153+
article.vinted_published_at = None
154+
if data.get("clear_ebay_publication") is True:
155+
article.published_on_ebay = False
156+
article.ebay_listing_id = None
157+
article.ebay_published_at = None

web/app/components/articles/ArticleForm.vue

Lines changed: 94 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ const emit = defineEmits<{
2727
submitEdit: [body: ArticleUpdateBody]
2828
}>()
2929
30+
/** Limite titre Vinted : espaces comptés comme un caractère. */
31+
const VINTED_TITLE_MAX_CHARS = 100
32+
3033
const title = ref('')
3134
const description = ref('')
3235
const pokemonName = ref('')
@@ -45,6 +48,9 @@ const isGraded = ref(false)
4548
const gradedGraderValueId = ref('')
4649
const gradedGradeValueId = ref('')
4750
const gradedCertNumber = ref('')
51+
/** Édition : cocher pour enregistrer l’article comme non publié sur le canal (suivi GoupixDex uniquement). */
52+
const clearVintedPublication = ref(false)
53+
const clearEbayPublication = ref(false)
4854
const purchasePrice = ref('')
4955
const sellPrice = ref('')
5056
const toast = useToast()
@@ -83,6 +89,23 @@ const canPublishEbay = computed(
8389
&& svcSettings.value?.ebay_listing_config_complete === true
8490
)
8591
92+
const titleLenVinted = computed(() => title.value.trim().length)
93+
94+
function titleWithinVintedLimit(): boolean {
95+
return titleLenVinted.value <= VINTED_TITLE_MAX_CHARS
96+
}
97+
98+
function assignTitleFromExternal(raw: string) {
99+
title.value = raw.trim().slice(0, VINTED_TITLE_MAX_CHARS)
100+
}
101+
102+
const titleFieldDescription = computed(() => {
103+
const n = titleLenVinted.value
104+
const max = VINTED_TITLE_MAX_CHARS
105+
const base = `${n} / ${max} caractères (limite Vinted, espaces inclus)`
106+
return n > max ? `${base} — raccourcissez avant enregistrement` : base
107+
})
108+
86109
onMounted(async () => {
87110
try {
88111
svcSettings.value = await getSettings()
@@ -139,6 +162,8 @@ watch(
139162
? a.graded_grade_value_id
140163
: ''
141164
gradedCertNumber.value = a.graded_cert_number ?? ''
165+
clearVintedPublication.value = false
166+
clearEbayPublication.value = false
142167
},
143168
{ immediate: true }
144169
)
@@ -175,7 +200,7 @@ function applyScanPrefill(scan: {
175200
ocr: Record<string, unknown>
176201
pricing: { cardmarket_eur: number | null, tcgplayer_usd: number | null }
177202
}) {
178-
title.value = scan.listing_preview.title
203+
assignTitleFromExternal(scan.listing_preview.title)
179204
description.value = scan.listing_preview.description
180205
const o = scan.ocr
181206
const en = typeof o.pokemon_name_english === 'string' ? o.pokemon_name_english : ''
@@ -214,7 +239,7 @@ async function applyEbayPrefill(p: {
214239
imageUrl?: string | null
215240
}) {
216241
if (p.title) {
217-
title.value = p.title
242+
assignTitleFromExternal(p.title)
218243
}
219244
if (p.description) {
220245
description.value = p.description
@@ -262,7 +287,7 @@ async function applyWardrobeSlot(p: WardrobeSlotPrefill) {
262287
wardrobeVintedListed.value = p.wardrobeVintedListed
263288
wardrobeVintedPublishedAtIso.value = p.vintedPublishedAtIso
264289
wardrobeImportSoldPrice.value = p.importSoldPrice
265-
title.value = p.title
290+
assignTitleFromExternal(p.title)
266291
description.value = p.description
267292
purchasePrice.value = p.purchasePrice || '0'
268293
sellPrice.value = p.sellPrice
@@ -306,6 +331,9 @@ defineExpose({
306331
})
307332
308333
function buildCreateFormData(): FormData {
334+
if (!titleWithinVintedLimit()) {
335+
throw new Error('ARTICLE_TITLE_TOO_LONG')
336+
}
309337
const fd = new FormData()
310338
fd.append('title', title.value.trim())
311339
fd.append('description', description.value)
@@ -369,6 +397,22 @@ function imageSrc(url: string) {
369397
}
370398
371399
function submit() {
400+
if (!title.value.trim()) {
401+
toast.add({
402+
title: 'Titre requis',
403+
description: 'Indiquez un titre pour l’article.',
404+
color: 'error'
405+
})
406+
return
407+
}
408+
if (!titleWithinVintedLimit()) {
409+
toast.add({
410+
title: 'Titre trop long',
411+
description: `Vinted : maximum ${VINTED_TITLE_MAX_CHARS} caractères (espaces inclus). Actuellement : ${title.value.trim().length}.`,
412+
color: 'error'
413+
})
414+
return
415+
}
372416
if (isGraded.value) {
373417
if (!gradedGraderValueId.value || !gradedGradeValueId.value) {
374418
toast.add({
@@ -383,7 +427,7 @@ function submit() {
383427
emit('submitCreate', buildCreateFormData())
384428
return
385429
}
386-
emit('submitEdit', {
430+
const editBody: ArticleUpdateBody = {
387431
title: title.value.trim(),
388432
description: description.value,
389433
pokemon_name: pokemonName.value.trim() || null,
@@ -400,15 +444,30 @@ function submit() {
400444
sell_price: sellPrice.value.trim()
401445
? Number(sellPrice.value.replace(',', '.'))
402446
: null
403-
})
447+
}
448+
if (clearVintedPublication.value) {
449+
editBody.clear_vinted_publication = true
450+
}
451+
if (clearEbayPublication.value) {
452+
editBody.clear_ebay_publication = true
453+
}
454+
emit('submitEdit', editBody)
404455
}
405456
</script>
406457

407458
<template>
408459
<div class="space-y-6">
409460
<div class="grid gap-4 sm:grid-cols-2">
410-
<UFormField label="Titre" required>
411-
<UInput v-model="title" class="w-full" />
461+
<UFormField
462+
label="Titre"
463+
required
464+
:description="titleFieldDescription"
465+
>
466+
<UInput
467+
v-model="title"
468+
class="w-full"
469+
:maxlength="mode === 'create' ? VINTED_TITLE_MAX_CHARS : undefined"
470+
/>
412471
</UFormField>
413472
<UFormField v-if="!isGraded" label="État">
414473
<USelect
@@ -628,6 +687,34 @@ function submit() {
628687
</div>
629688
</div>
630689

690+
<div
691+
v-if="
692+
mode === 'edit'
693+
&& article
694+
&& !article.is_sold
695+
&& ((article.published_on_vinted ?? false) || (article.published_on_ebay ?? false))
696+
"
697+
class="rounded-lg border border-default p-4 space-y-3"
698+
>
699+
<p class="text-sm font-medium text-highlighted">
700+
Statut de publication dans GoupixDex
701+
</p>
702+
<p class="text-xs text-muted leading-relaxed">
703+
Cochez pour indiquer que l’article n’est plus « en ligne » dans l’app. Cela ne retire pas l’annonce sur Vinted ou eBay :
704+
supprimez-la sur le site si besoin. Utile pour relancer une publication depuis GoupixDex.
705+
</p>
706+
<UCheckbox
707+
v-if="article.published_on_vinted ?? false"
708+
v-model="clearVintedPublication"
709+
label="Ne plus marquer comme publié sur Vinted"
710+
/>
711+
<UCheckbox
712+
v-if="article.published_on_ebay ?? false"
713+
v-model="clearEbayPublication"
714+
label="Ne plus marquer comme publié sur eBay"
715+
/>
716+
</div>
717+
631718
<UButton
632719
v-if="showSubmitButton"
633720
color="primary"

web/app/composables/useArticles.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,10 @@ export interface ArticleUpdateBody {
102102
graded_cert_number?: string | null
103103
purchase_price?: number
104104
sell_price?: number | null
105+
/** Édition : remet le suivi Vinted à « non publié » dans GoupixDex. */
106+
clear_vinted_publication?: boolean
107+
/** Édition : remet le suivi eBay à « non publié » dans GoupixDex. */
108+
clear_ebay_publication?: boolean
105109
}
106110

107111
export function useArticles() {

web/app/pages/articles/[id].vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ async function onSubmitEdit(body: ArticleUpdateBody) {
5656
submitting.value = true
5757
try {
5858
const updated = await updateArticle(id.value, body)
59+
article.value = updated
5960
toast.add({ title: 'Article mis à jour', color: 'success' })
6061
const listed =
6162
Boolean(updated.published_on_vinted ?? false) || Boolean(updated.published_on_ebay ?? false)

web/app/pages/articles/batch-create.vue

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,20 @@ async function submitAll() {
250250
})
251251
return
252252
}
253-
const fd = comp.buildCreateFormData()
253+
let fd: FormData
254+
try {
255+
fd = comp.buildCreateFormData()
256+
} catch (e) {
257+
if (e instanceof Error && e.message === 'ARTICLE_TITLE_TOO_LONG') {
258+
toast.add({
259+
title: `Article ${i + 1} — titre trop long`,
260+
description: 'Maximum 100 caractères pour Vinted (espaces inclus). Raccourcissez le titre.',
261+
color: 'error'
262+
})
263+
return
264+
}
265+
throw e
266+
}
254267
const title = fd.get('title')?.toString()?.trim()
255268
const purchase = fd.get('purchase_price')?.toString()?.trim()
256269
const images = fd.getAll('images') as File[]

0 commit comments

Comments
 (0)