from __future__ import annotations from datetime import date from typing import Literal from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from .images import MAX_IMAGES_PER_RECORD RECORD_ONLY_FIELDS = {"custom_fields", "image_tokens", "image_ids"} class MaterialRecordInput(BaseModel): model_config = ConfigDict(str_strip_whitespace=True) experiment_id: str = Field(min_length=1, max_length=100) test_date: date material_category: str = Field(min_length=1, max_length=100) material_name: str = Field(min_length=1, max_length=200) apparent_density: float | None = Field(default=None, ge=0) uv_absorption_355nm: float | None = Field(default=None, ge=0, le=100) material_thickness: float = Field(gt=0) moisture_content: float | None = Field(default=None, ge=0, le=100) thermal_conductivity: float | None = Field(default=None, ge=0) initial_decomposition_temp: float | None = None melting_vaporization_temp: float | None = None specific_heat_capacity: float | None = Field(default=None, ge=0) carbon_residue_rate: float | None = Field(default=None, ge=0, le=100) surface_roughness: float | None = Field(default=None, ge=0) hardness: float | None = Field(default=None, ge=0) actual_output_power: float = Field(ge=0) display_current: float = Field(ge=0) scanning_speed: float = Field(gt=0) pulse_frequency: float = Field(ge=0) pulse_width: float = Field(ge=0) defocus_amount: float scan_line_spacing: float = Field(gt=0) filling_method: str = Field(min_length=1, max_length=100) processing_size: str = Field(min_length=1, max_length=100) is_cut_through: bool carbonized_edge_width: float = Field(ge=0) etching_depth: float = Field(ge=0) is_fire_smolder: bool pattern_clarity_score: float = Field(ge=0, le=10) presentation_balance_score: float = Field(ge=0, le=10) finished_image_filename: str = Field(min_length=1, max_length=255) remarks: str = Field(min_length=1, max_length=5000) custom_fields: dict[str, str | float | int | bool | None] = Field(default_factory=dict) image_tokens: list[str] = Field(default_factory=list, max_length=MAX_IMAGES_PER_RECORD) # 省略 image_ids 表示"不动既有图片",显式传 [] 才是"全部删除"。默认值若是 [], # 任何不了解图片字段的旧页面或外部脚本,一次 PUT 就会静默删光整条记录的图。 image_ids: list[int] | None = Field(default=None, max_length=MAX_IMAGES_PER_RECORD) @field_validator("experiment_id", "material_category", "material_name", "filling_method", "processing_size", "finished_image_filename", "remarks") @classmethod def reject_blank(cls, value: str) -> str: if not value.strip(): raise ValueError("不能为空") return value.strip() @field_validator("image_tokens") @classmethod def validate_image_tokens(cls, values: list[str]) -> list[str]: cleaned = [value.strip() for value in values if value.strip()] if len(cleaned) != len(set(cleaned)): raise ValueError("上传令牌重复") if any(len(value) > 64 for value in cleaned): raise ValueError("上传令牌格式错误") return cleaned @field_validator("image_ids") @classmethod def validate_image_ids(cls, values: list[int] | None) -> list[int] | None: if values is None: return None if len(values) != len(set(values)): raise ValueError("图片编号重复") if any(value <= 0 for value in values): raise ValueError("图片编号无效") return values @model_validator(mode="after") def limit_total_images(self): if len(self.image_tokens) + len(self.image_ids or []) > MAX_IMAGES_PER_RECORD: raise ValueError(f"每条记录最多保留 {MAX_IMAGES_PER_RECORD} 张图片") return self class EntryRecordSubmission(MaterialRecordInput): confirm_username: str = Field(min_length=1, max_length=64) FieldInputType = Literal["text", "long_text", "number", "integer", "date", "boolean", "select"] class CustomFieldDefinitionInput(BaseModel): model_config = ConfigDict(str_strip_whitespace=True) label: str = Field(min_length=1, max_length=100) input_type: FieldInputType is_required: bool = False is_active: bool = True is_public: bool = True unit: str | None = Field(default=None, max_length=50) options: list[str] = Field(default_factory=list, max_length=50) sort_order: int = Field(default=0, ge=0, le=10000) @field_validator("options") @classmethod def validate_options(cls, values: list[str]) -> list[str]: cleaned = [value.strip() for value in values if value.strip()] if len(cleaned) != len(set(cleaned)): raise ValueError("选项不能重复") if any(len(value) > 100 for value in cleaned): raise ValueError("单个选项不能超过 100 个字符") return cleaned @field_validator("unit") @classmethod def normalize_unit(cls, value: str | None) -> str | None: return value.strip() or None if value is not None else None