Files
laser-data-hub/app/schemas.py
T
broccoliandClaude Fable 5 edfb216561 激光材料数据平台 1.3.0
公开数据看板、四位 Key 录入面板、管理后台与三级权限、动态字段配置、
PostgreSQL/SQLite 双支持、数据库备份,以及本版新增的成品图上传与预览。

图片相关:
- 录入表单支持相册选图与手机端直接拍照,每条记录最多 6 张
- 浏览器内压缩到最长边 1600 并剥离 EXIF,入库统一为 JPG/PNG
- 二进制直接入库,现有备份自动覆盖图片,部署无需新增卷
- 详情页缩略图宫格与全屏 lightbox,公开看板同样可见

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 22:06:02 +08:00

123 lines
5.1 KiB
Python

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