"""Geo and IP lookup Pydantic models. Response models for the ``GET /api/geo/lookup/{ip}`` endpoint. """ from __future__ import annotations from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import TYPE_CHECKING from pydantic import Field from app.models.response import BanGuiBaseModel if TYPE_CHECKING: import aiohttp import aiosqlite class GeoDetail(BanGuiBaseModel): """Enriched geolocation data for an IP address. Populated from the ip-api.com free API. """ country_code: str | None = Field( default=None, description="ISO 3166-1 alpha-2 country code.", ) country_name: str | None = Field( default=None, description="Human-readable country name.", ) asn: str | None = Field( default=None, description="Autonomous System Number (e.g. ``'AS3320'``).", ) org: str | None = Field( default=None, description="Organisation associated with the ASN.", ) class GeoCacheEntry(BanGuiBaseModel): """A single cached geolocation entry for an IP address. Represents a row from the ``geo_cache`` table in the application database. """ ip: str = Field(..., description="IP address (IPv4 or IPv6).") country_code: str | None = Field( default=None, description="ISO 3166-1 alpha-2 country code.", ) country_name: str | None = Field( default=None, description="Human-readable country name.", ) asn: str | None = Field( default=None, description="Autonomous System Number (e.g. ``'AS3320'``).", ) org: str | None = Field( default=None, description="Organisation associated with the ASN.", ) class GeoCacheStatsResponse(BanGuiBaseModel): """Response for ``GET /api/geo/stats``. Exposes diagnostic counters of the geo cache subsystem so operators can assess resolution health from the UI or CLI. """ cache_size: int = Field(..., description="Number of positive entries in the in-memory cache.") unresolved: int = Field(..., description="Number of geo_cache rows with country_code IS NULL.") neg_cache_size: int = Field(..., description="Number of entries in the in-memory negative cache.") dirty_size: int = Field(..., description="Number of newly resolved entries not yet flushed to disk.") hits: int = Field(default=0, description="Number of cache hits since last clear.") misses: int = Field(default=0, description="Number of cache misses since last clear.") class GeoReResolveResponse(BanGuiBaseModel): """Response for ``POST /api/geo/re-resolve``. Reports how many previously unresolved IPs were retried and how many gained a resolved country code after the re-resolve operation. """ resolved: int = Field(..., description="Number of IPs successfully resolved.") total: int = Field(..., description="Number of IPs retried.") class IpLookupResponse(BanGuiBaseModel): """Response for ``GET /api/geo/lookup/{ip}``. Aggregates current ban status and geographical information for an IP. """ ip: str = Field(..., description="The queried IP address.") currently_banned_in: list[str] = Field( default_factory=list, description="Names of jails where this IP is currently banned.", ) geo: GeoDetail | None = Field( default=None, description="Enriched geographical and network information.", ) # --------------------------------------------------------------------------- # shared service types # --------------------------------------------------------------------------- @dataclass class GeoInfo: """Geo resolution result used throughout backend services.""" country_code: str | None country_name: str | None asn: str | None org: str | None GeoEnricher = Callable[[str], Awaitable[GeoInfo | None]] GeoBatchLookup = Callable[ [list[str], "aiohttp.ClientSession", "aiosqlite.Connection | None"], Awaitable[dict[str, GeoInfo]], ] GeoCacheLookup = Callable[[list[str]], tuple[dict[str, GeoInfo], list[str]]]