kitchen/backend/services/nextcloud_service.py
jtricerolph 8d688b459d Initial kitchen scaffold — Phase 1 kitchen port (build-verified 2026-07-11)
FastAPI backend (Python 3.11, MSSQL ODBC for SambaPOS, Azure DI OCR),
kitchen_db on central PG. React/TS/Vite frontend with navy sidebar layout.

Backend: auth.py (APP_SLUG=kitchen, SimpleNamespace — archive routes use
.kitchen_id/.is_admin without modification), main.py (51 migrations, scheduler,
internal router for KDS bookings feed), api/internal.py, full archive API
(31 routers: invoices, recipes, menus, sambapos, resos, newbook, disputes,
purchase_orders, etc.), models, migrations, OCR pipeline.
kitchen_id pinned to 1 (B1 — single hotel).

Frontend: AuthGate (app=kitchen, token shim for archive compat — B5b pending),
Layout (navy sidebar, 6 sections, Lucide icons, teal --app-primary),
App.tsx (Outlet pattern, UploadApp outside Layout), index.css (full :root block).
strict: false — archive components have type issues; build clean.

Note: 45 archive components call fetch('/api/...') without /kitchen/ prefix
(B5b). Runtime 404s; deferred until after initial testing.

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

353 lines
13 KiB
Python

"""
Nextcloud WebDAV service for file operations.
Handles:
- WebDAV authentication and connection testing
- File upload/download/delete operations
- Directory creation and management
- File path generation based on invoice metadata
"""
import httpx
import logging
import re
import hashlib
from datetime import datetime, date
from typing import Tuple, Optional
logger = logging.getLogger(__name__)
class NextcloudService:
"""Service for Nextcloud WebDAV operations"""
def __init__(self, host: str, username: str, password: str, base_path: str = "/Kitchen Invoices"):
"""
Initialize Nextcloud service.
Args:
host: Nextcloud server URL (e.g., "https://cloud.example.com")
username: Nextcloud username
password: Nextcloud password or app password
base_path: Base directory in Nextcloud for storing files
"""
self.host = host.rstrip('/') if host else ""
self.username = username or ""
self.password = password or ""
self.base_path = (base_path if base_path is not None else "/Kitchen Invoices").strip('/')
# WebDAV endpoint
self.webdav_url = f"{self.host}/remote.php/dav/files/{self.username}" if self.host and self.username else ""
# Create async HTTP client - will be initialized when needed
self._client: Optional[httpx.AsyncClient] = None
async def _get_client(self, upload_timeout: Optional[float] = None) -> httpx.AsyncClient:
"""Get or create the async HTTP client.
Args:
upload_timeout: If provided, creates a new client with this write timeout
(for large file uploads like backups). Otherwise uses default.
"""
if upload_timeout is not None:
# Return a separate client with extended timeout for large uploads
return httpx.AsyncClient(
auth=(self.username, self.password),
timeout=httpx.Timeout(60.0, write=upload_timeout, read=upload_timeout),
follow_redirects=True
)
if self._client is None or self._client.is_closed:
self._client = httpx.AsyncClient(
auth=(self.username, self.password),
timeout=60.0,
follow_redirects=True
)
return self._client
async def test_connection(self) -> Tuple[bool, str]:
"""
Test WebDAV connection and authentication.
Returns:
(success, message) tuple
"""
if not self.host or not self.username or not self.password:
return (False, "Nextcloud not configured - missing host, username, or password")
try:
client = await self._get_client()
# PROPFIND on root to test auth
response = await client.request(
"PROPFIND",
self.webdav_url,
headers={"Depth": "0"}
)
if response.status_code == 207: # Multi-Status (success)
return (True, "Nextcloud connection successful")
elif response.status_code == 401:
return (False, "Authentication failed - check username/password")
elif response.status_code == 404:
return (False, f"WebDAV endpoint not found - check Nextcloud URL")
else:
return (False, f"Unexpected response: HTTP {response.status_code}")
except httpx.ConnectError:
return (False, f"Cannot connect to {self.host}")
except httpx.TimeoutException:
return (False, f"Connection timed out")
except Exception as e:
return (False, f"Connection error: {str(e)}")
async def ensure_directory(self, path: str) -> bool:
"""
Create directory and all parent directories if they don't exist.
Args:
path: Directory path relative to base_path
Returns:
True if directory exists or was created
"""
full_path = f"{self.base_path}/{path}".strip('/')
parts = full_path.split('/')
client = await self._get_client()
current_path = ""
for part in parts:
if not part:
continue
current_path = f"{current_path}/{part}" if current_path else part
url = f"{self.webdav_url}/{current_path}"
# Check if exists
try:
response = await client.request("PROPFIND", url, headers={"Depth": "0"})
if response.status_code == 404:
# Create directory
create_response = await client.request("MKCOL", url)
if create_response.status_code not in (201, 405): # 405 = already exists
logger.error(f"Failed to create directory {current_path}: HTTP {create_response.status_code}")
return False
logger.debug(f"Created directory: {current_path}")
except Exception as e:
logger.error(f"Error checking/creating directory {current_path}: {e}")
return False
return True
def generate_filename(
self,
invoice_date: Optional[date],
supplier_name: Optional[str],
invoice_number: Optional[str],
total: Optional[float],
original_filename: str,
upload_hash: Optional[str] = None
) -> str:
"""
Generate human-readable filename for archived invoice.
Format: {date}-{supplier}-{invoice_number}-{total}-{hash}.{ext}
Example: 2026-01-15-Brakes-INV001234-£234_50-a1b2c3d4.pdf
"""
# Sanitize supplier name (remove special chars, limit length)
safe_supplier = re.sub(r'[^\w\s-]', '', supplier_name or 'Unknown')
safe_supplier = re.sub(r'\s+', '-', safe_supplier.strip())[:30]
# Sanitize invoice number
safe_invoice = re.sub(r'[^\w-]', '', invoice_number or 'NoNumber')[:20]
# Format total with currency (replace . with _ for filename safety)
if total is not None:
total_str = f"£{total:.2f}".replace('.', '_')
else:
total_str = "£0_00"
# Get extension from original filename
ext = original_filename.split('.')[-1].lower() if original_filename and '.' in original_filename else 'pdf'
# Format date
if invoice_date:
date_str = invoice_date.strftime('%Y-%m-%d') if isinstance(invoice_date, date) else str(invoice_date)[:10]
else:
date_str = 'NoDate'
# Short hash (generate if not provided)
if upload_hash:
short_hash = upload_hash[:8]
else:
hash_input = f"{invoice_number or ''}{total or ''}{datetime.utcnow().isoformat()}"
short_hash = hashlib.md5(hash_input.encode()).hexdigest()[:8]
return f"{date_str}-{safe_supplier}-{safe_invoice}-{total_str}-{short_hash}.{ext}"
def generate_path(self, supplier_name: Optional[str], invoice_date: Optional[date]) -> str:
"""
Generate directory path for invoice archive.
Format: /{supplier}/{year}/{month}/
Example: /Brakes/2026/01/
"""
safe_supplier = re.sub(r'[^\w\s-]', '', supplier_name or 'Unknown')
safe_supplier = re.sub(r'\s+', '-', safe_supplier.strip())
if invoice_date:
if isinstance(invoice_date, date):
return f"{safe_supplier}/{invoice_date.year}/{invoice_date.month:02d}"
else:
# Try to parse string date
try:
dt = datetime.strptime(str(invoice_date)[:10], '%Y-%m-%d')
return f"{safe_supplier}/{dt.year}/{dt.month:02d}"
except ValueError:
pass
return f"{safe_supplier}/unknown"
async def upload_file(
self,
file_content: bytes,
directory_path: str,
filename: str,
timeout: Optional[float] = None
) -> Tuple[bool, str]:
"""
Upload file to Nextcloud.
Args:
file_content: File bytes
directory_path: Directory path (relative to base_path)
filename: Target filename
timeout: Optional extended timeout in seconds for large files
Returns:
(success, full_webdav_path or error_message)
"""
if not self.host or not self.username:
return (False, "Nextcloud not configured")
upload_client = None
try:
# Ensure directory exists
if not await self.ensure_directory(directory_path):
return (False, f"Failed to create directory: {directory_path}")
# Full path
full_path = f"{self.base_path}/{directory_path}/{filename}".strip('/')
url = f"{self.webdav_url}/{full_path}"
size_mb = len(file_content) / (1024 * 1024)
logger.info(f"Uploading {size_mb:.1f} MB to Nextcloud: {full_path}")
# Use extended timeout for large files
if timeout:
upload_client = await self._get_client(upload_timeout=timeout)
client = upload_client
else:
client = await self._get_client()
response = await client.put(url, content=file_content)
if response.status_code in (201, 204): # Created or No Content (overwritten)
logger.info(f"Uploaded file to Nextcloud: {full_path} ({size_mb:.1f} MB)")
return (True, full_path)
else:
return (False, f"Upload failed: HTTP {response.status_code}")
except httpx.TimeoutException as e:
size_mb = len(file_content) / (1024 * 1024)
logger.error(f"Nextcloud upload timed out for {filename} ({size_mb:.1f} MB): {type(e).__name__}")
return (False, f"Upload timed out ({size_mb:.1f} MB file) - try increasing timeout or check connection speed")
except Exception as e:
logger.error(f"Nextcloud upload error for {filename}: {type(e).__name__}: {e}")
return (False, f"{type(e).__name__}: {e}" if str(e) else type(e).__name__)
finally:
if upload_client and not upload_client.is_closed:
await upload_client.aclose()
async def download_file(self, path: str) -> Tuple[bool, bytes | str]:
"""
Download file from Nextcloud.
Args:
path: Full WebDAV path (relative to user's files root)
Returns:
(success, file_bytes or error_message)
"""
if not self.host or not self.username:
return (False, "Nextcloud not configured")
try:
url = f"{self.webdav_url}/{path}"
client = await self._get_client()
response = await client.get(url)
if response.status_code == 200:
return (True, response.content)
elif response.status_code == 404:
return (False, "File not found")
else:
return (False, f"Download failed: HTTP {response.status_code}")
except Exception as e:
return (False, str(e))
async def copy_to_deleted(
self,
source_path: str,
supplier_name: Optional[str],
original_filename: str
) -> Tuple[bool, str]:
"""
Copy file to deleted folder before deletion from DB.
Target: /{base_path}/{supplier}/deleted/[DELETED FROM FLASH] {filename}
"""
safe_supplier = re.sub(r'[^\w\s-]', '', supplier_name or 'Unknown')
safe_supplier = re.sub(r'\s+', '-', safe_supplier.strip())
deleted_dir = f"{safe_supplier}/deleted"
deleted_filename = f"[DELETED FROM FLASH] {original_filename}"
# Download original
success, content = await self.download_file(source_path)
if not success:
return (False, f"Failed to download original: {content}")
# Upload to deleted folder
return await self.upload_file(content, deleted_dir, deleted_filename)
async def delete_file(self, path: str) -> Tuple[bool, str]:
"""
Delete file from Nextcloud.
Args:
path: Full WebDAV path
Returns:
(success, message)
"""
if not self.host or not self.username:
return (False, "Nextcloud not configured")
try:
url = f"{self.webdav_url}/{path}"
client = await self._get_client()
response = await client.delete(url)
if response.status_code in (204, 404): # Deleted or already gone
return (True, "File deleted")
else:
return (False, f"Delete failed: HTTP {response.status_code}")
except Exception as e:
return (False, str(e))
async def close(self):
"""Close HTTP client"""
if self._client and not self._client.is_closed:
await self._client.aclose()
self._client = None