""" 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