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:
parent
d5ce38859d
commit
e481ad5258
3 changed files with 264 additions and 3 deletions
256
backend/api/reporting.py
Normal file
256
backend/api/reporting.py
Normal 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,
|
||||
}
|
||||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
-- ============================================
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue