Forecasting app: hybrid port to HNF stack

Python FastAPI ML backend kept intact; auth replaced with central hnf_session cookie verification. Frontend rebuilt on React 18 + TS + Vite with stack design system, Plotly charts retained. Shared Postgres via DATABASE_URL; schema applied on startup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-04 18:49:34 +00:00
commit 75d2c1fa9d
103 changed files with 70316 additions and 0 deletions

244
backend/api/resos_sync.py Normal file
View file

@ -0,0 +1,244 @@
"""
Resos Bookings Sync API Endpoints
Pattern: Similar to sync_bookings.py but for Resos
"""
import logging
from datetime import date, timedelta
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, BackgroundTasks
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from pydantic import BaseModel
from database import get_db
from auth import get_current_user
logger = logging.getLogger(__name__)
router = APIRouter()
class ResosSyncConfig(BaseModel):
"""Auto sync configuration for Resos bookings"""
auto_sync_enabled: bool
sync_time: str = "05:05" # HH:MM format
@router.get("/resos-bookings/status")
async def get_resos_sync_status(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Get sync status for Resos bookings data including last sync info."""
# Get last successful sync
result = await db.execute(
text("""
SELECT id, sync_type, started_at, completed_at, status,
records_fetched, records_created, records_updated,
date_from, date_to, error_message, triggered_by
FROM sync_log
WHERE source = 'resos' AND sync_type = 'bookings_data'
AND status = 'success'
ORDER BY completed_at DESC
LIMIT 1
""")
)
last_success = result.fetchone()
# Get last sync (any status)
result = await db.execute(
text("""
SELECT id, sync_type, started_at, completed_at, status,
records_fetched, records_created, records_updated,
date_from, date_to, error_message, triggered_by
FROM sync_log
WHERE source = 'resos' AND sync_type = 'bookings_data'
ORDER BY started_at DESC
LIMIT 1
""")
)
last_sync = result.fetchone()
# Get auto sync config
result = await db.execute(
text("SELECT config_value FROM system_config WHERE config_key = 'sync_resos_bookings_enabled'")
)
row = result.fetchone()
auto_enabled = row.config_value.lower() == 'true' if row and row.config_value else False
result = await db.execute(
text("SELECT config_value FROM system_config WHERE config_key = 'sync_resos_bookings_time'")
)
row = result.fetchone()
sync_time = row.config_value if row and row.config_value else '05:05'
# Get total records
result = await db.execute(text("SELECT COUNT(*) as count FROM resos_bookings_data"))
total_records = result.fetchone().count
# Get date range
result = await db.execute(
text("SELECT MIN(booking_date) as min_date, MAX(booking_date) as max_date FROM resos_bookings_data")
)
date_range = result.fetchone()
return {
"last_successful_sync": {
"completed_at": last_success.completed_at.isoformat() if last_success and last_success.completed_at else None,
"records_fetched": last_success.records_fetched if last_success else None,
"records_created": last_success.records_created if last_success else None,
"records_updated": last_success.records_updated if last_success else None,
"date_from": last_success.date_from.isoformat() if last_success and last_success.date_from else None,
"date_to": last_success.date_to.isoformat() if last_success and last_success.date_to else None,
"triggered_by": last_success.triggered_by if last_success else None,
} if last_success else None,
"last_sync": {
"started_at": last_sync.started_at.isoformat() if last_sync and last_sync.started_at else None,
"completed_at": last_sync.completed_at.isoformat() if last_sync and last_sync.completed_at else None,
"status": last_sync.status if last_sync else None,
"records_fetched": last_sync.records_fetched if last_sync else None,
"date_from": last_sync.date_from.isoformat() if last_sync and last_sync.date_from else None,
"date_to": last_sync.date_to.isoformat() if last_sync and last_sync.date_to else None,
"error_message": last_sync.error_message if last_sync else None,
"triggered_by": last_sync.triggered_by if last_sync else None,
} if last_sync else None,
"auto_sync": {
"enabled": auto_enabled,
"time": sync_time
},
"total_records": total_records,
"data_range": {
"from": date_range.min_date.isoformat() if date_range and date_range.min_date else None,
"to": date_range.max_date.isoformat() if date_range and date_range.max_date else None
}
}
@router.post("/resos-bookings/sync")
async def trigger_resos_sync(
background_tasks: BackgroundTasks,
from_date: Optional[date] = Query(None, description="Start date (default: today - 365)"),
to_date: Optional[date] = Query(None, description="End date (default: today + 365)"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Trigger a Resos bookings sync.
Default: -365 to +365 days (historical + forecast window)
"""
if not from_date:
from_date = date.today() - timedelta(days=365)
if not to_date:
to_date = date.today() + timedelta(days=365)
# Queue background task
background_tasks.add_task(
run_resos_sync_task,
from_date=from_date,
to_date=to_date,
triggered_by=f"user:{current_user['username']}"
)
return {
"status": "started",
"from_date": from_date.isoformat(),
"to_date": to_date.isoformat(),
"message": f"Resos bookings sync started for {from_date} to {to_date}"
}
@router.get("/resos-bookings/config")
async def get_resos_sync_config(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Get auto sync configuration for Resos bookings."""
# Get enabled setting
result = await db.execute(
text("SELECT config_value FROM system_config WHERE config_key = 'sync_resos_bookings_enabled'")
)
row = result.fetchone()
enabled = row.config_value.lower() == 'true' if row and row.config_value else False
# Get sync time setting
result = await db.execute(
text("SELECT config_value FROM system_config WHERE config_key = 'sync_resos_bookings_time'")
)
row = result.fetchone()
sync_time = row.config_value if row and row.config_value else '05:05'
return {
"auto_sync_enabled": enabled,
"sync_time": sync_time
}
@router.post("/resos-bookings/config")
async def update_resos_sync_config(
config: ResosSyncConfig,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Update auto sync configuration for Resos bookings."""
# Upsert enabled setting
await db.execute(
text("""
INSERT INTO system_config (config_key, config_value, description, updated_at, updated_by)
VALUES ('sync_resos_bookings_enabled', :value, 'Enable automatic Resos bookings sync', NOW(), :user)
ON CONFLICT (config_key) DO UPDATE SET
config_value = :value,
updated_at = NOW(),
updated_by = :user
"""),
{"value": str(config.auto_sync_enabled).lower(), "user": current_user['username']}
)
# Upsert sync time setting
await db.execute(
text("""
INSERT INTO system_config (config_key, config_value, description, updated_at, updated_by)
VALUES ('sync_resos_bookings_time', :value, 'Resos bookings sync time (HH:MM)', NOW(), :user)
ON CONFLICT (config_key) DO UPDATE SET
config_value = :value,
updated_at = NOW(),
updated_by = :user
"""),
{"value": config.sync_time, "user": current_user['username']}
)
await db.commit()
return {
"status": "success",
"auto_sync_enabled": config.auto_sync_enabled,
"sync_time": config.sync_time
}
def run_resos_sync_task(
from_date: date,
to_date: date,
triggered_by: str = "scheduler"
):
"""Background task to run Resos sync."""
import sys
import asyncio
from jobs.resos_bookings_sync import sync_resos_bookings_data
print(f"[SYNC-RESOS] Starting sync ({from_date} to {to_date})", flush=True)
sys.stdout.flush()
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(
sync_resos_bookings_data(from_date, to_date, triggered_by)
)
print(f"[SYNC-RESOS] Sync completed", flush=True)
except Exception as e:
print(f"[SYNC-RESOS] FAILED: {e}", flush=True)
import traceback
traceback.print_exc()
raise
finally:
loop.close()