forecasting/backend/api/sync_bookings.py
jtricerolph 75d2c1fa9d 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>
2026-07-04 18:49:34 +00:00

1426 lines
52 KiB
Python

"""
Newbook Bookings Data Sync API endpoints
Handles syncing booking data to newbook_bookings_data table
"""
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, SyncSessionLocal
from auth import get_current_user
logger = logging.getLogger(__name__)
router = APIRouter()
class SyncConfig(BaseModel):
"""Auto sync configuration for bookings"""
enabled: bool
sync_type: str = "incremental" # "incremental" or "full"
sync_time: str = "05:00" # HH:MM format
class OccupancySyncConfig(BaseModel):
"""Auto sync configuration for occupancy data"""
enabled: bool
sync_time: str = "05:00" # HH:MM format
class EarnedRevenueSyncConfig(BaseModel):
"""Auto sync configuration for earned revenue data"""
enabled: bool
sync_time: str = "05:10" # HH:MM format
class CurrentRatesSyncConfig(BaseModel):
"""Auto sync configuration for current rates data (pickup-v2)"""
enabled: bool
sync_time: str = "05:20" # HH:MM format
@router.get("/bookings-data/status")
async def get_bookings_sync_status(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get sync status for newbook 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 = 'newbook' 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 = 'newbook' 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_newbook_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_newbook_bookings_type'")
)
row = result.fetchone()
sync_type = row.config_value if row and row.config_value else 'incremental'
result = await db.execute(
text("SELECT config_value FROM system_config WHERE config_key = 'sync_newbook_bookings_time'")
)
row = result.fetchone()
sync_time = row.config_value if row and row.config_value else '05:00'
# Get total records in table
result = await db.execute(text("SELECT COUNT(*) as count FROM newbook_bookings_data"))
total_records = result.fetchone().count
return {
"last_successful_sync": {
"completed_at": last_success.completed_at if last_success else None,
"records_fetched": last_success.records_fetched if last_success else None,
"records_created": last_success.records_created if last_success 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 if last_sync else None,
"completed_at": last_sync.completed_at if last_sync else None,
"status": last_sync.status if last_sync else None,
"records_fetched": last_sync.records_fetched if last_sync 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,
"type": sync_type,
"time": sync_time
},
"total_records": total_records
}
@router.get("/bookings-data/logs")
async def get_bookings_sync_logs(
limit: int = Query(5, description="Number of logs to return"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get recent sync logs for bookings data.
"""
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 = 'newbook' AND sync_type = 'bookings_data'
ORDER BY started_at DESC
LIMIT :limit
"""),
{"limit": limit}
)
rows = result.fetchall()
return [
{
"id": row.id,
"started_at": row.started_at,
"completed_at": row.completed_at,
"status": row.status,
"records_fetched": row.records_fetched,
"records_created": row.records_created,
"date_from": row.date_from,
"date_to": row.date_to,
"error_message": row.error_message,
"triggered_by": row.triggered_by
}
for row in rows
]
@router.post("/bookings-data/sync")
async def trigger_bookings_sync(
background_tasks: BackgroundTasks,
sync_mode: str = Query("incremental", description="Sync mode: 'incremental', 'staying_range', or 'full'"),
from_date: Optional[date] = Query(None, description="Start date for staying range sync"),
to_date: Optional[date] = Query(None, description="End date for staying range sync"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Trigger a bookings data sync.
Modes:
- incremental: Fetch bookings modified since last successful sync (or last 7 days if no history)
- staying_range: Fetch bookings staying during the specified date range
- full: Fetch all bookings (warning: large dataset)
"""
if sync_mode == "staying_range" and (not from_date or not to_date):
raise HTTPException(status_code=400, detail="from_date and to_date required for staying_range mode")
# Queue background task
background_tasks.add_task(
run_bookings_data_sync,
sync_mode=sync_mode,
from_date=from_date,
to_date=to_date,
triggered_by=f"user:{current_user['username']}"
)
msg = f"Bookings data sync started ({sync_mode})"
if sync_mode == "staying_range":
msg = f"Bookings data sync started for staying period {from_date} to {to_date}"
return {
"status": "started",
"sync_mode": sync_mode,
"from_date": from_date,
"to_date": to_date,
"message": msg
}
@router.post("/bookings-data/config")
async def update_bookings_sync_config(
config: SyncConfig,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Update auto sync configuration for bookings data.
"""
# Upsert enabled setting
await db.execute(
text("""
INSERT INTO system_config (config_key, config_value, description, updated_at, updated_by)
VALUES ('sync_newbook_bookings_enabled', :value, 'Enable automatic Newbook bookings sync', NOW(), :user)
ON CONFLICT (config_key) DO UPDATE SET
config_value = :value,
updated_at = NOW(),
updated_by = :user
"""),
{"value": str(config.enabled).lower(), "user": current_user['username']}
)
# Upsert sync type setting
await db.execute(
text("""
INSERT INTO system_config (config_key, config_value, description, updated_at, updated_by)
VALUES ('sync_newbook_bookings_type', :value, 'Newbook bookings sync type (incremental/full)', NOW(), :user)
ON CONFLICT (config_key) DO UPDATE SET
config_value = :value,
updated_at = NOW(),
updated_by = :user
"""),
{"value": config.sync_type, "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_newbook_bookings_time', :value, 'Newbook 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",
"enabled": config.enabled,
"sync_type": config.sync_type,
"sync_time": config.sync_time
}
def run_bookings_data_sync(
sync_mode: str,
from_date: Optional[date] = None,
to_date: Optional[date] = None,
triggered_by: str = "scheduler"
):
"""
Background task to sync bookings data to newbook_bookings_data table.
Modes:
- incremental: Uses modified_since from last successful sync (or -7 days fallback)
- staying_range: Uses bookings_list with list_type="staying" for date range
- full: Fetches all bookings
"""
import json
import sys
import asyncio
from services.newbook_client import NewbookClient
print(f"[SYNC-BOOKINGS] Starting sync (mode={sync_mode})", flush=True)
sys.stdout.flush()
db = SyncSessionLocal()
try:
# Load credentials
def get_config(key):
result = db.execute(
text("SELECT config_value, is_encrypted FROM system_config WHERE config_key = :key"),
{"key": key}
)
row = result.fetchone()
if row and row.config_value:
if row.is_encrypted:
import base64
try:
return base64.b64decode(row.config_value.encode()).decode()
except:
return row.config_value
return row.config_value
return None
creds = {
'api_key': get_config('newbook_api_key'),
'username': get_config('newbook_username'),
'password': get_config('newbook_password'),
'region': get_config('newbook_region')
}
# Determine modified_since for incremental sync
modified_since = None
if sync_mode == "incremental":
# Get last successful sync
result = db.execute(
text("""
SELECT completed_at FROM sync_log
WHERE source = 'newbook' AND sync_type = 'bookings_data' AND status = 'success'
ORDER BY completed_at DESC LIMIT 1
""")
)
row = result.fetchone()
if row and row.completed_at:
modified_since = row.completed_at.isoformat()
print(f"[SYNC-BOOKINGS] Incremental: fetching since {modified_since}", flush=True)
else:
# Fallback: last 7 days
fallback_date = date.today() - timedelta(days=7)
modified_since = fallback_date.isoformat() + "T00:00:00"
print(f"[SYNC-BOOKINGS] No history, fallback to {modified_since}", flush=True)
# Log sync start
db.execute(
text("""
INSERT INTO sync_log (sync_type, source, started_at, status, date_from, date_to, triggered_by)
VALUES ('bookings_data', 'newbook', NOW(), 'running', :from_date, :to_date, :triggered_by)
"""),
{"from_date": from_date, "to_date": to_date, "triggered_by": triggered_by}
)
db.commit()
print("[SYNC-BOOKINGS] Creating NewbookClient...", flush=True)
async def do_sync():
async with NewbookClient(
api_key=creds['api_key'],
username=creds['username'],
password=creds['password'],
region=creds['region']
) as client:
# Test connection
if not await client.test_connection():
raise Exception("Newbook connection failed")
# Fetch bookings based on mode
if sync_mode == "staying_range" and from_date and to_date:
bookings = await client.get_bookings_by_stay_dates(
from_date=from_date,
to_date=to_date,
list_type="staying"
)
print(f"[SYNC-BOOKINGS] Fetched {len(bookings)} bookings (staying {from_date} to {to_date})", flush=True)
elif sync_mode == "full":
bookings = await client.get_bookings(modified_since=None)
print(f"[SYNC-BOOKINGS] Fetched {len(bookings)} bookings (full sync)", flush=True)
else:
# Incremental
bookings = await client.get_bookings(modified_since=modified_since)
print(f"[SYNC-BOOKINGS] Fetched {len(bookings)} bookings (incremental)", flush=True)
return bookings
# Run async sync - create new event loop for background task
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
bookings = loop.run_until_complete(do_sync())
finally:
loop.close()
records_created = 0
records_updated = 0
print(f"[SYNC-BOOKINGS] Processing {len(bookings)} bookings...", flush=True)
for i, booking in enumerate(bookings):
newbook_id = booking.get("booking_id")
if i > 0 and i % 100 == 0:
print(f"[SYNC-BOOKINGS] Processed {i}/{len(bookings)}", flush=True)
if not newbook_id:
continue
newbook_id = str(newbook_id)
try:
# Create sanitized raw JSON (remove guest PII)
raw_booking = {k: v for k, v in booking.items() if k != "guests"}
raw_json_str = json.dumps(raw_booking)
# Parse dates
arrival_raw = booking.get("booking_arrival")
departure_raw = booking.get("booking_departure")
arrival = arrival_raw.split(" ")[0] if arrival_raw else None
departure = departure_raw.split(" ")[0] if departure_raw else None
# Extract all fields for the extended schema
status = booking.get("booking_status")
category_id = str(booking.get("category_id")) if booking.get("category_id") else None
category_name = booking.get("category_name")
# Check if record exists
existing = db.execute(
text("SELECT id FROM newbook_bookings_data WHERE newbook_id = :nid"),
{"nid": newbook_id}
).fetchone()
# Upsert booking with extended fields
db.execute(
text("""
INSERT INTO newbook_bookings_data (
newbook_id, booking_reference, bookings_group_id,
booking_placed, arrival_date, departure_date, nights,
adults, children, infants, total_guests,
category_id, room_type, site_id, room_number,
status, total_amount, tariff_name, tariff_total,
travel_agent_id, travel_agent_name, travel_agent_commission,
booking_source_id, booking_source_name,
booking_parent_source_id, booking_parent_source_name,
booking_method_id, booking_method_name,
raw_json, fetched_at
) VALUES (
:newbook_id, :reference, :group_id,
:booking_placed, :arrival, :departure, :nights,
:adults, :children, :infants, :total_guests,
:category_id, :room_type, :site_id, :room_number,
:status, :total_amount, :tariff_name, :tariff_total,
:travel_agent_id, :travel_agent_name, :travel_agent_commission,
:source_id, :source_name,
:parent_source_id, :parent_source_name,
:method_id, :method_name,
:raw_json, NOW()
)
ON CONFLICT (newbook_id) DO UPDATE SET
booking_reference = EXCLUDED.booking_reference,
booking_placed = COALESCE(EXCLUDED.booking_placed, newbook_bookings_data.booking_placed),
status = EXCLUDED.status,
total_amount = EXCLUDED.total_amount,
tariff_total = EXCLUDED.tariff_total,
travel_agent_commission = EXCLUDED.travel_agent_commission,
raw_json = EXCLUDED.raw_json,
fetched_at = NOW()
"""),
{
"newbook_id": newbook_id,
"reference": booking.get("booking_reference_id"),
"group_id": str(booking.get("bookings_group_id")) if booking.get("bookings_group_id") else None,
"booking_placed": booking.get("booking_placed"),
"arrival": arrival,
"departure": departure,
"nights": booking.get("booking_length"),
"adults": int(booking.get("booking_adults") or 0),
"children": int(booking.get("booking_children") or 0),
"infants": int(booking.get("booking_infants") or 0),
"total_guests": int(booking.get("booking_adults") or 0) + int(booking.get("booking_children") or 0),
"category_id": category_id,
"room_type": category_name,
"site_id": str(booking.get("site_id")) if booking.get("site_id") else None,
"room_number": booking.get("site_name"),
"status": status,
"total_amount": booking.get("booking_total"),
"tariff_name": booking.get("tariff_name"),
"tariff_total": booking.get("tariff_total"),
"travel_agent_id": str(booking.get("travel_agent_id")) if booking.get("travel_agent_id") else None,
"travel_agent_name": booking.get("travel_agent_name"),
"travel_agent_commission": booking.get("travel_agent_commission"),
"source_id": str(booking.get("booking_source_id")) if booking.get("booking_source_id") else None,
"source_name": booking.get("booking_source_name"),
"parent_source_id": str(booking.get("booking_parent_source_id")) if booking.get("booking_parent_source_id") else None,
"parent_source_name": booking.get("booking_parent_source_name"),
"method_id": str(booking.get("booking_method_id")) if booking.get("booking_method_id") else None,
"method_name": booking.get("booking_method_name"),
"raw_json": raw_json_str
}
)
if existing:
records_updated += 1
else:
records_created += 1
db.commit()
except Exception as booking_error:
print(f"[SYNC-BOOKINGS] Error processing {newbook_id}: {booking_error}", flush=True)
logger.error(f"Error processing booking {newbook_id}: {booking_error}")
db.rollback()
continue
# Update sync log - success
db.execute(
text("""
UPDATE sync_log
SET completed_at = NOW(), status = 'success',
records_fetched = :fetched, records_created = :created, records_updated = :updated
WHERE id = (
SELECT id FROM sync_log
WHERE source = 'newbook' AND sync_type = 'bookings_data' AND status = 'running'
ORDER BY started_at DESC LIMIT 1
)
"""),
{"fetched": len(bookings), "created": records_created, "updated": records_updated}
)
db.commit()
print(f"[SYNC-BOOKINGS] Completed: {records_created} created, {records_updated} updated", flush=True)
logger.info(f"Bookings data sync completed: {records_created} created, {records_updated} updated")
# Trigger bookings aggregation after successful sync
print(f"[SYNC-BOOKINGS] Triggering bookings aggregation...", flush=True)
try:
from jobs.bookings_aggregation import run_bookings_aggregation
agg_loop = asyncio.new_event_loop()
asyncio.set_event_loop(agg_loop)
try:
agg_loop.run_until_complete(run_bookings_aggregation(triggered_by=triggered_by))
print(f"[SYNC-BOOKINGS] Bookings aggregation completed", flush=True)
finally:
agg_loop.close()
except Exception as agg_error:
print(f"[SYNC-BOOKINGS] Aggregation warning: {agg_error}", flush=True)
logger.warning(f"Bookings aggregation failed (non-fatal): {agg_error}")
except Exception as e:
print(f"[SYNC-BOOKINGS] FAILED: {e}", flush=True)
import traceback
traceback.print_exc()
logger.error(f"Bookings data sync failed: {e}")
try:
db.rollback()
db.execute(
text("""
UPDATE sync_log
SET completed_at = NOW(), status = 'failed', error_message = :error
WHERE id = (
SELECT id FROM sync_log
WHERE source = 'newbook' AND sync_type = 'bookings_data' AND status = 'running'
ORDER BY started_at DESC LIMIT 1
)
"""),
{"error": str(e)[:500]}
)
db.commit()
except Exception as log_error:
logger.error(f"Failed to update sync_log: {log_error}")
raise
finally:
db.close()
# ============================================
# OCCUPANCY DATA SYNC ENDPOINTS
# ============================================
@router.get("/occupancy-data/status")
async def get_occupancy_sync_status(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get sync status for newbook occupancy report 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 = 'newbook' AND sync_type = 'occupancy_report'
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 = 'newbook' AND sync_type = 'occupancy_report'
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_newbook_occupancy_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_newbook_occupancy_time'")
)
row = result.fetchone()
sync_time = row.config_value if row and row.config_value else '05:00'
# Get total records in table
result = await db.execute(text("SELECT COUNT(*) as count FROM newbook_occupancy_report_data"))
total_records = result.fetchone().count
# Get date range of data
result = await db.execute(
text("SELECT MIN(date) as min_date, MAX(date) as max_date FROM newbook_occupancy_report_data")
)
date_range = result.fetchone()
return {
"last_successful_sync": {
"completed_at": last_success.completed_at if last_success else None,
"records_fetched": last_success.records_fetched if last_success else None,
"records_created": last_success.records_created if last_success else None,
"date_from": last_success.date_from if last_success else None,
"date_to": last_success.date_to if last_success 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 if last_sync else None,
"completed_at": last_sync.completed_at if last_sync 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 if last_sync else None,
"date_to": last_sync.date_to if last_sync 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 if date_range else None,
"to": date_range.max_date if date_range else None
}
}
@router.get("/occupancy-data/logs")
async def get_occupancy_sync_logs(
limit: int = Query(5, description="Number of logs to return"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get recent sync logs for occupancy report data.
"""
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 = 'newbook' AND sync_type = 'occupancy_report'
ORDER BY started_at DESC
LIMIT :limit
"""),
{"limit": limit}
)
rows = result.fetchall()
return [
{
"id": row.id,
"started_at": row.started_at,
"completed_at": row.completed_at,
"status": row.status,
"records_fetched": row.records_fetched,
"records_created": row.records_created,
"date_from": row.date_from,
"date_to": row.date_to,
"error_message": row.error_message,
"triggered_by": row.triggered_by
}
for row in rows
]
@router.post("/occupancy-data/sync")
async def trigger_occupancy_sync(
background_tasks: BackgroundTasks,
from_date: Optional[date] = Query(None, description="Start date for sync (default: today - 7 days)"),
to_date: Optional[date] = Query(None, description="End date for sync (default: today + 365 days)"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Trigger an occupancy report data sync.
If dates not provided, defaults to -7 to +365 days from today.
"""
# Default date range
if not from_date:
from_date = date.today() - timedelta(days=7)
if not to_date:
to_date = date.today() + timedelta(days=365)
# Queue background task
background_tasks.add_task(
run_occupancy_data_sync,
from_date=from_date,
to_date=to_date,
triggered_by=f"user:{current_user['username']}"
)
return {
"status": "started",
"from_date": from_date,
"to_date": to_date,
"message": f"Occupancy data sync started for {from_date} to {to_date}"
}
@router.post("/occupancy-data/config")
async def update_occupancy_sync_config(
config: OccupancySyncConfig,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Update auto sync configuration for occupancy report data.
"""
# Upsert enabled setting
await db.execute(
text("""
INSERT INTO system_config (config_key, config_value, description, updated_at, updated_by)
VALUES ('sync_newbook_occupancy_enabled', :value, 'Enable automatic Newbook occupancy sync', NOW(), :user)
ON CONFLICT (config_key) DO UPDATE SET
config_value = :value,
updated_at = NOW(),
updated_by = :user
"""),
{"value": str(config.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_newbook_occupancy_time', :value, 'Newbook occupancy 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",
"enabled": config.enabled,
"sync_time": config.sync_time
}
def run_occupancy_data_sync(
from_date: date,
to_date: date,
triggered_by: str = "scheduler"
):
"""
Background task to sync occupancy report data to newbook_occupancy_report_data table.
"""
import sys
import asyncio
from jobs.data_sync import sync_newbook_occupancy_report
print(f"[SYNC-OCCUPANCY] Starting sync ({from_date} to {to_date})", flush=True)
sys.stdout.flush()
# Run async sync - create new event loop for background task
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(
sync_newbook_occupancy_report(from_date, to_date, triggered_by)
)
print(f"[SYNC-OCCUPANCY] Sync completed", flush=True)
except Exception as e:
print(f"[SYNC-OCCUPANCY] FAILED: {e}", flush=True)
import traceback
traceback.print_exc()
raise
finally:
loop.close()
# ============================================
# EARNED REVENUE DATA SYNC ENDPOINTS
# ============================================
@router.get("/earned-revenue-data/status")
async def get_earned_revenue_sync_status(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get sync status for newbook earned revenue 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 = 'newbook' AND sync_type = 'earned_revenue'
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 = 'newbook' AND sync_type = 'earned_revenue'
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_newbook_earned_revenue_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_newbook_earned_revenue_time'")
)
row = result.fetchone()
sync_time = row.config_value if row and row.config_value else '05:10'
# Get total records in table
result = await db.execute(text("SELECT COUNT(*) as count FROM newbook_earned_revenue_data"))
total_records = result.fetchone().count
# Get date range of data
result = await db.execute(
text("SELECT MIN(date) as min_date, MAX(date) as max_date FROM newbook_earned_revenue_data")
)
date_range = result.fetchone()
return {
"last_successful_sync": {
"completed_at": last_success.completed_at if last_success else None,
"records_fetched": last_success.records_fetched if last_success else None,
"records_created": last_success.records_created if last_success else None,
"date_from": last_success.date_from if last_success else None,
"date_to": last_success.date_to if last_success 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 if last_sync else None,
"completed_at": last_sync.completed_at if last_sync 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 if last_sync else None,
"date_to": last_sync.date_to if last_sync 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 if date_range else None,
"to": date_range.max_date if date_range else None
}
}
@router.get("/earned-revenue-data/logs")
async def get_earned_revenue_sync_logs(
limit: int = Query(5, description="Number of logs to return"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get recent sync logs for earned revenue data.
"""
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 = 'newbook' AND sync_type = 'earned_revenue'
ORDER BY started_at DESC
LIMIT :limit
"""),
{"limit": limit}
)
rows = result.fetchall()
return [
{
"id": row.id,
"started_at": row.started_at,
"completed_at": row.completed_at,
"status": row.status,
"records_fetched": row.records_fetched,
"records_created": row.records_created,
"date_from": row.date_from,
"date_to": row.date_to,
"error_message": row.error_message,
"triggered_by": row.triggered_by
}
for row in rows
]
@router.post("/earned-revenue-data/sync")
async def trigger_earned_revenue_sync(
background_tasks: BackgroundTasks,
from_date: Optional[date] = Query(None, description="Start date for sync (default: today - 7 days)"),
to_date: Optional[date] = Query(None, description="End date for sync (default: today)"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Trigger an earned revenue data sync.
If dates not provided, defaults to last 7 days (catches backdated adjustments).
"""
# Default date range - last 7 days (revenue is historical)
if not from_date:
from_date = date.today() - timedelta(days=7)
if not to_date:
to_date = date.today()
# Queue background task
background_tasks.add_task(
run_earned_revenue_data_sync,
from_date=from_date,
to_date=to_date,
triggered_by=f"user:{current_user['username']}"
)
return {
"status": "started",
"from_date": from_date,
"to_date": to_date,
"message": f"Earned revenue sync started for {from_date} to {to_date}"
}
@router.post("/earned-revenue-data/config")
async def update_earned_revenue_sync_config(
config: EarnedRevenueSyncConfig,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Update auto sync configuration for earned revenue data.
"""
# Upsert enabled setting
await db.execute(
text("""
INSERT INTO system_config (config_key, config_value, description, updated_at, updated_by)
VALUES ('sync_newbook_earned_revenue_enabled', :value, 'Enable automatic Newbook earned revenue sync', NOW(), :user)
ON CONFLICT (config_key) DO UPDATE SET
config_value = :value,
updated_at = NOW(),
updated_by = :user
"""),
{"value": str(config.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_newbook_earned_revenue_time', :value, 'Newbook earned revenue 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",
"enabled": config.enabled,
"sync_time": config.sync_time
}
def run_earned_revenue_data_sync(
from_date: date,
to_date: date,
triggered_by: str = "scheduler"
):
"""
Background task to sync earned revenue data to newbook_earned_revenue_data table.
"""
import sys
import asyncio
from jobs.data_sync import sync_newbook_earned_revenue
print(f"[SYNC-EARNED-REV] Starting sync ({from_date} to {to_date})", flush=True)
sys.stdout.flush()
# Run async sync - create new event loop for background task
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(
sync_newbook_earned_revenue(from_date, to_date, triggered_by)
)
print(f"[SYNC-EARNED-REV] Sync completed", flush=True)
except Exception as e:
print(f"[SYNC-EARNED-REV] FAILED: {e}", flush=True)
import traceback
traceback.print_exc()
raise
finally:
loop.close()
# ============================================
# CURRENT RATES SYNC (Pickup-V2)
# ============================================
@router.get("/current-rates/status")
async def get_current_rates_sync_status(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get sync status for Newbook current rates data.
Used for pickup-v2 upper bound calculations.
"""
# Auto-clear stuck syncs (running for more than 60 minutes)
await db.execute(
text("""
UPDATE sync_log
SET status = 'failed', completed_at = NOW(),
error_message = 'Auto-cleared: sync stuck for more than 60 minutes'
WHERE source = 'newbook' AND sync_type = 'current_rates'
AND status = 'running'
AND started_at < NOW() - INTERVAL '60 minutes'
""")
)
await db.commit()
# Get last successful sync
result = await db.execute(
text("""
SELECT id, sync_type, started_at, completed_at, status,
records_fetched, records_created, records_updated,
error_message, triggered_by
FROM sync_log
WHERE source = 'newbook' AND sync_type = 'current_rates'
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,
error_message, triggered_by
FROM sync_log
WHERE source = 'newbook' AND sync_type = 'current_rates'
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_newbook_current_rates_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_newbook_current_rates_time'")
)
row = result.fetchone()
sync_time = row.config_value if row and row.config_value else '05:20'
# Get total records in table
result = await db.execute(text("SELECT COUNT(*) as count FROM newbook_current_rates"))
total_records = result.fetchone().count
# Get date range of data
result = await db.execute(
text("SELECT MIN(rate_date) as min_date, MAX(rate_date) as max_date FROM newbook_current_rates")
)
date_range = result.fetchone()
# Get count by category
result = await db.execute(
text("SELECT category_id, COUNT(*) as count FROM newbook_current_rates GROUP BY category_id ORDER BY category_id")
)
category_counts = {row.category_id: row.count for row in result.fetchall()}
return {
"last_successful_sync": {
"completed_at": last_success.completed_at if last_success else None,
"records_fetched": last_success.records_fetched if last_success else None,
"records_created": last_success.records_created if last_success 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 if last_sync else None,
"completed_at": last_sync.completed_at if last_sync else None,
"status": last_sync.status if last_sync else None,
"records_fetched": last_sync.records_fetched if last_sync 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 if date_range else None,
"to": date_range.max_date if date_range else None
},
"category_counts": category_counts
}
@router.get("/current-rates/logs")
async def get_current_rates_sync_logs(
limit: int = Query(5, description="Number of logs to return"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get recent sync logs for current rates data.
"""
result = await db.execute(
text("""
SELECT id, sync_type, started_at, completed_at, status,
records_fetched, records_created, records_updated,
error_message, triggered_by
FROM sync_log
WHERE source = 'newbook' AND sync_type = 'current_rates'
ORDER BY started_at DESC
LIMIT :limit
"""),
{"limit": limit}
)
rows = result.fetchall()
return [
{
"id": row.id,
"started_at": row.started_at,
"completed_at": row.completed_at,
"status": row.status,
"records_fetched": row.records_fetched,
"records_created": row.records_created,
"error_message": row.error_message,
"triggered_by": row.triggered_by
}
for row in rows
]
class CurrentRatesSyncRequest(BaseModel):
horizon_days: Optional[int] = None # None = full 720-day run
@router.post("/current-rates/sync")
async def trigger_current_rates_sync(
background_tasks: BackgroundTasks,
request: CurrentRatesSyncRequest = CurrentRatesSyncRequest(),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Trigger a current rates sync from Newbook API.
Optional horizon_days param for manual range (default: 720 days).
"""
horizon_days = request.horizon_days or 720
# Auto-clear stuck syncs (running for more than 60 minutes)
await db.execute(
text("""
UPDATE sync_log
SET status = 'failed', completed_at = NOW(),
error_message = 'Auto-cleared: sync stuck for more than 60 minutes'
WHERE source = 'newbook' AND sync_type = 'current_rates'
AND status = 'running'
AND started_at < NOW() - INTERVAL '60 minutes'
""")
)
await db.commit()
# Check if a sync is already running
result = await db.execute(
text("""
SELECT id, started_at FROM sync_log
WHERE source = 'newbook' AND sync_type = 'current_rates'
AND status = 'running'
ORDER BY started_at DESC
LIMIT 1
""")
)
running = result.fetchone()
if running:
raise HTTPException(
status_code=409,
detail=f"A sync is already running (started at {running.started_at}). Please wait for it to complete."
)
# Queue background task
background_tasks.add_task(
run_current_rates_sync,
triggered_by=f"user:{current_user['username']}",
horizon_days=horizon_days
)
return {
"status": "started",
"message": f"Current rates sync started - fetching rates for next {horizon_days} days"
}
@router.post("/current-rates/config")
async def update_current_rates_sync_config(
config: CurrentRatesSyncConfig,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Update auto sync configuration for current rates data.
"""
# Upsert enabled setting
await db.execute(
text("""
INSERT INTO system_config (config_key, config_value, description, updated_at, updated_by)
VALUES ('sync_newbook_current_rates_enabled', :value, 'Enable automatic Newbook current rates sync', NOW(), :user)
ON CONFLICT (config_key) DO UPDATE SET
config_value = :value,
updated_at = NOW(),
updated_by = :user
"""),
{"value": str(config.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_newbook_current_rates_time', :value, 'Newbook current rates 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",
"enabled": config.enabled,
"sync_time": config.sync_time
}
@router.post("/current-rates/cancel")
async def cancel_current_rates_sync(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Cancel/clear a stuck current rates sync.
Marks any running sync as failed so a new sync can be started.
"""
result = await db.execute(
text("""
UPDATE sync_log
SET status = 'failed',
completed_at = NOW(),
error_message = 'Manually cancelled by user'
WHERE source = 'newbook'
AND sync_type = 'current_rates'
AND status = 'running'
RETURNING id, started_at
""")
)
cancelled_rows = result.fetchall()
await db.commit()
if cancelled_rows:
return {
"status": "success",
"message": f"Cancelled {len(cancelled_rows)} running sync(s)",
"cancelled_ids": [row.id for row in cancelled_rows]
}
else:
return {
"status": "success",
"message": "No running sync to cancel"
}
def run_current_rates_sync(triggered_by: str = "scheduler", horizon_days: int = 720):
"""
Background task to sync current rates from Newbook API.
"""
import sys
import asyncio
from jobs.fetch_current_rates import run_fetch_current_rates
from database import SyncSessionLocal
from sqlalchemy import text
print(f"[SYNC-CURRENT-RATES] Starting sync ({horizon_days} days)", flush=True)
sys.stdout.flush()
db = SyncSessionLocal()
log_id = None
try:
# Create sync log entry
result = db.execute(
text("""
INSERT INTO sync_log (source, sync_type, started_at, status, triggered_by)
VALUES ('newbook', 'current_rates', NOW(), 'running', :triggered_by)
RETURNING id
"""),
{"triggered_by": triggered_by}
)
log_id = result.fetchone().id
db.commit()
# Run async sync
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(run_fetch_current_rates(horizon_days=horizon_days))
print(f"[SYNC-CURRENT-RATES] Sync completed ({horizon_days} days)", flush=True)
# Count records
result = db.execute(text("SELECT COUNT(*) as count FROM newbook_current_rates"))
total_records = result.fetchone().count
# Update log as success
db.execute(
text("""
UPDATE sync_log
SET completed_at = NOW(), status = 'success',
records_fetched = :count, records_created = :count
WHERE id = :id
"""),
{"id": log_id, "count": total_records}
)
db.commit()
except Exception as e:
print(f"[SYNC-CURRENT-RATES] FAILED: {e}", flush=True)
import traceback
traceback.print_exc()
# Update log as failed
if log_id:
db.execute(
text("""
UPDATE sync_log
SET completed_at = NOW(), status = 'failed', error_message = :error
WHERE id = :id
"""),
{"id": log_id, "error": str(e)[:500]}
)
db.commit()
raise
finally:
loop.close()
finally:
db.close()