unis_manager/app/models.py

701 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

"""数据模型。
层级:Period(周期) -> Project(主要项目/工作项) -> Task(子任务) -> Update(执行情况)
辅助:Category(分类) / Tag(标记) / LedgerItem(客户项目台账) / Setting(配置)
权限:Role(角色,一份菜单权限) <- User(用户) ; UserSession(登录会话)
"""
from __future__ import annotations
import json
from datetime import datetime
from sqlalchemy import (
Boolean,
Column,
DateTime,
Float,
ForeignKey,
Integer,
String,
Table,
Text,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .db import Base
def now():
return datetime.now()
project_tags = Table(
"project_tags",
Base.metadata,
Column("project_id", Integer, ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True),
Column("tag_id", Integer, ForeignKey("tags.id", ondelete="CASCADE"), primary_key=True),
)
# ------------------------------------------------------------------ 人员名单
# 多负责人 / 团队成员是「一串人名」,不建关联表:直接塞进一个字符串列,
# 首尾都带逗号(",张三,李四,"),这样才能用 LIKE '%,张三,%' 精确匹配某个人,
# 不至于让「张三」命中「张三丰」。读出来 split,写进去 join。
PEOPLE_SEP = ","
def split_people(value: str | None) -> list[str]:
"""存 → 名单。"""
return [x for x in (value or "").split(PEOPLE_SEP) if x]
def dedupe_people(names) -> list[str]:
"""清洗前端传来的名单:去空白、去重、保持顺序。"""
out: list[str] = []
for n in names or []:
n = (n or "").strip()
if n and n not in out:
out.append(n)
return out
def join_people(names) -> str | None:
"""名单 → 存(空名单存 NULL)。"""
out = dedupe_people(names)
if not out:
return None
return f"{PEOPLE_SEP}{PEOPLE_SEP.join(out)}{PEOPLE_SEP}"
def people_like(name: str) -> str:
"""按人名匹配名单列的 LIKE 模式。"""
return f"%{PEOPLE_SEP}{(name or '').strip()}{PEOPLE_SEP}%"
def all_owners(owner: str | None, other_owners: str | None) -> list[str]:
"""主负责人 + 其他负责人,主负责人恒在第一位。"""
return dedupe_people([owner] + split_people(other_owners))
def set_people(obj, owners=None, members=None):
"""整体替换(不是增量):owners 传空数组即清空。传 None 表示这次不改。
owners 的第一位就是主负责人,写进 obj.owner —— 看板、筛选、导出、
人力资源库负载全都照旧读 owner,一个字都不用改。
"""
if owners is not None:
names = dedupe_people(owners)
obj.owner = names[0] if names else None
obj.other_owners = join_people(names[1:])
if members is not None:
obj.members = join_people(members)
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(DateTime, default=now, nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=now, onupdate=now, nullable=False
)
class Category(Base, TimestampMixin):
"""项目分类(如 平台产品 / AI / 定制项目 / 内部运营)。"""
__tablename__ = "categories"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
color: Mapped[str] = mapped_column(String(16), default="#2563eb", nullable=False)
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
projects: Mapped[list["Project"]] = relationship(back_populates="category")
def to_dict(self, with_count: bool = False):
data = {
"id": self.id,
"name": self.name,
"color": self.color,
"sort_order": self.sort_order,
}
if with_count:
data["project_count"] = len(self.projects)
return data
class Tag(Base):
"""自由标记(如 重点 / 需支援 / 待决策)。"""
__tablename__ = "tags"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(32), unique=True, nullable=False)
color: Mapped[str] = mapped_column(String(16), default="#6b7280", nullable=False)
def to_dict(self):
return {"id": self.id, "name": self.name, "color": self.color}
class Project(Base, TimestampMixin):
"""主要项目 / 工作项。"""
__tablename__ = "projects"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
category_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("categories.id"), nullable=True
)
owner: Mapped[str | None] = mapped_column(String(64), nullable=True) # 主负责人
other_owners: Mapped[str | None] = mapped_column(String(512), nullable=True) # 其他负责人
members: Mapped[str | None] = mapped_column(String(512), nullable=True) # 团队成员
status: Mapped[str] = mapped_column(String(32), default="in_progress", nullable=False)
priority: Mapped[str] = mapped_column(String(8), default="p2", nullable=False)
is_key: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) # 重点标记
progress: Mapped[float] = mapped_column(Float, default=0.0, nullable=False) # 最新进度 %
description: Mapped[str | None] = mapped_column(Text, nullable=True)
archived: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
category: Mapped[Category | None] = relationship(back_populates="projects")
tags: Mapped[list[Tag]] = relationship(secondary=project_tags, lazy="selectin")
tasks: Mapped[list["Task"]] = relationship(
back_populates="project", cascade="all, delete-orphan"
)
updates: Mapped[list["Update"]] = relationship(
back_populates="project", cascade="all, delete-orphan"
)
def to_dict(self, brief: bool = True):
data = {
"id": self.id,
"name": self.name,
"category_id": self.category_id,
"category": self.category.to_dict() if self.category else None,
"owner": self.owner,
"owners": all_owners(self.owner, self.other_owners),
"members": split_people(self.members),
"status": self.status,
"priority": self.priority,
"is_key": self.is_key,
"progress": round(self.progress or 0, 1),
"description": self.description,
"archived": self.archived,
"sort_order": self.sort_order,
"tags": [t.to_dict() for t in self.tags],
"updated_at": self.updated_at.isoformat(timespec="seconds") if self.updated_at else None,
}
if not brief:
data["task_count"] = len(self.tasks)
data["update_count"] = len(self.updates)
return data
class Period(Base):
"""周期(周 / 月)。"""
__tablename__ = "periods"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
label: Mapped[str] = mapped_column(String(64), nullable=False) # 8月第4周
kind: Mapped[str] = mapped_column(String(16), default="week", nullable=False) # week|month
year: Mapped[int] = mapped_column(Integer, nullable=False)
month: Mapped[int] = mapped_column(Integer, nullable=False)
week: Mapped[int] = mapped_column(Integer, default=0, nullable=False) # 月视图为 0
start_date: Mapped[str | None] = mapped_column(String(16), nullable=True)
end_date: Mapped[str | None] = mapped_column(String(16), nullable=True)
# 排序键:year*10000 + month*100 + week
sort_key: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
updates: Mapped[list["Update"]] = relationship(back_populates="period")
def to_dict(self):
return {
"id": self.id,
"label": self.label,
"kind": self.kind,
"year": self.year,
"month": self.month,
"week": self.week,
"start_date": self.start_date,
"end_date": self.end_date,
"sort_key": self.sort_key,
"full_label": f"{self.year}年{self.month}月" + (f"第{self.week}周" if self.week else ""),
}
class Task(Base, TimestampMixin):
"""项目下的子任务(对应原表『内容说明』)。"""
__tablename__ = "tasks"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
project_id: Mapped[int] = mapped_column(
Integer, ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
project: Mapped[Project] = relationship(back_populates="tasks")
updates: Mapped[list["Update"]] = relationship(back_populates="task")
def to_dict(self):
return {
"id": self.id,
"project_id": self.project_id,
"name": self.name,
"sort_order": self.sort_order,
}
class Update(Base, TimestampMixin):
"""某周期下、某项目(可细化到子任务)的执行情况。"""
__tablename__ = "updates"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
project_id: Mapped[int] = mapped_column(
Integer, ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
)
task_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True
)
period_id: Mapped[int] = mapped_column(
Integer, ForeignKey("periods.id", ondelete="CASCADE"), nullable=False, index=True
)
content: Mapped[str | None] = mapped_column(Text, nullable=True) # 执行情况正文
summary: Mapped[str | None] = mapped_column(Text, nullable=True) # 一句话摘要
progress: Mapped[float | None] = mapped_column(Float, nullable=True) # 该条进度 %
status: Mapped[str] = mapped_column(String(32), default="in_progress", nullable=False)
risk: Mapped[str | None] = mapped_column(Text, nullable=True)
next_step: Mapped[str | None] = mapped_column(Text, nullable=True)
owner: Mapped[str | None] = mapped_column(String(64), nullable=True)
source: Mapped[str] = mapped_column(String(16), default="manual", nullable=False) # import|ai|manual
raw_text: Mapped[str | None] = mapped_column(Text, nullable=True)
model: Mapped[str | None] = mapped_column(String(64), nullable=True)
project: Mapped[Project] = relationship(back_populates="updates")
task: Mapped[Task | None] = relationship(back_populates="updates")
period: Mapped[Period] = relationship(back_populates="updates")
def to_dict(self, with_project: bool = False):
data = {
"id": self.id,
"project_id": self.project_id,
"task_id": self.task_id,
"task_name": self.task.name if self.task else None,
"period_id": self.period_id,
"period_label": self.period.label if self.period else None,
"period_full": self.period.to_dict()["full_label"] if self.period else None,
"content": self.content,
"summary": self.summary,
"progress": self.progress,
"status": self.status,
"risk": self.risk,
"next_step": self.next_step,
"owner": self.owner,
"source": self.source,
"model": self.model,
"created_at": self.created_at.isoformat(timespec="seconds") if self.created_at else None,
}
if with_project and self.project:
data["project_name"] = self.project.name
data["project_is_key"] = self.project.is_key
return data
class WeeklyFocus(Base, TimestampMixin):
"""每周重点工作:单条录入、按周跟踪的短期任务。
与 Project / Update 分开——不用先建项目档案,一条就是一个活儿。
period_id 是录入周(新建时所在的那一周,语义同 Update.period_id);
预计完成时间是日历日期(due_date),不参与按周分组;
是否延期(delayed)由人工开关,更早周次延期未完成的条目结转进当周视图。
"""
__tablename__ = "weekly_focus"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
content: Mapped[str] = mapped_column(String(255), nullable=False) # 工作内容
detail: Mapped[str | None] = mapped_column(Text, nullable=True) # 说明
period_id: Mapped[int] = mapped_column(
Integer, ForeignKey("periods.id", ondelete="CASCADE"), nullable=False, index=True
) # 录入周(新建时所在的这一周,用于按周分组;不是预计完成时间所在的周)
due_date: Mapped[str | None] = mapped_column(String(32), nullable=True) # 预计完成时间(日历日期 YYYY-MM-DD,只作目标日期看)
result: Mapped[str | None] = mapped_column(Text, nullable=True) # 结果反馈
delayed: Mapped[bool | None] = mapped_column(Boolean, nullable=True) # 是否延期(人工开关)
status: Mapped[str] = mapped_column(String(32), default="not_started", nullable=False)
archived: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
period: Mapped[Period] = relationship()
def to_dict(self):
return {
"id": self.id,
"content": self.content,
"detail": self.detail,
"period_id": self.period_id,
"period_label": self.period.label if self.period else None,
"period_full": self.period.to_dict()["full_label"] if self.period else None,
"sort_key": self.period.sort_key if self.period else 0,
"due_date": self.due_date,
"result": self.result,
"delayed": bool(self.delayed),
"status": self.status,
"archived": self.archived,
"updated_at": self.updated_at.isoformat(timespec="seconds") if self.updated_at else None,
}
class LedgerItem(Base, TimestampMixin):
"""客户项目台账(来自《项目明细表》:区域 / 金额 / 类别 / 状态说明)。"""
__tablename__ = "ledger_items"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
region: Mapped[str | None] = mapped_column(String(64), nullable=True)
amount: Mapped[float | None] = mapped_column(Float, nullable=True)
category: Mapped[str | None] = mapped_column(String(32), nullable=True)
status_note: Mapped[str | None] = mapped_column(Text, nullable=True)
period_label: Mapped[str | None] = mapped_column(String(64), nullable=True)
period_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("periods.id", ondelete="SET NULL"), nullable=True
)
def to_dict(self):
return {
"id": self.id,
"name": self.name,
"region": self.region,
"amount": self.amount,
"category": self.category,
"status_note": self.status_note,
"period_label": self.period_label,
"period_id": self.period_id,
}
class CustomProject(Base, TimestampMixin):
"""定制项目(与通用研发项目完全分离)。
关注:商务阶段流转(签单 → 开发 → 交付 → 验收 → 计收)与按进度确认收入。
"""
__tablename__ = "custom_projects"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
customer: Mapped[str | None] = mapped_column(String(128), nullable=True) # 客户 / 办事处
region: Mapped[str | None] = mapped_column(String(64), nullable=True) # 区域(省/办)
amount: Mapped[float | None] = mapped_column(Float, nullable=True) # 合同/订单金额
currency: Mapped[str] = mapped_column(String(8), default="CNY", nullable=False)
stage: Mapped[str] = mapped_column(String(32), default="signed", nullable=False, index=True)
progress: Mapped[float] = mapped_column(Float, default=0.0, nullable=False) # 开发进度 %
manual_ratio: Mapped[float | None] = mapped_column(Float, nullable=True) # 手动收入确认比例(覆盖阶段推算)
revenue_amount: Mapped[float | None] = mapped_column(Float, nullable=True) # 已计收金额
owner: Mapped[str | None] = mapped_column(String(64), nullable=True) # 交付负责人(单人)
other_owners: Mapped[str | None] = mapped_column(String(512), nullable=True) # 已停用:定制项目不做多负责人
members: Mapped[str | None] = mapped_column(String(512), nullable=True) # 团队成员
entry_date: Mapped[str | None] = mapped_column(String(16), nullable=True) # 入库时间(YYYY-MM-DD)
sign_date: Mapped[str | None] = mapped_column(String(16), nullable=True) # 签单/下单时间
deliver_date: Mapped[str | None] = mapped_column(String(16), nullable=True) # 交付时间
accept_date: Mapped[str | None] = mapped_column(String(16), nullable=True) # 验收时间
revenue_date: Mapped[str | None] = mapped_column(String(16), nullable=True) # 计收时间
invoice_amount: Mapped[float | None] = mapped_column(Float, nullable=True) # 已开票金额
received_amount: Mapped[float | None] = mapped_column(Float, nullable=True) # 已回款金额
period_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("periods.id", ondelete="SET NULL"), nullable=True
) # 最近一次更新所在周期
remark: Mapped[str | None] = mapped_column(Text, nullable=True) # 最新状态说明
is_key: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
archived: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
source: Mapped[str] = mapped_column(String(16), default="import", nullable=False)
# ---- 市场/销售侧台账字段(来源《定开项目.xlsx》,2026-09 起)----
origin: Mapped[str | None] = mapped_column(String(32), nullable=True) # 项目归属:华智下单/存量项目/汇智直签/提前交付
project_code: Mapped[str | None] = mapped_column(
String(64), nullable=True, index=True
) # 项目ID(市场编号,导入时用于去重)
office: Mapped[str | None] = mapped_column(String(64), nullable=True) # 办事处
industry: Mapped[str | None] = mapped_column(String(32), nullable=True) # 行业:安平/政府/企业/教育…
man_days: Mapped[float | None] = mapped_column(Float, nullable=True) # 市场下单人天
market_owner: Mapped[str | None] = mapped_column(
String(128), nullable=True
) # 市场责任人(销售侧;多人用 / 分隔,与花名册无关)
project_status: Mapped[str | None] = mapped_column(
String(32), nullable=True
) # 项目状态:已验收/待开发/交付完成/进行中/退单
accept_plan: Mapped[str | None] = mapped_column(
String(32), nullable=True
) # 推动验收计划(Q1..Q4 / 27年)
risk_level: Mapped[str | None] = mapped_column(String(32), nullable=True) # 风险值:1.高风险 / 2.低风险
update_date: Mapped[str | None] = mapped_column(
String(16), nullable=True, index=True
) # 更新时间 YYYY-MM-DD:该行数据最后一次变动的日期,用于按周查看变化
accept_date_real: Mapped[str | None] = mapped_column(
String(16), nullable=True, index=True
) # 验收日期 YYYY-MM-DD(真实日期):与 accept_date(季度文本)区分,用于「本月/本年验收」统计
updates: Mapped[list["CustomUpdate"]] = relationship(
back_populates="project", cascade="all, delete-orphan"
)
def to_dict(self, with_revenue: bool = True):
from .constants import custom_revenue_ratio
ratio = custom_revenue_ratio(self.stage, self.progress, self.manual_ratio)
data = {
"id": self.id,
"name": self.name,
"customer": self.customer,
"region": self.region,
"amount": self.amount,
"stage": self.stage,
"progress": round(self.progress or 0, 1),
"manual_ratio": self.manual_ratio,
"revenue_amount": self.revenue_amount,
"owner": self.owner,
"members": split_people(self.members),
"entry_date": self.entry_date,
"sign_date": self.sign_date,
"deliver_date": self.deliver_date,
"accept_date": self.accept_date,
"revenue_date": self.revenue_date,
"invoice_amount": self.invoice_amount,
"received_amount": self.received_amount,
"period_id": self.period_id,
"remark": self.remark,
"is_key": self.is_key,
"archived": self.archived,
"source": self.source,
# 市场/销售侧台账字段
"origin": self.origin,
"project_code": self.project_code,
"office": self.office,
"industry": self.industry,
"man_days": self.man_days,
"market_owner": self.market_owner,
"project_status": self.project_status,
"accept_plan": self.accept_plan,
"risk_level": self.risk_level,
"update_date": self.update_date,
"accept_date_real": self.accept_date_real,
"update_count": len(self.updates),
"updated_at": self.updated_at.isoformat(timespec="seconds") if self.updated_at else None,
}
if with_revenue:
data["revenue_ratio"] = round(ratio * 100, 1)
data["confirmed_amount"] = round((self.amount or 0) * ratio, 2) # 按进度可确认收入
data["pending_amount"] = round(
(self.amount or 0) - (self.revenue_amount or 0), 2
) # 待计收
data["pending_invoice"] = round(
max(0.0, (self.amount or 0) - (self.invoice_amount or 0)), 2
)
data["pending_received"] = round(
max(0.0, (self.amount or 0) - (self.received_amount or 0)), 2
)
return data
class CustomUpdate(Base, TimestampMixin):
"""定制项目的阶段流转 / 周期执行记录(独立表)。"""
__tablename__ = "custom_updates"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
custom_project_id: Mapped[int] = mapped_column(
Integer, ForeignKey("custom_projects.id", ondelete="CASCADE"), nullable=False, index=True
)
period_id: Mapped[int] = mapped_column(
Integer, ForeignKey("periods.id", ondelete="CASCADE"), nullable=False, index=True
)
stage: Mapped[str | None] = mapped_column(String(32), nullable=True) # 该时点所处阶段
content: Mapped[str | None] = mapped_column(Text, nullable=True)
summary: Mapped[str | None] = mapped_column(Text, nullable=True)
progress: Mapped[float | None] = mapped_column(Float, nullable=True)
amount_delta: Mapped[float | None] = mapped_column(Float, nullable=True) # 本期新增下单金额
revenue_delta: Mapped[float | None] = mapped_column(Float, nullable=True) # 本期新增计收
risk: Mapped[str | None] = mapped_column(Text, nullable=True)
next_step: Mapped[str | None] = mapped_column(Text, nullable=True)
owner: Mapped[str | None] = mapped_column(String(64), nullable=True)
source: Mapped[str] = mapped_column(String(16), default="manual", nullable=False)
raw_text: Mapped[str | None] = mapped_column(Text, nullable=True)
model: Mapped[str | None] = mapped_column(String(64), nullable=True)
project: Mapped[CustomProject] = relationship(back_populates="updates")
period: Mapped[Period] = relationship()
def to_dict(self):
return {
"id": self.id,
"custom_project_id": self.custom_project_id,
"project_name": self.project.name if self.project else None,
"period_id": self.period_id,
"period_label": self.period.label if self.period else None,
"period_full": self.period.to_dict()["full_label"] if self.period else None,
"sort_key": self.period.sort_key if self.period else 0,
"stage": self.stage,
"content": self.content,
"summary": self.summary,
"progress": self.progress,
"amount_delta": self.amount_delta,
"revenue_delta": self.revenue_delta,
"risk": self.risk,
"next_step": self.next_step,
"owner": self.owner,
"source": self.source,
"model": self.model,
"created_at": self.created_at.isoformat(timespec="seconds") if self.created_at else None,
}
class Staff(Base, TimestampMixin):
"""人力资源库成员。
项目管理 / 定制项目的负责人一律从这里选取;归档后不再出现在下拉里,
历史记录中的人名保持不变。
"""
__tablename__ = "staff"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
group: Mapped[str | None] = mapped_column(String(64), nullable=True) # 归属小组
affiliation: Mapped[str | None] = mapped_column(String(32), nullable=True) # 人员归属(汇智/云数/外服)
note: Mapped[str | None] = mapped_column(Text, nullable=True) # 说明 / 备注
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
archived: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
def to_dict(self):
return {
"id": self.id,
"name": self.name,
"group": self.group,
"affiliation": self.affiliation,
"note": self.note,
"sort_order": self.sort_order,
"archived": self.archived,
"updated_at": self.updated_at.isoformat(timespec="seconds") if self.updated_at else None,
}
class Setting(Base):
"""键值配置(AI 参数等,可在页面设置中修改)。"""
__tablename__ = "settings"
key: Mapped[str] = mapped_column(String(64), primary_key=True)
value: Mapped[str | None] = mapped_column(Text, nullable=True)
class ImportLog(Base):
"""AI 整理留痕。"""
__tablename__ = "import_logs"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
project_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
period_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
raw_input: Mapped[str | None] = mapped_column(Text, nullable=True)
parsed_json: Mapped[str | None] = mapped_column(Text, nullable=True)
mode: Mapped[str] = mapped_column(String(16), default="rule", nullable=False)
model: Mapped[str | None] = mapped_column(String(64), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=now, nullable=False)
def to_dict(self):
return {
"id": self.id,
"project_id": self.project_id,
"period_id": self.period_id,
"mode": self.mode,
"model": self.model,
"raw_input": (self.raw_input or "")[:400],
"created_at": self.created_at.isoformat(timespec="seconds") if self.created_at else None,
}
# ---------------------------------------------------------------------- 权限
class Role(Base, TimestampMixin):
"""角色 = 一组功能菜单权限。
menus 存 JSON 文本:{"board":"rw","projects":"ro"},
键是菜单标识(见 security.MENUS),值 rw=可编辑 / ro=只读;没有这个键就是看不到该菜单。
"""
__tablename__ = "roles"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
menus: Mapped[str | None] = mapped_column(Text, nullable=True)
is_builtin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
def menu_map(self) -> dict:
if not self.menus:
return {}
try:
data = json.loads(self.menus)
except ValueError:
return {}
return data if isinstance(data, dict) else {}
def to_dict(self):
return {
"id": self.id,
"name": self.name,
"description": self.description,
"menus": self.menu_map(),
"is_builtin": self.is_builtin,
"updated_at": self.updated_at.isoformat(timespec="seconds") if self.updated_at else None,
}
class User(Base, TimestampMixin):
"""登录用户。密码只存 pbkdf2 派生值 + 盐,不存明文。"""
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(64), nullable=False)
username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
password_hash: Mapped[str] = mapped_column(String(128), nullable=False)
password_salt: Mapped[str] = mapped_column(String(64), nullable=False)
role_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("roles.id", ondelete="SET NULL"), nullable=True)
active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
last_login_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
role: Mapped[Role | None] = relationship("Role", lazy="joined")
@property
def menus(self) -> dict:
return self.role.menu_map() if self.role else {}
def to_dict(self):
return {
"id": self.id,
"name": self.name,
"username": self.username,
"role_id": self.role_id,
"role_name": self.role.name if self.role else None,
"menus": self.menus,
"active": self.active,
"last_login_at": self.last_login_at.isoformat(timespec="seconds") if self.last_login_at else None,
"updated_at": self.updated_at.isoformat(timespec="seconds") if self.updated_at else None,
}
class UserSession(Base):
"""登录会话。退出登录 / 禁用用户时删掉对应 token 即可立即失效。"""
__tablename__ = "user_sessions"
token: Mapped[str] = mapped_column(String(64), primary_key=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=now, nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)