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 logging
|
||||||
import sys
|
import sys
|
||||||
|
import os
|
||||||
|
import time
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
|
|
@ -15,7 +17,7 @@ from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from database import DATABASE_URL
|
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
|
from scheduler import start_scheduler, shutdown_scheduler
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -60,6 +62,7 @@ app = FastAPI(
|
||||||
version="1.0.0",
|
version="1.0.0",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
STARTED_AT = str(int(time.time() * 1000))
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
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(competitors.router, prefix="/competitors", tags=["Competitors"])
|
||||||
app.include_router(analysis.router, prefix="/analysis", tags=["Rate Analysis"])
|
app.include_router(analysis.router, prefix="/analysis", tags=["Rate Analysis"])
|
||||||
app.include_router(direct.router, prefix="/direct", tags=["Direct Rates"])
|
app.include_router(direct.router, prefix="/direct", tags=["Direct Rates"])
|
||||||
|
app.include_router(reporting.router, prefix="/reporting", tags=["Reporting"])
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health_check():
|
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_username', NULL, 'Proxy username for playwright_proxy backend'),
|
||||||
('booking_scraper_proxy_password', NULL, 'Proxy password 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_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;
|
ON CONFLICT (config_key) DO NOTHING;
|
||||||
|
|
||||||
-- ============================================
|
-- ============================================
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue