from collections.abc import Mapping, Sequence from pydantic import BaseModel, Field from .services.ddns import DdnsService class ErrorResponse(BaseModel): detail: str = Field(examples=["not found"]) class HostnameCreateResponse(BaseModel): hostname: str status: str = Field(description="Lifecycle state. Newly generated hostnames start as pending until registered.") update_url: str = Field(description="Secret update URL for router DynDNS updates.") dashboard_url: str = Field(description="Secret dashboard URL for reading, rotating, or deleting this hostname.") registration_url: str = Field( description="Secret URL used to register the pending hostname after secrets are saved." ) class HostnameStatusResponse(BaseModel): hostname: str status: str update_url: str = Field(description="Secret update URL for router DynDNS updates.") dashboard_url: str = Field(description="Secret dashboard URL for reading, rotating, or deleting this hostname.") ipv4: str | None = None ipv6: str | None = None updated_at: str | None = None history: list["HostnameUpdateEvent"] = Field(default_factory=list) class HostnameRotateUpdateResponse(BaseModel): hostname: str status: str update_url: str = Field(description="Secret replacement update URL for router DynDNS updates.") dashboard_url: str = Field(description="Secret dashboard URL for reading, rotating, or deleting this hostname.") class HostnameUpdateEvent(BaseModel): hostname: str ipv4: str | None = None ipv6: str | None = None status: str detail: str created_at: str count: int = 1 class AdminHostname(BaseModel): hostname: str status: str ipv4: str | None = None ipv6: str | None = None updated_at: str | None = None created_at: str | None = None accepted_at: str | None = None disabled: bool = False class AdminRecord(BaseModel): hostname: str ipv4: str | None = None ipv6: str | None = None updated_at: str class AdminUpdateEvent(BaseModel): hostname: str ipv4: str | None = None ipv6: str | None = None status: str detail: str source_ip: str created_at: str count: int = 1 class AdminCleanupRun(BaseModel): created_at: str unused_accounts_deleted: int class AdminScheduledTask(BaseModel): name: str interval_seconds: int next_run_at: str lease_owner: str | None = None lease_expires_at: str | None = None last_started_at: str | None = None last_finished_at: str | None = None last_status: str last_error: str updated_at: str class AdminMetrics(BaseModel): values: dict[str, float] average_update_latency_ms: float class AdminTotals(BaseModel): hostnames: int records: int update_events: int cleanup_runs: int class AdminDashboardResponse(BaseModel): totals: AdminTotals metrics: AdminMetrics hostnames: list[AdminHostname] update_events: list[AdminUpdateEvent] cleanup_runs: list[AdminCleanupRun] scheduled_tasks: list[AdminScheduledTask] limit: int class AdminHostnamesResponse(BaseModel): items: list[AdminHostname] limit: int offset: int total: int class AdminRecordsResponse(BaseModel): items: list[AdminRecord] limit: int offset: int total: int class AdminEventsResponse(BaseModel): items: list[AdminUpdateEvent] limit: int offset: int next_before: str | None = None total: int def _create_response(service: DdnsService, account: Mapping[str, object]) -> HostnameCreateResponse: return HostnameCreateResponse( hostname=str(account["hostname"]), status="active" if account.get("accepted_at") else "pending", update_url=service.fritz_update_url(account), dashboard_url=service.dashboard_url(account), registration_url=service.registration_url(account), ) def _hostname_status( service: DdnsService, account: Mapping[str, object], history: Sequence[Mapping[str, object]] | None = None, ) -> HostnameStatusResponse: account_for_url = {"update_slug": str(account["update_slug"])} account_for_dashboard = {"management_slug": str(account.get("management_slug", ""))} return HostnameStatusResponse( hostname=str(account["hostname"]), status="active" if account.get("accepted_at") else "pending", update_url=service.fritz_update_url(account_for_url), dashboard_url=service.dashboard_url(account_for_dashboard) if account_for_dashboard["management_slug"] else "", ipv4=str(account["ipv4"]) if account.get("ipv4") else None, ipv6=str(account["ipv6"]) if account.get("ipv6") else None, updated_at=str(account["updated_at"]) if account.get("updated_at") else None, history=[_hostname_update_event(event) for event in history or []], ) def _hostname_rotate_update_response( service: DdnsService, account: Mapping[str, object], dashboard_secret: str ) -> HostnameRotateUpdateResponse: return HostnameRotateUpdateResponse( hostname=str(account["hostname"]), status="active" if account.get("accepted_at") else "pending", update_url=service.fritz_update_url({"update_slug": str(account["update_slug"])}), dashboard_url=service.dashboard_url({"management_slug": dashboard_secret}), ) def _hostname_update_event(row: Mapping[str, object]) -> HostnameUpdateEvent: return HostnameUpdateEvent( hostname=str(row["hostname"]), ipv4=str(row["ipv4"]) if row.get("ipv4") else None, ipv6=str(row["ipv6"]) if row.get("ipv6") else None, status=str(row["status"]), detail=str(row["detail"]), created_at=str(row["created_at"]), count=_int_value(row.get("count"), 1), ) def _admin_hostname(account: Mapping[str, object]) -> AdminHostname: return AdminHostname( hostname=str(account["hostname"]), status="disabled" if account.get("disabled") else ("active" if account.get("accepted_at") else "pending"), ipv4=str(account["ipv4"]) if account.get("ipv4") else None, ipv6=str(account["ipv6"]) if account.get("ipv6") else None, updated_at=str(account["updated_at"]) if account.get("updated_at") else None, created_at=str(account["created_at"]) if account.get("created_at") else None, accepted_at=str(account["accepted_at"]) if account.get("accepted_at") else None, disabled=bool(account.get("disabled")), ) def _admin_metrics(metrics: dict[str, float]) -> AdminMetrics: samples = metrics.get("update_latency_samples_total", 0) average_latency = metrics.get("update_latency_ms_total", 0) / samples if samples else 0 return AdminMetrics(values=metrics, average_update_latency_ms=average_latency) def _admin_record(row: Mapping[str, object]) -> AdminRecord: return AdminRecord( hostname=str(row["hostname"]), ipv4=str(row["ipv4"]) if row.get("ipv4") else None, ipv6=str(row["ipv6"]) if row.get("ipv6") else None, updated_at=str(row["updated_at"]), ) def _admin_update_event(row: Mapping[str, object]) -> AdminUpdateEvent: return AdminUpdateEvent( hostname=str(row["hostname"]), ipv4=str(row["ipv4"]) if row.get("ipv4") else None, ipv6=str(row["ipv6"]) if row.get("ipv6") else None, status=str(row["status"]), detail=str(row["detail"]), source_ip=str(row["source_ip"]), created_at=str(row["created_at"]), count=_int_value(row.get("count"), 1), ) def _admin_cleanup_run(row: Mapping[str, object]) -> AdminCleanupRun: return AdminCleanupRun( created_at=str(row["created_at"]), unused_accounts_deleted=_int_value(row["unused_accounts_deleted"], 0), ) def _admin_scheduled_task(row: Mapping[str, object]) -> AdminScheduledTask: return AdminScheduledTask( name=str(row["name"]), interval_seconds=_int_value(row["interval_seconds"], 0), next_run_at=str(row["next_run_at"]), lease_owner=str(row["lease_owner"]) if row.get("lease_owner") else None, lease_expires_at=str(row["lease_expires_at"]) if row.get("lease_expires_at") else None, last_started_at=str(row["last_started_at"]) if row.get("last_started_at") else None, last_finished_at=str(row["last_finished_at"]) if row.get("last_finished_at") else None, last_status=str(row["last_status"]), last_error=str(row["last_error"]), updated_at=str(row["updated_at"]), ) def _int_value(value: object, default: int) -> int: if value is None: return default if isinstance(value, int): return value if isinstance(value, float): return int(value) if isinstance(value, str): return int(value) return default