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>
256 lines
12 KiB
Python
256 lines
12 KiB
Python
"""
|
||
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,
|
||
}
|