rates/backend/api/competitors.py
jtricerolph 1ffda18872 Market View: per-hotel rate history chart and Booking.com eye link
Replace full-cell link with two small icon buttons in each matrix cell:
- Eye icon links to the hotel's Booking.com page for that check-in date
- LineChart icon opens a history modal (Plotly multi-line chart)

The history chart plots best available rate over scrape runs, with one
line per room type so that changes in which room is cheapest show as
separate traces rather than a single jumpy line.

Backend: GET /competitors/hotels/{id}/rate-history/{stay_date} groups
by scrape batch, takes MIN(rate_gross) per room type per run, returns
series [{room_type, points: [{t, rate}]}].

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-08 15:41:32 +00:00

1232 lines
42 KiB
Python

"""
Competitor Rates API endpoints
Booking.com rate scraping, hotel management, and competitor comparison
"""
from typing import Optional, List, Dict, Any
from datetime import date, datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from pydantic import BaseModel
import logging
from database import get_db, SyncSessionLocal
from auth import get_current_user
from services import proxy as proxy_util
router = APIRouter()
logger = logging.getLogger(__name__)
# ============================================
# REQUEST/RESPONSE MODELS
# ============================================
class ScrapeRequest(BaseModel):
from_date: str
to_date: Optional[str] = None
class LocationConfigRequest(BaseModel):
location_name: str = ''
pages_to_scrape: int = 2
adults: int = 2
dest_id: Optional[str] = None # Booking.com numeric destination id (pins the search)
location_search_url: Optional[str] = None # Pasted Booking.com search URL (pins destination)
class HotelTierUpdate(BaseModel):
tier: str # 'own', 'competitor', 'market'
display_order: Optional[int] = None
class HotelResponse(BaseModel):
id: int
booking_com_id: str
name: str
booking_com_url: Optional[str]
star_rating: Optional[float]
review_score: Optional[float]
review_count: Optional[int]
tier: str
display_order: int
notes: Optional[str]
first_seen_at: Optional[datetime]
last_seen_at: Optional[datetime]
direct_hotel_id: Optional[int] = None
class RateResponse(BaseModel):
rate_date: str
hotel_id: int
hotel_name: str
tier: str
star_rating: Optional[float]
review_score: Optional[float]
availability_status: str
rate_gross: Optional[float]
room_type: Optional[str]
breakfast_included: Optional[bool]
free_cancellation: Optional[bool]
no_prepayment: Optional[bool]
rooms_left: Optional[int]
scraped_at: Optional[datetime]
class ScraperStatusResponse(BaseModel):
enabled: bool
backend: str
location_configured: bool
location_name: Optional[str]
last_scrape: Optional[dict]
# ============================================
# SCRAPER STATUS & CONFIGURATION
# ============================================
@router.get("/status", response_model=ScraperStatusResponse)
async def get_scraper_status(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Get current scraper status and configuration."""
# Get config values
config_result = await db.execute(
text("""
SELECT config_key, config_value FROM system_config
WHERE config_key IN (
'booking_scraper_enabled',
'booking_scraper_backend'
)
""")
)
config = {row.config_key: row.config_value for row in config_result.fetchall()}
# Get location config
location_result = await db.execute(
text("SELECT location_name FROM booking_scrape_config WHERE is_active = TRUE LIMIT 1")
)
location_row = location_result.fetchone()
# Get last scrape info
last_scrape_result = await db.execute(
text("""
SELECT batch_id, scrape_type, started_at, completed_at, status,
hotels_found, rates_scraped, error_message
FROM booking_scrape_log
ORDER BY started_at DESC
LIMIT 1
""")
)
last_scrape_row = last_scrape_result.fetchone()
last_scrape = None
if last_scrape_row:
last_scrape = {
'batch_id': str(last_scrape_row.batch_id),
'scrape_type': last_scrape_row.scrape_type,
'started_at': last_scrape_row.started_at.isoformat() if last_scrape_row.started_at else None,
'completed_at': last_scrape_row.completed_at.isoformat() if last_scrape_row.completed_at else None,
'status': last_scrape_row.status,
'hotels_found': last_scrape_row.hotels_found,
'rates_scraped': last_scrape_row.rates_scraped,
'error_message': last_scrape_row.error_message,
}
return ScraperStatusResponse(
enabled=config.get('booking_scraper_enabled', 'false') == 'true',
backend=config.get('booking_scraper_backend', 'playwright_local'),
location_configured=location_row is not None,
location_name=location_row.location_name if location_row else None,
last_scrape=last_scrape
)
@router.get("/config/system")
async def get_system_config(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Return all system_config rows as a flat dict (secrets masked)."""
result = await db.execute(text("SELECT config_key, config_value FROM system_config"))
out = {}
for row in result.fetchall():
if row.config_value and ('password' in row.config_key or 'secret' in row.config_key):
out[row.config_key] = '********'
else:
out[row.config_key] = row.config_value
return out
class SystemConfigUpdate(BaseModel):
key: str
value: str
@router.post("/config/system")
async def set_system_config(
payload: SystemConfigUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Upsert a single system_config key."""
await db.execute(
text("""
INSERT INTO system_config (config_key, config_value)
VALUES (:key, :value)
ON CONFLICT (config_key) DO UPDATE SET config_value = EXCLUDED.config_value
"""),
{'key': payload.key, 'value': payload.value}
)
await db.commit()
return {"status": "success", "key": payload.key}
# ── Booking.com scraper proxy config ─────────────────────────────────────────
class ProxyConfig(BaseModel):
enabled: bool = False
host: str = ''
port: str = '823'
username: str = ''
password: Optional[str] = None # None/'' => keep the stored password
country: str = 'gb'
@router.get("/config/proxy")
async def get_proxy_config(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Return the scraper proxy config. Password is never returned — only
a `password_set` flag indicating whether one is stored."""
result = await db.execute(
text("SELECT config_key, config_value FROM system_config WHERE config_key LIKE 'booking_proxy_%'")
)
cfg = {row.config_key: row.config_value for row in result.fetchall()}
return {
'enabled': cfg.get('booking_proxy_enabled') == 'true',
'host': cfg.get('booking_proxy_host', ''),
'port': cfg.get('booking_proxy_port', '823'),
'username': cfg.get('booking_proxy_username', ''),
'country': cfg.get('booking_proxy_country', 'gb'),
'password_set': bool(cfg.get('booking_proxy_password')),
}
@router.post("/config/proxy")
async def set_proxy_config(
payload: ProxyConfig,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Upsert the scraper proxy config. A blank password leaves the stored
one untouched, so the UI never has to round-trip the secret."""
values = {
'booking_proxy_enabled': 'true' if payload.enabled else 'false',
'booking_proxy_host': payload.host.strip(),
'booking_proxy_port': (payload.port or '823').strip(),
'booking_proxy_username': payload.username.strip(),
'booking_proxy_country': (payload.country or 'gb').strip(),
}
if payload.password: # only overwrite when a new value is provided
values['booking_proxy_password'] = payload.password.strip()
for key, value in values.items():
await db.execute(
text("""
INSERT INTO system_config (config_key, config_value)
VALUES (:key, :value)
ON CONFLICT (config_key) DO UPDATE SET config_value = EXCLUDED.config_value
"""),
{'key': key, 'value': value}
)
await db.commit()
return {"status": "success"}
@router.post("/config/proxy/test")
async def test_proxy_config(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Make a live request through the configured proxy and report the exit
IP and country, so the user can confirm credentials + geo before scraping."""
result = await db.execute(
text("SELECT config_key, config_value FROM system_config WHERE config_key LIKE 'booking_proxy_%'")
)
raw = {row.config_key: row.config_value for row in result.fetchall()}
# Test whatever creds are stored, regardless of the enabled toggle, so the
# user can verify before switching the proxy on.
cfg = {
'host': (raw.get('booking_proxy_host') or '').strip(),
'port': (raw.get('booking_proxy_port') or '823').strip(),
'username': (raw.get('booking_proxy_username') or '').strip(),
'password': (raw.get('booking_proxy_password') or '').strip(),
'country': (raw.get('booking_proxy_country') or 'gb').strip(),
}
if not (proxy_util.is_enabled(cfg) and cfg['password']):
raise HTTPException(status_code=400, detail="Proxy host, username and password must be saved first.")
proxy_url = proxy_util.httpx_proxy_url(cfg, proxy_util.new_session_id())
import httpx
try:
async with httpx.AsyncClient(proxy=proxy_url, timeout=40.0) as client:
resp = await client.get("https://ipinfo.io/json")
resp.raise_for_status()
data = resp.json()
except Exception as e:
logger.warning(f"Proxy test failed: {e}")
raise HTTPException(status_code=502, detail=f"Proxy test failed: {e}")
return {
'ok': True,
'ip': data.get('ip'),
'country': data.get('country'),
'city': data.get('city'),
'org': data.get('org'),
}
@router.post("/config/location")
async def set_location_config(
config: LocationConfigRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Set the location to scrape for competitor rates."""
from services.scraper_backends.playwright_local import location_params_from_url
# If a full search URL was pasted, lift the destination params out of it —
# dest_id for the column (visibility/fallback), and ss for the display name
# when the user didn't type one.
search_url = (config.location_search_url or '').strip() or None
url_params = location_params_from_url(search_url) if search_url else {}
dest_id = config.dest_id or url_params.get('dest_id')
location_name = config.location_name.strip() or url_params.get('ss', '')
if not location_name:
raise HTTPException(status_code=400, detail="Provide a location name or a search URL.")
# Deactivate existing configs
await db.execute(
text("UPDATE booking_scrape_config SET is_active = FALSE")
)
# Insert new config
await db.execute(
text("""
INSERT INTO booking_scrape_config
(location_name, pages_to_scrape, adults, dest_id, location_search_url, is_active)
VALUES (:location, :pages, :adults, :dest_id, :search_url, TRUE)
"""),
{'location': location_name, 'pages': config.pages_to_scrape,
'adults': config.adults, 'dest_id': dest_id, 'search_url': search_url}
)
await db.commit()
return {"status": "success", "location": location_name}
@router.post("/config/enable")
async def enable_scraper(
enabled: bool = True,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Enable or disable the booking.com scraper."""
await db.execute(
text("UPDATE system_config SET config_value = :val WHERE config_key = 'booking_scraper_enabled'"),
{'val': 'true' if enabled else 'false'}
)
await db.commit()
return {"status": "success", "enabled": enabled}
# ============================================
# MANUAL SCRAPE TRIGGER
# ============================================
def run_scrape_sync(from_date: date, to_date: date):
"""Run scrape in sync context for background task."""
import asyncio
from services.booking_scraper import run_manual_scrape, cleanup_stale_batches
db = SyncSessionLocal()
try:
# Clean up any stale batches before starting
cleanup_stale_batches(db, max_age_minutes=60)
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
result = loop.run_until_complete(run_manual_scrape(db, from_date, to_date))
logger.info(f"Background scrape completed: {result}")
finally:
loop.close()
except Exception as e:
logger.error(f"Background scrape failed: {e}", exc_info=True)
finally:
db.close()
@router.post("/scrape")
async def trigger_manual_scrape(
request: ScrapeRequest,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Trigger a manual scrape for the specified date range.
Runs in background - check /status for progress.
"""
try:
from_date = date.fromisoformat(request.from_date)
to_date = date.fromisoformat(request.to_date) if request.to_date else from_date
except ValueError as e:
raise HTTPException(status_code=400, detail=f"Invalid date format: {e}")
if to_date < from_date:
raise HTTPException(status_code=400, detail="to_date must be after from_date")
if (to_date - from_date).days > 30:
raise HTTPException(status_code=400, detail="Date range cannot exceed 30 days for manual scrape")
# Check if location is configured
location_result = await db.execute(
text("SELECT id FROM booking_scrape_config WHERE is_active = TRUE LIMIT 1")
)
if not location_result.fetchone():
raise HTTPException(status_code=400, detail="No scrape location configured. Set location first.")
# Only one scrape at a time — concurrent Chromium runs cause the page
# timeouts that produce partial results
#
# Best-effort early 409: the background task re-acquires the lock and will
# no-op (logging "another scrape is running") if it loses a millisecond-
# window race, so no double-run can slip through here.
from services.booking_scraper import SCRAPE_LOCK
if SCRAPE_LOCK.locked():
raise HTTPException(status_code=409, detail="A scrape is already running. Try again when it finishes.")
# Start background task
background_tasks.add_task(run_scrape_sync, from_date, to_date)
return {
"status": "started",
"from_date": from_date.isoformat(),
"to_date": to_date.isoformat(),
"message": "Scrape started in background. Check /status for progress."
}
# ============================================
# HOTELS MANAGEMENT
# ============================================
@router.get("/hotels", response_model=List[HotelResponse])
async def list_hotels(
tier: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
List all discovered hotels.
Filter by tier: 'own', 'competitor', 'market', or None for all.
"""
query = """
SELECT id, booking_com_id, name, booking_com_url,
star_rating, review_score, review_count,
tier, display_order, notes, first_seen_at, last_seen_at,
direct_hotel_id
FROM booking_com_hotels
WHERE is_active = TRUE
"""
params = {}
if tier:
if tier not in ('own', 'competitor', 'market'):
raise HTTPException(status_code=400, detail="Invalid tier. Must be 'own', 'competitor', or 'market'")
query += " AND tier = :tier"
params['tier'] = tier
query += " ORDER BY display_order, name"
result = await db.execute(text(query), params)
return [
HotelResponse(
id=row.id,
booking_com_id=row.booking_com_id or '',
name=row.name,
booking_com_url=row.booking_com_url,
star_rating=float(row.star_rating) if row.star_rating else None,
review_score=float(row.review_score) if row.review_score else None,
review_count=row.review_count,
tier=row.tier,
display_order=row.display_order,
notes=row.notes,
first_seen_at=row.first_seen_at,
last_seen_at=row.last_seen_at,
direct_hotel_id=row.direct_hotel_id
)
for row in result.fetchall()
]
@router.put("/hotels/{hotel_id}/tier")
async def update_hotel_tier(
hotel_id: int,
update: HotelTierUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Update a hotel's tier and display order.
Tiers:
- 'own': Your hotel (for parity checking)
- 'competitor': Main competitors (full tracking)
- 'market': Other hotels (context only)
"""
if update.tier not in ('own', 'competitor', 'market'):
raise HTTPException(status_code=400, detail="Invalid tier")
# If setting as 'own', clear any existing 'own' hotel
if update.tier == 'own':
await db.execute(
text("UPDATE booking_com_hotels SET tier = 'market' WHERE tier = 'own'")
)
# Update the hotel
set_clause = "tier = :tier"
params = {'hotel_id': hotel_id, 'tier': update.tier}
if update.display_order is not None:
set_clause += ", display_order = :order"
params['order'] = update.display_order
result = await db.execute(
text(f"UPDATE booking_com_hotels SET {set_clause} WHERE id = :hotel_id RETURNING id"),
params
)
if not result.fetchone():
raise HTTPException(status_code=404, detail="Hotel not found")
await db.commit()
# If this is now the own hotel, update system config
if update.tier == 'own':
await db.execute(
text("UPDATE system_config SET config_value = :val WHERE config_key = 'booking_scraper_own_hotel_id'"),
{'val': str(hotel_id)}
)
await db.commit()
return {"status": "success", "hotel_id": hotel_id, "tier": update.tier}
@router.put("/hotels/{hotel_id}/notes")
async def update_hotel_notes(
hotel_id: int,
notes: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Update notes for a hotel."""
result = await db.execute(
text("UPDATE booking_com_hotels SET notes = :notes WHERE id = :hotel_id RETURNING id"),
{'hotel_id': hotel_id, 'notes': notes}
)
if not result.fetchone():
raise HTTPException(status_code=404, detail="Hotel not found")
await db.commit()
return {"status": "success"}
class DirectLinkUpdate(BaseModel):
direct_hotel_id: Optional[int] = None
@router.put("/hotels/{hotel_id}/direct-link")
async def update_hotel_direct_link(
hotel_id: int,
payload: DirectLinkUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Link (or unlink with null) a Booking.com hotel to a direct booking
engine competitor, enabling the direct-rates sub-row in the rate matrix."""
if payload.direct_hotel_id is not None:
exists = await db.execute(
text("SELECT 1 FROM direct_competitor_hotels WHERE id = :id"),
{'id': payload.direct_hotel_id}
)
if not exists.fetchone():
raise HTTPException(status_code=404, detail="Direct hotel not found")
result = await db.execute(
text("UPDATE booking_com_hotels SET direct_hotel_id = :did WHERE id = :hotel_id RETURNING id"),
{'hotel_id': hotel_id, 'did': payload.direct_hotel_id}
)
if not result.fetchone():
raise HTTPException(status_code=404, detail="Hotel not found")
await db.commit()
return {"status": "success"}
# ============================================
# COMPETITOR RATES MATRIX
# ============================================
@router.get("/matrix")
async def get_competitor_matrix(
from_date: Optional[str] = None,
to_date: Optional[str] = None,
include_market: bool = False,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get rate comparison matrix for competitors.
Returns rates for own hotel and competitors, organized by date.
Set include_market=true to also include market tier hotels.
"""
today = date.today()
start = date.fromisoformat(from_date) if from_date else today
end = date.fromisoformat(to_date) if to_date else today + timedelta(days=30)
if end < start:
raise HTTPException(status_code=400, detail="to_date must be after from_date")
if (end - start).days > 90:
raise HTTPException(status_code=400, detail="Date range cannot exceed 90 days")
tier_filter = "h.tier IN ('own', 'competitor')"
if include_market:
tier_filter = "h.tier IN ('own', 'competitor', 'market')"
# Get hotels
hotels_result = await db.execute(
text(f"""
SELECT id, name, tier, display_order, star_rating, review_score,
booking_com_url, direct_hotel_id
FROM booking_com_hotels
WHERE is_active = TRUE AND {tier_filter.replace('h.', '')}
ORDER BY display_order, name
""")
)
hotels = [dict(row._mapping) for row in hotels_result.fetchall()]
# Get latest rates using the view
rates_result = await db.execute(
text(f"""
SELECT DISTINCT ON (r.hotel_id, r.rate_date)
r.hotel_id,
r.rate_date,
r.availability_status,
r.rate_gross,
r.room_type,
r.breakfast_included,
r.free_cancellation,
r.no_prepayment,
r.rooms_left,
r.scraped_at
FROM booking_com_rates r
JOIN booking_com_hotels h ON r.hotel_id = h.id
WHERE {tier_filter}
AND h.is_active = TRUE
AND r.rate_date >= :from_date AND r.rate_date <= :to_date
ORDER BY r.hotel_id, r.rate_date, r.scraped_at DESC
"""),
{'from_date': start, 'to_date': end}
)
# Build matrix: hotel_id -> date -> rate data
rates_by_hotel: Dict[int, Dict[str, dict]] = {}
for row in rates_result.fetchall():
hotel_id = row.hotel_id
rate_date = row.rate_date.isoformat()
if hotel_id not in rates_by_hotel:
rates_by_hotel[hotel_id] = {}
rates_by_hotel[hotel_id][rate_date] = {
'availability_status': row.availability_status,
'rate_gross': float(row.rate_gross) if row.rate_gross else None,
'room_type': row.room_type,
'breakfast_included': row.breakfast_included,
'free_cancellation': row.free_cancellation,
'no_prepayment': row.no_prepayment,
'rooms_left': row.rooms_left,
'scraped_at': row.scraped_at.isoformat() if row.scraped_at else None,
}
# Most recent scrape touching each date, across ALL hotels — a partial
# scrape may refresh the date without touching the displayed hotels, so
# per-cell scraped_at can lag behind this column-level timestamp
last_scraped_result = await db.execute(
text("""
SELECT rate_date, MAX(scraped_at) AS last_scraped
FROM booking_com_rates
WHERE rate_date >= :from_date AND rate_date <= :to_date
GROUP BY rate_date
"""),
{'from_date': start, 'to_date': end}
)
last_scraped = {
row.rate_date.isoformat(): row.last_scraped.isoformat() if row.last_scraped else None
for row in last_scraped_result.fetchall()
}
# Build date list
dates = []
current = start
while current <= end:
dates.append(current.isoformat())
current += timedelta(days=1)
return {
'from_date': start.isoformat(),
'to_date': end.isoformat(),
'dates': dates,
'hotels': hotels,
'rates': rates_by_hotel,
'last_scraped': last_scraped
}
# ============================================
# RATE PARITY (OWN HOTEL VS NEWBOOK)
# ============================================
@router.get("/parity")
async def get_rate_parity(
from_date: Optional[str] = None,
to_date: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Like-for-like parity comparison between our Booking.com rate and the
comparable Newbook tariff (matched on flex/prepaid + board via the
scraped rate's condition flags). Same logic as the daily alert job.
"""
import asyncio
from jobs.check_rate_parity import get_parity_config, gather_comparisons, expected_booking_rate
from database import SyncSessionLocal
today = date.today()
start = date.fromisoformat(from_date) if from_date else today
end = date.fromisoformat(to_date) if to_date else today + timedelta(days=30)
def _run():
sdb = SyncSessionLocal()
try:
cfg = get_parity_config(sdb)
return cfg, gather_comparisons(sdb, start, end, cfg)
finally:
sdb.close()
loop = asyncio.get_event_loop()
cfg, comparisons = await loop.run_in_executor(None, _run)
parity_issues = [
{
'rate_date': c["date"].isoformat(),
'booking_rate': c["booking_rate"],
'booking_basis': c["booking_basis"],
'booking_room_type': c["booking_room"],
'newbook_rate': c["newbook_rate"],
'newbook_tariff': c["newbook_tariff"],
'match_quality': c["match_quality"],
'expected_rate': round(expected_booking_rate(c["newbook_rate"], cfg), 2),
'difference_pct': round(c["dev_pct"], 2),
'difference_gbp': round(c["dev_gbp"], 2),
'alert_type': 'higher' if c["dev_pct"] > 0 else 'lower',
}
for c in comparisons if c["breach"]
]
return {
'from_date': start.isoformat(),
'to_date': end.isoformat(),
'markup_value': cfg["markup_value"],
'markup_unit': cfg["markup_unit"],
'tolerance_value': cfg["tolerance_value"],
'tolerance_unit': cfg["tolerance_unit"],
'dates_compared': len(comparisons),
'issues_count': len(parity_issues),
'issues': parity_issues
}
@router.get("/own-direct-rates")
async def get_own_direct_rates(
from_date: Optional[str] = None,
to_date: Optional[str] = None,
current_user: dict = Depends(get_current_user)
):
"""Our own hotel's best-available direct rate per date, from Newbook —
cheapest bookable non-dinner tariff (same selection rules as the parity
check). Used for the own-hotel direct sub-row in the rate matrix."""
import asyncio
from jobs.check_rate_parity import _candidate_tariffs
from database import SyncSessionLocal
today = date.today()
start = date.fromisoformat(from_date) if from_date else today
end = date.fromisoformat(to_date) if to_date else today + timedelta(days=30)
def _run():
sdb = SyncSessionLocal()
try:
rows = sdb.execute(text("""
SELECT DISTINCT ON (rate_date, category_id)
rate_date, category_id, rate_gross, tariffs_data
FROM newbook_current_rates
WHERE rate_date BETWEEN :fd AND :td
ORDER BY rate_date, category_id, valid_from DESC
"""), {"fd": start, "td": end}).mappings().all()
finally:
sdb.close()
by_date: dict = {}
for r in rows:
by_date.setdefault(r["rate_date"], []).append(r)
out = {}
for d, cat_rows in by_date.items():
days_ahead = (d - today).days
candidates = []
for cat in cat_rows:
candidates.extend(_candidate_tariffs(cat["tariffs_data"], days_ahead))
pool = [c for c in candidates if not c["dinner"]] or candidates
if pool:
best = min(pool, key=lambda c: c["rate"])
out[d.isoformat()] = {"rate": best["rate"], "tariff": best["name"]}
else:
headline = [float(c["rate_gross"]) for c in cat_rows
if c["rate_gross"] and float(c["rate_gross"]) > 0]
if headline:
out[d.isoformat()] = {"rate": min(headline), "tariff": "headline rate"}
return out
loop = asyncio.get_event_loop()
rates = await loop.run_in_executor(None, _run)
return {
"from_date": start.isoformat(),
"to_date": end.isoformat(),
"rates": rates,
}
@router.post("/parity/check")
async def trigger_parity_check(
current_user: dict = Depends(get_current_user)
):
"""Run the parity check now (same logic as the daily 06:45 job)."""
import asyncio
from jobs.check_rate_parity import run_parity_check
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, run_parity_check)
# ============================================
# PARITY ALERTS
# ============================================
@router.get("/parity/alerts")
async def get_parity_alerts(
status: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Get rate parity alerts."""
query = """
SELECT id, rate_date, room_category, newbook_rate, booking_com_rate,
difference_pct, alert_type, alert_status, created_at,
acknowledged_at, acknowledged_by, notes
FROM rate_parity_alerts
"""
params = {}
if status:
query += " WHERE alert_status = :status"
params['status'] = status
query += " ORDER BY rate_date DESC, created_at DESC LIMIT 100"
result = await db.execute(text(query), params)
return [
{
'id': row.id,
'rate_date': row.rate_date.isoformat(),
'room_category': row.room_category,
'newbook_rate': float(row.newbook_rate) if row.newbook_rate else None,
'booking_com_rate': float(row.booking_com_rate) if row.booking_com_rate else None,
'difference_pct': float(row.difference_pct) if row.difference_pct else None,
'alert_type': row.alert_type,
'alert_status': row.alert_status,
'created_at': row.created_at.isoformat() if row.created_at else None,
'acknowledged_at': row.acknowledged_at.isoformat() if row.acknowledged_at else None,
'acknowledged_by': row.acknowledged_by,
'notes': row.notes,
}
for row in result.fetchall()
]
@router.put("/parity/alerts/{alert_id}/acknowledge")
async def acknowledge_parity_alert(
alert_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Acknowledge a parity alert."""
result = await db.execute(
text("""
UPDATE rate_parity_alerts
SET alert_status = 'acknowledged',
acknowledged_at = NOW(),
acknowledged_by = :username
WHERE id = :alert_id
RETURNING id
"""),
{'alert_id': alert_id, 'username': current_user.get('username', 'unknown')}
)
if not result.fetchone():
raise HTTPException(status_code=404, detail="Alert not found")
await db.commit()
return {"status": "success"}
# ============================================
# QUEUE STATUS
# ============================================
@router.get("/queue-status")
async def get_queue_status(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Get current scrape queue status."""
result = await db.execute(
text("""
SELECT
status,
COUNT(*) as count,
MIN(rate_date) as earliest_date,
MAX(rate_date) as latest_date
FROM booking_scrape_queue
GROUP BY status
""")
)
status_counts = {row.status: {
'count': row.count,
'earliest': row.earliest_date.isoformat() if row.earliest_date else None,
'latest': row.latest_date.isoformat() if row.latest_date else None,
} for row in result.fetchall()}
# Get retry items (failed but under max_attempts)
retry_result = await db.execute(
text("""
SELECT COUNT(*) as count
FROM booking_scrape_queue
WHERE status = 'pending' AND attempts > 0
""")
)
retry_count = retry_result.fetchone().count
return {
'statuses': status_counts,
'retries_pending': retry_count,
'total_pending': status_counts.get('pending', {}).get('count', 0),
'total_completed': status_counts.get('completed', {}).get('count', 0),
'total_failed': status_counts.get('failed', {}).get('count', 0),
}
# ============================================
# SCHEDULE INFO
# ============================================
@router.get("/schedule-info")
async def get_schedule_info(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Get information about the scraping schedule."""
# Get configured time
time_result = await db.execute(
text("SELECT config_value FROM system_config WHERE config_key = 'booking_scraper_daily_time'")
)
time_row = time_result.fetchone()
daily_time = time_row.config_value if time_row and time_row.config_value else '05:30'
# Calculate what today's schedule would look like
from jobs.scrape_booking_rates import get_high_priority_dates, get_medium_priority_dates, get_low_priority_dates
high = get_high_priority_dates()
medium = get_medium_priority_dates()
low = get_low_priority_dates()
today = date.today()
weekday_name = today.strftime('%A')
return {
'daily_time': daily_time,
'today': today.isoformat(),
'weekday': weekday_name,
'tiers': {
'high': {
'description': 'Next 30 days (scraped first)',
'dates_today': len(high),
'range': f'{high[0].isoformat()} to {high[-1].isoformat()}' if high else None,
},
'medium': {
'description': 'Days 31-180 (scraped after high priority)',
'dates_today': len(medium),
'range': f'{medium[0].isoformat()} to {medium[-1].isoformat()}' if medium else None,
},
'low': {
'description': 'Days 181-365 (scraped last, or until rate limit)',
'dates_today': len(low),
'range': f'{low[0].isoformat()} to {low[-1].isoformat()}' if low else None,
},
},
'total_dates_today': len(set(high + medium + low)),
}
# ============================================
# SCRAPE COVERAGE (365-day view)
# ============================================
@router.get("/scrape-coverage")
async def get_scrape_coverage(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get 365-day scrape coverage showing last scraped time
and next expected scrape for every date.
"""
today = date.today()
end = today + timedelta(days=365)
# Get latest scraped_at per date (across all hotels)
result = await db.execute(
text("""
SELECT rate_date, MAX(scraped_at) as last_scraped
FROM booking_com_rates
WHERE rate_date >= :from_date AND rate_date <= :to_date
GROUP BY rate_date
"""),
{'from_date': today, 'to_date': end}
)
scraped_map = {row.rate_date: row.last_scraped for row in result.fetchall()}
# Compute tier and next scrape for each date
from jobs.scrape_booking_rates import compute_next_scrape_for_date
coverage = []
for offset in range(366):
d = today + timedelta(days=offset)
tier, next_scrape = compute_next_scrape_for_date(d)
last_scraped = scraped_map.get(d)
coverage.append({
'date': d.isoformat(),
'tier': tier,
'last_scraped': last_scraped.isoformat() if last_scraped else None,
'next_expected': next_scrape.isoformat() if next_scrape else None,
})
return {
'today': today.isoformat(),
'coverage': coverage,
}
# ============================================
# BOOKING.COM AVAILABILITY CHECK (for Bookability page)
# ============================================
@router.get("/booking-availability")
async def get_booking_availability(
from_date: Optional[str] = None,
to_date: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Check own hotel's availability on booking.com.
Returns a simple summary: for each date in the range, whether the own hotel
appears available on booking.com based on the latest scrape data.
"""
today = date.today()
start = date.fromisoformat(from_date) if from_date else today
end = date.fromisoformat(to_date) if to_date else today + timedelta(days=30)
# Get own hotel's latest scraped availability
result = await db.execute(
text("""
SELECT DISTINCT ON (r.rate_date)
r.rate_date,
r.availability_status,
r.rate_gross,
r.scraped_at
FROM booking_com_rates r
JOIN booking_com_hotels h ON r.hotel_id = h.id
WHERE h.tier = 'own'
AND r.rate_date >= :from_date AND r.rate_date <= :to_date
ORDER BY r.rate_date, r.scraped_at DESC
"""),
{'from_date': start, 'to_date': end}
)
rows = result.fetchall()
if not rows:
return {
'has_own_hotel': False,
'dates_checked': 0,
'dates_available': 0,
'dates_sold_out': 0,
'dates_no_data': 0,
'latest_scrape': None,
'dates': {},
}
dates_map = {}
dates_available = 0
dates_sold_out = 0
dates_no_data = 0
latest_scrape = None
for row in rows:
status = row.availability_status
dates_map[row.rate_date.isoformat()] = {
'status': status,
'rate': float(row.rate_gross) if row.rate_gross else None,
}
if status == 'available':
dates_available += 1
elif status == 'sold_out':
dates_sold_out += 1
else:
dates_no_data += 1
if row.scraped_at and (not latest_scrape or row.scraped_at > latest_scrape):
latest_scrape = row.scraped_at
return {
'has_own_hotel': True,
'dates_checked': len(rows),
'dates_available': dates_available,
'dates_sold_out': dates_sold_out,
'dates_no_data': dates_no_data,
'latest_scrape': latest_scrape.isoformat() if latest_scrape else None,
'dates': dates_map,
}
# ============================================
# HOTEL RATE HISTORY
# ============================================
@router.get("/hotels/{hotel_id}/rate-history/{stay_date}")
async def get_hotel_rate_history(
hotel_id: int,
stay_date: date,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Per-room-type best available rate over time for a single stay date.
Groups by scrape batch so each x-point is one scrape run, not one row.
Returns series: [{room_type, points: [{t, rate}]}]"""
result = await db.execute(
text("""
SELECT
COALESCE(b.started_at, date_trunc('hour', r.scraped_at)) AS scrape_time,
r.room_type,
MIN(r.rate_gross)::float AS best_rate
FROM booking_com_rates r
LEFT JOIN booking_scrape_log b ON b.batch_id = r.scrape_batch_id
WHERE r.hotel_id = :hotel_id
AND r.rate_date = :stay_date
AND r.rate_gross IS NOT NULL
AND r.availability_status = 'available'
GROUP BY COALESCE(b.started_at, date_trunc('hour', r.scraped_at)), r.room_type
ORDER BY scrape_time
"""),
{"hotel_id": hotel_id, "stay_date": stay_date},
)
by_room: dict = {}
for row in result.mappings():
rt = (row["room_type"] or "Unknown").split("\n")[0].strip()
by_room.setdefault(rt, []).append({
"t": row["scrape_time"].isoformat() if row["scrape_time"] else None,
"rate": row["best_rate"],
})
series = [{"room_type": k, "points": v} for k, v in by_room.items()]
series.sort(key=lambda s: s["points"][0]["t"] if s["points"] else "")
return {"stay_date": str(stay_date), "series": series}
# ============================================
# SCRAPE HISTORY
# ============================================
@router.get("/scrape-history")
async def get_scrape_history(
limit: int = 20,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Get recent scrape batch history."""
result = await db.execute(
text("""
SELECT batch_id, scrape_type, started_at, completed_at, status,
dates_queued, dates_completed, dates_failed,
hotels_found, rates_scraped, error_message,
blocked_at, resume_after
FROM booking_scrape_log
ORDER BY started_at DESC
LIMIT :limit
"""),
{'limit': limit}
)
return [
{
'batch_id': str(row.batch_id),
'scrape_type': row.scrape_type,
'started_at': row.started_at.isoformat() if row.started_at else None,
'completed_at': row.completed_at.isoformat() if row.completed_at else None,
'status': row.status,
'dates_queued': row.dates_queued,
'dates_completed': row.dates_completed,
'dates_failed': row.dates_failed,
'hotels_found': row.hotels_found,
'rates_scraped': row.rates_scraped,
'error_message': row.error_message,
'blocked_at': row.blocked_at.isoformat() if row.blocked_at else None,
'resume_after': row.resume_after.isoformat() if row.resume_after else None,
}
for row in result.fetchall()
]