"""员工自助面:申请加入 GitHub 组织、看自己在哪些组织里。 按「是不是本人」判,不按角色判 —— 与平台里其它 ``/me`` 面一致。整个 路由挂 ``_leaver_runtime_gate``:待离职的人不能提新申请。服务层另有 一道同样的闸,因为 MCP 工具是直接调服务的,不经过这里。 """ from __future__ import annotations import uuid from fastapi import APIRouter, Depends, Request from pydantic import BaseModel, Field from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.auth.models import AuthUser from app.core.database import get_db from app.core.dependencies import get_current_user from app.core.dependencies import ( require_active_employee_for_mutations as _leaver_runtime_gate, ) from app.core.exceptions import NotFoundError from app.core.response import success_response from app.identity.person_models import IdentityPerson from . import constants as const from . import service as svc from .models import GithubOrgJoinRequest from .request_service import GithubOrgRequestService from .serializers import serialize_join_request, serialize_membership router = APIRouter( prefix="/api/v1/me/github", tags=["github-org-self"], dependencies=[Depends(_leaver_runtime_gate)], ) class JoinRequestBody(BaseModel): org_id: str = Field(min_length=1, max_length=64) # 可选,而且**不是数据来源** —— 服务层以已登记的 ``github_id`` 为准, # 这里传的值只做一致性检查(不一致会 409)。字段保留是为了让老客户端 # 与 MCP 工具继续能传,并且传错时得到一个说得清的拒绝,而不是被静默 # 忽略后以为自己指定的那个生效了。 github_login: str | None = Field(default=None, max_length=200) reason: str | None = Field(default=None, max_length=1000) async def _person(db: AsyncSession, user: AuthUser) -> IdentityPerson: row = await db.get(IdentityPerson, user.id) if row is None: raise NotFoundError("IdentityPerson", str(user.id)) return row @router.get("/orgs") async def list_requestable_orgs( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """能申请哪些组织 + 我在其中每一个里的当前状态。 把「有哪些」和「我在不在里面」合成一次调用,是因为界面上它们必须 一起显示:一个已经在里面的组织不该给出「申请」按钮,而两次调用 之间的时间差正好够渲染出那个不该有的按钮。 """ person = await _person(db, user) orgs = await svc.list_orgs(db, request_enabled_only=True) mine = { m.org_id: m for m in await svc.list_memberships_for_person( db, str(person.id), live_only=True, ) } pending = { r.org_id: r for r in ( await db.execute( select(GithubOrgJoinRequest).where( GithubOrgJoinRequest.person_id == str(person.id), GithubOrgJoinRequest.status.in_( const.JOIN_REQUEST_OPEN_STATUSES, ), ) ) ).scalars().all() } data = [] for org in orgs: membership = mine.get(org.id) data.append( { "id": org.id, "slug": org.slug, "display_name": org.display_name, "html_url": f"https://github.com/{org.slug}", "description": org.description, "is_community": org.is_community, # 没填飞书选项值的组织提交必然被拒 —— 与其让员工填完表单 # 才收到一个 409,不如在列表上就说清楚。 "requestable": bool(org.feishu_option_value), "my_state": membership.state if membership else None, "my_github_login": membership.github_login if membership else None, "has_pending_request": org.id in pending, } ) return success_response( data, request=request, meta={ # HRIS 里登记的 handle,用来预填表单。稀疏 —— 只在入职采集, # 从不回溯,所以 null 是常态而不是异常。 "github_id_on_file": (person.github_id or None), }, ) @router.get("/memberships") async def my_memberships( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """我在哪些 GitHub 组织里,含还没接受的邀请。""" person = await _person(db, user) rows = await svc.list_memberships_for_person( db, str(person.id), live_only=False, ) orgs = {o.id: o for o in await svc.list_orgs(db, include_inactive=True)} return success_response( [serialize_membership(m, org=orgs.get(m.org_id)) for m in rows], request=request, ) @router.get("/requests") async def my_requests( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): person = await _person(db, user) rows = list( ( await db.execute( select(GithubOrgJoinRequest) .where(GithubOrgJoinRequest.person_id == str(person.id)) .order_by(GithubOrgJoinRequest.created_at.desc()) .limit(100) ) ).scalars().all() ) orgs = {o.id: o for o in await svc.list_orgs(db, include_inactive=True)} return success_response( [serialize_join_request(r, org=orgs.get(r.org_id)) for r in rows], request=request, ) @router.post("/requests", status_code=201) async def submit_request( payload: JoinRequestBody, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): person = await _person(db, user) trace_id = getattr(request.state, "trace_id", str(uuid.uuid4())) row = await GithubOrgRequestService(db).submit( requester=person, org_id=payload.org_id, github_login=payload.github_login, reason=payload.reason, trace_id=trace_id, ) orgs = {o.id: o for o in await svc.list_orgs(db, include_inactive=True)} return success_response( serialize_join_request(row, org=orgs.get(row.org_id)), request=request, ) @router.post("/requests/{request_id}/cancel") async def cancel_request( request_id: str, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): person = await _person(db, user) trace_id = getattr(request.state, "trace_id", str(uuid.uuid4())) service = GithubOrgRequestService(db) row = await service.cancel( request_id=request_id, actor=person, trace_id=trace_id, ) orgs = {o.id: o for o in await svc.list_orgs(db, include_inactive=True)} return success_response( { **serialize_join_request(row, org=orgs.get(row.org_id)), # 飞书那边撤没撤掉要如实说 —— 撤不掉意味着 org owner 那里还 # 挂着一张单子,员工需要知道。 "feishu_cancel_synced": service.last_feishu_cancel_synced, }, request=request, )