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>
This commit is contained in:
commit
8d688b459d
10003 changed files with 1928395 additions and 0 deletions
353
backend/services/nextcloud_service.py
Normal file
353
backend/services/nextcloud_service.py
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
"""
|
||||
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue