"""GET /api/departments, /api/staff, /api/accounts — organization browse endpoints. Read-only queries over the mirrored ``department``, ``staff``, ``wechat_account`` and ``whatsapp_account`` tables. * ``GET /api/departments`` -> ``list[DepartmentOut]`` ordered by ``path`` so the frontend can assemble the tree with a stable traversal order. * ``GET /api/staff`` -> ``Page[StaffOut]`` with optional ``department_id`` and ``is_on_job`` filters. * ``GET /api/accounts`` -> ``Page[AccountOut]`` projecting both staff-messaging account tables onto a unified shape (see :class:`AccountOut`). The optional ``channel`` filter restricts to one of ``wechat`` / ``whatsapp``; when omitted, both tables are unioned in memory. Staff-account volumes are modest (tens to low hundreds of rows), so the in-memory merge is cheap and keeps the API contract simple. """ from __future__ import annotations from typing import Annotated import sqlalchemy as sa from fastapi import APIRouter, Depends, Query from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_session from app.api.schemas import AccountOut, DepartmentOut, Page, StaffOut from app.db.models import Department, Staff, WechatAccount, WhatsappAccount router = APIRouter(prefix="/api", tags=["organization"]) SessionDep = Annotated[AsyncSession, Depends(get_session)] PageParam = Annotated[int, Query(ge=1)] PageSizeParam = Annotated[int, Query(ge=1, le=100)] @router.get("/departments") async def list_departments(session: SessionDep) -> list[DepartmentOut]: """Return every department ordered by ``path`` (nulls last). A plain list — not paginated — because the frontend rebuilds the tree from ``parent_id``/``path`` and pagination would split the topology awkwardly. """ stmt = sa.select(Department).order_by( sa.nullslast(Department.path.asc()), Department.dept_id.asc() ) rows = (await session.execute(stmt)).scalars().all() return [DepartmentOut.model_validate(row) for row in rows] @router.get("/staff") async def list_staff( session: SessionDep, page: PageParam = 1, page_size: PageSizeParam = 20, department_id: Annotated[str | None, Query()] = None, is_on_job: Annotated[bool | None, Query()] = None, ) -> Page[StaffOut]: """List staff with optional ``department_id`` / ``is_on_job`` filters.""" conditions = [] if department_id is not None: conditions.append(Staff.dept_id == department_id) if is_on_job is not None: conditions.append(Staff.is_on_job.is_(is_on_job)) total_stmt = sa.select(sa.func.count()).select_from(Staff) if conditions: total_stmt = total_stmt.where(*conditions) total = (await session.execute(total_stmt)).scalar_one() list_stmt = sa.select(Staff) if conditions: list_stmt = list_stmt.where(*conditions) list_stmt = ( list_stmt.order_by(Staff.user_id.asc()) .limit(page_size) .offset((page - 1) * page_size) ) rows = (await session.execute(list_stmt)).scalars().all() items = [StaffOut.model_validate(row) for row in rows] return Page(items=items, total=total, page=page, page_size=page_size) @router.get("/accounts") async def list_accounts( session: SessionDep, page: PageParam = 1, page_size: PageSizeParam = 20, channel: Annotated[str | None, Query()] = None, ) -> Page[AccountOut]: """List staff messaging accounts (WeChat / WhatsApp) in a unified shape. ``channel`` optional; one of ``wechat`` / ``whatsapp``. When omitted, both tables are merged in memory. Pagination is applied after the merge using a stable ``(channel, account_id)`` ordering. """ selected_channels = [channel] if channel else ["wechat", "whatsapp"] rows: list[AccountOut] = [] if "wechat" in selected_channels: result = (await session.execute(sa.select(WechatAccount))).scalars().all() for r in result: rows.append( AccountOut( channel="wechat", account_id=r.wx_id, nickname=r.nick_name, user_id=r.user_id, user_name=r.user_name, phone=r.phone, contacts_num=r.contacts_num, group_num=r.group_num, is_on_job=r.is_on_job, raw_json=r.raw_json, ) ) if "whatsapp" in selected_channels: result = (await session.execute(sa.select(WhatsappAccount))).scalars().all() for r in result: rows.append( AccountOut( channel="whatsapp", account_id=r.uid, nickname=r.nick_name, user_id=r.user_id, user_name=r.user_name, phone=r.phone, contacts_num=r.contacts_num, group_num=r.group_num, is_on_job=r.is_on_job, raw_json=r.raw_json, ) ) rows.sort(key=lambda a: (a.channel, a.account_id)) total = len(rows) start = (page - 1) * page_size items = rows[start : start + page_size] return Page(items=items, total=total, page=page, page_size=page_size)