"""Public-holiday calendar API — the reference-data surface for the External API. GET /api/v1/open/hr/holidays hr.holidays:read GET /api/v1/open/hr/holidays/countries hr.holidays:read Answers "which days is the office closed in country X" for downstream systems that schedule work — the same class of consumer as the leave-availability lookup in :mod:`app.domains.hr.open_leave_router` (an on-call rota that must not page someone on a public holiday, a job scheduler that skips non-working days). The credential / token / rate-limit / audit plane is the standalone External API feature (:mod:`app.api_clients`); this module adds only the HR data layer and gates on the holiday scope. Design contract (documented for integrators in ``docs/api/external-api.md`` §B.5): * **Listing, not lookup.** Unlike the absences API, this data names no person and is already published to every employee, so a plain country + date-window listing is safe and there is no per-client population filter to enforce. * **Coverage is explicit.** HR maintains one calendar per country per *year*. A country-year nobody has filled in returns ``coverage="no_data"``, never an empty ``holidays`` array — a scheduler must be able to tell "we know there are no holidays" from "nobody has told Helix yet", or it will happily book work on a national holiday. Tri-state: ``covered`` / ``no_data`` / ``unknown_country``. * **Makeup workdays are first-class.** China offsets long holidays with 调休 weekend workdays. They come back as their own entries with ``day_type="makeup_workday"``, so a caller computing working days gets the weekend right instead of treating every Saturday as free. * **Freshness.** ``meta.as_of`` is the newest ``updated_at`` across the returned scope, so a caller can tell how recently HR touched the calendar. """ from __future__ import annotations from datetime import UTC, date, datetime, timedelta from typing import Annotated from fastapi import APIRouter, Depends, Query, Request from sqlalchemy.ext.asyncio import AsyncSession from app.api_clients.auth import ApiClientContext, require_api_client from app.api_clients.scopes import SCOPE_HR_HOLIDAYS_READ from app.core.database import get_db from app.core.exceptions import ValidationError from app.core.geo import normalize_country_code from app.core.response import success_response from app.domains.hr import holiday_service as svc from app.domains.hr.holiday_models import MAKEUP_WORKDAY, PUBLIC_HOLIDAY router = APIRouter(prefix="/api/v1/open/hr", tags=["open-hr"]) #: One call may span at most ~3 years of calendar. Generous for a scheduler #: warming a cache, bounded enough to keep the query on the (country, day) index. _MAX_WINDOW_DAYS = 366 * 3 #: Countries per call. The whole company is 22, so this is not a real constraint #: — it exists so a malformed loop cannot ask for 10,000 codes. _MAX_COUNTRIES = 60 _HOLIDAYS_EXAMPLE = { "success": True, "data": [ { "country": "CN", "country_name": "China", "coverage": "covered", "years_covered": [2026], "holidays": [ { "date": "2026-05-01", "name": "Labor Day Holiday", "name_local": "劳动节", "day_type": "public_holiday", "observed_group": "cn-2026-labor-day-holiday", }, { "date": "2026-05-09", "name": "Makeup Workday (Labor Day Holiday)", "name_local": "劳动节调休上班", "day_type": "makeup_workday", "observed_group": "cn-2026-labor-day-holiday", }, ], }, { "country": "PT", "country_name": None, "coverage": "no_data", "years_covered": [], "holidays": [], }, ], "meta": {"as_of": "2026-08-12T02:15:00+00:00", "from": "2026-05-01", "to": "2026-05-31"}, "errors": [], } _COUNTRIES_EXAMPLE = { "success": True, "data": [ {"country": "CN", "country_name": "China", "year": 2026, "holiday_days": 33}, {"country": "JP", "country_name": "Japan", "year": 2026, "holiday_days": 22}, ], "meta": {"as_of": "2026-08-12T02:15:00+00:00"}, "errors": [], } def _parse_countries(raw: str | None) -> tuple[list[str], list[str]]: """Split a comma-separated code list into (resolved, unresolvable). Unresolvable codes are *returned*, not rejected: a caller iterating over its own country list should get ``unknown_country`` for the one code Helix does not recognise, rather than a 422 that hides the 20 codes that were fine. """ if not raw or not raw.strip(): return [], [] parts = [p.strip() for p in raw.split(",") if p.strip()] if len(parts) > _MAX_COUNTRIES: raise ValidationError( f"at most {_MAX_COUNTRIES} countries per call (got {len(parts)})", ) resolved: list[str] = [] unknown: list[str] = [] seen: set[str] = set() for part in parts: code = normalize_country_code(part) if code is None: unknown.append(part) elif code not in seen: seen.add(code) resolved.append(code) return resolved, unknown @router.get( "/holidays", summary="List public holidays by country and date window", responses={200: {"content": {"application/json": {"example": _HOLIDAYS_EXAMPLE}}}}, ) async def list_holidays( request: Request, country: Annotated[ str | None, Query( max_length=512, description=( "Comma-separated ISO-3166 alpha-2/alpha-3 codes or English " "country names. Omit for every maintained country." ), ), ] = None, date_from: Annotated[ date | None, Query(alias="from", description="Inclusive start date. Defaults to today."), ] = None, date_to: Annotated[ date | None, Query(alias="to", description="Inclusive end date. Defaults to 365 days out."), ] = None, year: Annotated[ int | None, Query( ge=svc.MIN_YEAR, le=svc.MAX_YEAR, description="Shorthand for a whole calendar year; conflicts with from/to.", ), ] = None, ctx: ApiClientContext = Depends(require_api_client(SCOPE_HR_HOLIDAYS_READ)), db: AsyncSession = Depends(get_db), ): """Public holidays and makeup workdays for a country set and date window.""" _ = ctx if year is not None and (date_from is not None or date_to is not None): raise ValidationError("pass either 'year' or 'from'/'to', not both") if year is not None: start, end = svc.year_bounds(year) else: start = date_from or datetime.now(UTC).date() end = date_to or (start + timedelta(days=365)) if end < start: raise ValidationError("'to' must not precede 'from'") if (end - start).days + 1 > _MAX_WINDOW_DAYS: raise ValidationError( f"the date window must not exceed {_MAX_WINDOW_DAYS} days", ) requested, unknown = _parse_countries(country) maintained = await svc.list_calendars(db) years_by_country: dict[str, list[int]] = {} labels: dict[str, str] = {} for cal in maintained: years_by_country.setdefault(cal.country, []).append(cal.year) labels.setdefault(cal.country, cal.label or cal.country) scope = requested or sorted(years_by_country) rows = await svc.list_days(db, countries=scope or None, start=start, end=end) \ if scope else [] by_country: dict[str, list] = {code: [] for code in scope} for row in rows: by_country.setdefault(row.country, []).append(row) # A country is "covered" only for the years the window actually touches: # asking about 2027 for a country maintained only through 2026 is no_data, # not "no holidays in 2027". window_years = set(range(start.year, end.year + 1)) data = [] for code in scope: covered_years = sorted(set(years_by_country.get(code, [])) & window_years) data.append({ "country": code, "country_name": labels.get(code), "coverage": "covered" if covered_years else "no_data", "years_covered": covered_years, "holidays": [ { "date": row.day.isoformat(), "name": row.name_en, "name_local": row.name_local, "day_type": row.day_type, "observed_group": row.group_key, } for row in sorted( by_country.get(code, []), key=lambda r: (r.day, r.name_en), ) ] if covered_years else [], }) for code in unknown: data.append({ "country": code, "country_name": None, "coverage": "unknown_country", "years_covered": [], "holidays": [], }) as_of = await svc.last_updated_at(db, countries=scope or None) return success_response( data=data, request=request, meta={ "as_of": as_of.isoformat() if as_of else None, "from": start.isoformat(), "to": end.isoformat(), "day_types": [PUBLIC_HOLIDAY, MAKEUP_WORKDAY], }, ) @router.get( "/holidays/countries", summary="List the country-years whose holiday calendar is maintained", responses={200: {"content": {"application/json": {"example": _COUNTRIES_EXAMPLE}}}}, ) async def list_holiday_countries( request: Request, year: Annotated[ int | None, Query(ge=svc.MIN_YEAR, le=svc.MAX_YEAR), ] = None, ctx: ApiClientContext = Depends(require_api_client(SCOPE_HR_HOLIDAYS_READ)), db: AsyncSession = Depends(get_db), ): """Discovery endpoint: what a caller can ask about before it asks. Saves a consumer from probing 200 country codes to find the 22 Helix knows, and makes an unmaintained country visible up front rather than as a ``no_data`` surprise mid-schedule. """ _ = ctx countries = await svc.list_countries(db, year=year) as_of = await svc.last_updated_at(db) return success_response( data=[ { "country": row["country"], "country_name": row["label"], "year": row["year"], "holiday_days": row["day_count"], "stated_days": row["stated_days"], } for row in countries ], request=request, meta={"as_of": as_of.isoformat() if as_of else None}, )