Add Power BI reporting endpoints — flat tabular data via API key auth

Three read-only endpoints under /reporting/:
- /hotels       hotel dimension table (tier, stars, review score)
- /rates        full rates fact — all sources, room types, scrape history in one flat table
- /occupancy    Newbook occupancy per date × room category

Rates UNION covers Booking.com scrapes, direct competitor engines (with
configured room/rate labels), and own hotel Newbook headline rates.
Authenticated via X-API-Key header; key stored in system_config.reporting_api_key.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-14 17:27:50 +00:00
parent d5ce38859d
commit e481ad5258
3 changed files with 264 additions and 3 deletions

256
backend/api/reporting.py Normal file
View file

@ -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,
}

View file

@ -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)}

View file

@ -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;
-- ============================================