"""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 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, }