kitchen/backend/services/file_archival_service.py
jtricerolph d7898ba897 Add 'use main stack settings' toggle for third-party integration credentials
Each of Newbook, Resos, SambaPOS, SMTP, and Nextcloud now has a checkbox at the
top of its credentials block. When enabled, the app reads auth credentials from
the central stack settings service (SETTINGS_URL + STACK_INTERNAL_SECRET) and
the local auth fields are grayed out. App-specific fields (base path, GL codes,
keywords, sync intervals, etc.) remain editable regardless.

Backend: new use_global_* columns on kitchen_settings, migration, global_settings_service
with apply_global_overrides() for in-memory credential injection, GET /api/settings/global-status
endpoint, and apply_global_overrides() called in test-connection endpoints and FileArchivalService.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-12 21:57:10 +00:00

367 lines
13 KiB
Python

"""
Service for managing invoice file archival to Nextcloud.
Handles:
- Automatic archival after invoice confirmation + Dext send
- File retrieval from Nextcloud for re-processing
- Deleted file handling
"""
import os
import hashlib
import logging
from datetime import datetime
from typing import Tuple, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from models.invoice import Invoice, InvoiceStatus
from models.settings import KitchenSettings
from models.supplier import Supplier
from services.nextcloud_service import NextcloudService
logger = logging.getLogger(__name__)
class FileArchivalService:
"""Service for managing invoice file archival"""
def __init__(self, db: AsyncSession, kitchen_id: int):
self.db = db
self.kitchen_id = kitchen_id
async def get_settings(self) -> Optional[KitchenSettings]:
"""Get kitchen settings, with global credential overrides applied in memory."""
result = await self.db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == self.kitchen_id)
)
settings = result.scalar_one_or_none()
if settings:
from services.global_settings_service import apply_global_overrides
await apply_global_overrides(settings)
return settings
async def is_ready_for_archival(self, invoice: Invoice) -> bool:
"""
Check if invoice is ready to be archived to Nextcloud.
Conditions:
- Status is CONFIRMED
- If Dext is enabled and auto-send is on, must have been sent
- File is still local
"""
if invoice.status != InvoiceStatus.CONFIRMED:
return False
if invoice.file_storage_location != "local":
return False
settings = await self.get_settings()
if not settings or not settings.nextcloud_enabled:
return False
# Check Dext requirement - only if auto-send is enabled
if settings.dext_email and settings.dext_auto_send_enabled:
if not invoice.dext_sent_at:
return False
return True
async def archive_invoice_file(self, invoice: Invoice) -> Tuple[bool, str]:
"""
Archive invoice file to Nextcloud.
Args:
invoice: Invoice to archive
Returns:
(success, message or path)
"""
settings = await self.get_settings()
if not settings or not settings.nextcloud_enabled:
return (False, "Nextcloud not enabled")
if not all([settings.nextcloud_host, settings.nextcloud_username, settings.nextcloud_password]):
return (False, "Nextcloud not configured")
if invoice.file_storage_location != "local":
return (False, "File already archived")
if not os.path.exists(invoice.image_path):
return (False, "Local file not found")
try:
# Get supplier name
supplier_name = "Unknown"
if invoice.supplier_id:
result = await self.db.execute(
select(Supplier).where(Supplier.id == invoice.supplier_id)
)
supplier = result.scalar_one_or_none()
if supplier:
supplier_name = supplier.name
# Read file
with open(invoice.image_path, 'rb') as f:
file_content = f.read()
# Generate hash from file content
file_hash = hashlib.md5(file_content).hexdigest()
# Initialize Nextcloud service
nc = NextcloudService(
settings.nextcloud_host,
settings.nextcloud_username,
settings.nextcloud_password,
settings.nextcloud_base_path
)
# Generate path and filename
dir_path = nc.generate_path(supplier_name, invoice.invoice_date)
filename = nc.generate_filename(
invoice.invoice_date,
supplier_name,
invoice.invoice_number,
float(invoice.total) if invoice.total else 0,
os.path.basename(invoice.image_path),
file_hash
)
# Upload to Nextcloud
success, result = await nc.upload_file(file_content, dir_path, filename)
await nc.close()
if not success:
return (False, f"Upload failed: {result}")
# Update invoice record
invoice.original_local_path = invoice.image_path
invoice.nextcloud_path = result
invoice.file_storage_location = "nextcloud"
invoice.archived_at = datetime.utcnow()
await self.db.commit()
logger.info(f"Archived invoice {invoice.id} to Nextcloud: {result}")
# Delete local file if setting enabled
if settings.nextcloud_delete_local and os.path.exists(invoice.image_path):
try:
os.remove(invoice.image_path)
logger.info(f"Deleted local file for invoice {invoice.id}: {invoice.image_path}")
except Exception as e:
logger.warning(f"Failed to delete local file {invoice.image_path}: {e}")
return (True, result)
except Exception as e:
logger.error(f"Archival failed for invoice {invoice.id}: {e}")
return (False, str(e))
async def get_file_content(self, invoice: Invoice) -> Tuple[bool, bytes | str]:
"""
Get file content, downloading from Nextcloud if needed.
Used for OCR re-processing or file serving.
Args:
invoice: Invoice to get file for
Returns:
(success, file_bytes or error_message)
"""
# Try local file first (image_path)
if os.path.exists(invoice.image_path):
with open(invoice.image_path, 'rb') as f:
return (True, f.read())
# Try original local path (if file was archived but local copy exists)
if invoice.original_local_path and os.path.exists(invoice.original_local_path):
with open(invoice.original_local_path, 'rb') as f:
return (True, f.read())
# Download from Nextcloud
if invoice.file_storage_location == "nextcloud" and invoice.nextcloud_path:
settings = await self.get_settings()
if not settings or not settings.nextcloud_enabled:
return (False, "Nextcloud not configured")
if not all([settings.nextcloud_host, settings.nextcloud_username, settings.nextcloud_password]):
return (False, "Nextcloud credentials not set")
nc = NextcloudService(
settings.nextcloud_host,
settings.nextcloud_username,
settings.nextcloud_password,
"" # Path already includes base
)
success, result = await nc.download_file(invoice.nextcloud_path)
await nc.close()
if success and isinstance(result, bytes):
# Cache locally for future use
try:
os.makedirs(os.path.dirname(invoice.image_path), exist_ok=True)
with open(invoice.image_path, 'wb') as f:
f.write(result)
except Exception as e:
logger.warning(f"Failed to cache file locally: {e}")
return (success, result)
return (False, "File not found")
async def handle_invoice_deletion(self, invoice: Invoice) -> Tuple[bool, str]:
"""
Handle file when invoice is being deleted.
If on Nextcloud, copy to deleted folder.
Args:
invoice: Invoice being deleted
Returns:
(success, message)
"""
if invoice.file_storage_location == "nextcloud" and invoice.nextcloud_path:
settings = await self.get_settings()
if settings and settings.nextcloud_enabled:
try:
# Get supplier name for deleted folder
supplier_name = "Unknown"
if invoice.supplier_id:
result = await self.db.execute(
select(Supplier).where(Supplier.id == invoice.supplier_id)
)
supplier = result.scalar_one_or_none()
if supplier:
supplier_name = supplier.name
nc = NextcloudService(
settings.nextcloud_host,
settings.nextcloud_username,
settings.nextcloud_password,
settings.nextcloud_base_path
)
# Copy to deleted folder
original_filename = os.path.basename(invoice.nextcloud_path)
success, msg = await nc.copy_to_deleted(
invoice.nextcloud_path,
supplier_name,
original_filename
)
await nc.close()
if not success:
logger.warning(f"Failed to copy to deleted folder: {msg}")
return (True, "File moved to deleted folder")
except Exception as e:
logger.error(f"Error handling file deletion: {e}")
# Local file deletion
if invoice.image_path and os.path.exists(invoice.image_path):
try:
os.remove(invoice.image_path)
except Exception as e:
logger.warning(f"Failed to delete local file: {e}")
# Also try to delete original local path if different
if invoice.original_local_path and invoice.original_local_path != invoice.image_path:
if os.path.exists(invoice.original_local_path):
try:
os.remove(invoice.original_local_path)
except Exception as e:
logger.warning(f"Failed to delete original local file: {e}")
return (True, "Local file deleted")
async def get_archive_stats(self) -> dict:
"""
Get archival statistics for this kitchen.
Returns:
dict with pending_count, archived_count, etc.
"""
settings = await self.get_settings()
# Count invoices ready for archival (confirmed + local)
pending_query = select(func.count(Invoice.id)).where(
Invoice.kitchen_id == self.kitchen_id,
Invoice.status == InvoiceStatus.CONFIRMED,
Invoice.file_storage_location == "local"
)
pending_result = await self.db.execute(pending_query)
pending_count = pending_result.scalar() or 0
# Count already archived invoices
archived_query = select(func.count(Invoice.id)).where(
Invoice.kitchen_id == self.kitchen_id,
Invoice.file_storage_location == "nextcloud"
)
archived_result = await self.db.execute(archived_query)
archived_count = archived_result.scalar() or 0
# Count all invoices with local files (any status)
local_query = select(func.count(Invoice.id)).where(
Invoice.kitchen_id == self.kitchen_id,
Invoice.file_storage_location == "local"
)
local_result = await self.db.execute(local_query)
local_count = local_result.scalar() or 0
return {
"pending_count": pending_count,
"archived_count": archived_count,
"local_count": local_count,
"nextcloud_enabled": settings.nextcloud_enabled if settings else False,
"nextcloud_configured": bool(
settings and settings.nextcloud_host and
settings.nextcloud_username and settings.nextcloud_password
) if settings else False
}
async def archive_all_pending(self) -> Tuple[int, int, list]:
"""
Archive all pending invoices to Nextcloud.
Returns:
(success_count, failed_count, error_messages)
"""
settings = await self.get_settings()
if not settings or not settings.nextcloud_enabled:
return (0, 0, ["Nextcloud not enabled"])
if not all([settings.nextcloud_host, settings.nextcloud_username, settings.nextcloud_password]):
return (0, 0, ["Nextcloud not configured"])
# Get all invoices ready for archival
query = select(Invoice).where(
Invoice.kitchen_id == self.kitchen_id,
Invoice.status == InvoiceStatus.CONFIRMED,
Invoice.file_storage_location == "local"
)
result = await self.db.execute(query)
invoices = result.scalars().all()
success_count = 0
failed_count = 0
errors = []
for invoice in invoices:
if await self.is_ready_for_archival(invoice):
try:
success, msg = await self.archive_invoice_file(invoice)
if success:
success_count += 1
else:
failed_count += 1
errors.append(f"Invoice {invoice.id}: {msg}")
except Exception as e:
failed_count += 1
errors.append(f"Invoice {invoice.id}: {str(e)}")
return (success_count, failed_count, errors)