rates/backend/api/direct.py
jtricerolph 46020576f8 Stats: fall back to all rate plans when no benchmark rate configured
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 06:53:44 +00:00

651 lines
26 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.

"""
Direct booking engine API — hotel management, discovery, scrape control, rate data.
"""
import json
import logging
from datetime import date, timedelta
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from pydantic import BaseModel
from sqlalchemy import text
from database import AsyncSessionLocal
from auth import get_current_user, require_cap
from services.direct_profiles import PROFILES, detect_profile, get_profile
from services.direct_scraper import run_discovery, get_discovery_status, run_scrape
router = APIRouter()
log = logging.getLogger(__name__)
# ─── Pydantic models ─────────────────────────────────────────────────────────
class HotelCreate(BaseModel):
name: str
profile_name: str
params: dict
class HotelUpdate(BaseModel):
name: Optional[str] = None
room_labels: Optional[dict] = None
rate_labels: Optional[dict] = None
room_order: Optional[list] = None
benchmark_room: Optional[str] = None
benchmark_rate: Optional[str] = None
tier_base_room: Optional[str] = None
tier_offsets: Optional[dict] = None
scrape_enabled: Optional[bool] = None
params: Optional[dict] = None
class DetectRequest(BaseModel):
url: str
# ─── Benchmark helpers (ported from guestline-monitor) ──────────────────────
def _resolve_bench(prices: dict, bench_room: str, offsets: dict):
"""
Derive the benchmark room's price from whatever room prices are available.
`prices` maps room_id -> price on the benchmark rate plan.
All offsets are relative to tier_base_room; bench = room_price - room_offset + bench_offset.
Returns (price, is_calculated).
"""
if prices.get(bench_room) is not None:
return float(prices[bench_room]), False
bench_offset = offsets.get(bench_room, 0)
for room_id, room_offset in offsets.items():
if room_id == bench_room:
continue
if prices.get(room_id) is not None:
return float(prices[room_id]) - room_offset + bench_offset, True
for v in prices.values():
if v is not None:
return float(v), True
return None, False
def _room_stock(rows) -> dict:
"""room_id -> MAX(availability) ever seen = estimated stock."""
return {r["room_id"]: r["stock"] for r in rows}
# ─── Profiles ────────────────────────────────────────────────────────────────
@router.get("/profiles")
async def list_profiles(user=Depends(get_current_user)):
require_cap(user, "view_direct_rates")
return [
{
"name": name,
"label": cls.label,
"required_params": cls.required_params,
}
for name, cls in PROFILES.items()
]
@router.post("/profiles/detect")
async def detect_engine(req: DetectRequest, user=Depends(get_current_user)):
require_cap(user, "manage_hotels")
result = detect_profile(req.url)
if not result:
raise HTTPException(status_code=422, detail="Could not detect booking engine from URL")
return result
# ─── Hotel CRUD ───────────────────────────────────────────────────────────────
@router.get("/hotels")
async def list_hotels(user=Depends(get_current_user)):
require_cap(user, "view_direct_rates")
async with AsyncSessionLocal() as db:
result = await db.execute(
text("""SELECT h.id, h.name, h.profile_name, h.params,
h.room_labels, h.rate_labels, h.room_order,
h.benchmark_room, h.benchmark_rate, h.tier_base_room, h.tier_offsets,
h.scrape_enabled, h.last_scraped_at,
COUNT(DISTINCT r.stay_date) AS scraped_dates,
MAX(r.scraped_at) AS last_rate_at
FROM direct_competitor_hotels h
LEFT JOIN direct_rates r ON r.hotel_id = h.id
GROUP BY h.id
ORDER BY h.id""")
)
rows = result.mappings().all()
return [dict(r) for r in rows]
@router.post("/hotels", status_code=201)
async def create_hotel(body: HotelCreate, user=Depends(get_current_user)):
require_cap(user, "manage_hotels")
if body.profile_name not in PROFILES:
raise HTTPException(status_code=400, detail=f"Unknown profile: {body.profile_name}")
async with AsyncSessionLocal() as db:
result = await db.execute(
text("""INSERT INTO direct_competitor_hotels (name, profile_name, params)
VALUES (:name, :profile, :params) RETURNING id"""),
{"name": body.name, "profile": body.profile_name, "params": json.dumps(body.params)}
)
new_id = result.fetchone()[0]
await db.commit()
return {"id": new_id, "name": body.name, "profile_name": body.profile_name}
@router.put("/hotels/{hotel_id}")
async def update_hotel(hotel_id: int, body: HotelUpdate, user=Depends(get_current_user)):
require_cap(user, "manage_hotels")
updates = {}
if body.name is not None: updates["name"] = body.name
if body.scrape_enabled is not None: updates["scrape_enabled"] = body.scrape_enabled
if body.benchmark_room is not None: updates["benchmark_room"] = body.benchmark_room
if body.benchmark_rate is not None: updates["benchmark_rate"] = body.benchmark_rate
if body.tier_base_room is not None: updates["tier_base_room"] = body.tier_base_room
if body.params is not None: updates["params"] = json.dumps(body.params)
if body.room_labels is not None: updates["room_labels"] = json.dumps(body.room_labels)
if body.rate_labels is not None: updates["rate_labels"] = json.dumps(body.rate_labels)
if body.room_order is not None: updates["room_order"] = json.dumps(body.room_order)
if body.tier_offsets is not None: updates["tier_offsets"] = json.dumps(body.tier_offsets)
if not updates:
raise HTTPException(status_code=400, detail="No fields to update")
set_clause = ", ".join(f"{k} = :{k}" for k in updates)
updates["hotel_id"] = hotel_id
async with AsyncSessionLocal() as db:
await db.execute(
text(f"UPDATE direct_competitor_hotels SET {set_clause} WHERE id = :hotel_id"),
updates
)
await db.commit()
return {"ok": True}
@router.delete("/hotels/{hotel_id}", status_code=204)
async def delete_hotel(hotel_id: int, user=Depends(get_current_user)):
require_cap(user, "manage_hotels")
async with AsyncSessionLocal() as db:
await db.execute(
text("DELETE FROM direct_competitor_hotels WHERE id = :id"),
{"id": hotel_id}
)
await db.commit()
# ─── Discovery ───────────────────────────────────────────────────────────────
@router.post("/hotels/{hotel_id}/discover")
async def trigger_discovery(hotel_id: int, background_tasks: BackgroundTasks, user=Depends(get_current_user)):
require_cap(user, "manage_hotels")
async with AsyncSessionLocal() as db:
row = await db.execute(
text("SELECT profile_name, params FROM direct_competitor_hotels WHERE id = :id"),
{"id": hotel_id}
)
hotel = row.mappings().fetchone()
if not hotel:
raise HTTPException(status_code=404, detail="Hotel not found")
background_tasks.add_task(run_discovery, hotel_id, hotel["profile_name"], hotel["params"])
return {"status": "discovery started", "hotel_id": hotel_id}
@router.get("/hotels/{hotel_id}/discovery-status")
async def discovery_status(hotel_id: int, user=Depends(get_current_user)):
require_cap(user, "view_direct_rates")
return get_discovery_status(hotel_id)
# ─── Manual scrape trigger ────────────────────────────────────────────────────
@router.post("/hotels/{hotel_id}/scrape")
async def trigger_scrape(hotel_id: int, background_tasks: BackgroundTasks, user=Depends(get_current_user)):
require_cap(user, "manage_scraper")
async with AsyncSessionLocal() as db:
row = await db.execute(
text("SELECT profile_name, params FROM direct_competitor_hotels WHERE id = :id"),
{"id": hotel_id}
)
hotel = row.mappings().fetchone()
if not hotel:
raise HTTPException(status_code=404, detail="Hotel not found")
def _bg():
run_scrape(hotel_id, hotel["profile_name"], hotel["params"])
background_tasks.add_task(_bg)
return {"status": "scrape started", "hotel_id": hotel_id}
# ─── Rate data ────────────────────────────────────────────────────────────────
@router.get("/hotels/{hotel_id}/dates")
async def hotel_dates(
hotel_id: int,
from_date: date = None,
to_date: date = None,
user=Depends(get_current_user)
):
require_cap(user, "view_direct_rates")
if from_date is None:
from_date = date.today()
if to_date is None:
to_date = date.today() + timedelta(days=89)
async with AsyncSessionLocal() as db:
# Hotel config for labels/tier offsets
cfg_row = await db.execute(
text("SELECT name, room_labels, rate_labels, tier_offsets, benchmark_room, benchmark_rate, tier_base_room FROM direct_competitor_hotels WHERE id = :id"),
{"id": hotel_id}
)
hotel = cfg_row.mappings().fetchone()
if not hotel:
raise HTTPException(status_code=404, detail="Hotel not found")
# Latest snapshot per (date, room, rate)
rates_result = await db.execute(
text("""
SELECT DISTINCT ON (stay_date, room_id, rate_id)
stay_date, room_id, rate_id, availability,
price_incl, min_stay_nights, scraped_at
FROM direct_rates
WHERE hotel_id = :hid
AND stay_date BETWEEN :fd AND :td
ORDER BY stay_date, room_id, rate_id, scraped_at DESC
"""),
{"hid": hotel_id, "fd": from_date, "td": to_date}
)
rows = rates_result.mappings().all()
# Stock = max availability ever seen per room (across all history)
stock_result = await db.execute(
text("""SELECT room_id, MAX(availability) AS stock
FROM direct_rates WHERE hotel_id = :hid GROUP BY room_id"""),
{"hid": hotel_id}
)
room_stock = {r["room_id"]: r["stock"] for r in stock_result.mappings().all()}
bench_room = hotel["benchmark_room"] or ""
bench_rate = hotel["benchmark_rate"] or ""
offsets = hotel["tier_offsets"] or {}
max_rooms = sum(v for v in room_stock.values() if v) or None
# Group by date; availability counted once per room (max across its rate plans)
by_date: dict = {}
for r in rows:
d = by_date.setdefault(r["stay_date"], {
"room_avail": {}, "bench_prices": {}, "prices": [],
"min_stay": None, "scraped_at": None,
})
room_avail = d["room_avail"]
room_avail[r["room_id"]] = max(room_avail.get(r["room_id"], 0), r["availability"] or 0)
if r["price_incl"] is not None and (r["availability"] or 0) > 0:
d["prices"].append(float(r["price_incl"]))
if r["rate_id"] == bench_rate and r["price_incl"] is not None:
d["bench_prices"][r["room_id"]] = r["price_incl"]
if r["min_stay_nights"] and (d["min_stay"] is None or r["min_stay_nights"] > d["min_stay"]):
d["min_stay"] = r["min_stay_nights"]
if d["scraped_at"] is None or r["scraped_at"] > d["scraped_at"]:
d["scraped_at"] = r["scraped_at"]
dates = []
for stay_date in sorted(by_date):
d = by_date[stay_date]
total_avail = sum(d["room_avail"].values())
bench_price, bench_calc = _resolve_bench(d["bench_prices"], bench_room, offsets)
dates.append({
"stay_date": stay_date,
"total_avail": total_avail,
"cheapest_rate": min(d["prices"]) if d["prices"] else None,
"bench_rate": round(bench_price, 2) if bench_price is not None else None,
"bench_calculated": bench_calc,
"has_availability": total_avail > 0,
"min_stay_nights": d["min_stay"],
"has_min_stay": bool(d["min_stay"] and d["min_stay"] > 1),
"scraped_at": d["scraped_at"],
})
return {
"hotel_id": hotel_id,
"hotel_name": hotel["name"],
"from_date": str(from_date),
"to_date": str(to_date),
"max_rooms": max_rooms,
"room_labels": hotel["room_labels"] or {},
"rate_labels": hotel["rate_labels"] or {},
"benchmark_room": bench_room or None,
"benchmark_rate": bench_rate or None,
"dates": dates,
}
@router.get("/hotels/{hotel_id}/date/{rate_date}/rooms")
async def hotel_date_rooms(
hotel_id: int,
rate_date: date,
user=Depends(get_current_user)
):
require_cap(user, "view_direct_rates")
async with AsyncSessionLocal() as db:
cfg_row = await db.execute(
text("SELECT room_labels, rate_labels, room_order, tier_offsets, tier_base_room, benchmark_room, benchmark_rate FROM direct_competitor_hotels WHERE id = :id"),
{"id": hotel_id}
)
hotel = cfg_row.mappings().fetchone()
if not hotel:
raise HTTPException(status_code=404, detail="Hotel not found")
# Latest snapshot per room/rate for this date
rooms_result = await db.execute(
text("""
SELECT DISTINCT ON (room_id, rate_id)
room_id, rate_id, availability, price_excl, price_incl,
currency, min_stay_nights, scraped_at
FROM direct_rates
WHERE hotel_id = :hid AND stay_date = :sd
ORDER BY room_id, rate_id, scraped_at DESC
"""),
{"hid": hotel_id, "sd": rate_date}
)
rows = [dict(r) for r in rooms_result.mappings().all()]
# Stock = max availability ever seen per room
stock_result = await db.execute(
text("""SELECT room_id, MAX(availability) AS stock
FROM direct_rates WHERE hotel_id = :hid GROUP BY room_id"""),
{"hid": hotel_id}
)
room_stock = {r["room_id"]: r["stock"] for r in stock_result.mappings().all()}
room_labels = hotel["room_labels"] or {}
rate_labels = hotel["rate_labels"] or {}
tier_offsets = hotel["tier_offsets"] or {}
benchmark_room = hotel["benchmark_room"] or ""
benchmark_rate = hotel["benchmark_rate"] or ""
bench_offset = tier_offsets.get(benchmark_room, 0)
# Group flat room×rate rows into one entry per room
rooms: dict = {}
for r in rows:
rid = r["room_id"]
room = rooms.setdefault(rid, {
"room_id": rid,
"room_label": room_labels.get(rid, rid),
"availability": r["availability"],
"stock": room_stock.get(rid),
"best_rate": None,
"bench_rate": None,
"bench_calculated": False,
"min_stay_nights": r["min_stay_nights"],
"unavailable": False,
"rates": [],
})
room["availability"] = max(room["availability"] or 0, r["availability"] or 0)
if r["price_incl"] is not None:
p = float(r["price_incl"])
if room["best_rate"] is None or p < room["best_rate"]:
room["best_rate"] = p
if r["min_stay_nights"] and not room["min_stay_nights"]:
room["min_stay_nights"] = r["min_stay_nights"]
room["rates"].append({
"rate_id": r["rate_id"],
"rate_label": rate_labels.get(r["rate_id"], r["rate_id"]),
"price_incl": r["price_incl"],
"price_excl": r["price_excl"],
"min_stay_nights": r["min_stay_nights"],
})
# Resolve the benchmark room's price today from whatever rooms are present
bench_prices = {}
for rid, room in rooms.items():
p = next((rt["price_incl"] for rt in room["rates"] if rt["rate_id"] == benchmark_rate), None)
if p is not None:
bench_prices[rid] = p
bench_price, _ = _resolve_bench(bench_prices, benchmark_room, tier_offsets)
# Per-room benchmark: direct quote if present, else estimate via tier offsets
for rid, room in rooms.items():
direct = bench_prices.get(rid)
room_offset = tier_offsets.get(rid)
if direct is not None:
room["bench_rate"] = round(float(direct), 2)
elif bench_price is not None and room_offset is not None:
room["bench_rate"] = round(bench_price + (room_offset - bench_offset), 2)
room["bench_calculated"] = True
# Inject known rooms absent from this date's snapshot (no availability at all)
for rid in set(room_stock) - set(rooms):
room_offset = tier_offsets.get(rid)
derived = round(bench_price + (room_offset - bench_offset), 2) \
if bench_price is not None and room_offset is not None else None
rooms[rid] = {
"room_id": rid,
"room_label": room_labels.get(rid, rid),
"availability": None,
"stock": room_stock.get(rid),
"best_rate": None,
"bench_rate": derived,
"bench_calculated": derived is not None,
"min_stay_nights": None,
"unavailable": True,
"rates": [],
}
# Sort by configured room_order, then by stock (largest first)
room_order = hotel["room_order"] or []
def sort_key(r):
try:
return (0, room_order.index(r["room_id"]), 0)
except ValueError:
return (1, -(r["stock"] or 0), 0)
return {
"hotel_id": hotel_id,
"date": str(rate_date),
"bench_price": bench_price,
"rooms": sorted(rooms.values(), key=sort_key),
}
# ─── Rate history ─────────────────────────────────────────────────────────────
@router.get("/hotels/{hotel_id}/history/{stay_date}")
async def rate_history(
hotel_id: int,
stay_date: date,
room_id: Optional[str] = None,
rate_id: Optional[str] = None,
mode: Optional[str] = None,
user=Depends(get_current_user)
):
"""
Time series of tracked rates for one stay date, across scrape runs.
- room_id + rate_id: history for that exact room/rate combo
- mode=benchmark: benchmark-room price history (tier-resolved)
- default: total availability + cheapest rate per scrape
"""
require_cap(user, "view_direct_rates")
async with AsyncSessionLocal() as db:
cfg_row = await db.execute(
text("SELECT benchmark_room, benchmark_rate, tier_offsets FROM direct_competitor_hotels WHERE id = :id"),
{"id": hotel_id}
)
hotel = cfg_row.mappings().fetchone()
if not hotel:
raise HTTPException(status_code=404, detail="Hotel not found")
if room_id and rate_id:
result = await db.execute(
text("""SELECT scraped_at, availability, price_incl
FROM direct_rates
WHERE hotel_id = :hid AND stay_date = :sd
AND room_id = :room AND rate_id = :rate
ORDER BY scraped_at"""),
{"hid": hotel_id, "sd": stay_date, "room": room_id, "rate": rate_id}
)
return [dict(r) for r in result.mappings().all()]
result = await db.execute(
text("""SELECT scraped_at, room_id, rate_id, availability, price_incl
FROM direct_rates
WHERE hotel_id = :hid AND stay_date = :sd
ORDER BY scraped_at"""),
{"hid": hotel_id, "sd": stay_date}
)
rows = result.mappings().all()
bench_room = hotel["benchmark_room"] or ""
bench_rate = hotel["benchmark_rate"] or ""
offsets = hotel["tier_offsets"] or {}
# Group by scrape timestamp
by_scrape: dict = {}
for r in rows:
s = by_scrape.setdefault(r["scraped_at"], {"room_avail": {}, "bench_prices": {}, "prices": []})
s["room_avail"][r["room_id"]] = max(s["room_avail"].get(r["room_id"], 0), r["availability"] or 0)
if r["price_incl"] is not None:
s["prices"].append(float(r["price_incl"]))
if r["rate_id"] == bench_rate:
s["bench_prices"][r["room_id"]] = r["price_incl"]
series = []
for scraped_at in sorted(by_scrape):
s = by_scrape[scraped_at]
if mode == "benchmark":
price, calc = _resolve_bench(s["bench_prices"], bench_room, offsets)
series.append({
"scraped_at": scraped_at,
"price_incl": round(price, 2) if price is not None else None,
"calculated": calc,
})
else:
series.append({
"scraped_at": scraped_at,
"availability": sum(s["room_avail"].values()),
"price_incl": min(s["prices"]) if s["prices"] else None,
})
return series
# ─── Per-room stats ───────────────────────────────────────────────────────────
STAT_WINDOWS = {
"this_week": 7,
"this_month": 30,
"next_6mo": 182,
"next_12mo": 365,
}
@router.get("/hotels/{hotel_id}/stats")
async def hotel_stats(hotel_id: int, user=Depends(get_current_user)):
"""
Per-room stats on the benchmark rate across time windows: stock (max
availability ever seen), avg rate, current occupancy vs stock, and
estimated final occupancy from the equivalent look-back window (past
dates no longer change, so they reflect completed bookings).
"""
require_cap(user, "view_direct_rates")
today = date.today()
async with AsyncSessionLocal() as db:
cfg_row = await db.execute(
text("SELECT room_labels, benchmark_rate FROM direct_competitor_hotels WHERE id = :id"),
{"id": hotel_id}
)
hotel = cfg_row.mappings().fetchone()
if not hotel:
raise HTTPException(status_code=404, detail="Hotel not found")
stock_result = await db.execute(
text("""SELECT room_id, MAX(availability) AS stock
FROM direct_rates WHERE hotel_id = :hid GROUP BY room_id"""),
{"hid": hotel_id}
)
stock = {r["room_id"]: r["stock"] for r in stock_result.mappings().all()}
bench_rate = hotel["benchmark_rate"] or ""
result = await db.execute(
text("""SELECT DISTINCT ON (stay_date, room_id, rate_id)
stay_date, room_id, rate_id, availability, price_incl
FROM direct_rates
WHERE hotel_id = :hid
AND stay_date BETWEEN :fd AND :td
ORDER BY stay_date, room_id, rate_id, scraped_at DESC"""),
{"hid": hotel_id,
"fd": today - timedelta(days=365), "td": today + timedelta(days=365)}
)
rows = result.mappings().all()
room_labels = hotel["room_labels"] or {}
# One entry per (room, date): prefer the benchmark rate plan when scraped,
# otherwise fall back to max availability / cheapest price across plans.
by_room_date: dict = {}
for r in rows:
entry = by_room_date.setdefault((r["room_id"], r["stay_date"]), {
"stay_date": r["stay_date"], "availability": 0, "price_incl": None, "bench": False,
})
is_bench = bench_rate and r["rate_id"] == bench_rate
if is_bench:
entry.update({
"availability": r["availability"] or 0,
"price_incl": r["price_incl"],
"bench": True,
})
elif not entry["bench"]:
entry["availability"] = max(entry["availability"], r["availability"] or 0)
if r["price_incl"] is not None and (entry["price_incl"] is None or r["price_incl"] < entry["price_incl"]):
entry["price_incl"] = r["price_incl"]
by_room: dict = {}
for (room_id, _), entry in by_room_date.items():
by_room.setdefault(room_id, []).append(entry)
result_out = {}
for room_id, room_stock_val in stock.items():
room_rows = by_room.get(room_id, [])
room_stock_safe = room_stock_val or 1
windows = {}
for wname, days in STAT_WINDOWS.items():
future = [r for r in room_rows if today <= r["stay_date"] < today + timedelta(days=days)]
past = [r for r in room_rows if today - timedelta(days=days) <= r["stay_date"] < today]
if not future and not past:
windows[wname] = None
continue
prices = [float(r["price_incl"]) for r in future if r["price_incl"] is not None]
avg_price = round(sum(prices) / len(prices), 2) if prices else None
avg_occ = None
if future:
avg_avail = sum(r["availability"] or 0 for r in future) / len(future)
avg_occ = round((1 - avg_avail / room_stock_safe) * 100, 1)
est_final_occ = None
if past:
past_sold = sum(room_stock_safe - (r["availability"] or 0) for r in past)
est_final_occ = round(past_sold / (len(past) * room_stock_safe) * 100, 1)
windows[wname] = {
"dates": len(future),
"avg_price": avg_price,
"avg_occ": avg_occ,
"past_dates": len(past),
"est_final_occ": est_final_occ,
}
result_out[room_id] = {
"stock": room_stock_val,
"room_label": room_labels.get(room_id, room_id),
"windows": windows,
}
return result_out