""" 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.get("/health") async def scrape_health(user=Depends(get_current_user)): """Competitor hotels currently flagged as stale — drives the Direct Rates banner.""" require_cap(user, "view_direct_rates") async with AsyncSessionLocal() as db: rows = (await db.execute(text(""" SELECT h.id AS hotel_id, h.name, hh.fresh_as_of, hh.stale_since FROM direct_competitor_hotels h JOIN direct_scrape_health hh ON hh.hotel_id = h.id WHERE hh.state = 'stale' AND h.scrape_enabled = true ORDER BY h.name """))).mappings().all() return {"stale": [dict(r) for r in rows]} @router.post("/health/check") async def scrape_health_check(background_tasks: BackgroundTasks, user=Depends(get_current_user)): """Re-evaluate scrape freshness on demand (also runs automatically after each scrape).""" require_cap(user, "manage_hotels") from jobs.check_scrape_health import check_scrape_health background_tasks.add_task(check_scrape_health) return {"status": "checking"} @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() @router.get("/hotels/{hotel_id}/config") async def hotel_config(hotel_id: int, user=Depends(get_current_user)): """Full config for one hotel plus the room/rate IDs seen in scraped data, for the benchmark / tier-offset / label configuration UI.""" require_cap(user, "manage_hotels") async with AsyncSessionLocal() as db: cfg_row = await db.execute( text("""SELECT id, name, profile_name, params, room_labels, rate_labels, room_order, benchmark_room, benchmark_rate, tier_base_room, tier_offsets, scrape_enabled 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") ids_result = await db.execute( text("""SELECT DISTINCT room_id, rate_id FROM direct_rates WHERE hotel_id = :hid"""), {"hid": hotel_id} ) pairs = ids_result.fetchall() room_ids = sorted({p[0] for p in pairs}) rate_ids = sorted({p[1] for p in pairs}) # Include any rooms known only from labels/order (e.g. discovery without a scrape yet) for rid in (hotel["room_labels"] or {}): if rid not in room_ids: room_ids.append(rid) for rid in (hotel["rate_labels"] or {}): if rid not in rate_ids: rate_ids.append(rid) out = dict(hotel) out["room_ids"] = room_ids out["rate_ids"] = rate_ids return out # ─── 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