179 lines
7.4 KiB
Python
179 lines
7.4 KiB
Python
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'] = 'pickup'
|
|
postal_code: str = Field(default='', max_length=8)
|
|
|
|
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
|
|
|
|
class Review(StrictModel):
|
|
items: list[Item] = Field(min_length=1, max_length=30)
|
|
|
|
class Pay(StrictModel):
|
|
quote_id: UUID
|
|
|
|
class Move(StrictModel):
|
|
state: Literal['rec','tra','fil','imp','cor','fin']
|
|
version: int = Field(ge=0)
|
|
reason: str = Field(default='', max_length=1000)
|
|
|
|
class Register(StrictModel):
|
|
customer: Customer
|
|
password: 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)
|