Files
laser-data-hub/app/models.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

192 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
from datetime import date, datetime
from typing import Optional
from sqlalchemy import BigInteger, Boolean, Date, DateTime, Float, ForeignKey, Index, Integer, LargeBinary, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, deferred, mapped_column, relationship
from .db import Base
def now() -> datetime:
return datetime.now().astimezone()
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
display_name: Mapped[str] = mapped_column(String(100))
password_hash: Mapped[str] = mapped_column(String(300))
role: Mapped[str] = mapped_column(String(20), default="uploader", index=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
must_change_password: Mapped[bool] = mapped_column(Boolean, default=True)
entry_key_hash: Mapped[Optional[str]] = mapped_column(String(64), unique=True, nullable=True, index=True)
entry_key_updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, onupdate=now)
class LoginSession(Base):
__tablename__ = "login_sessions"
id: Mapped[int] = mapped_column(primary_key=True)
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
ip_address: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
user_agent: Mapped[Optional[str]] = mapped_column(String(300), nullable=True)
user: Mapped[User] = relationship()
class EntrySession(Base):
__tablename__ = "entry_sessions"
id: Mapped[int] = mapped_column(primary_key=True)
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
ip_address: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
user: Mapped[User] = relationship()
class EntryBatch(Base):
__tablename__ = "entry_batches"
id: Mapped[int] = mapped_column(primary_key=True)
entry_no: Mapped[str] = mapped_column(String(40), unique=True, index=True)
record_count: Mapped[int] = mapped_column(Integer, default=1)
status: Mapped[str] = mapped_column(String(20), default="success", index=True)
created_by: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, index=True)
creator: Mapped[User] = relationship()
class MaterialRecord(Base):
__tablename__ = "material_records"
__table_args__ = (
UniqueConstraint("entry_batch_id", "experiment_id", name="uq_entry_experiment"),
Index("ix_record_material_date", "material_category", "test_date"),
)
id: Mapped[int] = mapped_column(primary_key=True)
entry_batch_id: Mapped[int] = mapped_column(ForeignKey("entry_batches.id", ondelete="CASCADE"), index=True)
experiment_id: Mapped[str] = mapped_column(String(100), unique=True, index=True)
test_date: Mapped[date] = mapped_column(Date, index=True)
material_category: Mapped[str] = mapped_column(String(100), index=True)
material_name: Mapped[str] = mapped_column(String(200), index=True)
apparent_density: Mapped[Optional[float]] = mapped_column(Float)
uv_absorption_355nm: Mapped[Optional[float]] = mapped_column(Float)
material_thickness: Mapped[float] = mapped_column(Float)
moisture_content: Mapped[Optional[float]] = mapped_column(Float)
thermal_conductivity: Mapped[Optional[float]] = mapped_column(Float)
initial_decomposition_temp: Mapped[Optional[float]] = mapped_column(Float)
melting_vaporization_temp: Mapped[Optional[float]] = mapped_column(Float)
specific_heat_capacity: Mapped[Optional[float]] = mapped_column(Float)
carbon_residue_rate: Mapped[Optional[float]] = mapped_column(Float)
surface_roughness: Mapped[Optional[float]] = mapped_column(Float)
hardness: Mapped[Optional[float]] = mapped_column(Float)
actual_output_power: Mapped[float] = mapped_column(Float)
display_current: Mapped[float] = mapped_column(Float)
scanning_speed: Mapped[float] = mapped_column(Float)
pulse_frequency: Mapped[float] = mapped_column(Float)
pulse_width: Mapped[float] = mapped_column(Float)
defocus_amount: Mapped[float] = mapped_column(Float)
scan_line_spacing: Mapped[float] = mapped_column(Float)
filling_method: Mapped[str] = mapped_column(String(100))
processing_size: Mapped[str] = mapped_column(String(100))
is_cut_through: Mapped[bool] = mapped_column(Boolean)
carbonized_edge_width: Mapped[float] = mapped_column(Float)
etching_depth: Mapped[float] = mapped_column(Float)
is_fire_smolder: Mapped[bool] = mapped_column(Boolean)
pattern_clarity_score: Mapped[float] = mapped_column(Float)
presentation_balance_score: Mapped[float] = mapped_column(Float)
finished_image_filename: Mapped[str] = mapped_column(String(255))
remarks: Mapped[str] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
entry_batch: Mapped[EntryBatch] = relationship()
custom_values: Mapped[list["CustomFieldValue"]] = relationship(back_populates="record", cascade="all, delete-orphan")
images: Mapped[list["RecordImage"]] = relationship(back_populates="record", cascade="all, delete-orphan", passive_deletes=True, order_by="RecordImage.sort_order, RecordImage.id")
class RecordImage(Base):
"""实验成品图。二进制直接入库,现有数据库备份即可覆盖图片,无需额外挂载卷。"""
__tablename__ = "record_images"
id: Mapped[int] = mapped_column(primary_key=True)
record_id: Mapped[Optional[int]] = mapped_column(ForeignKey("material_records.id", ondelete="CASCADE"), nullable=True, index=True)
token: Mapped[str] = mapped_column(String(64), unique=True, index=True)
content_type: Mapped[str] = mapped_column(String(40))
original_name: Mapped[str] = mapped_column(String(255))
size_bytes: Mapped[int] = mapped_column(Integer)
width: Mapped[int] = mapped_column(Integer)
height: Mapped[int] = mapped_column(Integer)
sha256: Mapped[str] = mapped_column(String(64), index=True)
# deferred:详情接口只需要元信息,默认加载会把每条记录的几 MB 图片全读进内存,
# 而公开详情是匿名可访问的,等于把看板变成内存放大器。
data: Mapped[bytes] = deferred(mapped_column(LargeBinary))
thumbnail: Mapped[Optional[bytes]] = deferred(mapped_column(LargeBinary, nullable=True))
thumbnail_type: Mapped[Optional[str]] = mapped_column(String(40), nullable=True)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
created_by: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, index=True)
record: Mapped[Optional[MaterialRecord]] = relationship(back_populates="images")
creator: Mapped[User] = relationship()
class CustomFieldDefinition(Base):
__tablename__ = "custom_field_definitions"
id: Mapped[int] = mapped_column(primary_key=True)
field_key: Mapped[str] = mapped_column(String(50), unique=True, index=True)
label: Mapped[str] = mapped_column(String(100))
input_type: Mapped[str] = mapped_column(String(20), index=True)
is_required: Mapped[bool] = mapped_column(Boolean, default=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
is_public: Mapped[bool] = mapped_column(Boolean, default=True)
unit: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
options_json: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
sort_order: Mapped[int] = mapped_column(Integer, default=0, index=True)
created_by: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, onupdate=now)
creator: Mapped[User] = relationship()
values: Mapped[list["CustomFieldValue"]] = relationship(back_populates="definition")
class CustomFieldValue(Base):
__tablename__ = "custom_field_values"
__table_args__ = (UniqueConstraint("record_id", "field_id", name="uq_record_custom_field"),)
id: Mapped[int] = mapped_column(primary_key=True)
record_id: Mapped[int] = mapped_column(ForeignKey("material_records.id", ondelete="CASCADE"), index=True)
field_id: Mapped[int] = mapped_column(ForeignKey("custom_field_definitions.id", ondelete="RESTRICT"), index=True)
raw_value: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, onupdate=now)
record: Mapped[MaterialRecord] = relationship(back_populates="custom_values")
definition: Mapped[CustomFieldDefinition] = relationship(back_populates="values")
class AuditLog(Base):
__tablename__ = "audit_logs"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[Optional[int]] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
action: Mapped[str] = mapped_column(String(80), index=True)
resource_type: Mapped[str] = mapped_column(String(80), index=True)
resource_id: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
detail: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
ip_address: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, index=True)
user: Mapped[Optional[User]] = relationship()
class BackupRecord(Base):
__tablename__ = "backup_records"
id: Mapped[int] = mapped_column(primary_key=True)
filename: Mapped[str] = mapped_column(String(255), unique=True)
database_type: Mapped[str] = mapped_column(String(30))
# BigInteger:图片入库后备份很容易超过 2 GiBPostgreSQL 的 integer 到那里就溢出了
size_bytes: Mapped[int] = mapped_column(BigInteger)
sha256: Mapped[str] = mapped_column(String(64))
created_by: Mapped[Optional[int]] = mapped_column(ForeignKey("users.id"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, index=True)
creator: Mapped[Optional[User]] = relationship()