Files
HSAP/platform/as_platform/db/models.py
Chengfang Lu 483e027482 feat: 合并 Docker Compose、标注表格优化与部署文档
将 platform + CVAT 合并为单文件 docker-compose.yml,完善 .env 与 init/dev_up 脚本;
新增 docs/DEPLOY.md 与更新 README 以支持新机器部署;含数据湖示例、车队地图、
紧凑表格 UI、ADAS det_7cls 路径与批次台账等近期改动。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 17:06:31 +08:00

685 lines
28 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.
"""ORM 模型。"""
from __future__ import annotations
import json
from datetime import datetime, timezone
from sqlalchemy import (
Boolean,
Column,
DateTime,
Float,
ForeignKey,
Integer,
JSON,
String,
Table,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import relationship
from as_platform.db.engine import Base
user_roles = Table(
"user_roles",
Base.metadata,
Column("user_id", Integer, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True),
Column("role_id", Integer, ForeignKey("roles.id", ondelete="CASCADE"), primary_key=True),
)
role_permissions = Table(
"role_permissions",
Base.metadata,
Column("role_id", Integer, ForeignKey("roles.id", ondelete="CASCADE"), primary_key=True),
Column("permission_id", Integer, ForeignKey("permissions.id", ondelete="CASCADE"), primary_key=True),
)
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
feishu_open_id = Column(String(64), unique=True, nullable=True, index=True)
feishu_union_id = Column(String(64), unique=True, nullable=True, index=True)
feishu_user_id = Column(String(64), unique=True, nullable=True, index=True)
feishu_tenant_key = Column(String(128), nullable=True)
feishu_department_ids_json = Column(Text, nullable=True)
name = Column(String(128), nullable=False, default="")
email = Column(String(256), nullable=True)
avatar_url = Column(String(512), nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime(timezone=True), default=_utcnow)
updated_at = Column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow)
roles = relationship("Role", secondary=user_roles, back_populates="users")
def feishu_department_ids(self) -> list[str]:
if not self.feishu_department_ids_json:
return []
try:
data = json.loads(self.feishu_department_ids_json)
if isinstance(data, list):
return [str(x) for x in data]
except Exception:
return []
return []
class Role(Base):
__tablename__ = "roles"
id = Column(Integer, primary_key=True)
code = Column(String(32), unique=True, nullable=False)
name = Column(String(64), nullable=False)
users = relationship("User", secondary=user_roles, back_populates="roles")
permissions = relationship("Permission", secondary=role_permissions, back_populates="roles")
class Permission(Base):
__tablename__ = "permissions"
id = Column(Integer, primary_key=True)
code = Column(String(64), unique=True, nullable=False)
name = Column(String(128), nullable=False)
roles = relationship("Role", secondary=role_permissions, back_populates="permissions")
class Approval(Base):
__tablename__ = "approvals"
id = Column(String(64), primary_key=True)
status = Column(String(32), nullable=False, default="pending", index=True)
action = Column(String(64), nullable=False)
action_label = Column(String(128), nullable=True)
params_json = Column(Text, nullable=False, default="{}")
note = Column(Text, nullable=True)
submitted_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
submitted_by_name = Column(String(128), nullable=True)
submitted_at = Column(DateTime(timezone=True), default=_utcnow)
reviewed_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
reviewed_by_name = Column(String(128), nullable=True)
reviewed_at = Column(DateTime(timezone=True), nullable=True)
review_comment = Column(Text, nullable=True)
rejection_category = Column(String(32), nullable=True, default="")
job_id = Column(String(64), nullable=True)
executed_at = Column(DateTime(timezone=True), nullable=True)
result_json = Column(Text, nullable=True)
def params(self) -> dict:
return json.loads(self.params_json or "{}")
def set_params(self, data: dict) -> None:
self.params_json = json.dumps(data, ensure_ascii=False)
def result(self) -> dict | None:
if not self.result_json:
return None
return json.loads(self.result_json)
def set_result(self, data: dict) -> None:
self.result_json = json.dumps(data, ensure_ascii=False)
def to_dict(self) -> dict:
return {
"id": self.id,
"status": self.status,
"action": self.action,
"action_label": self.action_label,
"params": self.params(),
"note": self.note,
"submitted_by": self.submitted_by_name,
"submitted_by_user_id": self.submitted_by_user_id,
"submitted_at": self.submitted_at.isoformat() if self.submitted_at else None,
"reviewed_by": self.reviewed_by_name,
"reviewed_by_user_id": self.reviewed_by_user_id,
"reviewed_at": self.reviewed_at.isoformat() if self.reviewed_at else None,
"review_comment": self.review_comment,
"rejection_category": self.rejection_category or "",
"job_id": self.job_id,
"executed_at": self.executed_at.isoformat() if self.executed_at else None,
"result": self.result(),
}
class Job(Base):
__tablename__ = "jobs"
id = Column(String(64), primary_key=True)
status = Column(String(32), nullable=False, default="queued", index=True)
action = Column(String(64), nullable=False)
params_json = Column(Text, nullable=False, default="{}")
approval_id = Column(String(64), ForeignKey("approvals.id"), nullable=True)
created_at = Column(DateTime(timezone=True), default=_utcnow)
started_at = Column(DateTime(timezone=True), nullable=True)
finished_at = Column(DateTime(timezone=True), nullable=True)
result_json = Column(Text, nullable=True)
def params(self) -> dict:
return json.loads(self.params_json or "{}")
def set_params(self, data: dict) -> None:
self.params_json = json.dumps(data, ensure_ascii=False)
def result(self) -> dict | None:
if not self.result_json:
return None
return json.loads(self.result_json)
def set_result(self, data: dict) -> None:
self.result_json = json.dumps(data, ensure_ascii=False)
def to_dict(self) -> dict:
return {
"id": self.id,
"status": self.status,
"action": self.action,
"params": self.params(),
"approval_id": self.approval_id,
"created_at": self.created_at.isoformat() if self.created_at else None,
"started_at": self.started_at.isoformat() if self.started_at else None,
"finished_at": self.finished_at.isoformat() if self.finished_at else None,
"result": self.result(),
}
class DatasetCandidate(Base):
__tablename__ = "dataset_candidates"
id = Column(String(64), primary_key=True)
project = Column(String(32), nullable=False, index=True)
task = Column(String(64), nullable=True, index=True)
mode = Column(String(64), nullable=True)
status = Column(String(32), nullable=False, default="uploaded", index=True)
source_type = Column(String(32), nullable=False, default="upload")
original_name = Column(String(255), nullable=True)
upload_path = Column(String(1024), nullable=False)
analyzed_source_path = Column(String(1024), nullable=True)
format_id = Column(String(64), nullable=True)
split_counts_json = Column(Text, nullable=False, default="{}")
quality_json = Column(Text, nullable=True)
error_message = Column(Text, nullable=True)
upload_size_bytes = Column(Integer, nullable=False, default=0)
submitted_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
submitted_by_name = Column(String(128), nullable=True)
analysis_job_id = Column(String(64), ForeignKey("jobs.id"), nullable=True)
inbox_path = Column(String(1024), nullable=True)
promoted_batch = Column(String(128), nullable=True)
external_id = Column(String(128), nullable=True, index=True)
feishu_record_id = Column(String(64), nullable=True, index=True)
created_at = Column(DateTime(timezone=True), default=_utcnow)
updated_at = Column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow)
def split_counts(self) -> dict:
return json.loads(self.split_counts_json or "{}")
def set_split_counts(self, data: dict) -> None:
self.split_counts_json = json.dumps(data, ensure_ascii=False)
def quality(self) -> dict | None:
if not self.quality_json:
return None
return json.loads(self.quality_json)
def set_quality(self, data: dict) -> None:
self.quality_json = json.dumps(data, ensure_ascii=False)
def to_dict(self) -> dict:
return {
"id": self.id,
"project": self.project,
"task": self.task,
"mode": self.mode,
"status": self.status,
"source_type": self.source_type,
"original_name": self.original_name,
"upload_path": self.upload_path,
"analyzed_source_path": self.analyzed_source_path,
"format_id": self.format_id,
"split_counts": self.split_counts(),
"quality": self.quality(),
"error_message": self.error_message,
"upload_size_bytes": self.upload_size_bytes,
"submitted_by_user_id": self.submitted_by_user_id,
"submitted_by_name": self.submitted_by_name,
"analysis_job_id": self.analysis_job_id,
"inbox_path": self.inbox_path,
"promoted_batch": self.promoted_batch,
"external_id": self.external_id,
"feishu_record_id": self.feishu_record_id,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
class BatchDelivery(Base):
"""平台内数据送标申请(替代飞书多维表格拉取)。"""
__tablename__ = "batch_deliveries"
__table_args__ = (
UniqueConstraint("project", "task", "mode", "batch_name", name="uq_batch_deliveries_batch"),
)
id = Column(String(64), primary_key=True)
project = Column(String(32), nullable=False, index=True)
task = Column(String(64), nullable=True, index=True)
mode = Column(String(64), nullable=True)
batch_name = Column(String(128), nullable=False, index=True)
source_type = Column(String(64), nullable=True)
vehicle_scene = Column(String(256), nullable=True)
collection_start = Column(String(32), nullable=True)
collection_end = Column(String(32), nullable=True)
data_path = Column(String(1024), nullable=False)
estimated_count = Column(Integer, nullable=True)
remark = Column(Text, nullable=True)
status = Column(String(32), nullable=False, default="draft", index=True)
owner_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
owner_name = Column(String(128), nullable=True)
submitted_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
submitted_by_name = Column(String(128), nullable=True)
approval_id = Column(String(64), nullable=True, index=True)
candidate_id = Column(String(64), nullable=True, index=True)
inbox_path = Column(String(1024), nullable=True)
error_message = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), default=_utcnow)
updated_at = Column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow)
def to_dict(self) -> dict:
return {
"id": self.id,
"project": self.project,
"task": self.task,
"mode": self.mode,
"batch_name": self.batch_name,
"source_type": self.source_type,
"vehicle_scene": self.vehicle_scene,
"collection_start": self.collection_start,
"collection_end": self.collection_end,
"data_path": self.data_path,
"estimated_count": self.estimated_count,
"remark": self.remark,
"status": self.status,
"owner_user_id": self.owner_user_id,
"owner_name": self.owner_name,
"submitted_by_user_id": self.submitted_by_user_id,
"submitted_by_name": self.submitted_by_name,
"submitted_by": self.submitted_by_name,
"approval_id": self.approval_id,
"candidate_id": self.candidate_id,
"inbox_path": self.inbox_path,
"error_message": self.error_message,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
class BatchIndex(Base):
"""批次列表索引:由扫盘/登记/阶段变更写入,列表 API 只读此表。"""
__tablename__ = "batch_index"
campaign_id = Column(String(64), primary_key=True)
project = Column(String(32), nullable=False, index=True)
task = Column(String(64), nullable=True, index=True)
mode = Column(String(64), nullable=True)
batch = Column(String(128), nullable=False, index=True)
pack = Column(String(64), nullable=True)
location = Column(String(32), nullable=False, default="inbox")
stage = Column(String(32), nullable=False, index=True)
batch_path = Column(String(1024), nullable=True)
scope_key = Column(String(128), nullable=True)
engineer = Column(String(128), nullable=True)
format = Column(String(32), nullable=True)
image_count = Column(Integer, nullable=False, default=0)
label_count = Column(Integer, nullable=False, default=0)
has_meta = Column(Boolean, nullable=False, default=False)
registry_only = Column(Boolean, nullable=False, default=False)
next_cli = Column(String(512), nullable=True)
domain = Column(String(32), nullable=True)
domain_label = Column(String(64), nullable=True)
task_label = Column(String(128), nullable=True)
mode_label = Column(String(128), nullable=True)
labeling_profile = Column(String(128), nullable=True)
export_default = Column(String(128), nullable=True)
ml_adapter = Column(String(128), nullable=True)
indexed_at = Column(DateTime(timezone=True), nullable=False)
archived = Column(Boolean, nullable=False, default=False, index=True)
def to_list_row(self) -> dict:
return {
"project": self.project,
"task": self.task,
"mode": self.mode,
"batch": self.batch,
"pack": self.pack,
"stage": self.stage,
"location": self.location,
"path": self.batch_path,
"engineer": self.engineer,
"format": self.format,
"counts": {"images": self.image_count, "labels": self.label_count},
"has_meta": self.has_meta,
"next_cli": self.next_cli,
"scope_key": self.scope_key,
"domain": self.domain,
"domain_label": self.domain_label,
"task_label": self.task_label,
"mode_label": self.mode_label,
"labeling_profile": self.labeling_profile,
"export_default": self.export_default,
"ml_adapter": self.ml_adapter,
"campaign_id": self.campaign_id,
"registry_only": self.registry_only,
"indexed_at": self.indexed_at.isoformat() if self.indexed_at else None,
}
class FeishuBitableLink(Base):
"""HSAP 批次与飞书多维表格行的对应关系。"""
__tablename__ = "feishu_bitable_links"
id = Column(Integer, primary_key=True)
batch_key = Column(String(256), unique=True, nullable=False, index=True)
record_id = Column(String(64), unique=True, nullable=True, index=True)
delivery_id = Column(String(128), nullable=True, index=True)
project = Column(String(32), nullable=False)
task = Column(String(64), nullable=True)
mode = Column(String(64), nullable=True)
batch = Column(String(128), nullable=False)
campaign_id = Column(String(64), nullable=True)
inbox_path = Column(String(1024), nullable=True)
last_sync_at = Column(DateTime(timezone=True), nullable=True)
class FleetVehicle(Base):
__tablename__ = "fleet_vehicles"
id = Column(Integer, primary_key=True)
plate_no = Column(String(32), nullable=False, index=True)
tbox_device_id = Column(String(64), unique=True, nullable=False, index=True)
name = Column(String(128), nullable=False, default="")
team = Column(String(64), nullable=True)
status = Column(String(32), nullable=False, default="active")
last_lat = Column(Float, nullable=True)
last_lng = Column(Float, nullable=True)
last_speed_kmh = Column(Float, nullable=True)
last_ts = Column(DateTime(timezone=True), nullable=True)
online = Column(Boolean, nullable=False, default=False)
meta_json = Column(Text, nullable=False, default="{}")
created_at = Column(DateTime(timezone=True), default=_utcnow)
updated_at = Column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow)
def meta(self) -> dict:
return json.loads(self.meta_json or "{}")
def set_meta(self, data: dict) -> None:
self.meta_json = json.dumps(data, ensure_ascii=False)
def to_dict(self) -> dict:
return {
"id": self.id,
"plate_no": self.plate_no,
"tbox_device_id": self.tbox_device_id,
"name": self.name or self.plate_no,
"team": self.team,
"status": self.status,
"last_lat": self.last_lat,
"last_lng": self.last_lng,
"last_speed_kmh": self.last_speed_kmh,
"last_ts": self.last_ts.isoformat() if self.last_ts else None,
"online": self.online,
"meta": self.meta(),
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
class FleetCollectionRun(Base):
__tablename__ = "fleet_collection_runs"
id = Column(Integer, primary_key=True)
vehicle_id = Column(Integer, ForeignKey("fleet_vehicles.id", ondelete="CASCADE"), nullable=False, index=True)
run_no = Column(String(64), nullable=False, index=True)
engineer = Column(String(128), nullable=True)
project = Column(String(32), nullable=True)
batch = Column(String(128), nullable=True)
started_at = Column(DateTime(timezone=True), default=_utcnow)
ended_at = Column(DateTime(timezone=True), nullable=True)
status = Column(String(32), nullable=False, default="active", index=True)
mileage_km = Column(Float, nullable=False, default=0.0)
source = Column(String(32), nullable=False, default="tbox")
note = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), default=_utcnow)
updated_at = Column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow)
def to_dict(self) -> dict:
return {
"id": self.id,
"vehicle_id": self.vehicle_id,
"run_no": self.run_no,
"engineer": self.engineer,
"project": self.project,
"batch": self.batch,
"started_at": self.started_at.isoformat() if self.started_at else None,
"ended_at": self.ended_at.isoformat() if self.ended_at else None,
"status": self.status,
"mileage_km": round(self.mileage_km or 0.0, 3),
"source": self.source,
"note": self.note,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
class FleetTrackPoint(Base):
__tablename__ = "fleet_track_points"
id = Column(Integer, primary_key=True)
run_id = Column(Integer, ForeignKey("fleet_collection_runs.id", ondelete="CASCADE"), nullable=False, index=True)
ts = Column(DateTime(timezone=True), nullable=False, index=True)
lat = Column(Float, nullable=False)
lng = Column(Float, nullable=False)
speed_kmh = Column(Float, nullable=True)
heading = Column(Float, nullable=True)
alt_m = Column(Float, nullable=True)
def to_dict(self) -> dict:
return {
"id": self.id,
"run_id": self.run_id,
"ts": self.ts.isoformat() if self.ts else None,
"lat": self.lat,
"lng": self.lng,
"speed_kmh": self.speed_kmh,
"heading": self.heading,
"alt_m": self.alt_m,
}
class FleetRunMilestone(Base):
__tablename__ = "fleet_run_milestones"
id = Column(Integer, primary_key=True)
run_id = Column(Integer, ForeignKey("fleet_collection_runs.id", ondelete="CASCADE"), nullable=False, index=True)
name = Column(String(128), nullable=False, default="")
type = Column(String(32), nullable=False, default="waypoint")
lat = Column(Float, nullable=False)
lng = Column(Float, nullable=False)
mileage_km = Column(Float, nullable=True)
occurred_at = Column(DateTime(timezone=True), nullable=True)
payload_json = Column(Text, nullable=False, default="{}")
def to_dict(self) -> dict:
return {
"id": self.id,
"run_id": self.run_id,
"name": self.name,
"type": self.type,
"lat": self.lat,
"lng": self.lng,
"mileage_km": self.mileage_km,
"occurred_at": self.occurred_at.isoformat() if self.occurred_at else None,
"payload": json.loads(self.payload_json or "{}"),
}
class LabelingCampaignAccess(Base):
"""Campaign 级访问授权(内部/第三方),非派单。"""
__tablename__ = "labeling_campaign_access"
id = Column(Integer, primary_key=True)
campaign_id = Column(String(64), nullable=False, index=True)
principal_type = Column(String(32), nullable=False, default="role")
principal_id = Column(String(128), nullable=False, index=True)
access_role = Column(String(32), nullable=False, default="vendor")
created_at = Column(DateTime(timezone=True), default=_utcnow)
def to_dict(self) -> dict:
return {
"id": self.id,
"campaign_id": self.campaign_id,
"principal_type": self.principal_type,
"principal_id": self.principal_id,
"access_role": self.access_role,
"created_at": self.created_at.isoformat() if self.created_at else None,
}
class LabelingExportJob(Base):
"""标注活动关联的 export / ml-predict 任务记录。"""
__tablename__ = "labeling_export_jobs"
id = Column(String(64), primary_key=True)
campaign_id = Column(String(64), nullable=False, index=True)
action = Column(String(64), nullable=False, index=True)
job_id = Column(String(64), nullable=True, index=True)
status = Column(String(32), nullable=False, default="queued", index=True)
result_json = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), default=_utcnow)
finished_at = Column(DateTime(timezone=True), nullable=True)
def to_dict(self) -> dict:
return {
"id": self.id,
"campaign_id": self.campaign_id,
"action": self.action,
"job_id": self.job_id,
"status": self.status,
"result": json.loads(self.result_json or "null") if self.result_json else None,
"created_at": self.created_at.isoformat() if self.created_at else None,
"finished_at": self.finished_at.isoformat() if self.finished_at else None,
}
class LabelingCampaign(Base):
"""标注活动:与 pending 批次 (project, task, mode, batch, location) 一一对应。"""
__tablename__ = "labeling_campaigns"
id = Column(String(64), primary_key=True)
project = Column(String(32), nullable=False, index=True)
task = Column(String(64), nullable=False, index=True)
mode = Column(String(64), nullable=True, index=True)
batch = Column(String(128), nullable=False, index=True)
pack = Column(String(64), nullable=True)
location = Column(String(32), nullable=False, default="inbox")
status = Column(String(32), nullable=False, default="not_opened", index=True)
assigned_to_user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
assigned_to_name = Column(String(128), nullable=True)
config_xml = Column(Text, nullable=True)
cvat_task_id = Column(Integer, nullable=True, index=True)
cvat_job_url = Column(String(512), nullable=True)
annotation_types = Column(JSON, nullable=True)
created_at = Column(DateTime(timezone=True), default=_utcnow)
updated_at = Column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow)
def to_dict(self) -> dict:
return {
"id": self.id,
"project": self.project,
"task": self.task,
"mode": self.mode,
"batch": self.batch,
"pack": self.pack,
"location": self.location,
"status": self.status,
"assigned_to_user_id": self.assigned_to_user_id,
"assigned_to_name": self.assigned_to_name,
"config_xml": self.config_xml,
"cvat_task_id": self.cvat_task_id,
"cvat_job_url": self.cvat_job_url,
"annotation_types": self.annotation_types,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
class LabelingTaskAssignment(Base):
"""Campaign 内单张图task_id分包给标注员。"""
__tablename__ = "labeling_task_assignments"
__table_args__ = (UniqueConstraint("campaign_id", "task_id", name="uq_labeling_campaign_task"),)
id = Column(Integer, primary_key=True, autoincrement=True)
campaign_id = Column(String(64), ForeignKey("labeling_campaigns.id", ondelete="CASCADE"), nullable=False, index=True)
task_id = Column(String(32), nullable=False, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
assigned_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
assigned_at = Column(DateTime(timezone=True), default=_utcnow)
completed_at = Column(DateTime(timezone=True), nullable=True)
completed_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
def to_dict(self) -> dict:
return {
"id": self.id,
"campaign_id": self.campaign_id,
"task_id": self.task_id,
"user_id": self.user_id,
"assigned_by_user_id": self.assigned_by_user_id,
"assigned_at": self.assigned_at.isoformat() if self.assigned_at else None,
"completed_at": self.completed_at.isoformat() if self.completed_at else None,
"completed_by_user_id": self.completed_by_user_id,
}
class OperationLog(Base):
__tablename__ = "operation_logs"
id = Column(Integer, primary_key=True, autoincrement=True)
timestamp = Column(DateTime(timezone=True), default=_utcnow, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
user_name = Column(String(128), nullable=True)
category = Column(String(32), nullable=False, index=True)
action = Column(String(64), nullable=False)
target_type = Column(String(32), nullable=True)
target_id = Column(String(128), nullable=True)
summary = Column(String(512), nullable=True)
detail_json = Column(Text, nullable=True)
ip_address = Column(String(64), nullable=True)
def to_dict(self) -> dict:
return {
"id": self.id,
"timestamp": self.timestamp.isoformat() if self.timestamp else None,
"user_id": self.user_id,
"user_name": self.user_name,
"category": self.category,
"action": self.action,
"target_type": self.target_type,
"target_id": self.target_id,
"summary": self.summary,
"detail": json.loads(self.detail_json) if self.detail_json else None,
"ip_address": self.ip_address,
}