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>
353 lines
13 KiB
Python
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
|