feat(分组权限): 分组管理接口 + 管理页 + 端到端验收
后端 endpoints/groups.py(**仅 SUPER_ADMIN**): · 组的 CRUD(两级,子组不配范围则继承父组 —— 生产大组配一次,下面的 生产/测试小组都不用再配) · 成员增删 / 设组长 / 候选人下拉(复用 MOM 查询口径,部门已钉死为 ORG_DEPARTMENT) · **删组仅限空组**:级联删是一次静默的批量权限变更,误点一下一批人就突然 看不到数据了;强制「先移人再删组」多一步,但出错时是可见的 · 停用组的语义写死在接口文档:成员**立即**退回未分组状态 为什么只有超管能管分组(不是偏好,是必须): 被显式分进组的 SUPERVISOR 会从「全厂」降级为只看本组;若允许主管管理分组, 他把自己移出组就能恢复全厂视野 —— 这是一条现成的提权路径,分组对他无效。 前端: · AdminGroupsPage:组列表 + 可见范围勾选 + 成员管理 + 组长标记 + 二次确认 · AdminLayout 加菜单项,页头显示数据范围徽标 —— 空范围(未分组)用橙色显眼 提示,否则用户看到空列表会以为系统坏了,这是最难排查的一类反馈 · AuthContext 登录后补拉一次 /auth/me 拿 scope(登录接口不查库、不返回它) · constants/task.ts 新增 isSuperAdmin,不手写 === 比较 端到端验收(实测):先造 1 生产 + 1 售后产品,然后 生产组员 → 1 条,全 PRODUCTION;范围经「生产小组 → 生产大组」继承而来 维修组员 → 1 条,全 AFTER_SALES 超管 → 2 条,全量 任务列表 total 与 returned 一致(验证 count/select 双过滤) 扫码跨组仍 200(符合「能看、不能操作」的既定决策) 停用维修大组 → 成员立即退回未分组 上述测试数据已还原
This commit is contained in:
468
backend/app/api/v1/endpoints/groups.py
Normal file
468
backend/app/api/v1/endpoints/groups.py
Normal file
@ -0,0 +1,468 @@
|
||||
"""业务分组管理 API —— **仅超级管理员**可访问
|
||||
|
||||
⚠️ 为什么只有 SUPER_ADMIN 能管分组,SUPERVISOR 不行:
|
||||
|
||||
按数据范围规则,被显式分进组的 SUPERVISOR 会从「全厂」**降级**为只看本组。
|
||||
如果允许 SUPERVISOR 管理分组,那么他被分组之后,只要把自己从组里移出去
|
||||
就能恢复全厂视野 —— 这是一条现成的提权路径,分组对他完全无效。
|
||||
|
||||
所以这里用 require_roles(SUPER_ADMIN),**不能**用 require_admin
|
||||
(后者含 SUPERVISOR)。
|
||||
|
||||
写操作会被 audit_middleware 自动采集 —— 分组变更是高权限动作,追责必须有据。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import require_roles
|
||||
from app.core.lifecycle import PHASE_LABELS
|
||||
from app.core.mom_database import MomSessionLocal
|
||||
from app.core.roles import SUPER_ADMIN
|
||||
from app.models.business_group import (
|
||||
BusinessGroup,
|
||||
BusinessGroupMember,
|
||||
BusinessGroupPhase,
|
||||
)
|
||||
from app.schemas.group import (
|
||||
GroupCreate,
|
||||
GroupDetailOut,
|
||||
GroupMemberAdd,
|
||||
GroupMemberOut,
|
||||
GroupMemberUpdate,
|
||||
GroupOut,
|
||||
GroupUpdate,
|
||||
MemberCandidate,
|
||||
PhaseOption,
|
||||
)
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/groups",
|
||||
tags=["业务分组"],
|
||||
dependencies=[Depends(require_roles(SUPER_ADMIN))],
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 内部工具
|
||||
# ============================================================
|
||||
|
||||
def _valid_phases() -> dict[str, str]:
|
||||
"""合法的 phase 取值 → 中文标签(单一事实来源是 core/lifecycle.py)"""
|
||||
return dict(PHASE_LABELS)
|
||||
|
||||
|
||||
async def _load_phases(db: AsyncSession, group_ids: list[int]) -> dict[int, list[str]]:
|
||||
"""批量取这些组**自己配置**的可见范围(不含继承)"""
|
||||
if not group_ids:
|
||||
return {}
|
||||
rows = await db.execute(
|
||||
select(BusinessGroupPhase.group_id, BusinessGroupPhase.phase)
|
||||
.where(BusinessGroupPhase.group_id.in_(group_ids))
|
||||
)
|
||||
out: dict[int, list[str]] = {}
|
||||
for gid, ph in rows.all():
|
||||
out.setdefault(gid, []).append(ph)
|
||||
return out
|
||||
|
||||
|
||||
async def _effective_phases(
|
||||
db: AsyncSession, group: BusinessGroup, own: dict[int, list[str]],
|
||||
) -> list[str]:
|
||||
"""实际生效的可见范围:自己配了就用,没配则向上取父组的。
|
||||
|
||||
继承让「生产大组配一次 PRODUCTION,下面的生产/测试小组都不用再配」成立。
|
||||
"""
|
||||
mine = own.get(group.id)
|
||||
if mine:
|
||||
return sorted(mine)
|
||||
if group.parent_id:
|
||||
return sorted(own.get(group.parent_id, []))
|
||||
return []
|
||||
|
||||
|
||||
async def _member_counts(db: AsyncSession) -> dict[int, int]:
|
||||
rows = await db.execute(
|
||||
select(BusinessGroupMember.group_id, func.count())
|
||||
.group_by(BusinessGroupMember.group_id)
|
||||
)
|
||||
return {gid: cnt for gid, cnt in rows.all()}
|
||||
|
||||
|
||||
def _to_out(group: BusinessGroup, phases: list[str], count: int,
|
||||
parent_name: str | None, own_phases: list[str]) -> GroupOut:
|
||||
return GroupOut(
|
||||
id=group.id,
|
||||
name=group.name,
|
||||
parent_id=group.parent_id,
|
||||
parent_name=parent_name,
|
||||
description=group.description,
|
||||
sort_order=group.sort_order,
|
||||
is_active=group.is_active,
|
||||
phases=sorted(own_phases),
|
||||
effective_phases=phases,
|
||||
phase_labels=[PHASE_LABELS.get(p, p) for p in phases],
|
||||
member_count=count,
|
||||
)
|
||||
|
||||
|
||||
async def _get_group_or_404(db: AsyncSession, group_id: int) -> BusinessGroup:
|
||||
group = await db.get(BusinessGroup, group_id)
|
||||
if not group:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, f"分组 {group_id} 不存在")
|
||||
return group
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 元数据:可选阶段
|
||||
# 放在 /{group_id} 之前注册,否则 "phases" 会被当成 group_id 解析
|
||||
# ============================================================
|
||||
|
||||
@router.get("/phase-options", response_model=list[PhaseOption])
|
||||
async def list_phase_options():
|
||||
"""可选的生命周期阶段 —— 供前端渲染勾选框,避免前端写死这两个值"""
|
||||
return [PhaseOption(value=v, label=l) for v, l in _valid_phases().items()]
|
||||
|
||||
|
||||
@router.get("/member-candidates", response_model=list[MemberCandidate])
|
||||
def list_member_candidates(
|
||||
keyword: str = Query("", description="按姓名/账号模糊搜索"),
|
||||
limit: int = Query(500, ge=1, le=1000),
|
||||
):
|
||||
"""候选人下拉 —— 复用与 users.py 一致的 MOM 查询口径(部门已钉死为 ORG_DEPARTMENT)。
|
||||
|
||||
注意这里**不复用 users.py 的端点函数**:那个函数与 FastAPI 的 Query 默认值
|
||||
耦合,直接调用拿到的是 Query 对象而非值。所以照抄同一条 SQL 的写法,
|
||||
但部门条件取自同一处 settings.ORG_DEPARTMENT,口径不会漂移。
|
||||
"""
|
||||
db = MomSessionLocal()
|
||||
try:
|
||||
base_sql = """
|
||||
SELECT username,
|
||||
SPLIT_PART(username, '/', 1) AS full_name
|
||||
FROM sys_user
|
||||
WHERE department = :dept
|
||||
"""
|
||||
params = {"dept": settings.ORG_DEPARTMENT, "lim": limit}
|
||||
if keyword.strip():
|
||||
sql_text = base_sql + " AND username ILIKE :kw ORDER BY username LIMIT :lim"
|
||||
params["kw"] = f"%{keyword.strip()}%"
|
||||
else:
|
||||
sql_text = base_sql + " ORDER BY username LIMIT :lim"
|
||||
|
||||
from sqlalchemy import text
|
||||
rows = db.execute(text(sql_text), params).fetchall()
|
||||
return [
|
||||
MemberCandidate(
|
||||
username=row.username.split("/")[-1] if "/" in row.username else row.username,
|
||||
full_name=row.full_name or row.username,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status.HTTP_502_BAD_GATEWAY, f"MOM 用户查询失败: {str(e)}"
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 组的 CRUD
|
||||
# ============================================================
|
||||
|
||||
@router.get("", response_model=list[GroupOut])
|
||||
async def list_groups(db: AsyncSession = Depends(get_db)):
|
||||
"""列出全部业务分组(含停用的),带成员数与生效范围"""
|
||||
groups = (await db.execute(
|
||||
select(BusinessGroup).order_by(BusinessGroup.sort_order, BusinessGroup.id)
|
||||
)).scalars().all()
|
||||
|
||||
own = await _load_phases(db, [g.id for g in groups])
|
||||
counts = await _member_counts(db)
|
||||
names = {g.id: g.name for g in groups}
|
||||
|
||||
return [
|
||||
_to_out(
|
||||
g,
|
||||
await _effective_phases(db, g, own),
|
||||
counts.get(g.id, 0),
|
||||
names.get(g.parent_id) if g.parent_id else None,
|
||||
own.get(g.id, []),
|
||||
)
|
||||
for g in groups
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{group_id}", response_model=GroupDetailOut)
|
||||
async def get_group(group_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""分组详情 + 成员列表"""
|
||||
group = await _get_group_or_404(db, group_id)
|
||||
|
||||
own = await _load_phases(db, [group.id] + ([group.parent_id] if group.parent_id else []))
|
||||
members = (await db.execute(
|
||||
select(BusinessGroupMember)
|
||||
.where(BusinessGroupMember.group_id == group.id)
|
||||
.order_by(BusinessGroupMember.is_leader.desc(), BusinessGroupMember.user_id)
|
||||
)).scalars().all()
|
||||
|
||||
# 成员姓名走 MOM(带 2h TTL 缓存,见 mom_cache)
|
||||
from app.services.mom_cache import get_display_names
|
||||
name_map = get_display_names([m.user_id for m in members]) if members else {}
|
||||
|
||||
parent_name = None
|
||||
if group.parent_id:
|
||||
parent = await db.get(BusinessGroup, group.parent_id)
|
||||
parent_name = parent.name if parent else None
|
||||
|
||||
base = _to_out(
|
||||
group,
|
||||
await _effective_phases(db, group, own),
|
||||
len(members),
|
||||
parent_name,
|
||||
own.get(group.id, []),
|
||||
)
|
||||
return GroupDetailOut(
|
||||
**base.model_dump(),
|
||||
members=[
|
||||
GroupMemberOut(
|
||||
user_id=m.user_id,
|
||||
display_name=name_map.get(m.user_id) or m.user_id,
|
||||
is_leader=m.is_leader,
|
||||
)
|
||||
for m in members
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=GroupOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_group(payload: GroupCreate, db: AsyncSession = Depends(get_db)):
|
||||
"""新建分组。parent_id 为空即建大组,否则是挂在某个大组下的小组。"""
|
||||
valid = _valid_phases()
|
||||
bad = [p for p in payload.phases if p not in valid]
|
||||
if bad:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"非法的阶段取值: {bad}")
|
||||
|
||||
dup = await db.scalar(select(BusinessGroup.id).where(BusinessGroup.name == payload.name))
|
||||
if dup:
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, f"分组名「{payload.name}」已存在")
|
||||
|
||||
if payload.parent_id is not None:
|
||||
parent = await db.get(BusinessGroup, payload.parent_id)
|
||||
if not parent:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "上级分组不存在")
|
||||
if parent.parent_id is not None:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "只支持两级:不能挂在子组下")
|
||||
|
||||
group = BusinessGroup(
|
||||
name=payload.name,
|
||||
parent_id=payload.parent_id,
|
||||
description=payload.description,
|
||||
sort_order=payload.sort_order,
|
||||
)
|
||||
db.add(group)
|
||||
await db.flush()
|
||||
|
||||
for p in payload.phases:
|
||||
db.add(BusinessGroupPhase(group_id=group.id, phase=p))
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(group)
|
||||
|
||||
# 生效范围要算上继承 —— 否则新建子组时返回的 effective_phases 是空的,
|
||||
# 与紧接着的列表查询结果对不上(前端直接拿返回值渲染会闪一下「无范围」)
|
||||
own = await _load_phases(db, [group.id] + ([group.parent_id] if group.parent_id else []))
|
||||
parent_name = None
|
||||
if group.parent_id:
|
||||
parent = await db.get(BusinessGroup, group.parent_id)
|
||||
parent_name = parent.name if parent else None
|
||||
return _to_out(
|
||||
group,
|
||||
await _effective_phases(db, group, own),
|
||||
0,
|
||||
parent_name,
|
||||
own.get(group.id, []),
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{group_id}", response_model=GroupOut)
|
||||
async def update_group(
|
||||
group_id: int, payload: GroupUpdate, db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""修改分组。
|
||||
|
||||
⚠️ `is_active=false` 的语义是「该组所有成员**立即**退回未分组状态」——
|
||||
这是一次批量权限变更,前端必须二次确认后再调。
|
||||
"""
|
||||
group = await _get_group_or_404(db, group_id)
|
||||
|
||||
if payload.name is not None and payload.name != group.name:
|
||||
dup = await db.scalar(
|
||||
select(BusinessGroup.id).where(
|
||||
BusinessGroup.name == payload.name, BusinessGroup.id != group_id,
|
||||
)
|
||||
)
|
||||
if dup:
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, f"分组名「{payload.name}」已存在")
|
||||
group.name = payload.name
|
||||
|
||||
if payload.parent_id is not None:
|
||||
if payload.parent_id == group_id:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "不能把自己设为自己的上级")
|
||||
parent = await db.get(BusinessGroup, payload.parent_id)
|
||||
if not parent:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "上级分组不存在")
|
||||
if parent.parent_id is not None:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "只支持两级:不能挂在子组下")
|
||||
group.parent_id = payload.parent_id
|
||||
|
||||
if payload.description is not None:
|
||||
group.description = payload.description
|
||||
if payload.sort_order is not None:
|
||||
group.sort_order = payload.sort_order
|
||||
if payload.is_active is not None:
|
||||
group.is_active = payload.is_active
|
||||
|
||||
# 可见范围:整组覆盖式更新
|
||||
if payload.phases is not None:
|
||||
valid = _valid_phases()
|
||||
bad = [p for p in payload.phases if p not in valid]
|
||||
if bad:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"非法的阶段取值: {bad}")
|
||||
await db.execute(
|
||||
delete(BusinessGroupPhase).where(BusinessGroupPhase.group_id == group_id)
|
||||
)
|
||||
for p in payload.phases:
|
||||
db.add(BusinessGroupPhase(group_id=group_id, phase=p))
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(group)
|
||||
|
||||
own = await _load_phases(db, [group.id] + ([group.parent_id] if group.parent_id else []))
|
||||
counts = await _member_counts(db)
|
||||
parent_name = None
|
||||
if group.parent_id:
|
||||
parent = await db.get(BusinessGroup, group.parent_id)
|
||||
parent_name = parent.name if parent else None
|
||||
return _to_out(
|
||||
group,
|
||||
await _effective_phases(db, group, own),
|
||||
counts.get(group.id, 0),
|
||||
parent_name,
|
||||
own.get(group.id, []),
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_group(group_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""删除分组。
|
||||
|
||||
⚠️ **仅允许删空组**。有成员时返回 409,要求先移除成员或改为停用。
|
||||
级联删除是一次**静默的批量权限变更** —— 误点一下,一批人就突然看不到
|
||||
数据了。强制多走一步,出错时是可见的。
|
||||
"""
|
||||
group = await _get_group_or_404(db, group_id)
|
||||
|
||||
member_count = await db.scalar(
|
||||
select(func.count()).select_from(BusinessGroupMember)
|
||||
.where(BusinessGroupMember.group_id == group_id)
|
||||
) or 0
|
||||
if member_count:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
f"该分组下还有 {member_count} 名成员。请先移除成员,或改为「停用」而不是删除。",
|
||||
)
|
||||
|
||||
children = await db.scalar(
|
||||
select(func.count()).select_from(BusinessGroup)
|
||||
.where(BusinessGroup.parent_id == group_id)
|
||||
) or 0
|
||||
if children:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
f"该分组下还有 {children} 个子组,请先处理子组。",
|
||||
)
|
||||
|
||||
await db.delete(group)
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 成员管理
|
||||
# ============================================================
|
||||
|
||||
@router.post("/{group_id}/members", response_model=GroupMemberOut,
|
||||
status_code=status.HTTP_201_CREATED)
|
||||
async def add_member(
|
||||
group_id: int, payload: GroupMemberAdd, db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""把一个人加进分组。一个人可以在多个组 —— 多组 = 多看一个范围。"""
|
||||
await _get_group_or_404(db, group_id)
|
||||
|
||||
exists = await db.scalar(
|
||||
select(BusinessGroupMember.id).where(
|
||||
BusinessGroupMember.group_id == group_id,
|
||||
BusinessGroupMember.user_id == payload.user_id,
|
||||
)
|
||||
)
|
||||
if exists:
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, "该成员已在此分组中")
|
||||
|
||||
member = BusinessGroupMember(
|
||||
group_id=group_id, user_id=payload.user_id, is_leader=payload.is_leader,
|
||||
)
|
||||
db.add(member)
|
||||
await db.commit()
|
||||
|
||||
from app.services.mom_cache import get_display_names
|
||||
name_map = get_display_names([payload.user_id])
|
||||
return GroupMemberOut(
|
||||
user_id=payload.user_id,
|
||||
display_name=name_map.get(payload.user_id) or payload.user_id,
|
||||
is_leader=payload.is_leader,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{group_id}/members/{user_id}", response_model=GroupMemberOut)
|
||||
async def update_member(
|
||||
group_id: int, user_id: str, payload: GroupMemberUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""设置/取消组长。组长数据范围与组员相同,额外能管理本组成员。"""
|
||||
member = (await db.execute(
|
||||
select(BusinessGroupMember).where(
|
||||
BusinessGroupMember.group_id == group_id,
|
||||
BusinessGroupMember.user_id == user_id,
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
if not member:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "该成员不在此分组中")
|
||||
|
||||
member.is_leader = payload.is_leader
|
||||
await db.commit()
|
||||
|
||||
from app.services.mom_cache import get_display_names
|
||||
name_map = get_display_names([user_id])
|
||||
return GroupMemberOut(
|
||||
user_id=user_id,
|
||||
display_name=name_map.get(user_id) or user_id,
|
||||
is_leader=payload.is_leader,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{group_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def remove_member(group_id: int, user_id: str, db: AsyncSession = Depends(get_db)):
|
||||
"""把成员移出分组。若此人不再属于任何组,将退回「未分组」状态。"""
|
||||
result = await db.execute(
|
||||
delete(BusinessGroupMember).where(
|
||||
BusinessGroupMember.group_id == group_id,
|
||||
BusinessGroupMember.user_id == user_id,
|
||||
)
|
||||
)
|
||||
if result.rowcount == 0:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "该成员不在此分组中")
|
||||
await db.commit()
|
||||
@ -18,6 +18,7 @@ from app.api.v1.endpoints.webhooks import router as webhooks_router
|
||||
from app.api.v1.endpoints.external_products import router as external_products_router
|
||||
from app.api.v1.endpoints.screen import router as screen_router
|
||||
from app.api.v1.endpoints.audit import router as audit_router
|
||||
from app.api.v1.endpoints.groups import router as groups_router
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
@ -39,3 +40,4 @@ api_router.include_router(webhooks_router)
|
||||
api_router.include_router(external_products_router)
|
||||
api_router.include_router(screen_router)
|
||||
api_router.include_router(audit_router)
|
||||
api_router.include_router(groups_router)
|
||||
|
||||
65
backend/app/schemas/group.py
Normal file
65
backend/app/schemas/group.py
Normal file
@ -0,0 +1,65 @@
|
||||
"""业务分组 Schemas"""
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PhaseOption(BaseModel):
|
||||
"""可选的生命周期阶段 —— 由服务端下发,前端不要写死这两个值"""
|
||||
value: str
|
||||
label: str
|
||||
|
||||
|
||||
class GroupMemberOut(BaseModel):
|
||||
user_id: str # MOM 短账号(与 Task.assignee_id 同口径)
|
||||
display_name: str
|
||||
is_leader: bool
|
||||
|
||||
|
||||
class GroupOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
parent_id: int | None = None
|
||||
parent_name: str | None = None
|
||||
description: str | None = None
|
||||
sort_order: int = 0
|
||||
is_active: bool = True
|
||||
phases: list[str] = [] # 该组自己的可见范围(不含继承)
|
||||
effective_phases: list[str] = [] # 实际生效范围(自己没配则取父组的)
|
||||
phase_labels: list[str] = [] # 生效范围的中文标签
|
||||
member_count: int = 0
|
||||
|
||||
|
||||
class GroupDetailOut(GroupOut):
|
||||
members: list[GroupMemberOut] = []
|
||||
|
||||
|
||||
class GroupCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=50, description="分组显示名")
|
||||
parent_id: int | None = Field(None, description="上级大组ID;为空则建的是大组")
|
||||
description: str | None = Field(None, max_length=200)
|
||||
sort_order: int = 0
|
||||
phases: list[str] = Field(default_factory=list, description="可见的生命周期阶段")
|
||||
|
||||
|
||||
class GroupUpdate(BaseModel):
|
||||
name: str | None = Field(None, min_length=1, max_length=50)
|
||||
parent_id: int | None = None
|
||||
description: str | None = Field(None, max_length=200)
|
||||
sort_order: int | None = None
|
||||
is_active: bool | None = None
|
||||
phases: list[str] | None = Field(
|
||||
None, description="整组覆盖式更新;传 [] 表示清空(此时继承父组)",
|
||||
)
|
||||
|
||||
|
||||
class GroupMemberAdd(BaseModel):
|
||||
user_id: str = Field(..., min_length=1, max_length=64, description="MOM 账号")
|
||||
is_leader: bool = False
|
||||
|
||||
|
||||
class GroupMemberUpdate(BaseModel):
|
||||
is_leader: bool
|
||||
|
||||
|
||||
class MemberCandidate(BaseModel):
|
||||
username: str # 短账号,存进组的就是它
|
||||
full_name: str # 真实姓名,仅供界面展示
|
||||
Reference in New Issue
Block a user