forecasting/backend/api/config.py
jtricerolph 3650c92175 Move Resos API key to central Settings service; add sidebar scrollbar styling
Removes the standalone resos_api_key from the forecasting app's own
system_config table. All credential fetches now go through
central_settings.get_resos_credentials() / get_resos_credentials_sync()
which pull from the Settings app (LXC 116) via the internal integration
endpoint — the same pattern already used for NewBook. The Resos API
Config section is removed from the forecasting Settings page; users
manage the key in the central Settings app instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-13 14:35:35 +00:00

1239 lines
42 KiB
Python

"""
Configuration API endpoints
Manage system configuration including API credentials
"""
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, UploadFile, File, Form
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from pydantic import BaseModel
import base64
import io
import logging
from database import get_db
from auth import get_current_user, get_all_api_keys, create_api_key, revoke_api_key, delete_api_key
router = APIRouter()
logger = logging.getLogger(__name__)
# ============================================
# NEWBOOK SETTINGS ENDPOINTS
# ============================================
class NewbookSettingsResponse(BaseModel):
newbook_api_key: Optional[str] = None
newbook_api_key_set: bool = False
newbook_username: Optional[str] = None
newbook_password_set: bool = False
newbook_region: Optional[str] = None
class NewbookSettingsUpdate(BaseModel):
newbook_api_key: Optional[str] = None
newbook_username: Optional[str] = None
newbook_password: Optional[str] = None
newbook_region: Optional[str] = None
@router.get("/settings/newbook", response_model=NewbookSettingsResponse)
async def get_newbook_settings(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Get all Newbook settings in one response"""
result = await db.execute(
text("""
SELECT config_key, config_value, is_encrypted
FROM system_config
WHERE config_key IN ('newbook_api_key', 'newbook_username', 'newbook_password', 'newbook_region')
""")
)
rows = result.fetchall()
settings = {}
for row in rows:
key = row.config_key
value = row.config_value
is_encrypted = row.is_encrypted
if key == 'newbook_api_key':
settings['newbook_api_key'] = None
settings['newbook_api_key_set'] = bool(value)
elif key == 'newbook_username':
settings['newbook_username'] = value
elif key == 'newbook_password':
settings['newbook_password_set'] = bool(value)
elif key == 'newbook_region':
settings['newbook_region'] = value
return NewbookSettingsResponse(**settings)
@router.post("/settings/newbook")
async def update_newbook_settings(
settings: NewbookSettingsUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Update Newbook settings"""
username = current_user.get("username")
# Only update fields that are provided (not None)
if settings.newbook_api_key is not None:
encrypted_value = simple_encrypt(settings.newbook_api_key)
await db.execute(
text("""
UPDATE system_config
SET config_value = :value, is_encrypted = true, updated_at = NOW(), updated_by = :username
WHERE config_key = 'newbook_api_key'
"""),
{"value": encrypted_value, "username": username}
)
if settings.newbook_username is not None:
await db.execute(
text("""
UPDATE system_config
SET config_value = :value, updated_at = NOW(), updated_by = :username
WHERE config_key = 'newbook_username'
"""),
{"value": settings.newbook_username, "username": username}
)
if settings.newbook_password is not None:
encrypted_value = simple_encrypt(settings.newbook_password)
await db.execute(
text("""
UPDATE system_config
SET config_value = :value, is_encrypted = true, updated_at = NOW(), updated_by = :username
WHERE config_key = 'newbook_password'
"""),
{"value": encrypted_value, "username": username}
)
if settings.newbook_region is not None:
await db.execute(
text("""
UPDATE system_config
SET config_value = :value, updated_at = NOW(), updated_by = :username
WHERE config_key = 'newbook_region'
"""),
{"value": settings.newbook_region, "username": username}
)
await db.commit()
return {"status": "saved", "message": "Newbook settings updated"}
@router.post("/settings/newbook/test")
async def test_newbook_settings(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Test Newbook connection with current settings"""
return await _test_newbook(db)
# ============================================
# ROOM CATEGORIES ENDPOINTS
# ============================================
from typing import List
class RoomCategoryResponse(BaseModel):
id: int
site_id: str
site_name: str
site_type: Optional[str] = None
room_count: int = 0
is_included: bool = True
display_order: int = 0
class Config:
from_attributes = True
class RoomCategoryUpdate(BaseModel):
id: int
is_included: Optional[bool] = None
display_order: Optional[int] = None
class RoomCategoryBulkUpdate(BaseModel):
updates: List[RoomCategoryUpdate]
@router.get("/room-categories", response_model=List[RoomCategoryResponse])
async def get_room_categories(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Get all room categories"""
result = await db.execute(
text("""
SELECT id, site_id, site_name, site_type, room_count, is_included, display_order
FROM newbook_room_categories
ORDER BY display_order, site_name
""")
)
rows = result.fetchall()
return [
{
"id": row.id,
"site_id": row.site_id,
"site_name": row.site_name,
"site_type": row.site_type,
"room_count": row.room_count,
"is_included": row.is_included,
"display_order": row.display_order
}
for row in rows
]
@router.post("/room-categories/fetch")
async def fetch_room_categories(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Fetch room categories from Newbook API site_list endpoint"""
import httpx
from services.central_settings import get_newbook_credentials
central = await get_newbook_credentials()
if central:
api_key, username, password, region = central["api_key"], central["username"], central["password"], central["region"]
else:
api_key = await _get_config_value(db, "newbook_api_key")
username = await _get_config_value(db, "newbook_username")
password = await _get_config_value(db, "newbook_password")
region = await _get_config_value(db, "newbook_region")
if not all([api_key, username, password, region]):
raise HTTPException(status_code=400, detail="Newbook credentials not configured")
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
"https://api.newbook.cloud/rest/site_list",
json={
"region": region,
"api_key": api_key
},
auth=(username, password),
headers={"Content-Type": "application/json"}
)
if response.status_code != 200:
raise HTTPException(status_code=400, detail=f"Newbook API error: {response.status_code}")
data = response.json()
if not data.get("success"):
raise HTTPException(status_code=400, detail=data.get("message", "API request failed"))
sites = data.get("data", [])
# Aggregate by category_id - Newbook returns:
# - category_id: numeric category ID (e.g., "56")
# - category_name: category name (e.g., "Holiday Rentals")
# - site_id: individual room ID (not what we want)
# - site_name: individual room name (not what we want)
room_categories = {}
for site in sites:
cat_id = site.get("category_id")
cat_name = site.get("category_name") or "Unknown"
if not cat_id:
continue
cat_id_str = str(cat_id)
if cat_id_str not in room_categories:
room_categories[cat_id_str] = {
"site_id": cat_id_str,
"site_name": cat_name,
"room_count": 0
}
room_categories[cat_id_str]["room_count"] += 1
# Upsert room categories - create new ones if they don't exist
updated = 0
created = 0
for cat_id, cat in room_categories.items():
result = await db.execute(
text("""
INSERT INTO newbook_room_categories (site_id, site_name, site_type, room_count, fetched_at)
VALUES (:site_id, :site_name, :site_name, :room_count, NOW())
ON CONFLICT (site_id) DO UPDATE SET
site_name = EXCLUDED.site_name,
room_count = EXCLUDED.room_count,
fetched_at = NOW()
"""),
cat
)
if result.rowcount > 0:
updated += 1
await db.commit()
return {
"status": "success",
"count": updated,
"message": f"Updated {updated} room categories ({len(sites)} total rooms from API)"
}
except httpx.TimeoutException:
raise HTTPException(status_code=400, detail="Connection timed out")
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@router.patch("/room-categories/bulk-update")
async def bulk_update_room_categories(
request: RoomCategoryBulkUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Bulk update room category is_included flag and/or display_order"""
updated = 0
for upd in request.updates:
# Build dynamic update based on provided fields
set_clauses = []
params = {"id": upd.id}
if upd.is_included is not None:
set_clauses.append("is_included = :is_included")
params["is_included"] = upd.is_included
if upd.display_order is not None:
set_clauses.append("display_order = :display_order")
params["display_order"] = upd.display_order
if not set_clauses:
continue # Nothing to update
query = f"""
UPDATE newbook_room_categories
SET {', '.join(set_clauses)}
WHERE id = :id
"""
result = await db.execute(text(query), params)
if result.rowcount > 0:
updated += 1
await db.commit()
return {"status": "success", "updated": updated}
# ============================================
# GL ACCOUNT ENDPOINTS
# ============================================
class GLAccountResponse(BaseModel):
id: int
gl_account_id: str
gl_code: Optional[str] = None
gl_name: Optional[str] = None
gl_group_id: Optional[str] = None
gl_group_name: Optional[str] = None
department: Optional[str] = None
is_active: bool = True
class Config:
from_attributes = True
class GLAccountDepartmentUpdate(BaseModel):
id: int
department: Optional[str] = None # 'accommodation', 'dry', 'wet', or null
class GLAccountBulkDepartmentUpdate(BaseModel):
updates: List[GLAccountDepartmentUpdate]
@router.get("/gl-accounts", response_model=List[GLAccountResponse])
async def get_gl_accounts(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Get all GL accounts sorted by group then name"""
result = await db.execute(
text("""
SELECT id, gl_account_id, gl_code, gl_name, gl_group_id, gl_group_name, department, is_active
FROM newbook_gl_accounts
WHERE is_active = TRUE
ORDER BY gl_group_name NULLS LAST, gl_name
""")
)
rows = result.fetchall()
return [
{
"id": row.id,
"gl_account_id": row.gl_account_id,
"gl_code": row.gl_code,
"gl_name": row.gl_name,
"gl_group_id": row.gl_group_id,
"gl_group_name": row.gl_group_name,
"department": row.department,
"is_active": row.is_active
}
for row in rows
]
@router.post("/gl-accounts/fetch")
async def fetch_gl_accounts(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Fetch GL accounts from Newbook API gl_account_list endpoint"""
import httpx
from services.central_settings import get_newbook_credentials
central = await get_newbook_credentials()
if central:
api_key, username, password, region = central["api_key"], central["username"], central["password"], central["region"]
else:
api_key = await _get_config_value(db, "newbook_api_key")
username = await _get_config_value(db, "newbook_username")
password = await _get_config_value(db, "newbook_password")
region = await _get_config_value(db, "newbook_region")
if not all([api_key, username, password, region]):
raise HTTPException(status_code=400, detail="Newbook credentials not configured")
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
"https://api.newbook.cloud/rest/gl_account_list",
json={
"region": region,
"api_key": api_key
},
auth=(username, password),
headers={"Content-Type": "application/json"}
)
if response.status_code != 200:
raise HTTPException(status_code=400, detail=f"Newbook API error: {response.status_code}")
data = response.json()
if not data.get("success"):
raise HTTPException(status_code=400, detail=data.get("message", "API request failed"))
accounts = data.get("data", [])
# Debug: Log first account to see actual field names
import logging
logger = logging.getLogger(__name__)
if accounts and len(accounts) > 0:
logger.info(f"GL Account first item keys: {list(accounts[0].keys())}")
logger.info(f"GL Account first item: {accounts[0]}")
# Upsert GL accounts - preserve existing department mappings
# Newbook API field names: gl_account_id, gl_account_name, gl_account_code, gl_group_id, gl_group_name
for acc in accounts:
# Get account ID - prefer gl_account_id, fall back to id
gl_account_id = str(acc.get("gl_account_id") or acc.get("id") or "")
if not gl_account_id:
continue
# Get account name - Newbook uses gl_account_name
gl_name = acc.get("gl_account_name") or acc.get("name") or ""
# Get account code - Newbook uses gl_account_code
gl_code = acc.get("gl_account_code") or acc.get("code") or ""
# Extract code from name if not available (format: "4100 - Room Revenue")
if not gl_code and " - " in gl_name:
gl_code = gl_name.split(" - ")[0].strip()
# Get group info - Newbook uses gl_group_id and gl_group_name directly
gl_group_id = str(acc.get("gl_group_id") or "")
gl_group_name = acc.get("gl_group_name") or ""
await db.execute(
text("""
INSERT INTO newbook_gl_accounts (gl_account_id, gl_code, gl_name, gl_group_id, gl_group_name, fetched_at)
VALUES (:gl_account_id, :gl_code, :gl_name, :gl_group_id, :gl_group_name, NOW())
ON CONFLICT (gl_account_id) DO UPDATE SET
gl_code = EXCLUDED.gl_code,
gl_name = EXCLUDED.gl_name,
gl_group_id = EXCLUDED.gl_group_id,
gl_group_name = EXCLUDED.gl_group_name,
fetched_at = NOW()
"""),
{
"gl_account_id": gl_account_id,
"gl_code": gl_code,
"gl_name": gl_name,
"gl_group_id": gl_group_id,
"gl_group_name": gl_group_name
}
)
await db.commit()
return {
"status": "success",
"count": len(accounts),
"message": f"Fetched {len(accounts)} GL accounts"
}
except httpx.TimeoutException:
raise HTTPException(status_code=400, detail="Connection timed out")
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@router.patch("/gl-accounts/department")
async def update_gl_account_departments(
request: GLAccountBulkDepartmentUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Bulk update GL account department mappings"""
updated = 0
for upd in request.updates:
# Validate department value
if upd.department and upd.department not in ('accommodation', 'dry', 'wet'):
raise HTTPException(status_code=400, detail=f"Invalid department: {upd.department}")
result = await db.execute(
text("""
UPDATE newbook_gl_accounts
SET department = :department
WHERE id = :id
"""),
{"id": upd.id, "department": upd.department}
)
if result.rowcount > 0:
updated += 1
await db.commit()
return {"status": "success", "updated": updated}
# ============================================
# GENERIC CONFIG ENDPOINTS
# ============================================
class ConfigValue(BaseModel):
key: str
value: str
is_encrypted: bool = False
class ConfigResponse(BaseModel):
key: str
value: Optional[str]
description: Optional[str]
def simple_encrypt(value: str) -> str:
"""Simple obfuscation for sensitive values (use proper encryption in production)"""
return base64.b64encode(value.encode()).decode()
def simple_decrypt(value: str) -> str:
"""Decrypt obfuscated values"""
try:
return base64.b64decode(value.encode()).decode()
except:
return value
@router.get("/gl-accounts")
async def get_gl_accounts(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get cached GL accounts from Newbook (for reference when configuring GL code mapping).
"""
result = await db.execute(
text("SELECT gl_account_id, gl_code, gl_name, is_active FROM newbook_gl_accounts ORDER BY gl_code")
)
rows = result.fetchall()
return [
{
"gl_account_id": row.gl_account_id,
"gl_code": row.gl_code,
"gl_name": row.gl_name,
"is_active": row.is_active
}
for row in rows
]
@router.get("/")
async def list_config(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
List all configuration keys with masked values for encrypted fields.
"""
result = await db.execute(
text("SELECT config_key, config_value, is_encrypted, description, updated_at FROM system_config ORDER BY config_key")
)
rows = result.fetchall()
return [
{
"key": row.config_key,
"value": "********" if row.is_encrypted and row.config_value else row.config_value,
"is_encrypted": row.is_encrypted,
"description": row.description,
"updated_at": row.updated_at,
"is_set": row.config_value is not None and row.config_value != ""
}
for row in rows
]
@router.post("/")
async def set_config(
config: ConfigValue,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Set a configuration value.
Sensitive values (API keys, passwords) are encrypted before storage.
"""
value = config.value
if config.is_encrypted and value:
value = simple_encrypt(value)
result = await db.execute(
text("""
UPDATE system_config
SET config_value = :value,
is_encrypted = :is_encrypted,
updated_at = NOW(),
updated_by = :username
WHERE config_key = :key
RETURNING config_key
"""),
{
"key": config.key,
"value": value,
"is_encrypted": config.is_encrypted,
"username": current_user.get("username")
}
)
await db.commit()
row = result.fetchone()
if not row:
raise HTTPException(status_code=404, detail=f"Config key not found: {config.key}")
return {"status": "saved", "key": config.key}
@router.post("/test/{api}")
async def test_api_connection(
api: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Test connection to an external API using stored credentials.
"""
if api == "newbook":
return await _test_newbook(db)
elif api == "resos":
return await _test_resos(db)
else:
raise HTTPException(status_code=400, detail=f"Unknown API: {api}")
async def _get_config_value(db: AsyncSession, key: str) -> Optional[str]:
"""Get a config value, decrypting if necessary"""
result = await db.execute(
text("SELECT config_value, is_encrypted FROM system_config WHERE config_key = :key"),
{"key": key}
)
row = result.fetchone()
if not row or not row.config_value:
return None
if row.is_encrypted:
return simple_decrypt(row.config_value)
return row.config_value
async def _test_newbook(db: AsyncSession):
"""Test Newbook API connection with full credentials"""
import httpx
import logging
logger = logging.getLogger(__name__)
# Central Settings service first, app-local config fallback
from services.central_settings import get_newbook_credentials
central = await get_newbook_credentials()
if central:
api_key = central["api_key"]
username = central["username"]
password = central["password"]
region = central["region"]
else:
api_key = await _get_config_value(db, "newbook_api_key")
username = await _get_config_value(db, "newbook_username")
password = await _get_config_value(db, "newbook_password")
region = await _get_config_value(db, "newbook_region")
# Log what we have (masked)
logger.info(f"Testing Newbook: api_key={'set' if api_key else 'empty'}, username={username}, region={region}")
# Check we have all required credentials
missing = []
if not api_key:
missing.append("API Key")
if not username:
missing.append("Username")
if not password:
missing.append("Password")
if not region:
missing.append("Region")
if missing:
raise HTTPException(
status_code=400,
detail=f"Missing required credentials: {', '.join(missing)}"
)
try:
async with httpx.AsyncClient(timeout=30.0) as client:
# Test with api_keys endpoint - uses Basic Auth + JSON body
response = await client.post(
"https://api.newbook.cloud/rest/api_keys",
json={
"region": region,
"api_key": api_key,
"list_type": "inhouse"
},
auth=(username, password),
headers={"Content-Type": "application/json"}
)
logger.info(f"Newbook test response status: {response.status_code}")
if response.status_code == 200:
data = response.json()
if data.get("success"):
return {
"status": "connected",
"message": "Newbook connection successful!"
}
else:
error_msg = data.get("message", "Authentication failed")
raise HTTPException(status_code=400, detail=f"Newbook API error: {error_msg}")
else:
try:
error_data = response.json()
error_msg = error_data.get("message", str(error_data))
except:
error_msg = response.text[:200] if response.text else "No details"
raise HTTPException(
status_code=400,
detail=f"Newbook authentication failed ({response.status_code}): {error_msg}"
)
except httpx.TimeoutException:
raise HTTPException(status_code=400, detail="Connection timed out")
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
async def _test_resos(db: AsyncSession):
"""Test Resos API connection"""
import httpx
from services.central_settings import get_resos_credentials
creds = await get_resos_credentials()
api_key = creds["api_key"] if creds else None
if not api_key:
raise HTTPException(status_code=400, detail="Resos API key not configured")
try:
auth_header = f"Basic {base64.b64encode(f'{api_key}:'.encode()).decode()}"
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(
"https://api.resos.com/v1/openingHours",
headers={"Authorization": auth_header}
)
if response.status_code == 200:
return {"status": "connected", "message": "Resos connection successful"}
elif response.status_code == 401:
raise HTTPException(status_code=400, detail="Invalid API key")
else:
raise HTTPException(status_code=400, detail=f"Resos returned status {response.status_code}")
except httpx.TimeoutException:
raise HTTPException(status_code=400, detail="Connection timed out")
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
# ============================================
# TAX RATES ENDPOINTS
# ============================================
class TaxRateCreate(BaseModel):
tax_type: str
rate: float # e.g., 0.20 for 20%
effective_from: str # Date string YYYY-MM-DD
class TaxRateResponse(BaseModel):
id: int
tax_type: str
rate: float
effective_from: str
created_at: str
@router.get("/tax-rates")
async def get_tax_rates(
tax_type: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get all tax rates, optionally filtered by type.
Returns rates ordered by effective_from date descending.
"""
query = """
SELECT id, tax_type, rate, effective_from, created_at
FROM tax_rates
"""
params = {}
if tax_type:
query += " WHERE tax_type = :tax_type"
params["tax_type"] = tax_type
query += " ORDER BY tax_type, effective_from DESC"
result = await db.execute(text(query), params)
rows = result.fetchall()
return [
{
"id": row.id,
"tax_type": row.tax_type,
"rate": float(row.rate),
"effective_from": str(row.effective_from),
"created_at": str(row.created_at) if row.created_at else None
}
for row in rows
]
@router.get("/tax-rates/effective")
async def get_effective_tax_rate(
tax_type: str,
as_of_date: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get the effective tax rate for a given type and date.
Returns the rate that was in effect on the specified date.
"""
result = await db.execute(
text("""
SELECT id, tax_type, rate, effective_from
FROM tax_rates
WHERE tax_type = :tax_type
AND effective_from <= :as_of_date
ORDER BY effective_from DESC
LIMIT 1
"""),
{"tax_type": tax_type, "as_of_date": as_of_date}
)
row = result.fetchone()
if not row:
raise HTTPException(
status_code=404,
detail=f"No tax rate found for {tax_type} as of {as_of_date}"
)
return {
"tax_type": row.tax_type,
"rate": float(row.rate),
"effective_from": str(row.effective_from)
}
@router.post("/tax-rates")
async def create_tax_rate(
tax_rate: TaxRateCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Create a new tax rate entry.
Each entry has an effective_from date - the rate applies from that date
until a newer rate entry takes effect.
"""
try:
result = await db.execute(
text("""
INSERT INTO tax_rates (tax_type, rate, effective_from)
VALUES (:tax_type, :rate, :effective_from)
RETURNING id, tax_type, rate, effective_from, created_at
"""),
{
"tax_type": tax_rate.tax_type,
"rate": tax_rate.rate,
"effective_from": tax_rate.effective_from
}
)
await db.commit()
row = result.fetchone()
return {
"status": "created",
"tax_rate": {
"id": row.id,
"tax_type": row.tax_type,
"rate": float(row.rate),
"effective_from": str(row.effective_from),
"created_at": str(row.created_at)
}
}
except Exception as e:
await db.rollback()
if "unique constraint" in str(e).lower():
raise HTTPException(
status_code=400,
detail=f"Tax rate for {tax_rate.tax_type} already exists for date {tax_rate.effective_from}"
)
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/tax-rates/{rate_id}")
async def delete_tax_rate(
rate_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Delete a tax rate entry by ID."""
result = await db.execute(
text("DELETE FROM tax_rates WHERE id = :id RETURNING id"),
{"id": rate_id}
)
await db.commit()
row = result.fetchone()
if not row:
raise HTTPException(status_code=404, detail=f"Tax rate {rate_id} not found")
return {"status": "deleted", "id": rate_id}
# ============================================
# FORECAST SNAPSHOT SETTINGS
# ============================================
class ForecastSnapshotSettings(BaseModel):
"""Forecast snapshot automation settings"""
enabled: bool = False
time: str = "06:00"
models: str = "prophet,xgboost,catboost,blended"
days_ahead: int = 90
@router.get("/settings/forecast-snapshot", response_model=ForecastSnapshotSettings)
async def get_forecast_snapshot_settings(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Get forecast snapshot automation settings."""
result = await db.execute(
text("""
SELECT config_key, config_value
FROM system_config
WHERE config_key IN (
'forecast_snapshot_enabled',
'forecast_snapshot_time',
'forecast_snapshot_models',
'forecast_snapshot_days_ahead'
)
""")
)
rows = result.fetchall()
config_dict = {row.config_key: row.config_value for row in rows}
return ForecastSnapshotSettings(
enabled=config_dict.get('forecast_snapshot_enabled', 'false').lower() in ('true', '1', 'yes', 'enabled'),
time=config_dict.get('forecast_snapshot_time', '06:00'),
models=config_dict.get('forecast_snapshot_models', 'prophet,xgboost,catboost,blended'),
days_ahead=int(config_dict.get('forecast_snapshot_days_ahead', '90'))
)
@router.post("/settings/forecast-snapshot")
async def update_forecast_snapshot_settings(
settings: ForecastSnapshotSettings,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Update forecast snapshot automation settings."""
# Update or insert each setting
config_updates = {
'forecast_snapshot_enabled': 'true' if settings.enabled else 'false',
'forecast_snapshot_time': settings.time,
'forecast_snapshot_models': settings.models,
'forecast_snapshot_days_ahead': str(settings.days_ahead)
}
for key, value in config_updates.items():
await db.execute(
text("""
INSERT INTO system_config (config_key, config_value, updated_at)
VALUES (:key, :value, NOW())
ON CONFLICT (config_key)
DO UPDATE SET config_value = :value, updated_at = NOW()
"""),
{"key": key, "value": value}
)
await db.commit()
return {"status": "updated", "message": "Forecast snapshot settings saved successfully"}
@router.post("/settings/forecast-snapshot/test")
async def test_forecast_snapshot(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Manually trigger a forecast snapshot for testing."""
from jobs.weekly_forecast_snapshot import run_weekly_forecast_snapshot
try:
await run_weekly_forecast_snapshot()
return {"status": "success", "message": "Forecast snapshot completed successfully"}
except Exception as e:
logger.error(f"Manual forecast snapshot failed: {e}")
raise HTTPException(status_code=500, detail=f"Forecast snapshot failed: {str(e)}")
# ============================================
# API KEY MANAGEMENT ENDPOINTS
# ============================================
class ApiKeyCreate(BaseModel):
name: str
@router.get("/api-keys")
async def list_api_keys(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
List all API keys (without showing the actual key values).
Returns key prefix, name, status, and usage info.
"""
keys = await get_all_api_keys(db)
return {"keys": keys}
@router.post("/api-keys")
async def create_new_api_key(
request: ApiKeyCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Generate a new API key.
IMPORTANT: The full key is only returned ONCE in this response.
Store it securely - it cannot be retrieved again.
"""
if not request.name or len(request.name.strip()) < 2:
raise HTTPException(status_code=400, detail="Name must be at least 2 characters")
key_data = await create_api_key(
db,
name=request.name.strip(),
created_by=current_user.get("username", "unknown")
)
return {
"status": "created",
"message": "API key created. Copy the key now - it will not be shown again!",
"key": key_data["key"], # Full key - only time it's shown!
"id": key_data["id"],
"name": key_data["name"],
"key_prefix": key_data["key_prefix"],
"created_at": key_data["created_at"]
}
@router.post("/api-keys/{key_id}/revoke")
async def revoke_key(
key_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Revoke (deactivate) an API key.
The key will no longer work but record is kept for audit purposes.
"""
await revoke_api_key(db, key_id)
return {"status": "revoked", "message": "API key has been revoked"}
@router.delete("/api-keys/{key_id}")
async def delete_key(
key_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Permanently delete an API key.
"""
await delete_api_key(db, key_id)
return {"status": "deleted", "message": "API key has been deleted"}
# ============================================
# AI INSIGHTS SETTINGS
# ============================================
class AIInsightsSettingsResponse(BaseModel):
enabled: bool = False
api_key_set: bool = False
model: str = "claude-haiku-4-5-20251001"
schedule_time: str = "07:15"
daily_token_budget: int = 5000
class AIInsightsSettingsUpdate(BaseModel):
enabled: Optional[bool] = None
api_key: Optional[str] = None
model: Optional[str] = None
schedule_time: Optional[str] = None
daily_token_budget: Optional[int] = None
@router.get("/settings/ai-insights", response_model=AIInsightsSettingsResponse)
async def get_ai_insights_settings(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Get AI Insights configuration."""
result = await db.execute(
text("""
SELECT config_key, config_value, COALESCE(is_encrypted, false) as is_encrypted
FROM system_config
WHERE config_key LIKE 'ai_insights_%'
""")
)
config = {}
for row in result.fetchall():
config[row.config_key] = row.config_value
if row.config_key == 'ai_insights_api_key':
config['_api_key_set'] = bool(row.config_value)
return AIInsightsSettingsResponse(
enabled=config.get('ai_insights_enabled', 'false').lower() in ('true', '1', 'yes'),
api_key_set=config.get('_api_key_set', False),
model=config.get('ai_insights_model', 'claude-haiku-4-5-20251001'),
schedule_time=config.get('ai_insights_schedule_time', '07:15'),
daily_token_budget=int(config.get('ai_insights_daily_token_budget', '5000')),
)
@router.post("/settings/ai-insights")
async def update_ai_insights_settings(
settings: AIInsightsSettingsUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Update AI Insights configuration."""
updates = {}
if settings.enabled is not None:
updates['ai_insights_enabled'] = ('true' if settings.enabled else 'false', False)
if settings.api_key is not None and settings.api_key.strip():
updates['ai_insights_api_key'] = (simple_encrypt(settings.api_key.strip()), True)
if settings.model is not None:
updates['ai_insights_model'] = (settings.model, False)
if settings.schedule_time is not None:
updates['ai_insights_schedule_time'] = (settings.schedule_time, False)
if settings.daily_token_budget is not None:
updates['ai_insights_daily_token_budget'] = (str(settings.daily_token_budget), False)
for key, (value, encrypted) in updates.items():
await db.execute(
text("""
INSERT INTO system_config (config_key, config_value, is_encrypted, updated_at)
VALUES (:key, :value, :encrypted, NOW())
ON CONFLICT (config_key) DO UPDATE
SET config_value = :value, is_encrypted = :encrypted, updated_at = NOW()
"""),
{"key": key, "value": value, "encrypted": encrypted}
)
await db.commit()
return {"status": "saved", "keys_updated": list(updates.keys())}
@router.post("/settings/ai-insights/test")
async def test_ai_insights_connection(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Test Anthropic API connection with stored API key."""
api_key = await _get_config_value(db, "ai_insights_api_key")
if not api_key:
raise HTTPException(status_code=400, detail="No API key configured")
model = await _get_config_value(db, "ai_insights_model") or "claude-haiku-4-5-20251001"
try:
import anthropic
client = anthropic.AsyncAnthropic(api_key=api_key)
try:
response = await client.messages.create(
model=model,
max_tokens=10,
messages=[{"role": "user", "content": "Say 'connected' in one word."}]
)
return {
"status": "connected",
"message": f"Successfully connected to {model}",
"response": response.content[0].text if response.content else "",
}
finally:
await client.close()
except anthropic.AuthenticationError:
raise HTTPException(status_code=401, detail="Invalid API key")
except Exception as e:
raise HTTPException(status_code=500, detail=f"Connection failed: {str(e)}")