"""Dict serializers for attendance reports/cycles/events. Person identity (name / email / employee_no / employment_status) is batch-resolved so the frontend can render the shared PersonTag (which strikes through + tags leavers) without an N+1. Datetimes are emitted as ISO strings; naive values (MySQL strips tzinfo on reload) are returned as-is rather than crashing on an aware/naive mix. """ from __future__ import annotations from datetime import datetime from uuid import UUID from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.domains.attendance.models import ( AttendanceCycle, AttendanceReport, AttendanceReportEvent, AttendanceRosterOverride, ) from app.identity.person_models import IdentityPerson def _iso(dt: datetime | None) -> str | None: return dt.isoformat() if dt is not None else None async def load_person_index( db: AsyncSession, person_ids: set[UUID], ) -> dict[UUID, IdentityPerson]: ids = {pid for pid in person_ids if pid is not None} if not ids: return {} rows = ( await db.execute( select(IdentityPerson).where(IdentityPerson.id.in_(ids)) ) ).scalars().all() return {p.id: p for p in rows} def _person_block(person: IdentityPerson | None) -> dict: if person is None: return { "person_name": None, "person_email": None, "person_employee_no": None, "person_employment_status": None, } return { "person_name": person.display_name, "person_email": person.work_email, "person_employee_no": person.employee_no, "person_employment_status": person.employment_status, } def serialize_cycle(cycle: AttendanceCycle, *, report_count: int = 0) -> dict: return { "id": str(cycle.id), "cycle_key": cycle.cycle_key, "start_date": cycle.start_date.isoformat(), "end_date": cycle.end_date.isoformat(), "intern_department": cycle.intern_department, "report_count": report_count, "pending_not_in_helix": cycle.pending_not_in_helix_json or [], "created_at": _iso(cycle.created_at), } def serialize_report( report: AttendanceReport, *, person_index: dict[UUID, IdentityPerson], cycle_key: str | None = None, include_snapshot: bool = False, ) -> dict: person = person_index.get(report.person_id) leader = person_index.get(report.leader_person_id) if report.leader_person_id else None block = _person_block(person) out = { "id": str(report.id), "cycle_id": str(report.cycle_id), "cycle_key": cycle_key, "status": report.status, "version": report.version, "person_id": str(report.person_id), "leader_person_id": ( str(report.leader_person_id) if report.leader_person_id else None ), "leader_name": leader.display_name if leader else None, "work_days": report.work_days, "abnormal_days": report.abnormal_days, "lack_count": report.lack_count, "late_count": report.late_count, "early_count": report.early_count, "remedied_count": report.remedied_count, "data_quality": report.data_quality_json, "generated_at": _iso(report.generated_at), "last_sent_at": _iso(report.last_sent_at), "intern_confirmed_at": _iso(report.intern_confirmed_at), "leader_confirmed_at": _iso(report.leader_confirmed_at), "returned_at": _iso(report.returned_at), "return_reason": report.return_reason, "returned_by": report.returned_by, **block, } if include_snapshot: out["snapshot"] = report.snapshot_json return out def serialize_roster_override( override: AttendanceRosterOverride, *, person_index: dict[UUID, IdentityPerson], ) -> dict: person = person_index.get(override.person_id) return { "person_id": str(override.person_id), "mode": override.mode, "note": override.note, "created_at": _iso(override.created_at), **_person_block(person), } def serialize_event( event: AttendanceReportEvent, *, person_index: dict[UUID, IdentityPerson], ) -> dict: actor = ( person_index.get(event.actor_person_id) if event.actor_person_id else None ) return { "event_type": event.event_type, "actor_person_id": ( str(event.actor_person_id) if event.actor_person_id else None ), "actor_name": actor.display_name if actor else None, "from_status": event.from_status, "to_status": event.to_status, "reason": event.reason, "created_at": _iso(event.created_at), }