"""HR Tools router. Currently exposes the 年假计算器 (annual leave calculator) and its history. Authorization model: - ``POST /annual-leave/calculate`` — HR + global_admin only. Running a new calculation is HR's job; employees can't trigger it for themselves. - ``GET /annual-leave/history`` / ``GET /annual-leave/history/{record_id}`` — open to every authenticated user. **Two-pronged self-scope** applies to every caller, regardless of role: visible row = (row.person_id == current_user.id) OR (row.calculated_by_user_id == current_user.id) A row is visible iff the caller is its subject OR its calculator. Sandbox rows (``person_id`` IS NULL) reach the calculator branch only — i.e. only the HR who created the sandbox sees it. The ``person_id`` query parameter on the list endpoint is accepted for API back-compat but unconditionally ignored. The detail endpoint returns 404 (not 403, don't leak existence) for any row where neither branch matches. Rationale for the two-pronged shape (instead of subject-only): strict ``person_id == self`` returned 0 rows in the history tab for HR who had only ever run calculations for other people. Their calculation footprint is part of their own work — they need to see it to verify / iterate on / audit-trace it without bouncing through the separate ``audit_logs`` admin surface. Subjects retain visibility on rows about themselves (whoever ran the calculation), so an employee can always see HR's settlement for them. Each row is visible to AT MOST two users — the subject and the calculator — so the leak surface is bounded; a third party (admin browsing the calculator page) sees nothing they didn't personally touch. Finance is still excluded by product decision (rule itself is HR-owned). See ``DECISIONS.md`` for the HR-vs-Finance scope call. History note: PR #305 (the 年假计算器 launch) shipped HR + admin with cross-person read; reverted same-day in PR #306 to strict ``person_id == self`` after the dev saw a colleague's row. That was over-tight — Shuai Feng's history was 0 rows even though he had personally calculated for ZhouYan. Then this revision (this PR) settled on the two-pronged shape that balances privacy with HR's audit-of-own-work need. """ from __future__ import annotations from uuid import UUID from fastapi import APIRouter, Depends, Query, Request 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, get_real_ip, require_hr_admin, ) from app.core.exceptions import NotFoundError from app.core.response import paginated_response, success_response from app.domains.hr_tools.schemas import ( AnnualLeaveCalculateRequest, AnnualLeaveCalculateResponse, ) from app.domains.hr_tools.service import AnnualLeaveCalculatorService router = APIRouter(prefix="/api/v1/hr-tools", tags=["hr-tools"]) @router.post("/annual-leave/calculate") async def calculate_annual_leave( request: Request, body: AnnualLeaveCalculateRequest, current_user: AuthUser = Depends(require_hr_admin()), db: AsyncSession = Depends(get_db), ): """Compute fiscal-year segmented annual-leave proration for one person and persist the full computation graph for audit.""" service = AnnualLeaveCalculatorService(db) data: AnnualLeaveCalculateResponse = await service.calculate_and_record( body=body, actor=current_user, trace_id=request.state.trace_id, ip_address=get_real_ip(request), user_agent=request.headers.get("user-agent"), ) return success_response(data=data.model_dump(mode="json"), request=request) @router.get("/annual-leave/history") async def list_annual_leave_history( request: Request, person_id: UUID | None = Query(None), # accepted for API compat; ignored page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """List rows the caller can see (subject OR calculator). Two-pronged self-scope applies to every caller (see module docstring). The ``person_id`` query parameter is accepted for API back-compat but ignored — the only filter that matters is the OR'd visibility clause. """ del person_id service = AnnualLeaveCalculatorService(db) items, total = await service.list_history( person_id=None, visible_to_user_id=current_user.id, limit=page_size, offset=(page - 1) * page_size, ) return paginated_response( data=[i.model_dump(mode="json") for i in items], total=total, page=page, page_size=page_size, request=request, ) @router.get("/annual-leave/history/{record_id}") async def get_annual_leave_history_item( request: Request, record_id: UUID, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Return one historical calculation by id. Two-pronged self-scope: 404 (not 403, don't leak existence) unless the caller is either the subject (``person_id == current_user.id``) or the calculator (``calculated_by_user_id == current_user.id``). Sandbox rows (``person_id IS NULL``) pass only via the calculator branch. """ service = AnnualLeaveCalculatorService(db) data = await service.get_history_item(record_id) is_subject = data.person_id is not None and data.person_id == current_user.id is_calculator = data.calculated_by_user_id == current_user.id if not (is_subject or is_calculator): raise NotFoundError("AnnualLeaveCalculation", str(record_id)) return success_response(data=data.model_dump(mode="json"), request=request)