import re from decimal import Decimal from typing import Literal from uuid import UUID from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator class StrictModel(BaseModel): model_config = ConfigDict(extra='forbid', allow_inf_nan=False) class Customer(StrictModel): cnpj: str zap: str mail: str = Field(max_length=254) @field_validator('cnpj') @classmethod def cnpj_valid(cls, value): digits = re.sub(r'\D', '', value) if len(digits) != 14 or len(set(digits)) == 1 or not digits.isascii(): raise ValueError('Invalid CNPJ') def check(base, weights): rem = sum(int(n) * w for n,w in zip(base, weights)) % 11 return 0 if rem < 2 else 11-rem if check(digits[:12], [5,4,3,2,9,8,7,6,5,4,3,2]) != int(digits[12]) or check(digits[:13], [6,5,4,3,2,9,8,7,6,5,4,3,2]) != int(digits[13]): raise ValueError('Invalid CNPJ') return digits @field_validator('zap') @classmethod def phone_valid(cls, value): digits = re.sub(r'\D', '', value) if len(digits) not in (10,11) or not digits.isascii(): raise ValueError('Invalid phone') return digits @field_validator('mail') @classmethod def email_valid(cls, value): if not re.fullmatch(r'[^\s@]+@[^\s@]+\.[a-zA-Z]{2,}', value.strip()): raise ValueError('Invalid email') return value.strip() class Freight(StrictModel): service: Literal['pickup','mock-standard','jadlog'] = 'pickup' postal_code: str = Field(default='', max_length=8) class FreightEstimate(Freight): """The cart's estimate. The charged freight is quoted again by the server from the approved items, never from these numbers.""" metres: Decimal | None = Field(default=None, gt=0, le=12000) declared_cents: int = Field(default=0, ge=0, le=100_000_000, strict=True) UF = Literal['AC','AL','AP','AM','BA','CE','DF','ES','GO','MA','MT','MS','MG','PA','PB', 'PR','PE','PI','RJ','RN','RS','RO','RR','SC','SP','SE','TO'] class Destination(StrictModel): """Where a shipped order goes. A CEP alone quotes freight; it does not deliver.""" recipient: str = Field(min_length=2, max_length=120) street: str = Field(min_length=2, max_length=160) number: str = Field(min_length=1, max_length=20) complement: str = Field(default='', max_length=80) district: str = Field(min_length=2, max_length=80) city: str = Field(min_length=2, max_length=80) state: UF postal_code: str = Field(pattern=r'^[0-9]{8}$') @field_validator('recipient', 'street', 'number', 'complement', 'district', 'city', mode='before') @classmethod def trimmed(cls, value): if isinstance(value, str): value = ' '.join(value.split()) if any(ord(ch) < 32 for ch in value): raise ValueError('Invalid characters') return value class UploadStart(StrictModel): name: str = Field(min_length=1, max_length=200, pattern=r'^[^/\\\x00-\x1f]+$') size: int = Field(gt=0, strict=True) @field_validator('name') @classmethod def artwork_extension(cls, value): # Union of existing Site product formats; this is NOT malware/pre-flight validation. if not re.search(r'\.(png|jpe?g|webp|tiff?|pdf|psd|psb|ai|cdr)$', value, re.I): raise ValueError('Unsupported artwork file extension') return value class ProductionSource(StrictModel): upload_id: UUID kind: Literal['sheet', 'artwork'] width_cm: Decimal = Field(gt=0, le=57) length_cm: Decimal = Field(gt=0, le=6000) copies: int = Field(ge=1, le=200, strict=True) rotation_degrees: Literal[0, 90] = 0 mirrored: bool = False measurement: Literal['file', 'customer'] class ProductionPlacement(StrictModel): source_index: int = Field(ge=0, le=19, strict=True) copy_index: int = Field(ge=0, le=199, strict=True) x_cm: Decimal = Field(ge=0, le=57) y_cm: Decimal = Field(ge=0, le=1200000) width_cm: Decimal = Field(gt=0, le=57) length_cm: Decimal = Field(gt=0, le=6000) rotation_degrees: Literal[0, 90, 180, 270] mirrored: bool class ProductionSpec(StrictModel): version: Literal[2] film_width_cm: Decimal height_cm: Decimal = Field(gt=0, le=1200000) sources: list[ProductionSource] = Field(min_length=1, max_length=20) placements: list[ProductionPlacement] = Field(min_length=1, max_length=4000) class Item(StrictModel): mode: Literal['file','avulsa','uvfile','uv'] metres: Decimal = Field(gt=0, le=12000) grade: int = Field(ge=0, le=100, strict=True) uploads: list[UUID] = Field(min_length=1, max_length=20) production: ProductionSpec quality_status: Literal['ok', 'warning', 'unverified'] quality_acknowledged: bool @model_validator(mode='after') def production_matches_uploads(self): if [source.upload_id for source in self.production.sources] != self.uploads: raise ValueError('Production sources must match uploaded files in order') is_sheet = self.mode in ('file', 'uvfile') film_width = Decimal('28.5') if self.mode in ('uvfile', 'uv') else Decimal('57') if self.production.film_width_cm != film_width: raise ValueError('Production film width does not match the product') for source in self.production.sources: if (source.kind == 'sheet') != is_sheet or source.width_cm > film_width: raise ValueError('Production source does not fit the selected product') if is_sheet and (source.rotation_degrees or source.mirrored): raise ValueError('Finished sheets cannot be rotated or mirrored by the layout') expected={(index,copy) for index,source in enumerate(self.production.sources) for copy in range(source.copies)} placed=set() tolerance=Decimal('0.02') for placement in self.production.placements: key=(placement.source_index,placement.copy_index) if key not in expected or key in placed: raise ValueError('Production placement has a missing or duplicate source copy') placed.add(key) source=self.production.sources[placement.source_index] auto_rotation=(placement.rotation_degrees-source.rotation_degrees)%360 if auto_rotation not in (0,90) or placement.mirrored != source.mirrored: raise ValueError('Production placement changes the source transform') width,length=(source.width_cm,source.length_cm) if auto_rotation==0 else (source.length_cm,source.width_cm) if abs(placement.width_cm-width)>tolerance or abs(placement.length_cm-length)>tolerance: raise ValueError('Production placement changes the source size') if placement.x_cm+placement.width_cm>film_width+tolerance or placement.y_cm+placement.length_cm>self.production.height_cm+tolerance: raise ValueError('Production placement is outside the film') if is_sheet and (placement.x_cm or auto_rotation): raise ValueError('Finished sheets must retain their original orientation') if placed != expected: raise ValueError('Production layout does not cover every source copy') if self.quality_status == 'warning' and not self.quality_acknowledged: raise ValueError('Resolution warning must be acknowledged') return self class QuoteRequest(StrictModel): request_key: UUID customer: Customer items: list[Item] = Field(min_length=1, max_length=30) freight: Freight destination: Destination | None = None @model_validator(mode='after') def destination_matches_freight(self): if self.freight.service == 'pickup': if self.destination is not None: raise ValueError('Pickup orders do not take a delivery address') elif self.destination is None: raise ValueError('Delivery requires the full address') elif self.destination.postal_code != self.freight.postal_code: raise ValueError('The delivery address CEP must be the CEP freight was quoted for') return self class Review(StrictModel): items: list[Item] = Field(min_length=1, max_length=30) class Pay(StrictModel): quote_id: UUID class PaymentMethod(StrictModel): type: Literal['pix', 'card'] # Card fields come from the provider's own form, which tokenises the card # in the browser; the number never reaches this server. token: str | None = Field(default=None, max_length=200) payment_method_id: str | None = Field(default=None, max_length=40, pattern=r'^[a-z_]+$') installments: int = Field(default=1, ge=1, le=12, strict=True) issuer_id: str | None = Field(default=None, max_length=40) # The cardholder's document from the card form: for a card, the payer is # the cardholder, not necessarily the company on the invoice. payer_document_type: Literal['CPF', 'CNPJ'] | None = None payer_document: str | None = Field(default=None, pattern=r'^[0-9]{11,14}$') @model_validator(mode='after') def card_needs_token(self): if self.type == 'card' and not (self.token and self.payment_method_id): raise ValueError('Card payment requires the provider token and method') return self class PaymentIntent(StrictModel): quote_id: UUID method: PaymentMethod class Move(StrictModel): state: Literal['rec','tra','fil','imp','cor','fin'] version: int = Field(ge=0) reason: str = Field(default='', max_length=1000) class Resolution(StrictModel): note: str = Field(min_length=3, max_length=1000) class Register(StrictModel): customer: Customer password: str = Field(min_length=12, max_length=128) class ProfileUpdate(StrictModel): """What a customer may change about their account; the CNPJ is not among it.""" zap: str address: Destination | None = None @field_validator('zap') @classmethod def phone_valid(cls, value): digits = re.sub(r'\D', '', value) if len(digits) not in (10,11) or not digits.isascii(): raise ValueError('Invalid phone') return digits class EmailChange(StrictModel): email: str = Field(max_length=254) password: str = Field(min_length=1, max_length=128) @field_validator('email') @classmethod def email_valid(cls, value): value = value.strip().lower() if not re.fullmatch(r'[^\s@]+@[^\s@]+\.[a-zA-Z]{2,}', value): raise ValueError('Invalid email') return value class PasswordChange(StrictModel): current: str = Field(min_length=1, max_length=128) new: str = Field(min_length=12, max_length=128) class Login(StrictModel): email: str = Field(min_length=3, max_length=254) password: str = Field(min_length=1, max_length=128) class OperatorLogin(StrictModel): email: str = Field(min_length=3, max_length=254) password: str = Field(min_length=1, max_length=128) @field_validator('email') @classmethod def operator_email_valid(cls, value): value = value.strip().lower() if not re.fullmatch(r'[^\s@]+@[^\s@]+\.[a-zA-Z]{2,}', value): raise ValueError('Invalid email') return value class FileReference(StrictModel): upload_id: UUID item_index: int = Field(ge=0, strict=True) class ArtworkSubmission(StrictModel): version: int = Field(ge=0, strict=True) files: list[FileReference] = Field(min_length=1, max_length=60) note: str = Field(min_length=1, max_length=1000)