rates/backend/api/reporting.py
jtricerolph e481ad5258 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>
2026-07-14 17:27:50 +00:00

256 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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