"""Read-only PostgreSQL provider for live channel analytics and details.""" from __future__ import annotations import calendar import hashlib import os from dataclasses import dataclass from datetime import date, datetime from decimal import Decimal, InvalidOperation from typing import Any, Callable, Dict, List, Optional, Tuple from channel_analytics.contracts import ( DETAIL_VERSION, UNLABELED_ROOM_TYPE, AggregatedRoomFact, AnalyticsError, build_dashboard, json_number, validate_month_key, ) TARGET_DATABASE = "booking_test" DATABASE_ENV = "DASHBOARD_DATABASE_URL" FALLBACK_DATABASE_ENV = "ARR_DATABASE_URL" CURRENT_REPORT_SQL = """ SELECT min(current_version.business_date) AS data_start_date, max(current_version.business_date) AS as_of_date, max(current_version.activated_at) AS updated_at, count(*) AS daily_version_count FROM finance.current_daily_versions AS current_version WHERE current_version.business_date BETWEEN %s AND %s """.strip() REPORT_MANIFEST_SQL = """ SELECT metrics.channel_key, sum(metrics.row_count) AS row_count FROM finance.current_daily_versions AS current_version JOIN finance.daily_channel_metrics AS metrics ON metrics.daily_version_id = current_version.daily_version_id WHERE current_version.business_date BETWEEN %s AND %s GROUP BY metrics.channel_key ORDER BY min(current_version.business_date), min(metrics.channel_order), metrics.channel_key """.strip() REPORT_PIN_COUNT_SQL = """ SELECT current_version.business_date, current_version.daily_version_id, source.sha256 FROM finance.current_daily_versions AS current_version JOIN finance.daily_versions AS version ON version.id = current_version.daily_version_id JOIN ingestion.artifacts AS source ON source.id = version.source_artifact_id WHERE current_version.business_date BETWEEN %s AND %s ORDER BY current_version.business_date, current_version.daily_version_id """.strip() ROOM_AGGREGATES_SQL = """ SELECT facts.channel_key, coalesce(nullif(btrim(facts.room_category_label), ''), %s) AS room_type, count(*) AS reservation_rows, sum(facts.no_of_rooms) AS rooms_sold, sum(facts.no_of_rooms * facts.nights) AS room_nights, sum(facts.total_price) AS total_price FROM finance.v_active_daily_facts AS facts WHERE facts.business_date BETWEEN %s AND %s GROUP BY facts.channel_key, room_type ORDER BY facts.channel_key, room_type """.strip() CHANNEL_DETAIL_SQL = """ SELECT facts.arrival, facts.departure, facts.nights, facts.no_of_rooms, facts.company_name, facts.rate_code, coalesce(nullif(btrim(facts.room_category_label), ''), %s) AS room_type, facts.real_price, facts.total_price FROM finance.v_active_daily_facts AS facts WHERE facts.business_date BETWEEN %s AND %s AND facts.channel_key = %s ORDER BY facts.arrival, facts.id LIMIT %s OFFSET %s """.strip() MONTHS_SQL = """ SELECT DISTINCT date_trunc('month', business_date)::date AS period_start FROM finance.current_daily_versions ORDER BY period_start DESC """.strip() class AnalyticsRepositoryError(AnalyticsError): pass class AnalyticsMonthNotFound(AnalyticsRepositoryError): def __init__(self) -> None: super().__init__( "ANALYTICS_MONTH_NOT_FOUND", "current monthly database projection was not found", ) @dataclass(frozen=True) class DatabaseConfig: dsn: str @classmethod def from_environment(cls) -> "DatabaseConfig": dsn = os.environ.get(DATABASE_ENV, "").strip() if not dsn: dsn = os.environ.get(FALLBACK_DATABASE_ENV, "").strip() if not dsn: raise AnalyticsRepositoryError( "ANALYTICS_DATABASE_CONFIG_MISSING", f"{DATABASE_ENV} or {FALLBACK_DATABASE_ENV} is required", ) return cls(dsn) @dataclass(frozen=True) class ChannelManifestItem: worksheet: str worksheet_order: int row_count: int @dataclass(frozen=True) class ReportContext: period_start: date period_end: date data_start_date: date as_of_date: date activated_at: datetime source_projection_sha256: str daily_version_count: int channels: Tuple[ChannelManifestItem, ...] @property def month_key(self) -> str: return f"{self.period_start.year:04d}-{self.period_start.month:02d}" @property def filename(self) -> str: return f"database-projection-{self.month_key}.json" def _default_connect(dsn: str) -> Any: try: import psycopg # type: ignore[import-not-found] except ImportError: raise AnalyticsRepositoryError( "ANALYTICS_DATABASE_DRIVER_UNAVAILABLE", "PostgreSQL driver is unavailable", ) from None try: return psycopg.connect(dsn, autocommit=False) except Exception: raise AnalyticsRepositoryError( "ANALYTICS_DATABASE_UNAVAILABLE", "database connection failed", ) from None def _integer(value: Any, field_name: str) -> int: if isinstance(value, bool): raise AnalyticsRepositoryError( "ANALYTICS_DATABASE_RESULT_INVALID", f"database {field_name} is invalid", ) try: number = Decimal(str(value)) except (InvalidOperation, TypeError, ValueError): raise AnalyticsRepositoryError( "ANALYTICS_DATABASE_RESULT_INVALID", f"database {field_name} is invalid", ) from None if ( not number.is_finite() or number < 0 or number != number.to_integral_value() ): raise AnalyticsRepositoryError( "ANALYTICS_DATABASE_RESULT_INVALID", f"database {field_name} is invalid", ) return int(number) def _decimal(value: Any, field_name: str) -> Decimal: try: number = Decimal(str(value)) except (InvalidOperation, TypeError, ValueError): raise AnalyticsRepositoryError( "ANALYTICS_DATABASE_RESULT_INVALID", f"database {field_name} is invalid", ) from None if not number.is_finite() or number < 0: raise AnalyticsRepositoryError( "ANALYTICS_DATABASE_RESULT_INVALID", f"database {field_name} is invalid", ) return number def _date(value: Any, field_name: str) -> date: if isinstance(value, datetime): value = value.date() if isinstance(value, date): return value try: return date.fromisoformat(str(value)) except ValueError: raise AnalyticsRepositoryError( "ANALYTICS_DATABASE_RESULT_INVALID", f"database {field_name} is invalid", ) from None def _datetime(value: Any) -> datetime: if isinstance(value, datetime): return value try: return datetime.fromisoformat(str(value).replace("Z", "+00:00")) except ValueError: raise AnalyticsRepositoryError( "ANALYTICS_DATABASE_RESULT_INVALID", "database activated_at is invalid", ) from None def _projection_sha256(rows: List[Tuple[Any, ...]]) -> str: identity = "\n".join( f"{_date(row[0], 'business_date').isoformat()}:{_integer(row[1], 'daily_version_id')}:{str(row[2]).lower()}" for row in rows ) return hashlib.sha256(identity.encode("utf-8")).hexdigest() class PostgresAnalyticsRepository: def __init__( self, config: DatabaseConfig, connect: Optional[Callable[[str], Any]] = None, ) -> None: self._config = config self._connect = connect or _default_connect def _open(self) -> Any: try: return self._connect(self._config.dsn) except AnalyticsRepositoryError: raise except Exception: raise AnalyticsRepositoryError( "ANALYTICS_DATABASE_UNAVAILABLE", "database connection failed", ) from None @staticmethod def _begin(cursor: Any) -> None: cursor.execute( "SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY" ) cursor.execute("SET LOCAL statement_timeout = '10s'") cursor.execute("SET LOCAL lock_timeout = '3s'") cursor.execute( "SELECT current_database(), current_setting('transaction_read_only')" ) row = cursor.fetchone() if not row or row[0] != TARGET_DATABASE or row[1] != "on": raise AnalyticsRepositoryError( "ANALYTICS_DATABASE_TARGET_INVALID", "database target or read-only mode is invalid", ) @staticmethod def _period(month_key: str) -> Tuple[date, date]: year, month = validate_month_key(month_key) return ( date(year, month, 1), date(year, month, calendar.monthrange(year, month)[1]), ) @staticmethod def _load_context( cursor: Any, period_start: date, period_end: date, ) -> ReportContext: cursor.execute(CURRENT_REPORT_SQL, (period_start, period_end)) row = cursor.fetchone() if not row or row[0] is None or row[1] is None or _integer(row[3], "daily_version_count") == 0: raise AnalyticsMonthNotFound() data_start_date = _date(row[0], "data_start_date") as_of_date = _date(row[1], "as_of_date") activated_at = _datetime(row[2]) daily_version_count = _integer(row[3], "daily_version_count") cursor.execute(REPORT_PIN_COUNT_SQL, (period_start, period_end)) identity_rows = list(cursor.fetchall()) if len(identity_rows) != daily_version_count: raise AnalyticsRepositoryError( "ANALYTICS_DATABASE_RESULT_INVALID", "current daily version identity is incomplete", ) for identity_row in identity_rows: digest = str(identity_row[2] or "").lower() if len(digest) != 64 or any( character not in "0123456789abcdef" for character in digest ): raise AnalyticsRepositoryError( "ANALYTICS_DATABASE_RESULT_INVALID", "current source artifact identity is invalid", ) cursor.execute(REPORT_MANIFEST_SQL, (period_start, period_end)) manifest_rows = list(cursor.fetchall()) channels = tuple( ChannelManifestItem( worksheet=str(item[0] or ""), worksheet_order=order, row_count=_integer(item[1], "row_count"), ) for order, item in enumerate(manifest_rows, 1) ) if ( not channels or len({item.worksheet for item in channels}) != len(channels) or any(not item.worksheet.strip() for item in channels) ): raise AnalyticsRepositoryError( "ANALYTICS_CHANNEL_MANIFEST_INVALID", "monthly channel projection is invalid", ) return ReportContext( period_start=period_start, period_end=period_end, data_start_date=data_start_date, as_of_date=as_of_date, activated_at=activated_at, source_projection_sha256=_projection_sha256(identity_rows), daily_version_count=daily_version_count, channels=channels, ) @staticmethod def _aggregates( cursor: Any, context: ReportContext, ) -> Tuple[AggregatedRoomFact, ...]: cursor.execute( ROOM_AGGREGATES_SQL, ( UNLABELED_ROOM_TYPE, context.period_start, context.period_end, ), ) facts = tuple( AggregatedRoomFact( worksheet=str(row[0] or ""), room_type=str(row[1] or UNLABELED_ROOM_TYPE), reservation_rows=_integer(row[2], "reservation_rows"), rooms_sold=_integer(row[3], "rooms_sold"), room_nights=_integer(row[4], "room_nights"), total_price=_decimal(row[5], "total_price"), ) for row in cursor.fetchall() ) actual_counts: Dict[str, int] = {} for fact in facts: actual_counts[fact.worksheet] = ( actual_counts.get(fact.worksheet, 0) + fact.reservation_rows ) expected_names = {item.worksheet for item in context.channels} if ( not set(actual_counts).issubset(expected_names) or any( item.row_count != actual_counts.get(item.worksheet, 0) for item in context.channels ) ): raise AnalyticsRepositoryError( "ANALYTICS_CHANNEL_MANIFEST_INVALID", "monthly channel projection does not match active facts", ) return facts def read_dashboard(self, month_key: str) -> Dict[str, Any]: period_start, period_end = self._period(month_key) connection = self._open() try: with connection.transaction(): with connection.cursor() as cursor: self._begin(cursor) context = self._load_context( cursor, period_start, period_end, ) facts = self._aggregates(cursor, context) return build_dashboard( month_key=context.month_key, updated_at=context.activated_at.isoformat(), max_arrival_date=context.as_of_date.isoformat(), source_monthly_sha256=context.source_projection_sha256, channel_names=(item.worksheet for item in context.channels), facts=facts, min_arrival_date=context.data_start_date.isoformat(), ) except AnalyticsError: raise except Exception: raise AnalyticsRepositoryError( "ANALYTICS_DATABASE_QUERY_FAILED", "database analytics query failed", ) from None finally: connection.close() def read_channel_detail( self, month_key: str, worksheet: str, limit: int = 100, offset: int = 0, ) -> Dict[str, Any]: if ( not isinstance(worksheet, str) or not worksheet.strip() or worksheet != worksheet.strip() or isinstance(limit, bool) or isinstance(offset, bool) or limit < 1 or limit > 500 or offset < 0 ): raise AnalyticsRepositoryError( "ANALYTICS_DETAIL_REQUEST_INVALID", "channel detail request is invalid", ) period_start, period_end = self._period(month_key) connection = self._open() try: with connection.transaction(): with connection.cursor() as cursor: self._begin(cursor) context = self._load_context( cursor, period_start, period_end, ) manifest = { item.worksheet: item for item in context.channels } if worksheet not in manifest: raise AnalyticsRepositoryError( "ANALYTICS_CHANNEL_NOT_FOUND", "channel is not present in the current month", ) cursor.execute( CHANNEL_DETAIL_SQL, ( UNLABELED_ROOM_TYPE, context.period_start, context.period_end, worksheet, limit, offset, ), ) rows = [ { "arrival": _date(row[0], "arrival").isoformat(), "departure": _date( row[1], "departure", ).isoformat(), "nights": _integer(row[2], "nights"), "no_of_rooms": _integer( row[3], "no_of_rooms", ), "company_name": str(row[4] or ""), "rate_code": str(row[5] or ""), "room_type": str( row[6] or UNLABELED_ROOM_TYPE ), "real_price": json_number( _decimal(row[7], "real_price") ), "total_price": json_number( _decimal(row[8], "total_price") ), } for row in cursor.fetchall() ] return { "version": DETAIL_VERSION, "month_key": context.month_key, "worksheet": worksheet, "updated_at": context.activated_at.isoformat(), "max_arrival_date": context.as_of_date.isoformat(), "source_monthly_sha256": ( context.source_projection_sha256 ), "total_rows": manifest[worksheet].row_count, "limit": limit, "offset": offset, "rows": rows, } except AnalyticsError: raise except Exception: raise AnalyticsRepositoryError( "ANALYTICS_DATABASE_QUERY_FAILED", "database channel detail query failed", ) from None finally: connection.close() def list_months(self) -> List[Dict[str, Any]]: connection = self._open() try: with connection.transaction(): with connection.cursor() as cursor: self._begin(cursor) cursor.execute(MONTHS_SQL) period_rows = list(cursor.fetchall()) contexts = [ self._load_context( cursor, period_start, date( period_start.year, period_start.month, calendar.monthrange( period_start.year, period_start.month, )[1], ), ) for period_start in ( _date(row[0], "period_start") for row in period_rows ) ] return [ { "month_key": context.month_key, "max_arrival_date": context.as_of_date.isoformat(), "updated_at": context.activated_at.isoformat(), "filename": context.filename, "source_monthly_sha256": ( context.source_projection_sha256 ), "channel_count": len(context.channels), "row_count": sum( item.row_count for item in context.channels ), } for context in contexts ] except AnalyticsError: raise except Exception: raise AnalyticsRepositoryError( "ANALYTICS_DATABASE_QUERY_FAILED", "database monthly list query failed", ) from None finally: connection.close()