- settings/src/integrations/schema.js: rename azure from 'Azure AD' to 'Azure Document Intelligence', swap fields to endpoint + api_key - Add use_global_azure column (migration + model) - global_settings_service: add azure to check_global_status and apply_global_overrides - api/settings.py: expose use_global_azure in response/update; apply overrides in test_azure_connection before credential check - Settings.tsx: add 'Use credentials from main stack settings' toggle for Azure OCR section (endpoint/key disabled when on, test button enabled when global is configured); remove Users section (managed centrally via auth service), clean up UserData interface, users query and mutations Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
838 lines
29 KiB
Python
838 lines
29 KiB
Python
from fastapi import APIRouter, Depends, HTTPException
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select
|
|
from pydantic import BaseModel
|
|
|
|
from database import get_db
|
|
from models.user import User
|
|
from models.settings import KitchenSettings
|
|
from auth import get_current_user, require_cap
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
class SettingsResponse(BaseModel):
|
|
azure_endpoint: str | None
|
|
azure_key_set: bool # Don't expose the actual key, just whether it's set
|
|
currency_symbol: str
|
|
date_format: str
|
|
high_quantity_threshold: int
|
|
# SMTP settings
|
|
smtp_host: str | None
|
|
smtp_port: int | None
|
|
smtp_username: str | None
|
|
smtp_password_set: bool # Don't expose the actual password
|
|
smtp_use_tls: bool
|
|
smtp_from_email: str | None
|
|
smtp_from_name: str | None
|
|
support_email: str | None
|
|
# Dext integration
|
|
dext_email: str | None
|
|
dext_include_notes: bool
|
|
dext_include_non_stock: bool
|
|
dext_auto_send_enabled: bool
|
|
dext_manual_send_enabled: bool
|
|
dext_include_annotations: bool
|
|
# PDF annotation settings
|
|
pdf_annotations_enabled: bool
|
|
pdf_preview_show_annotations: bool
|
|
# OCR post-processing options
|
|
ocr_clean_product_codes: bool
|
|
ocr_filter_subtotal_rows: bool
|
|
ocr_use_weight_as_quantity: bool
|
|
# Cost distribution settings
|
|
cost_distribution_max_days: int
|
|
# LLM settings — see LLM-MANIFEST.md for removal instructions
|
|
llm_enabled: bool = False
|
|
anthropic_api_key_set: bool = False # Don't expose the actual key
|
|
llm_model: str | None = None
|
|
llm_confidence_threshold: float | None = None
|
|
llm_monthly_token_limit: int = 500000
|
|
llm_features_enabled: dict | None = None
|
|
# Global stack settings delegation
|
|
use_global_smtp: bool = False
|
|
use_global_azure: bool = False
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
class SettingsUpdate(BaseModel):
|
|
azure_endpoint: str | None = None
|
|
azure_key: str | None = None
|
|
currency_symbol: str | None = None
|
|
date_format: str | None = None
|
|
high_quantity_threshold: int | None = None
|
|
# SMTP settings
|
|
smtp_host: str | None = None
|
|
smtp_port: int | None = None
|
|
smtp_username: str | None = None
|
|
smtp_password: str | None = None # Only set if provided
|
|
smtp_use_tls: bool | None = None
|
|
smtp_from_email: str | None = None
|
|
smtp_from_name: str | None = None
|
|
support_email: str | None = None
|
|
# Dext integration
|
|
dext_email: str | None = None
|
|
dext_include_notes: bool | None = None
|
|
dext_include_non_stock: bool | None = None
|
|
dext_auto_send_enabled: bool | None = None
|
|
dext_manual_send_enabled: bool | None = None
|
|
dext_include_annotations: bool | None = None
|
|
# PDF annotation settings
|
|
pdf_annotations_enabled: bool | None = None
|
|
pdf_preview_show_annotations: bool | None = None
|
|
# OCR post-processing options
|
|
ocr_clean_product_codes: bool | None = None
|
|
ocr_filter_subtotal_rows: bool | None = None
|
|
ocr_use_weight_as_quantity: bool | None = None
|
|
# Cost distribution settings
|
|
cost_distribution_max_days: int | None = None
|
|
# LLM settings — see LLM-MANIFEST.md for removal instructions
|
|
llm_enabled: bool | None = None
|
|
anthropic_api_key: str | None = None # Only set if provided
|
|
llm_model: str | None = None
|
|
llm_confidence_threshold: float | None = None
|
|
llm_monthly_token_limit: int | None = None
|
|
llm_features_enabled: dict | None = None
|
|
# Global stack settings delegation
|
|
use_global_smtp: bool | None = None
|
|
use_global_azure: bool | None = None
|
|
|
|
|
|
@router.get("/", response_model=SettingsResponse)
|
|
async def get_settings(
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Get current kitchen settings"""
|
|
result = await db.execute(
|
|
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
|
)
|
|
settings = result.scalar_one_or_none()
|
|
|
|
if not settings:
|
|
# Create default settings if none exist
|
|
settings = KitchenSettings(
|
|
kitchen_id=current_user.kitchen_id,
|
|
currency_symbol="£",
|
|
date_format="DD/MM/YYYY"
|
|
)
|
|
db.add(settings)
|
|
await db.commit()
|
|
await db.refresh(settings)
|
|
|
|
return _build_settings_response(settings)
|
|
|
|
|
|
def _build_settings_response(settings: KitchenSettings) -> SettingsResponse:
|
|
"""Build SettingsResponse from a KitchenSettings model instance."""
|
|
return SettingsResponse(
|
|
azure_endpoint=settings.azure_endpoint,
|
|
azure_key_set=bool(settings.azure_key),
|
|
currency_symbol=settings.currency_symbol,
|
|
date_format=settings.date_format,
|
|
high_quantity_threshold=settings.high_quantity_threshold,
|
|
# SMTP settings
|
|
smtp_host=settings.smtp_host,
|
|
smtp_port=settings.smtp_port,
|
|
smtp_username=settings.smtp_username,
|
|
smtp_password_set=bool(settings.smtp_password),
|
|
smtp_use_tls=settings.smtp_use_tls,
|
|
smtp_from_email=settings.smtp_from_email,
|
|
smtp_from_name=settings.smtp_from_name,
|
|
support_email=settings.support_email,
|
|
# Dext integration
|
|
dext_email=settings.dext_email,
|
|
dext_include_notes=settings.dext_include_notes,
|
|
dext_include_non_stock=settings.dext_include_non_stock,
|
|
dext_auto_send_enabled=settings.dext_auto_send_enabled,
|
|
dext_manual_send_enabled=settings.dext_manual_send_enabled,
|
|
dext_include_annotations=settings.dext_include_annotations,
|
|
# PDF annotation settings
|
|
pdf_annotations_enabled=settings.pdf_annotations_enabled,
|
|
pdf_preview_show_annotations=settings.pdf_preview_show_annotations,
|
|
# OCR post-processing options
|
|
ocr_clean_product_codes=settings.ocr_clean_product_codes,
|
|
ocr_filter_subtotal_rows=settings.ocr_filter_subtotal_rows,
|
|
ocr_use_weight_as_quantity=settings.ocr_use_weight_as_quantity,
|
|
cost_distribution_max_days=settings.cost_distribution_max_days,
|
|
# LLM settings — see LLM-MANIFEST.md for removal instructions
|
|
llm_enabled=settings.llm_enabled,
|
|
anthropic_api_key_set=bool(settings.anthropic_api_key),
|
|
llm_model=settings.llm_model,
|
|
llm_confidence_threshold=float(settings.llm_confidence_threshold) if settings.llm_confidence_threshold else None,
|
|
llm_monthly_token_limit=settings.llm_monthly_token_limit,
|
|
llm_features_enabled=settings.llm_features_enabled,
|
|
use_global_smtp=settings.use_global_smtp,
|
|
use_global_azure=settings.use_global_azure,
|
|
)
|
|
|
|
|
|
@router.patch("/", response_model=SettingsResponse)
|
|
async def update_settings(
|
|
update: SettingsUpdate,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Update kitchen settings"""
|
|
result = await db.execute(
|
|
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
|
)
|
|
settings = result.scalar_one_or_none()
|
|
|
|
if not settings:
|
|
settings = KitchenSettings(kitchen_id=current_user.kitchen_id)
|
|
db.add(settings)
|
|
|
|
# Update fields
|
|
update_data = update.model_dump(exclude_unset=True)
|
|
for field, value in update_data.items():
|
|
if value is not None:
|
|
setattr(settings, field, value)
|
|
|
|
await db.commit()
|
|
await db.refresh(settings)
|
|
|
|
return _build_settings_response(settings)
|
|
|
|
|
|
@router.post("/test-azure")
|
|
async def test_azure_connection(
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Test Azure Document Intelligence connection"""
|
|
result = await db.execute(
|
|
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
|
)
|
|
settings = result.scalar_one_or_none()
|
|
|
|
if not settings:
|
|
raise HTTPException(status_code=400, detail="Azure credentials not configured")
|
|
|
|
from services.global_settings_service import apply_global_overrides
|
|
await apply_global_overrides(settings)
|
|
|
|
if not settings.azure_endpoint or not settings.azure_key:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Azure credentials not configured"
|
|
)
|
|
|
|
try:
|
|
from azure.ai.formrecognizer import DocumentAnalysisClient
|
|
from azure.core.credentials import AzureKeyCredential
|
|
|
|
client = DocumentAnalysisClient(
|
|
endpoint=settings.azure_endpoint,
|
|
credential=AzureKeyCredential(settings.azure_key)
|
|
)
|
|
# Simple connection test - this will validate credentials
|
|
# The actual analysis would happen during invoice processing
|
|
return {"status": "success", "message": "Azure connection successful"}
|
|
except Exception as e:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Azure connection failed: {str(e)}"
|
|
)
|
|
|
|
|
|
@router.post("/test-smtp")
|
|
async def test_smtp_connection(
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Test SMTP connection with current settings"""
|
|
from services.email_service import EmailService
|
|
|
|
result = await db.execute(
|
|
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
|
)
|
|
settings = result.scalar_one_or_none()
|
|
|
|
if not settings:
|
|
raise HTTPException(status_code=400, detail="SMTP not configured")
|
|
|
|
from services.global_settings_service import apply_global_overrides
|
|
await apply_global_overrides(settings)
|
|
|
|
if not settings.smtp_host or not settings.smtp_from_email:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="SMTP not fully configured. Please set SMTP host and from email."
|
|
)
|
|
|
|
email_service = EmailService(settings)
|
|
success, message = email_service.test_connection()
|
|
|
|
if not success:
|
|
raise HTTPException(status_code=400, detail=message)
|
|
|
|
return {"status": "success", "message": message}
|
|
|
|
|
|
# ============ Kitchen Details Endpoints ============
|
|
|
|
class KitchenDetailsResponse(BaseModel):
|
|
kitchen_display_name: str | None = None
|
|
kitchen_address_line1: str | None = None
|
|
kitchen_address_line2: str | None = None
|
|
kitchen_city: str | None = None
|
|
kitchen_postcode: str | None = None
|
|
kitchen_phone: str | None = None
|
|
kitchen_email: str | None = None
|
|
|
|
|
|
class KitchenDetailsUpdate(BaseModel):
|
|
kitchen_display_name: str | None = None
|
|
kitchen_address_line1: str | None = None
|
|
kitchen_address_line2: str | None = None
|
|
kitchen_city: str | None = None
|
|
kitchen_postcode: str | None = None
|
|
kitchen_phone: str | None = None
|
|
kitchen_email: str | None = None
|
|
|
|
|
|
@router.get("/kitchen-details", response_model=KitchenDetailsResponse)
|
|
async def get_kitchen_details(
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Get kitchen details for PO letterhead"""
|
|
result = await db.execute(
|
|
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
|
)
|
|
settings = result.scalar_one_or_none()
|
|
|
|
if not settings:
|
|
return KitchenDetailsResponse()
|
|
|
|
return KitchenDetailsResponse(
|
|
kitchen_display_name=settings.kitchen_display_name,
|
|
kitchen_address_line1=settings.kitchen_address_line1,
|
|
kitchen_address_line2=settings.kitchen_address_line2,
|
|
kitchen_city=settings.kitchen_city,
|
|
kitchen_postcode=settings.kitchen_postcode,
|
|
kitchen_phone=settings.kitchen_phone,
|
|
kitchen_email=settings.kitchen_email,
|
|
)
|
|
|
|
|
|
@router.patch("/kitchen-details", response_model=KitchenDetailsResponse)
|
|
async def update_kitchen_details(
|
|
update: KitchenDetailsUpdate,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Update kitchen details for PO letterhead"""
|
|
result = await db.execute(
|
|
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
|
)
|
|
settings = result.scalar_one_or_none()
|
|
|
|
if not settings:
|
|
settings = KitchenSettings(kitchen_id=current_user.kitchen_id)
|
|
db.add(settings)
|
|
|
|
update_data = update.model_dump(exclude_unset=True)
|
|
for field, value in update_data.items():
|
|
setattr(settings, field, value)
|
|
|
|
await db.commit()
|
|
await db.refresh(settings)
|
|
|
|
return KitchenDetailsResponse(
|
|
kitchen_display_name=settings.kitchen_display_name,
|
|
kitchen_address_line1=settings.kitchen_address_line1,
|
|
kitchen_address_line2=settings.kitchen_address_line2,
|
|
kitchen_city=settings.kitchen_city,
|
|
kitchen_postcode=settings.kitchen_postcode,
|
|
kitchen_phone=settings.kitchen_phone,
|
|
kitchen_email=settings.kitchen_email,
|
|
)
|
|
|
|
|
|
# ============ Page Restrictions Endpoints ============
|
|
|
|
class PageRestrictionsResponse(BaseModel):
|
|
restricted_pages: list[str]
|
|
|
|
|
|
class PageRestrictionsUpdate(BaseModel):
|
|
restricted_pages: list[str]
|
|
|
|
|
|
@router.get("/page-restrictions", response_model=PageRestrictionsResponse)
|
|
async def get_page_restrictions(
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Get list of pages restricted to admin users only"""
|
|
result = await db.execute(
|
|
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
|
)
|
|
settings = result.scalar_one_or_none()
|
|
|
|
if not settings:
|
|
return PageRestrictionsResponse(restricted_pages=[])
|
|
|
|
# Parse comma-separated list
|
|
restricted = []
|
|
if settings.admin_restricted_pages:
|
|
restricted = [p.strip() for p in settings.admin_restricted_pages.split(',') if p.strip()]
|
|
|
|
return PageRestrictionsResponse(restricted_pages=restricted)
|
|
|
|
|
|
@router.patch("/page-restrictions", response_model=PageRestrictionsResponse)
|
|
async def update_page_restrictions(
|
|
update: PageRestrictionsUpdate,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Update list of pages restricted to admin users only (admin only)"""
|
|
if not current_user.is_admin:
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail="Only admins can modify page restrictions"
|
|
)
|
|
|
|
result = await db.execute(
|
|
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
|
)
|
|
settings = result.scalar_one_or_none()
|
|
|
|
if not settings:
|
|
settings = KitchenSettings(kitchen_id=current_user.kitchen_id)
|
|
db.add(settings)
|
|
|
|
# Store as comma-separated string
|
|
settings.admin_restricted_pages = ','.join(update.restricted_pages) if update.restricted_pages else None
|
|
|
|
await db.commit()
|
|
|
|
return PageRestrictionsResponse(restricted_pages=update.restricted_pages)
|
|
|
|
|
|
# ============ Nextcloud Settings Endpoints ============
|
|
|
|
class NextcloudSettingsResponse(BaseModel):
|
|
nextcloud_host: str | None
|
|
nextcloud_username: str | None
|
|
nextcloud_password_set: bool
|
|
nextcloud_base_path: str | None
|
|
nextcloud_enabled: bool
|
|
nextcloud_delete_local: bool
|
|
use_global_nextcloud: bool = False
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
class NextcloudSettingsUpdate(BaseModel):
|
|
nextcloud_host: str | None = None
|
|
nextcloud_username: str | None = None
|
|
nextcloud_password: str | None = None
|
|
nextcloud_base_path: str | None = None
|
|
nextcloud_enabled: bool | None = None
|
|
nextcloud_delete_local: bool | None = None
|
|
use_global_nextcloud: bool | None = None
|
|
|
|
|
|
class NextcloudStatsResponse(BaseModel):
|
|
pending_count: int
|
|
archived_count: int
|
|
local_count: int
|
|
nextcloud_enabled: bool
|
|
nextcloud_configured: bool
|
|
|
|
|
|
class NextcloudArchiveResponse(BaseModel):
|
|
success_count: int
|
|
failed_count: int
|
|
errors: list[str]
|
|
|
|
|
|
@router.get("/nextcloud", response_model=NextcloudSettingsResponse)
|
|
async def get_nextcloud_settings(
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Get Nextcloud settings"""
|
|
result = await db.execute(
|
|
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
|
)
|
|
settings = result.scalar_one_or_none()
|
|
|
|
if not settings:
|
|
return NextcloudSettingsResponse(
|
|
nextcloud_host=None,
|
|
nextcloud_username=None,
|
|
nextcloud_password_set=False,
|
|
nextcloud_base_path="/Kitchen Invoices",
|
|
nextcloud_enabled=False,
|
|
nextcloud_delete_local=False,
|
|
use_global_nextcloud=False,
|
|
)
|
|
|
|
return NextcloudSettingsResponse(
|
|
nextcloud_host=settings.nextcloud_host,
|
|
nextcloud_username=settings.nextcloud_username,
|
|
nextcloud_password_set=bool(settings.nextcloud_password),
|
|
nextcloud_base_path=settings.nextcloud_base_path,
|
|
nextcloud_enabled=settings.nextcloud_enabled,
|
|
nextcloud_delete_local=settings.nextcloud_delete_local,
|
|
use_global_nextcloud=settings.use_global_nextcloud,
|
|
)
|
|
|
|
|
|
@router.patch("/nextcloud", response_model=NextcloudSettingsResponse)
|
|
async def update_nextcloud_settings(
|
|
update: NextcloudSettingsUpdate,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Update Nextcloud settings"""
|
|
if not current_user.is_admin:
|
|
raise HTTPException(status_code=403, detail="Admin only")
|
|
|
|
result = await db.execute(
|
|
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
|
)
|
|
settings = result.scalar_one_or_none()
|
|
|
|
if not settings:
|
|
settings = KitchenSettings(kitchen_id=current_user.kitchen_id)
|
|
db.add(settings)
|
|
|
|
update_data = update.model_dump(exclude_unset=True)
|
|
for field, value in update_data.items():
|
|
# Map 'nextcloud_password' to the model field
|
|
if field == 'nextcloud_password' and value:
|
|
setattr(settings, field, value)
|
|
elif value is not None:
|
|
setattr(settings, field, value)
|
|
|
|
await db.commit()
|
|
await db.refresh(settings)
|
|
|
|
return NextcloudSettingsResponse(
|
|
nextcloud_host=settings.nextcloud_host,
|
|
nextcloud_username=settings.nextcloud_username,
|
|
nextcloud_password_set=bool(settings.nextcloud_password),
|
|
nextcloud_base_path=settings.nextcloud_base_path,
|
|
nextcloud_enabled=settings.nextcloud_enabled,
|
|
nextcloud_delete_local=settings.nextcloud_delete_local,
|
|
use_global_nextcloud=settings.use_global_nextcloud,
|
|
)
|
|
|
|
|
|
@router.get("/global-status")
|
|
async def get_global_status(
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Check which integrations are configured in the central stack settings service."""
|
|
from services.global_settings_service import check_global_status
|
|
return await check_global_status()
|
|
|
|
|
|
@router.post("/nextcloud/test")
|
|
async def test_nextcloud_connection(
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Test Nextcloud WebDAV connection"""
|
|
from services.nextcloud_service import NextcloudService
|
|
|
|
result = await db.execute(
|
|
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
|
)
|
|
settings = result.scalar_one_or_none()
|
|
|
|
if not settings:
|
|
raise HTTPException(status_code=400, detail="Nextcloud not fully configured")
|
|
|
|
from services.global_settings_service import apply_global_overrides
|
|
await apply_global_overrides(settings)
|
|
|
|
if not all([settings.nextcloud_host, settings.nextcloud_username, settings.nextcloud_password]):
|
|
raise HTTPException(status_code=400, detail="Nextcloud not fully configured")
|
|
|
|
nc = NextcloudService(
|
|
settings.nextcloud_host,
|
|
settings.nextcloud_username,
|
|
settings.nextcloud_password,
|
|
settings.nextcloud_base_path
|
|
)
|
|
|
|
success, message = await nc.test_connection()
|
|
await nc.close()
|
|
|
|
if not success:
|
|
raise HTTPException(status_code=400, detail=message)
|
|
|
|
return {"status": "success", "message": message}
|
|
|
|
|
|
@router.get("/nextcloud/stats", response_model=NextcloudStatsResponse)
|
|
async def get_nextcloud_stats(
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Get Nextcloud archive statistics"""
|
|
from services.file_archival_service import FileArchivalService
|
|
|
|
archival_service = FileArchivalService(db, current_user.kitchen_id)
|
|
stats = await archival_service.get_archive_stats()
|
|
|
|
return NextcloudStatsResponse(**stats)
|
|
|
|
|
|
@router.post("/nextcloud/archive-all", response_model=NextcloudArchiveResponse)
|
|
async def archive_all_pending(
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Manually archive all pending invoices to Nextcloud"""
|
|
if not current_user.is_admin:
|
|
raise HTTPException(status_code=403, detail="Admin only")
|
|
|
|
from services.file_archival_service import FileArchivalService
|
|
|
|
archival_service = FileArchivalService(db, current_user.kitchen_id)
|
|
success_count, failed_count, errors = await archival_service.archive_all_pending()
|
|
|
|
return NextcloudArchiveResponse(
|
|
success_count=success_count,
|
|
failed_count=failed_count,
|
|
errors=errors
|
|
)
|
|
|
|
|
|
@router.post("/nextcloud/archive/{invoice_id}")
|
|
async def archive_single_invoice(
|
|
invoice_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Archive a single invoice to Nextcloud (for testing)"""
|
|
if not current_user.is_admin:
|
|
raise HTTPException(status_code=403, detail="Admin only")
|
|
|
|
from models.invoice import Invoice
|
|
from services.file_archival_service import FileArchivalService
|
|
|
|
# Get the invoice
|
|
result = await db.execute(
|
|
select(Invoice).where(
|
|
Invoice.id == invoice_id,
|
|
Invoice.kitchen_id == current_user.kitchen_id
|
|
)
|
|
)
|
|
invoice = result.scalar_one_or_none()
|
|
|
|
if not invoice:
|
|
raise HTTPException(status_code=404, detail="Invoice not found")
|
|
|
|
if invoice.file_storage_location == "nextcloud":
|
|
return {"status": "skipped", "message": "Invoice already archived to Nextcloud"}
|
|
|
|
# Check Nextcloud config
|
|
settings_result = await db.execute(
|
|
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
|
)
|
|
settings = settings_result.scalar_one_or_none()
|
|
|
|
if not settings or not all([settings.nextcloud_host, settings.nextcloud_username, settings.nextcloud_password]):
|
|
raise HTTPException(status_code=400, detail="Nextcloud not fully configured")
|
|
|
|
archival_service = FileArchivalService(db, current_user.kitchen_id)
|
|
success, message = await archival_service.archive_invoice_file(invoice)
|
|
|
|
if not success:
|
|
raise HTTPException(status_code=500, detail=message)
|
|
|
|
return {"status": "success", "message": message, "nextcloud_path": invoice.nextcloud_path}
|
|
|
|
|
|
# ============ API Access Endpoints ============
|
|
|
|
class ApiAccessResponse(BaseModel):
|
|
api_key: str | None
|
|
api_key_enabled: bool
|
|
|
|
|
|
class ApiAccessUpdate(BaseModel):
|
|
api_key_enabled: bool
|
|
|
|
|
|
@router.get("/api-access", response_model=ApiAccessResponse)
|
|
async def get_api_access(
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Get API access settings"""
|
|
result = await db.execute(
|
|
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
|
)
|
|
settings = result.scalar_one_or_none()
|
|
|
|
if not settings:
|
|
return ApiAccessResponse(api_key=None, api_key_enabled=False)
|
|
|
|
return ApiAccessResponse(
|
|
api_key=settings.api_key,
|
|
api_key_enabled=settings.api_key_enabled,
|
|
)
|
|
|
|
|
|
@router.patch("/api-access", response_model=ApiAccessResponse)
|
|
async def update_api_access(
|
|
update: ApiAccessUpdate,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Update API access settings (enable/disable)"""
|
|
if not current_user.is_admin:
|
|
raise HTTPException(status_code=403, detail="Admin only")
|
|
|
|
result = await db.execute(
|
|
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
|
)
|
|
settings = result.scalar_one_or_none()
|
|
|
|
if not settings:
|
|
settings = KitchenSettings(kitchen_id=current_user.kitchen_id)
|
|
db.add(settings)
|
|
|
|
settings.api_key_enabled = update.api_key_enabled
|
|
|
|
await db.commit()
|
|
await db.refresh(settings)
|
|
|
|
return ApiAccessResponse(
|
|
api_key=settings.api_key,
|
|
api_key_enabled=settings.api_key_enabled,
|
|
)
|
|
|
|
|
|
@router.post("/api-access/regenerate")
|
|
async def regenerate_api_key(
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Generate or regenerate the API key"""
|
|
import secrets
|
|
|
|
if not current_user.is_admin:
|
|
raise HTTPException(status_code=403, detail="Admin only")
|
|
|
|
result = await db.execute(
|
|
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
|
)
|
|
settings = result.scalar_one_or_none()
|
|
|
|
if not settings:
|
|
settings = KitchenSettings(kitchen_id=current_user.kitchen_id)
|
|
db.add(settings)
|
|
|
|
new_key = secrets.token_urlsafe(32)
|
|
settings.api_key = new_key
|
|
settings.api_key_enabled = True
|
|
|
|
await db.commit()
|
|
await db.refresh(settings)
|
|
|
|
return {"api_key": new_key, "api_key_enabled": True}
|
|
|
|
|
|
# ============ LLM Usage Stats Endpoints ============
|
|
# LLM FEATURE — see LLM-MANIFEST.md for removal instructions
|
|
|
|
|
|
class LlmUsageStatsResponse(BaseModel):
|
|
total_calls: int = 0
|
|
successful_calls: int = 0
|
|
failed_calls: int = 0
|
|
total_input_tokens: int = 0
|
|
total_output_tokens: int = 0
|
|
total_tokens: int = 0
|
|
estimated_cost_usd: float = 0.0
|
|
cache_entries_this_month: int = 0
|
|
|
|
|
|
@router.get("/llm-usage", response_model=LlmUsageStatsResponse)
|
|
async def get_llm_usage(
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Get aggregated LLM usage stats for the current month"""
|
|
from services.llm_service import get_usage_stats
|
|
|
|
stats = await get_usage_stats(db, current_user.kitchen_id)
|
|
return LlmUsageStatsResponse(**stats)
|
|
|
|
|
|
@router.post("/test-llm")
|
|
async def test_llm_connection(
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Test Anthropic API connection with current settings"""
|
|
result = await db.execute(
|
|
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
|
)
|
|
settings = result.scalar_one_or_none()
|
|
|
|
if not settings or not settings.anthropic_api_key:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Anthropic API key not configured"
|
|
)
|
|
|
|
if not settings.llm_enabled:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="LLM features are disabled. Enable them in Settings first."
|
|
)
|
|
|
|
try:
|
|
import anthropic
|
|
from services.llm_service import DEFAULT_LLM_MODEL
|
|
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
|
|
response = await client.messages.create(
|
|
model=settings.llm_model or DEFAULT_LLM_MODEL,
|
|
max_tokens=10,
|
|
messages=[{"role": "user", "content": "Say 'ok'"}],
|
|
)
|
|
return {"status": "success", "message": f"Connection successful. Model: {response.model}"}
|
|
except anthropic.AuthenticationError:
|
|
raise HTTPException(status_code=400, detail="Authentication failed — check your API key")
|
|
except Exception as e:
|
|
raise HTTPException(status_code=400, detail=f"Connection failed: {str(e)}")
|
|
|
|
|
|
# LLM FEATURE — see LLM-MANIFEST.md for removal instructions
|
|
@router.get("/llm-models")
|
|
async def get_llm_models(
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Fetch available models from Anthropic API."""
|
|
result = await db.execute(
|
|
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
|
)
|
|
settings = result.scalar_one_or_none()
|
|
|
|
if not settings or not settings.anthropic_api_key:
|
|
return {"models": [], "default": None, "error": "No API key configured"}
|
|
|
|
from services.llm_service import list_available_models, DEFAULT_LLM_MODEL
|
|
models = await list_available_models(settings.anthropic_api_key)
|
|
|
|
return {
|
|
"models": models,
|
|
"default": DEFAULT_LLM_MODEL,
|
|
"current": settings.llm_model or DEFAULT_LLM_MODEL,
|
|
}
|