Deletes bookability.py, competitor_rates.py, Bookability.tsx, CompetitorRates.tsx. Removes their imports, router mounts, and nav items. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
158 lines
6.4 KiB
Python
158 lines
6.4 KiB
Python
"""
|
|
Forecasting Application — FastAPI Backend
|
|
Auth is handled by the central HNF stack cookie (hnf_session).
|
|
"""
|
|
import logging
|
|
import sys
|
|
from contextlib import asynccontextmanager
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
|
handlers=[logging.StreamHandler(sys.stdout)]
|
|
)
|
|
|
|
from fastapi import FastAPI
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from sqlalchemy import text
|
|
|
|
from database import async_engine
|
|
from api import (
|
|
forecast, sync, export, budget, accuracy, evolution, crossref,
|
|
explain, config, historical, resos, backtest, sync_bookings,
|
|
resos_sync, reports, special_dates, backup, public, ai_insights,
|
|
)
|
|
from scheduler import start_scheduler, shutdown_scheduler
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
# Apply database schema (idempotent CREATE TABLE IF NOT EXISTS)
|
|
# Uses psycopg2 directly — schema.sql contains PL/pgSQL with $1/$2 syntax
|
|
# that SQLAlchemy text() misinterprets as bindparams.
|
|
try:
|
|
import os, psycopg2
|
|
from database import DATABASE_URL
|
|
schema_path = os.path.join(os.path.dirname(__file__), 'schema.sql')
|
|
if os.path.exists(schema_path):
|
|
with open(schema_path) as f:
|
|
sql = f.read()
|
|
conn = psycopg2.connect(DATABASE_URL)
|
|
try:
|
|
conn.autocommit = True
|
|
with conn.cursor() as cur:
|
|
cur.execute(sql)
|
|
logging.getLogger(__name__).info("Schema applied successfully")
|
|
finally:
|
|
conn.close()
|
|
except Exception as e:
|
|
logging.getLogger(__name__).warning(f"Schema init failed: {e}")
|
|
|
|
# Startup: clean up stale scrape batches
|
|
try:
|
|
from services.booking_scraper import cleanup_stale_batches
|
|
from database import SyncSessionLocal
|
|
db = SyncSessionLocal()
|
|
try:
|
|
cleanup_stale_batches(db, max_age_minutes=10)
|
|
finally:
|
|
db.close()
|
|
except Exception as e:
|
|
logging.getLogger(__name__).warning(f"Stale batch cleanup failed: {e}")
|
|
|
|
# Startup: mark any sync_log entries stuck as 'running' as failed
|
|
# (these are orphaned by container restarts mid-job)
|
|
try:
|
|
import psycopg2
|
|
from database import DATABASE_URL
|
|
conn = psycopg2.connect(DATABASE_URL)
|
|
try:
|
|
conn.autocommit = True
|
|
with conn.cursor() as cur:
|
|
cur.execute("""
|
|
UPDATE sync_log
|
|
SET status = 'failed',
|
|
completed_at = NOW(),
|
|
error_message = 'Interrupted by container restart'
|
|
WHERE status = 'running' AND completed_at IS NULL
|
|
""")
|
|
if cur.rowcount:
|
|
logging.getLogger(__name__).info(f"Cleared {cur.rowcount} stale sync_log entries")
|
|
finally:
|
|
conn.close()
|
|
except Exception as e:
|
|
logging.getLogger(__name__).warning(f"Stale sync_log cleanup failed: {e}")
|
|
|
|
# Ensure ai_insights table exists
|
|
try:
|
|
from database import SyncSessionLocal
|
|
db = SyncSessionLocal()
|
|
try:
|
|
db.execute(text("""
|
|
CREATE TABLE IF NOT EXISTS ai_insights (
|
|
id SERIAL PRIMARY KEY,
|
|
generated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
insight_type VARCHAR(50) NOT NULL DEFAULT 'daily_summary',
|
|
content TEXT NOT NULL,
|
|
model VARCHAR(100),
|
|
input_tokens INTEGER,
|
|
output_tokens INTEGER,
|
|
data_snapshot JSONB,
|
|
triggered_by VARCHAR(50) DEFAULT 'scheduler'
|
|
)
|
|
"""))
|
|
db.execute(text("""
|
|
CREATE INDEX IF NOT EXISTS idx_ai_insights_generated
|
|
ON ai_insights(generated_at DESC)
|
|
"""))
|
|
db.commit()
|
|
finally:
|
|
db.close()
|
|
except Exception as e:
|
|
logging.getLogger(__name__).warning(f"AI insights table creation failed: {e}")
|
|
|
|
start_scheduler()
|
|
yield
|
|
shutdown_scheduler()
|
|
|
|
|
|
app = FastAPI(
|
|
title="Forecasting API",
|
|
description="Hotel & Restaurant Forecasting Service",
|
|
version="2.0.0",
|
|
lifespan=lifespan,
|
|
)
|
|
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["*"],
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
# All routes — nginx strips /forecasting/api/ prefix before reaching here
|
|
app.include_router(forecast.router, prefix="/forecast", tags=["Forecasts"])
|
|
app.include_router(sync.router, prefix="/sync", tags=["Data Sync"])
|
|
app.include_router(export.router, prefix="/export", tags=["Exports"])
|
|
app.include_router(budget.router, prefix="/budget", tags=["Budgets"])
|
|
app.include_router(accuracy.router, prefix="/accuracy", tags=["Accuracy"])
|
|
app.include_router(evolution.router, prefix="/evolution", tags=["Forecast Evolution"])
|
|
app.include_router(crossref.router, prefix="/crossref", tags=["Cross-Reference"])
|
|
app.include_router(explain.router, prefix="/explain", tags=["Explainability"])
|
|
app.include_router(config.router, prefix="/config", tags=["Configuration"])
|
|
app.include_router(historical.router, prefix="/historical", tags=["Historical Data"])
|
|
app.include_router(resos.router, prefix="/resos", tags=["Resos Mapping"])
|
|
app.include_router(backtest.router, prefix="/backtest", tags=["Backtesting"])
|
|
app.include_router(sync_bookings.router, prefix="/sync", tags=["Data Sync"])
|
|
app.include_router(resos_sync.router, prefix="/sync", tags=["Data Sync"])
|
|
app.include_router(reports.router, prefix="/reports", tags=["Reports"])
|
|
app.include_router(special_dates.router, prefix="/settings", tags=["Settings"])
|
|
app.include_router(backup.router, prefix="/backup", tags=["Backup & Restore"])
|
|
app.include_router(public.router, prefix="/public", tags=["Public API"])
|
|
app.include_router(ai_insights.router, prefix="/ai-insights", tags=["AI Insights"])
|
|
|
|
|
|
@app.get("/health")
|
|
async def health_check():
|
|
return {"status": "healthy", "service": "forecasting-api"}
|