""" Reporting API — flat tabular data for Power BI consumption. Authenticated via X-API-Key header (static key stored in system_config.reporting_api_key). """ import logging from datetime import date, datetime, timezone from typing import Optional from fastapi import APIRouter, Depends, Header, HTTPException, Query from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession from database import get_db router = APIRouter() log = logging.getLogger(__name__) async def require_api_key( x_api_key: Optional[str] = Header(None), db: AsyncSession = Depends(get_db), ): result = await db.execute( text("SELECT config_value FROM system_config WHERE config_key = 'reporting_api_key'") ) row = result.fetchone() if not row or not row.config_value: raise HTTPException(status_code=503, detail="Reporting API key not configured. Set reporting_api_key in system_config.") if x_api_key != row.config_value: raise HTTPException(status_code=401, detail="Invalid API key") def _now_iso() -> str: return datetime.now(timezone.utc).isoformat() # ─── Hotels dimension ───────────────────────────────────────────────────────── @router.get("/hotels") async def reporting_hotels( db: AsyncSession = Depends(get_db), _: None = Depends(require_api_key), ): """ All active hotels with tier, star rating and review score. Use as the dimension table — join to /reporting/rates on hotel_id. """ result = await db.execute(text(""" SELECT id AS hotel_id, name, tier, star_rating, review_score, review_count FROM booking_com_hotels WHERE is_active = TRUE ORDER BY display_order, name """)) rows = result.mappings().all() return { "generated_at": _now_iso(), "count": len(rows), "data": [dict(r) for r in rows], } # ─── Rates fact table ───────────────────────────────────────────────────────── @router.get("/rates") async def reporting_rates( from_date: date = Query(..., description="Stay date range start (YYYY-MM-DD)"), to_date: date = Query(..., description="Stay date range end (YYYY-MM-DD), max 90 days"), db: AsyncSession = Depends(get_db), _: None = Depends(require_api_key), ): """ All rate data — one row per hotel × stay_date × source × room_type × rate_plan × scrape_time. Covers: - source=bookingcom Booking.com scraped rates for all tracked hotels - source=direct Direct booking engine rates for competitors + Newbook rates for own hotel Current rate = filter to MAX(scrape_time) per hotel × stay_date × source × room_type × rate_plan_id History/trend = all rows, plotted by scrape_time """ if to_date < from_date: raise HTTPException(status_code=400, detail="to_date must be after from_date") if (to_date - from_date).days > 90: raise HTTPException(status_code=400, detail="Date range cannot exceed 90 days") result = await db.execute(text(""" -- Booking.com scraped rates (all hotels, all scrapes, all room type / plan combos) SELECT h.id AS hotel_id, r.rate_date AS stay_date, 'bookingcom' AS source, COALESCE(r.room_type, 'Best available') AS room_type, COALESCE(r.rate_plan_id, (CASE WHEN r.breakfast_included THEN 'bb' ELSE 'ro' END) || (CASE WHEN r.free_cancellation THEN '_flex' ELSE '_nr' END) || COALESCE('_' || r.max_persons::text, '')) AS rate_plan_id, CASE WHEN r.breakfast_included THEN 'B&B' ELSE 'Room only' END AS meal_plan, CASE WHEN r.free_cancellation THEN 'Free' ELSE 'Non-refundable' END AS cancellation, r.max_persons, NULL::integer AS min_stay_nights, r.scraped_at AS scrape_time, r.rate_gross, NULL::numeric AS rate_net, COALESCE(r.availability_status, 'no_data') AS availability_status, r.rooms_left AS rooms_left FROM booking_com_rates r JOIN booking_com_hotels h ON r.hotel_id = h.id WHERE h.is_active = TRUE AND r.rate_date BETWEEN :from_date AND :to_date UNION ALL -- Direct booking engine rates for competitors (linked via booking_com_hotels.direct_hotel_id) SELECT h.id AS hotel_id, dr.stay_date, 'direct' AS source, COALESCE(dh.room_labels ->> dr.room_id, dr.room_id) AS room_type, dr.rate_id AS rate_plan_id, COALESCE(dh.rate_labels ->> dr.rate_id, dr.rate_id) AS meal_plan, NULL AS cancellation, NULL::integer AS max_persons, dr.min_stay_nights, dr.scraped_at AS scrape_time, dr.price_incl AS rate_gross, dr.price_excl AS rate_net, CASE WHEN COALESCE(dr.availability, 0) > 0 THEN 'available' ELSE 'sold_out' END AS availability_status, dr.availability AS rooms_left FROM direct_rates dr JOIN direct_competitor_hotels dh ON dr.hotel_id = dh.id JOIN booking_com_hotels h ON h.direct_hotel_id = dh.id WHERE h.is_active = TRUE AND dr.stay_date BETWEEN :from_date AND :to_date UNION ALL -- Own hotel direct rates from Newbook (headline rate per room category per snapshot) SELECT h.id AS hotel_id, ncr.rate_date AS stay_date, 'direct' AS source, COALESCE(nc.site_name, ncr.category_id) AS room_type, ncr.category_id AS rate_plan_id, NULL AS meal_plan, NULL AS cancellation, NULL::integer AS max_persons, NULL::integer AS min_stay_nights, ncr.valid_from AS scrape_time, ncr.rate_gross, ncr.rate_net, 'available' AS availability_status, NULL::integer AS rooms_left FROM newbook_current_rates ncr LEFT JOIN newbook_room_categories nc ON ncr.category_id = nc.site_id CROSS JOIN ( SELECT id FROM booking_com_hotels WHERE tier = 'own' AND is_active = TRUE LIMIT 1 ) h WHERE ncr.rate_date BETWEEN :from_date AND :to_date ORDER BY hotel_id, stay_date, source, room_type, rate_plan_id, scrape_time """), {"from_date": from_date, "to_date": to_date}) rows = result.mappings().all() data = [] for r in rows: data.append({ "hotel_id": r["hotel_id"], "stay_date": r["stay_date"].isoformat() if r["stay_date"] else None, "source": r["source"], "room_type": r["room_type"], "rate_plan_id": r["rate_plan_id"], "meal_plan": r["meal_plan"], "cancellation": r["cancellation"], "max_persons": r["max_persons"], "min_stay_nights": r["min_stay_nights"], "scrape_time": r["scrape_time"].isoformat() if r["scrape_time"] else None, "rate_gross": float(r["rate_gross"]) if r["rate_gross"] is not None else None, "rate_net": float(r["rate_net"]) if r["rate_net"] is not None else None, "availability_status": r["availability_status"], "rooms_left": r["rooms_left"], }) return { "generated_at": _now_iso(), "from_date": from_date.isoformat(), "to_date": to_date.isoformat(), "count": len(data), "data": data, } # ─── Occupancy fact table ───────────────────────────────────────────────────── @router.get("/occupancy") async def reporting_occupancy( from_date: date = Query(..., description="Date range start (YYYY-MM-DD)"), to_date: date = Query(..., description="Date range end (YYYY-MM-DD), max 366 days"), db: AsyncSession = Depends(get_db), _: None = Depends(require_api_key), ): """ Own hotel occupancy — one row per date × room category (latest Newbook snapshot). """ if to_date < from_date: raise HTTPException(status_code=400, detail="to_date must be after from_date") if (to_date - from_date).days > 366: raise HTTPException(status_code=400, detail="Date range cannot exceed 366 days") result = await db.execute(text(""" SELECT DISTINCT ON (date, category_id) date, category_id, COALESCE(category_name, category_id) AS category_name, available, occupied, maintenance, allotted, revenue_gross, revenue_net, occupancy_pct FROM newbook_occupancy_report_data WHERE date BETWEEN :from_date AND :to_date ORDER BY date, category_id, valid_from DESC """), {"from_date": from_date, "to_date": to_date}) rows = result.mappings().all() data = [] for r in rows: data.append({ "date": r["date"].isoformat() if r["date"] else None, "category_id": r["category_id"], "category_name": r["category_name"], "available": r["available"], "occupied": r["occupied"], "maintenance": r["maintenance"], "allotted": r["allotted"], "revenue_gross": float(r["revenue_gross"]) if r["revenue_gross"] is not None else None, "revenue_net": float(r["revenue_net"]) if r["revenue_net"] is not None else None, "occupancy_pct": float(r["occupancy_pct"]) if r["occupancy_pct"] is not None else None, }) return { "generated_at": _now_iso(), "from_date": from_date.isoformat(), "to_date": to_date.isoformat(), "count": len(data), "data": data, }