diff --git a/backend/api/reporting.py b/backend/api/reporting.py new file mode 100644 index 0000000..c8f00eb --- /dev/null +++ b/backend/api/reporting.py @@ -0,0 +1,256 @@ +""" +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, + } diff --git a/backend/main.py b/backend/main.py index fa40673..afc0492 100644 --- a/backend/main.py +++ b/backend/main.py @@ -3,6 +3,8 @@ Rate Monitor — FastAPI Backend """ import logging import sys +import os +import time from contextlib import asynccontextmanager logging.basicConfig( @@ -15,7 +17,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from database import DATABASE_URL -from api import bookability, competitors, analysis, direct +from api import bookability, competitors, analysis, direct, reporting from scheduler import start_scheduler, shutdown_scheduler @@ -60,6 +62,7 @@ app = FastAPI( version="1.0.0", lifespan=lifespan, ) +STARTED_AT = str(int(time.time() * 1000)) app.add_middleware( CORSMiddleware, @@ -73,8 +76,9 @@ app.include_router(bookability.router, prefix="/bookability", tags=["Book app.include_router(competitors.router, prefix="/competitors", tags=["Competitors"]) app.include_router(analysis.router, prefix="/analysis", tags=["Rate Analysis"]) app.include_router(direct.router, prefix="/direct", tags=["Direct Rates"]) +app.include_router(reporting.router, prefix="/reporting", tags=["Reporting"]) @app.get("/health") async def health_check(): - return {"status": "healthy", "service": "rate-monitor-api"} + return {"status": "healthy", "service": "rate-monitor-api", "version": os.environ.get("BUILD_VERSION", STARTED_AT)} diff --git a/backend/schema.sql b/backend/schema.sql index 3c72a2e..45eb8dc 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -23,7 +23,8 @@ INSERT INTO system_config (config_key, config_value, description) VALUES ('booking_scraper_proxy_username', NULL, 'Proxy username for playwright_proxy backend'), ('booking_scraper_proxy_password', NULL, 'Proxy password for playwright_proxy backend'), ('sync_newbook_current_rates_enabled', 'false', 'Enable automatic Newbook current rates sync (true/false)'), -('sync_newbook_current_rates_time', '05:20', 'Newbook current rates sync time (HH:MM)') +('sync_newbook_current_rates_time', '05:20', 'Newbook current rates sync time (HH:MM)'), +('reporting_api_key', NULL, 'Static API key for Power BI reporting endpoints (X-API-Key header)') ON CONFLICT (config_key) DO NOTHING; -- ============================================