公开数据看板、四位 Key 录入面板、管理后台与三级权限、动态字段配置、 PostgreSQL/SQLite 双支持、数据库备份,以及本版新增的成品图上传与预览。 图片相关: - 录入表单支持相册选图与手机端直接拍照,每条记录最多 6 张 - 浏览器内压缩到最长边 1600 并剥离 EXIF,入库统一为 JPG/PNG - 二进制直接入库,现有备份自动覆盖图片,部署无需新增卷 - 详情页缩略图宫格与全屏 lightbox,公开看板同样可见 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
156 lines
6.3 KiB
Python
156 lines
6.3 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
from datetime import date
|
|
from decimal import Decimal, InvalidOperation
|
|
from typing import Any
|
|
|
|
from fastapi import HTTPException
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from .models import CustomFieldDefinition, CustomFieldValue, MaterialRecord
|
|
|
|
|
|
def options_for(definition: CustomFieldDefinition) -> list[str]:
|
|
try:
|
|
return json.loads(definition.options_json) if definition.options_json else []
|
|
except (json.JSONDecodeError, TypeError):
|
|
return []
|
|
|
|
|
|
def definition_json(definition: CustomFieldDefinition, usage_count: int | None = None) -> dict[str, Any]:
|
|
data = {
|
|
"id": definition.id,
|
|
"field_key": definition.field_key,
|
|
"label": definition.label,
|
|
"input_type": definition.input_type,
|
|
"is_required": definition.is_required,
|
|
"is_active": definition.is_active,
|
|
"is_public": definition.is_public,
|
|
"unit": definition.unit,
|
|
"options": options_for(definition),
|
|
"sort_order": definition.sort_order,
|
|
"created_at": definition.created_at.isoformat(),
|
|
"updated_at": definition.updated_at.isoformat(),
|
|
}
|
|
if usage_count is not None:
|
|
data["usage_count"] = usage_count
|
|
return data
|
|
|
|
|
|
def active_definitions(db: Session) -> list[CustomFieldDefinition]:
|
|
return list(db.scalars(select(CustomFieldDefinition).where(CustomFieldDefinition.is_active.is_(True)).order_by(CustomFieldDefinition.sort_order, CustomFieldDefinition.id)).all())
|
|
|
|
|
|
def _empty(value: Any) -> bool:
|
|
return value is None or (isinstance(value, str) and not value.strip())
|
|
|
|
|
|
def validate_raw_value(definition: CustomFieldDefinition, value: Any) -> str | None:
|
|
if _empty(value):
|
|
if definition.is_required:
|
|
raise HTTPException(422, f"{definition.label}:该字段为必填项")
|
|
return None
|
|
raw = str(value).strip()
|
|
try:
|
|
if definition.input_type == "number":
|
|
if not Decimal(raw).is_finite():
|
|
raise ValueError
|
|
elif definition.input_type == "integer":
|
|
number = Decimal(raw)
|
|
if isinstance(value, bool) or not number.is_finite() or number != number.to_integral_value():
|
|
raise ValueError
|
|
elif definition.input_type == "date":
|
|
date.fromisoformat(raw)
|
|
elif definition.input_type == "boolean":
|
|
if raw.lower() not in {"true", "false", "1", "0"}:
|
|
raise ValueError
|
|
raw = "true" if raw.lower() in {"true", "1"} else "false"
|
|
elif definition.input_type == "select" and raw not in options_for(definition):
|
|
raise ValueError
|
|
except (ValueError, InvalidOperation):
|
|
raise HTTPException(422, f"{definition.label}:数据格式不符合当前字段类型")
|
|
if len(raw) > 10000:
|
|
raise HTTPException(422, f"{definition.label}:内容不能超过 10000 个字符")
|
|
return raw
|
|
|
|
|
|
def validate_custom_values(db: Session, submitted: dict[str, Any], existing_record: MaterialRecord | None = None) -> dict[CustomFieldDefinition, str | None]:
|
|
definitions = active_definitions(db)
|
|
by_key = {item.field_key: item for item in definitions}
|
|
unknown = set(submitted) - set(by_key)
|
|
if unknown:
|
|
raise HTTPException(422, "包含未知或已停用的自定义字段")
|
|
existing_keys = {value.definition.field_key for value in existing_record.custom_values} if existing_record else set()
|
|
validated: dict[CustomFieldDefinition, str | None] = {}
|
|
for definition in definitions:
|
|
if definition.field_key not in submitted and existing_record is not None:
|
|
if definition.is_required and definition.field_key not in existing_keys:
|
|
raise HTTPException(422, f"{definition.label}:该字段为必填项")
|
|
continue
|
|
validated[definition] = validate_raw_value(definition, submitted.get(definition.field_key))
|
|
return validated
|
|
|
|
|
|
def apply_custom_values(db: Session, record: MaterialRecord, values: dict[CustomFieldDefinition, str | None]) -> None:
|
|
existing = {item.field_id: item for item in record.custom_values}
|
|
for definition, raw_value in values.items():
|
|
item = existing.get(definition.id)
|
|
if raw_value is None:
|
|
if item:
|
|
db.delete(item)
|
|
continue
|
|
if item:
|
|
item.raw_value = raw_value
|
|
else:
|
|
db.add(CustomFieldValue(record_id=record.id, field_id=definition.id, raw_value=raw_value))
|
|
|
|
|
|
def render_custom_value(value: CustomFieldValue) -> dict[str, Any]:
|
|
definition, raw = value.definition, value.raw_value or ""
|
|
compatible = True
|
|
processed: Any = raw
|
|
try:
|
|
if definition.input_type == "number":
|
|
number = Decimal(raw)
|
|
if not number.is_finite():
|
|
raise ValueError
|
|
processed = float(number)
|
|
elif definition.input_type == "integer":
|
|
number = Decimal(raw)
|
|
if number != number.to_integral_value():
|
|
raise ValueError
|
|
processed = int(number)
|
|
elif definition.input_type == "date":
|
|
processed = date.fromisoformat(raw).isoformat()
|
|
elif definition.input_type == "boolean":
|
|
if raw.lower() not in {"true", "false", "1", "0"}:
|
|
raise ValueError
|
|
processed = raw.lower() in {"true", "1"}
|
|
elif definition.input_type == "select" and raw not in options_for(definition):
|
|
compatible = False
|
|
except (ValueError, InvalidOperation):
|
|
compatible = False
|
|
processed = raw
|
|
display = "是" if processed is True else "否" if processed is False else str(processed)
|
|
if definition.unit and display:
|
|
display = f"{display} {definition.unit}"
|
|
return {
|
|
"field_key": definition.field_key,
|
|
"label": definition.label,
|
|
"input_type": definition.input_type,
|
|
"unit": definition.unit,
|
|
"raw_value": raw,
|
|
"value": processed,
|
|
"display_value": display,
|
|
"compatible": compatible,
|
|
"is_active": definition.is_active,
|
|
"is_public": definition.is_public,
|
|
}
|
|
|
|
|
|
def definition_usage_counts(db: Session) -> dict[int, int]:
|
|
rows = db.execute(select(CustomFieldValue.field_id, func.count(CustomFieldValue.id)).group_by(CustomFieldValue.field_id)).all()
|
|
return {field_id: count for field_id, count in rows}
|