代码提交

This commit is contained in:
xwt
2026-09-21 19:03:31 +08:00
parent 34d656492c
commit 97986d9a1a
217 changed files with 12042 additions and 0 deletions
+59
View File
@@ -0,0 +1,59 @@
"""集中导出所有 ORM 模型。
Base.metadata 只有把模型都 import 进来才知道有哪些表,
所以 init_db / alembic 一律 `from app.model import *`。
"""
from app.model.account import Account
from app.model.advisor import Advisor
from app.model.assoc import class_teachers
from app.model.base import (
DEL_FLAG_DELETED,
DEL_FLAG_NORMAL,
Base,
SoftDeleteMixin,
TimestampMixin,
)
from app.model.clazz import Clazz
from app.model.constants import (
CLASS_STATUS_TEXT,
GENDER_TEXT,
ROLE_TEXT,
STUDENT_STATUS_TEXT,
ClassStatus,
Education,
Gender,
Role,
ScoreFlag,
StudentStatus,
)
from app.model.employment import Employment
from app.model.score import Score
from app.model.student import Student
from app.model.teacher import Teacher
__all__ = [
"Account",
"Advisor",
"Base",
"ClassStatus",
"Clazz",
"DEL_FLAG_DELETED",
"DEL_FLAG_NORMAL",
"Education",
"Employment",
"Gender",
"Role",
"Score",
"ScoreFlag",
"SoftDeleteMixin",
"Student",
"StudentStatus",
"Teacher",
"TimestampMixin",
"CLASS_STATUS_TEXT",
"GENDER_TEXT",
"ROLE_TEXT",
"STUDENT_STATUS_TEXT",
"class_teachers",
]
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+35
View File
@@ -0,0 +1,35 @@
"""登录账号表(需求第 5 节的"用户认证模块",这版直接做进来了)。"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.model.base import Base, SoftDeleteMixin, TimestampMixin
from app.model.constants import ROLE_TEXT, Role
class Account(Base, TimestampMixin, SoftDeleteMixin):
__tablename__ = "account"
__table_args__ = {"comment": "登录账号表"}
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, comment="登录名")
hashed_password: Mapped[str] = mapped_column(String(120), nullable=False, comment="bcrypt 哈希后的密码")
real_name: Mapped[str] = mapped_column(String(30), nullable=False, comment="姓名")
role: Mapped[str] = mapped_column(String(20), default=Role.ADVISOR.value, nullable=False, comment="角色 admin/advisor/viewer")
advisor_id: Mapped[int | None] = mapped_column(ForeignKey("advisor.id"), comment="关联顾问(顾问账号专用)")
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, comment="是否启用")
last_login_at: Mapped[datetime | None] = mapped_column(DateTime, comment="最近登录时间")
advisor: Mapped["Advisor"] = relationship(lazy="joined") # noqa: F821
@property
def role_text(self) -> str:
return ROLE_TEXT.get(self.role, self.role)
@property
def is_admin(self) -> bool:
return self.role == Role.ADMIN.value
+22
View File
@@ -0,0 +1,22 @@
"""顾问表(需求 2.1 学生表里的"顾问编号"指向它,也是第 5 节的扩展模块)。"""
from __future__ import annotations
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from app.model.base import Base, SoftDeleteMixin, TimestampMixin
class Advisor(Base, TimestampMixin, SoftDeleteMixin):
__tablename__ = "advisor"
__table_args__ = {"comment": "顾问表"}
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
advisor_no: Mapped[str] = mapped_column(String(20), unique=True, nullable=False, comment="顾问编号")
name: Mapped[str] = mapped_column(String(30), nullable=False, index=True, comment="顾问姓名")
gender: Mapped[int] = mapped_column(default=1, nullable=False, comment="性别 1=男 2=女")
phone: Mapped[str | None] = mapped_column(String(20), comment="联系电话")
email: Mapped[str | None] = mapped_column(String(60), comment="邮箱")
dept: Mapped[str | None] = mapped_column(String(50), comment="所属部门")
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
+19
View File
@@ -0,0 +1,19 @@
"""班级 <-> 老师 多对多中间表。
单独放一个文件是为了让 clazz.py / teacher.py 都能引用它,
又不会互相 import 形成环。
"""
from __future__ import annotations
from sqlalchemy import Column, ForeignKey, Table
from app.model.base import Base
class_teachers = Table(
"class_teachers",
Base.metadata,
Column("class_id", ForeignKey("clazz.id", ondelete="CASCADE"), primary_key=True, comment="班级ID"),
Column("teacher_id", ForeignKey("teacher.id", ondelete="CASCADE"), primary_key=True, comment="老师ID"),
comment="班级-授课老师 关联表(一个班多个授课老师,一个老师带多个班)",
)
+64
View File
@@ -0,0 +1,64 @@
"""ORM 基类与公共 Mixin。
约定:
* 主键统一 id 自增
* 所有表都带 created_at / updated_at
* 业务表一律逻辑删除:is_del = 0 表示正常,1 表示已删除
* 查询正常数据统一写 `Model.alive()`,禁止在 DAO 里手写 `is_del == 1`
(上一版项目就是把方向写反导致了"查不到刚建的数据")
"""
from __future__ import annotations
from datetime import datetime
from typing import Any
from sqlalchemy import DateTime, SmallInteger
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
DEL_FLAG_NORMAL: int = 0
DEL_FLAG_DELETED: int = 1
class Base(DeclarativeBase):
"""SQLAlchemy 2.0 DeclarativeBase。"""
def to_dict(self, exclude: set[str] | None = None) -> dict[str, Any]:
exclude = exclude or set()
return {
c.name: getattr(self, c.name)
for c in self.__table__.columns
if c.name not in exclude
}
def __repr__(self) -> str: # pragma: no cover - 调试用
pk = getattr(self, "id", None)
return f"<{self.__class__.__name__} id={pk}>"
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.now, nullable=False, comment="创建时间"
)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.now, onupdate=datetime.now, nullable=False, comment="更新时间"
)
class SoftDeleteMixin:
is_del: Mapped[int] = mapped_column(
SmallInteger,
default=DEL_FLAG_NORMAL,
server_default="0",
nullable=False,
index=True,
comment="逻辑删除:0=正常 1=已删除",
)
@classmethod
def alive(cls):
"""正常数据的过滤条件,供 select().where() 使用。"""
return cls.is_del == DEL_FLAG_NORMAL
def soft_delete(self) -> None:
self.is_del = DEL_FLAG_DELETED
+68
View File
@@ -0,0 +1,68 @@
"""班级表(需求 2.4)。"""
from __future__ import annotations
from datetime import date
from sqlalchemy import Date, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.model.assoc import class_teachers
from app.model.base import Base, SoftDeleteMixin, TimestampMixin
from app.model.constants import CLASS_STATUS_TEXT, ClassStatus
class Clazz(Base, TimestampMixin, SoftDeleteMixin):
__tablename__ = "clazz"
__table_args__ = {"comment": "班级表"}
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
class_no: Mapped[str] = mapped_column(String(30), unique=True, nullable=False, comment="班级编号,如 JAVA2401")
name: Mapped[str] = mapped_column(String(50), nullable=False, index=True, comment="班级名称")
direction: Mapped[str | None] = mapped_column(String(50), comment="班级方向,如 Java 就业班")
open_date: Mapped[date | None] = mapped_column(Date, comment="开课时间")
close_date: Mapped[date | None] = mapped_column(Date, comment="结课时间")
classroom: Mapped[str | None] = mapped_column(String(30), comment="教室")
capacity: Mapped[int | None] = mapped_column(default=40, comment="计划招生人数")
status: Mapped[int] = mapped_column(default=int(ClassStatus.RUNNING), nullable=False, comment="班级状态 1=在读 2=已结课 3=已解散")
head_teacher_id: Mapped[int | None] = mapped_column(ForeignKey("teacher.id"), comment="班主任(老师)")
advisor_id: Mapped[int | None] = mapped_column(ForeignKey("advisor.id"), comment="带班顾问")
description: Mapped[str | None] = mapped_column(Text, comment="班级说明")
# ---------------- 关系 ----------------
head_teacher: Mapped["Teacher"] = relationship(lazy="joined", foreign_keys=[head_teacher_id]) # noqa: F821
advisor: Mapped["Advisor"] = relationship(lazy="joined") # noqa: F821
teachers: Mapped[list["Teacher"]] = relationship( # noqa: F821
secondary=class_teachers,
back_populates="classes",
lazy="selectin",
)
# viewonly:人数统计走 DAO 的聚合查询,这个关系只在班级详情里用来展开学生
students: Mapped[list["Student"]] = relationship( # noqa: F821
back_populates="klass",
viewonly=True,
lazy="select",
)
# ---------------- 出参派生字段 ----------------
@property
def status_text(self) -> str:
return CLASS_STATUS_TEXT.get(self.status, "未知")
@property
def head_teacher_name(self) -> str | None:
return self.head_teacher.name if self.head_teacher else None
@property
def advisor_name(self) -> str | None:
return self.advisor.name if self.advisor else None
@property
def teacher_names(self) -> list[str]:
return [t.name for t in self.teachers if t.is_del == 0]
@property
def teacher_ids(self) -> list[int]:
return [t.id for t in self.teachers if t.is_del == 0]
+92
View File
@@ -0,0 +1,92 @@
"""全局枚举与文案映射。
数据库里一律存 int 代号(省得 MySQL ENUM 改起来要 ALTER),
对外出参把代号 + 中文文案一起给出去,前端不用再维护一份映射表。
"""
from __future__ import annotations
from enum import Enum, IntEnum
class Gender(IntEnum):
MALE = 1
FEMALE = 2
GENDER_TEXT: dict[int, str] = {Gender.MALE: "男", Gender.FEMALE: "女"}
class StudentStatus(IntEnum):
STUDYING = 1 # 在读
EMPLOYING = 2 # 进入就业(已开放就业,还没拿到 offer)
EMPLOYED = 3 # 已就业(已下发 offer)
STUDENT_STATUS_TEXT: dict[int, str] = {
StudentStatus.STUDYING: "在读",
StudentStatus.EMPLOYING: "进入就业",
StudentStatus.EMPLOYED: "已就业",
}
class ClassStatus(IntEnum):
RUNNING = 1 # 在读
GRADUATED = 2 # 已结课
DISBANDED = 3 # 已解散
CLASS_STATUS_TEXT: dict[int, str] = {
ClassStatus.RUNNING: "在读",
ClassStatus.GRADUATED: "已结课",
ClassStatus.DISBANDED: "已解散",
}
class ScoreFlag(IntEnum):
NORMAL = 0 # 正常
WARNING = 1 # 低于红线,需重点关注
class Education(str, Enum):
"""学历(自由文本也用这几个做下拉默认值)。"""
JUNIOR = "初中"
SENIOR = "高中"
TECHNICAL = "中专"
COLLEGE = "大专"
BACHELOR = "本科"
MASTER = "硕士"
class Role(str, Enum):
ADMIN = "admin" # 教务管理员:全部权限
ADVISOR = "advisor" # 顾问:日常录入
VIEWER = "viewer" # 只读
ROLE_TEXT: dict[str, str] = {
Role.ADMIN: "管理员",
Role.ADVISOR: "顾问",
Role.VIEWER: "只读访客",
}
def gender_to_code(value: int | str | None) -> int | None:
"""把 "男"/"女"/1/2 统一转成代号,供查询参数使用。"""
if value in (None, ""):
return None
if isinstance(value, int):
return value if value in (1, 2) else None
text = str(value).strip()
if text in ("男", "M", "m", "male", "Male"):
return int(Gender.MALE)
if text in ("女", "F", "f", "female", "Female"):
return int(Gender.FEMALE)
if text.isdigit():
return int(text)
return None
def gender_to_text(value: int | None) -> str | None:
return GENDER_TEXT.get(value) if value is not None else None
+90
View File
@@ -0,0 +1,90 @@
"""就业信息表(需求 2.3)。
关于「学生姓名、学生班级属于冗余字段」的处理
--------------------------------------------------------------------------
需求点名要自行设计表结构来平衡查询效率与一致性。这里的取舍:
* **学生姓名不落库。** 它是 student.name 的投影,1:1 关系,
JOIN 一次的成本可以忽略;而一旦落库,改名就会两处不一致,收益为零。查询接口
统一 `JOIN student` 取名字。
* **班级落库(class_id 而非 class_name)。** 因为"按班级筛就业、按班级算就业率/
平均就业时长"是高频统计场景,每次 JOIN student 再 JOIN clazz 成本更高;
而且存 id 不存名字,班级改名不会导致脏数据。
代价是学生转班时要同步——由 ``EmploymentService.sync_class_id()`` 在学生
接口里兜住,不给"记得改"留口子。
* **就业时长不落库**,= offer_date - open_date,用 SQL DATEDIFF 或 Python 现算。
状态联动(需求 2.3 可选扩展,这里是必做):
登记就业 -> 有 open_date 则学生 status=2(进入就业),
-> 有 offer_date 则学生 status=3(已就业)
删除就业记录 -> 学生状态回退(3 -> 2 -> 1),不允许跨级跳。
"""
from __future__ import annotations
from datetime import date
from decimal import Decimal
from sqlalchemy import Date, ForeignKey, Numeric, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.model.base import Base, SoftDeleteMixin, TimestampMixin
class Employment(Base, TimestampMixin, SoftDeleteMixin):
__tablename__ = "employment"
__table_args__ = {"comment": "学生就业信息表"}
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
stu_id: Mapped[int] = mapped_column(
ForeignKey("student.id"), unique=True, nullable=False, index=True,
comment="学生ID(一学生一条就业记录)",
)
class_id: Mapped[int | None] = mapped_column(
ForeignKey("clazz.id"), index=True,
comment="班级ID(冗余,用于按班统计,转班时由 service 同步)",
)
open_date: Mapped[date | None] = mapped_column(Date, comment="就业开放时间")
offer_date: Mapped[date | None] = mapped_column(Date, comment="offer 下发时间")
company: Mapped[str | None] = mapped_column(String(80), index=True, comment="就业公司名称")
salary: Mapped[Decimal | None] = mapped_column(Numeric(10, 2), index=True, comment="就业薪资(元/月)")
position: Mapped[str | None] = mapped_column(String(50), comment="岗位名称")
city: Mapped[str | None] = mapped_column(String(30), comment="就业城市")
remark: Mapped[str | None] = mapped_column(Text, comment="备注")
student: Mapped["Student"] = relationship(back_populates="employment", lazy="joined") # noqa: F821
klass: Mapped["Clazz"] = relationship(lazy="joined", foreign_keys=[class_id]) # noqa: F821
# ---------------- 出参派生字段 ----------------
@property
def student_name(self) -> str | None:
return self.student.name if self.student else None
@property
def stu_no(self) -> str | None:
return self.student.stu_no if self.student else None
@property
def student_status(self) -> int | None:
return self.student.status if self.student else None
@property
def student_status_text(self) -> str | None:
return self.student.status_text if self.student else None
@property
def class_name(self) -> str | None:
# 冗余字段只存了 id,名字实时取,保证与班级表一致
return self.klass.name if self.klass and self.klass.is_del == 0 else None
@property
def duration_days(self) -> int | None:
"""就业时长 = offer 下发时间 - 就业开放时间(天)。"""
if self.open_date and self.offer_date:
return (self.offer_date - self.open_date).days
return None
@property
def salary_wan(self) -> float | None:
return round(float(self.salary) / 10000, 2) if self.salary is not None else None
+51
View File
@@ -0,0 +1,51 @@
"""成绩表(需求 2.2):一个学生 N 次考核,一对多。
红线预警:录入分数低于及格线时 flag=1,并往学生备注里追加一条提醒,
统计模块的"多次不及格"直接按 flag 聚合,不用每次临时算阈值。
"""
from __future__ import annotations
from datetime import date
from decimal import Decimal
from sqlalchemy import Date, ForeignKey, Numeric, SmallInteger, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.model.base import Base, SoftDeleteMixin, TimestampMixin
from app.model.constants import ScoreFlag
class Score(Base, TimestampMixin, SoftDeleteMixin):
__tablename__ = "score"
__table_args__ = (
UniqueConstraint("stu_id", "exam_seq", name="uk_score_stu_seq"),
{"comment": "学生考核成绩表"},
)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
stu_id: Mapped[int] = mapped_column(ForeignKey("student.id"), nullable=False, index=True, comment="学生ID")
exam_seq: Mapped[int] = mapped_column(SmallInteger, nullable=False, index=True, comment="考核序次(第几次考核)")
exam_name: Mapped[str | None] = mapped_column(String(50), comment="考核名称,如 阶段一考试")
exam_date: Mapped[date | None] = mapped_column(Date, comment="考核日期")
score: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, comment="成绩")
flag: Mapped[int] = mapped_column(
SmallInteger, default=int(ScoreFlag.NORMAL), nullable=False, index=True,
comment="预警标记 0=正常 1=低于红线需重点关注",
)
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
student: Mapped["Student"] = relationship(back_populates="scores", lazy="joined") # noqa: F821
# ---------------- 出参派生字段 ----------------
@property
def is_warning(self) -> bool:
return self.flag == int(ScoreFlag.WARNING)
@property
def student_name(self) -> str | None:
return self.student.name if self.student else None
@property
def class_name(self) -> str | None:
return self.student.class_name if self.student else None
+113
View File
@@ -0,0 +1,113 @@
"""学生表(需求 2.1),系统的主表。
关于「年龄」的一个设计取舍
--------------------------------------------------------------------------
需求里把年龄列成了学生字段。这里**不落 age 字段**,只存 birth_date:
* 年龄 = 按今天算出来的派生值。今天是 24 岁,明年今天还是 24 就错了,
必须靠定时任务去刷,多一个必定会脏的字段。
* 需要"按年龄筛选"的场景(需求 2.6.1)用 SQL 表达式算:
``app.core.utils.age_expression()`` 把 birth_date 折成年数参与 WHERE。
* 出参同时给 birth_date 和算好的 age,前端拿来直接用。
唯一例外:录入时对方只知道年龄不知道生日,入参允许只传 age,
service 会用 ``guess_birth_date()`` 折算一个占位生日并标记 ``birth_date_estimated=True``。
"""
from __future__ import annotations
from datetime import date
from sqlalchemy import Date, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.model.base import Base, SoftDeleteMixin, TimestampMixin
from app.model.constants import GENDER_TEXT, STUDENT_STATUS_TEXT, StudentStatus
from app.core.utils import calc_age
class Student(Base, TimestampMixin, SoftDeleteMixin):
__tablename__ = "student"
__table_args__ = {"comment": "学生基本信息表"}
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
stu_no: Mapped[str] = mapped_column(String(30), unique=True, nullable=False, index=True, comment="学号(按规则生成,非自增)")
name: Mapped[str] = mapped_column(String(30), nullable=False, index=True, comment="姓名")
gender: Mapped[int] = mapped_column(default=1, nullable=False, index=True, comment="性别 1=男 2=女")
birth_date: Mapped[date | None] = mapped_column(Date, comment="出生日期(年龄由此实时计算,不落库)")
birth_date_estimated: Mapped[int] = mapped_column(default=0, nullable=False, comment="生日是否为按年龄推算的占位值 0=否 1=是")
native_place: Mapped[str | None] = mapped_column(String(60), comment="籍贯")
graduate_school: Mapped[str | None] = mapped_column(String(80), comment="毕业院校")
major: Mapped[str | None] = mapped_column(String(60), comment="专业")
education: Mapped[str | None] = mapped_column(String(20), comment="学历")
enroll_date: Mapped[date | None] = mapped_column(Date, comment="入学时间")
graduate_date: Mapped[date | None] = mapped_column(Date, comment="毕业时间")
phone: Mapped[str | None] = mapped_column(String(20), comment="联系电话")
id_card: Mapped[str | None] = mapped_column(String(30), comment="身份证号")
class_id: Mapped[int | None] = mapped_column(ForeignKey("clazz.id"), index=True, comment="所属班级")
advisor_id: Mapped[int | None] = mapped_column(ForeignKey("advisor.id"), index=True, comment="顾问编号")
status: Mapped[int] = mapped_column(
default=int(StudentStatus.STUDYING), nullable=False, index=True,
comment="状态 1=在读 2=进入就业 3=已就业",
)
remark: Mapped[str | None] = mapped_column(Text, comment="备注(成绩预警会自动追加)")
# ---------------- 关系 ----------------
klass: Mapped["Clazz"] = relationship( # noqa: F821
back_populates="students", lazy="joined", foreign_keys=[class_id]
)
advisor: Mapped["Advisor"] = relationship(lazy="joined") # noqa: F821
scores: Mapped[list["Score"]] = relationship( # noqa: F821
back_populates="student",
lazy="selectin",
order_by="Score.exam_seq",
cascade="all, delete-orphan",
)
employment: Mapped["Employment"] = relationship( # noqa: F821
back_populates="student",
uselist=False,
lazy="joined",
)
# ---------------- 出参派生字段 ----------------
@property
def age(self) -> int | None:
return calc_age(self.birth_date)
@property
def gender_text(self) -> str | None:
return GENDER_TEXT.get(self.gender)
@property
def status_text(self) -> str:
return STUDENT_STATUS_TEXT.get(self.status, "未知")
@property
def class_name(self) -> str | None:
return self.klass.name if self.klass and self.klass.is_del == 0 else None
@property
def class_no(self) -> str | None:
return self.klass.class_no if self.klass and self.klass.is_del == 0 else None
@property
def advisor_name(self) -> str | None:
return self.advisor.name if self.advisor and self.advisor.is_del == 0 else None
@property
def score_count(self) -> int:
return len([s for s in self.scores if s.is_del == 0])
@property
def avg_score(self) -> float | None:
valid = [float(s.score) for s in self.scores if s.is_del == 0 and s.score is not None]
return round(sum(valid) / len(valid), 2) if valid else None
@property
def fail_count(self) -> int:
return len([s for s in self.scores if s.is_del == 0 and s.flag == 1])
+48
View File
@@ -0,0 +1,48 @@
"""老师表(需求 2.5)。"""
from __future__ import annotations
from datetime import date
from sqlalchemy import Date, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.model.assoc import class_teachers
from app.model.base import Base, SoftDeleteMixin, TimestampMixin
from app.model.constants import GENDER_TEXT
class Teacher(Base, TimestampMixin, SoftDeleteMixin):
__tablename__ = "teacher"
__table_args__ = {"comment": "授课老师表"}
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
teacher_no: Mapped[str] = mapped_column(String(20), unique=True, nullable=False, comment="工号")
name: Mapped[str] = mapped_column(String(30), nullable=False, index=True, comment="姓名")
gender: Mapped[int] = mapped_column(default=1, nullable=False, comment="性别 1=男 2=女")
phone: Mapped[str | None] = mapped_column(String(20), comment="手机号")
email: Mapped[str | None] = mapped_column(String(60), comment="邮箱")
title: Mapped[str | None] = mapped_column(String(30), comment="职称,如 讲师/高级讲师")
subject: Mapped[str | None] = mapped_column(String(50), comment="授课方向,如 Java/前端/大数据")
hire_date: Mapped[date | None] = mapped_column(Date, comment="入职时间")
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
# 带班信息:多对多
classes: Mapped[list["Clazz"]] = relationship( # noqa: F821
secondary=class_teachers,
back_populates="teachers",
lazy="selectin",
)
# ---------------- 出参派生字段 ----------------
@property
def gender_text(self) -> str | None:
return GENDER_TEXT.get(self.gender)
@property
def class_names(self) -> list[str]:
return [c.name for c in self.classes if c.is_del == 0]
@property
def class_count(self) -> int:
return len(self.class_names)