""" Simple Upload Router =================== Handles file and directory uploads without automatic processing. Just stores files in Supabase storage and creates database records. Features: - Single file upload - Directory/folder upload with manifest - No automatic processing - Immediate response to users - Directory structure preservation """ import os import uuid import json import tempfile import logging from typing import Dict, List, Optional, Any from pathlib import Path from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, BackgroundTasks from fastapi.responses import JSONResponse from modules.auth.supabase_bearer import SupabaseBearer from modules.database.supabase.utils.client import SupabaseServiceRoleClient from modules.database.supabase.utils.storage import StorageAdmin from modules.logger_tool import initialise_logger router = APIRouter() auth = SupabaseBearer() logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), 'default', True) def _choose_bucket(scope: str, user_id: str, school_id: Optional[str]) -> str: """Choose appropriate bucket based on scope - matches old system logic.""" scope = (scope or 'teacher').lower() if scope == 'school' and school_id: return f"cc.institutes.{school_id}.private" # teacher / student fall back to users bucket for now return 'cc.users' @router.post("/files/upload") async def upload_single_file( cabinet_id: str = Form(...), path: str = Form(...), scope: str = Form(...), file: UploadFile = File(...), payload: Dict[str, Any] = Depends(auth) ): """ Simple single file upload - no automatic processing. Just stores the file and creates a database record. """ try: user_id = payload.get('sub') or payload.get('user_id') if not user_id: raise HTTPException(status_code=401, detail="User ID required") # Read file content file_bytes = await file.read() file_size = len(file_bytes) mime_type = file.content_type or 'application/octet-stream' filename = file.filename or path logger.info(f"📤 Simple upload: {filename} ({file_size} bytes) for user {user_id}") # Initialize services client = SupabaseServiceRoleClient() storage = StorageAdmin() # Generate file ID and storage path file_id = str(uuid.uuid4()) # Use same bucket logic as old system for consistency bucket = _choose_bucket('teacher', user_id, None) # Default to teacher scope storage_path = f"{cabinet_id}/{file_id}/{filename}" # Store file in Supabase storage try: storage.upload_file(bucket, storage_path, file_bytes, mime_type, upsert=True) except Exception as e: logger.error(f"Storage upload failed for {file_id}: {e}") raise HTTPException(status_code=500, detail=f"Storage upload failed: {str(e)}") # Create database record try: insert_res = client.supabase.table('files').insert({ 'id': file_id, 'name': filename, 'cabinet_id': cabinet_id, 'bucket': bucket, 'path': storage_path, 'mime_type': mime_type, 'uploaded_by': user_id, 'size_bytes': file_size, 'source': 'classroomcopilot-web', 'is_directory': False, 'processing_status': 'uploaded', 'relative_path': filename # For single files, relative path is just the filename }).execute() if not insert_res.data: # Clean up storage on DB failure try: storage.delete_file(bucket, storage_path) except: pass raise HTTPException(status_code=500, detail="Failed to create file record") file_record = insert_res.data[0] except Exception as e: logger.error(f"Database insert failed for {file_id}: {e}") # Clean up storage try: storage.delete_file(bucket, storage_path) except: pass raise HTTPException(status_code=500, detail=f"Database error: {str(e)}") logger.info(f"✅ Simple upload completed: {file_id}") return { 'status': 'success', 'message': 'File uploaded successfully', 'file': file_record, 'processing_required': False, # No automatic processing 'next_steps': 'File is ready for manual processing if needed' } except HTTPException: raise except Exception as e: logger.error(f"Upload error: {e}") raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)}") @router.post("/files/upload-directory") async def upload_directory( cabinet_id: str = Form(...), scope: str = Form(...), directory_name: str = Form(...), files: List[UploadFile] = File(...), file_paths: str = Form(...), # JSON string of relative paths payload: Dict[str, Any] = Depends(auth) ): """ Upload a complete directory/folder with all files. Preserves directory structure and creates a manifest. """ try: user_id = payload.get('sub') or payload.get('user_id') if not user_id: raise HTTPException(status_code=401, detail="User ID required") # Parse file paths try: relative_paths = json.loads(file_paths) except json.JSONDecodeError: raise HTTPException(status_code=400, detail="Invalid file_paths JSON") if len(files) != len(relative_paths): raise HTTPException(status_code=400, detail="Files and paths count mismatch") logger.info(f"📁 Directory upload: {directory_name} ({len(files)} files) for user {user_id}") # Initialize services client = SupabaseServiceRoleClient() storage = StorageAdmin() # Generate session ID for this directory upload upload_session_id = str(uuid.uuid4()) directory_id = str(uuid.uuid4()) # Use same bucket logic as old system for consistency bucket = _choose_bucket('teacher', user_id, None) # Calculate total size and build manifest total_size = 0 directory_structure = {} uploaded_files = [] directory_records = {} # Track created directories: {relative_path: directory_id} # First, analyze all paths to determine directory structure all_directories = set() for relative_path in relative_paths: # Get all parent directories for this file path_parts = relative_path.split('/') for i in range(len(path_parts) - 1): # Exclude the filename dir_path = '/'.join(path_parts[:i+1]) all_directories.add(dir_path) logger.info(f"📁 Creating directory structure with {len(all_directories)} directories: {sorted(all_directories)}") try: # Create directory records for all directories (sorted to create parents first) sorted_directories = sorted(all_directories, key=lambda x: (len(x.split('/')), x)) for dir_path in sorted_directories: dir_id = str(uuid.uuid4()) path_parts = dir_path.split('/') dir_name = path_parts[-1] # Last part is the directory name # Determine parent directory parent_id = None if len(path_parts) > 1: parent_path = '/'.join(path_parts[:-1]) parent_id = directory_records.get(parent_path) directory_record = { 'id': dir_id, 'name': dir_name, 'cabinet_id': cabinet_id, 'bucket': bucket, 'path': f"{cabinet_id}/{dir_id}/", 'mime_type': 'inode/directory', 'uploaded_by': user_id, 'size_bytes': 0, 'source': 'classroomcopilot-web', 'is_directory': True, 'parent_directory_id': parent_id, 'upload_session_id': upload_session_id, 'processing_status': 'uploaded', 'relative_path': dir_path } directory_records[dir_path] = dir_id # Insert directory record result = client.supabase.table('files').insert(directory_record).execute() logger.info(f"📁 Created directory: {dir_path} (ID: {dir_id}, Parent: {parent_id})") # Process each file for i, (file, relative_path) in enumerate(zip(files, relative_paths)): try: # Read file content file_bytes = await file.read() file_size = len(file_bytes) mime_type = file.content_type or 'application/octet-stream' filename = file.filename or f"file_{i}" total_size += file_size # Generate file ID and determine parent directory file_id = str(uuid.uuid4()) # Find the correct parent directory for this file path_parts = relative_path.split('/') if len(path_parts) > 1: parent_dir_path = '/'.join(path_parts[:-1]) parent_directory_id = directory_records.get(parent_dir_path) else: parent_directory_id = None # File is in root cabinet # Use parent directory ID for storage path if exists, otherwise use a generated path if parent_directory_id: storage_path = f"{cabinet_id}/{parent_directory_id}/{path_parts[-1]}" else: storage_path = f"{cabinet_id}/{file_id}" # Store file in Supabase storage storage.upload_file(bucket, storage_path, file_bytes, mime_type, upsert=True) # Create file record file_record = { 'id': file_id, 'name': filename, 'cabinet_id': cabinet_id, 'bucket': bucket, 'path': storage_path, 'mime_type': mime_type, 'uploaded_by': user_id, 'size_bytes': file_size, 'source': 'classroomcopilot-web', 'is_directory': False, 'parent_directory_id': parent_directory_id, 'relative_path': relative_path, 'upload_session_id': upload_session_id, 'processing_status': 'uploaded' } uploaded_files.append(file_record) # Build directory structure for manifest _add_to_directory_structure(directory_structure, relative_path, { 'size': file_size, 'mime_type': mime_type, 'file_id': file_id }) logger.info(f"📄 Uploaded file {i+1}/{len(files)}: {relative_path}") except Exception as e: logger.error(f"Failed to upload file {relative_path}: {e}") # Continue with other files, don't fail entire upload continue # Create directory manifest directory_manifest = { 'total_files': len(uploaded_files), 'total_size_bytes': total_size, 'directory_structure': directory_structure, 'upload_timestamp': '2024-09-23T12:00:00Z', # TODO: Use actual timestamp 'upload_method': 'directory_picker', 'upload_session_id': upload_session_id } # Update root directory with manifest and total size (if root directory exists) root_directory_id = directory_records.get(sorted_directories[0]) if sorted_directories else None if root_directory_id: update_res = client.supabase.table('files').update({ 'size_bytes': total_size, 'directory_manifest': directory_manifest }).eq('id', root_directory_id).execute() if not update_res.data: logger.warning("Failed to update root directory with manifest") # Insert all file records in batch if uploaded_files: files_insert_res = client.supabase.table('files').insert(uploaded_files).execute() if not files_insert_res.data: logger.warning("Some file records failed to insert") logger.info(f"✅ Directory upload completed: {root_directory_id} ({len(uploaded_files)} files, {len(sorted_directories)} directories)") return { 'status': 'success', 'message': f'Directory uploaded successfully with {len(uploaded_files)} files in {len(sorted_directories)} directories', 'directories_created': len(sorted_directories), 'files_count': len(uploaded_files), 'total_size_bytes': total_size, 'root_directory_id': root_directory_id, 'upload_session_id': upload_session_id, 'processing_required': False, # No automatic processing 'next_steps': 'Files are ready for manual processing if needed' } except Exception as e: logger.error(f"Directory upload failed: {e}") # TODO: Implement cleanup of partially uploaded files raise HTTPException(status_code=500, detail=f"Directory upload failed: {str(e)}") except HTTPException: raise except Exception as e: logger.error(f"Directory upload error: {e}") raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)}") def _add_to_directory_structure(structure: Dict, relative_path: str, file_info: Dict): """Add a file to the directory structure manifest.""" path_parts = relative_path.split('/') current_level = structure # Navigate/create directory structure for i, part in enumerate(path_parts): if i == len(path_parts) - 1: # This is the file itself current_level[part] = file_info else: # This is a directory if part not in current_level: current_level[part] = {} current_level = current_level[part] @router.get("/files") def list_files( cabinet_id: str, include_directories: bool = True, parent_directory_id: Optional[str] = None, page: int = 1, per_page: int = 20, search: Optional[str] = None, sort_by: str = 'created_at', sort_order: str = 'desc', payload: Dict[str, Any] = Depends(auth) ): """ List files with pagination, search, and sorting support. Args: cabinet_id: Cabinet to list files from include_directories: Whether to include directory entries parent_directory_id: Filter by parent directory page: Page number (1-based) per_page: Items per page (max 100) search: Search term for filename sort_by: Field to sort by (name, size_bytes, created_at, processing_status) sort_order: Sort order (asc, desc) """ try: client = SupabaseServiceRoleClient() # Validate pagination parameters page = max(1, page) per_page = min(max(1, per_page), 100) # Limit to 100 items per page offset = (page - 1) * per_page # Validate sort parameters valid_sort_fields = ['name', 'size_bytes', 'created_at', 'processing_status', 'mime_type'] if sort_by not in valid_sort_fields: sort_by = 'created_at' if sort_order.lower() not in ['asc', 'desc']: sort_order = 'desc' # Build base query query = client.supabase.table('files').select('*').eq('cabinet_id', cabinet_id) count_query = client.supabase.table('files').select('id', count='exact').eq('cabinet_id', cabinet_id) # Apply filters if parent_directory_id: query = query.eq('parent_directory_id', parent_directory_id) count_query = count_query.eq('parent_directory_id', parent_directory_id) elif not include_directories: query = query.eq('is_directory', False) count_query = count_query.eq('is_directory', False) # Apply search filter if search: search_term = f"%{search}%" query = query.ilike('name', search_term) count_query = count_query.ilike('name', search_term) # Get total count count_res = count_query.execute() total_count = count_res.count if hasattr(count_res, 'count') else len(count_res.data or []) # Apply sorting and pagination query = query.order(sort_by, desc=(sort_order.lower() == 'desc')) query = query.range(offset, offset + per_page - 1) res = query.execute() files = res.data or [] # Calculate pagination metadata total_pages = (total_count + per_page - 1) // per_page has_next = page < total_pages has_prev = page > 1 return { 'files': files, 'pagination': { 'page': page, 'per_page': per_page, 'total_count': total_count, 'total_pages': total_pages, 'has_next': has_next, 'has_prev': has_prev, 'offset': offset }, 'filters': { 'search': search, 'sort_by': sort_by, 'sort_order': sort_order, 'include_directories': include_directories, 'parent_directory_id': parent_directory_id } } except Exception as e: logger.error(f"List files error: {e}") raise HTTPException(status_code=500, detail=str(e)) @router.get("/files/{file_id}") def get_file_details(file_id: str, payload: Dict[str, Any] = Depends(auth)): """Get detailed information about a file or directory.""" try: client = SupabaseServiceRoleClient() res = client.supabase.table('files').select('*').eq('id', file_id).single().execute() if not res.data: raise HTTPException(status_code=404, detail="File not found") file_data = res.data # If it's a directory, also get its contents if file_data.get('is_directory'): contents_res = client.supabase.table('files').select('*').eq('parent_directory_id', file_id).execute() file_data['contents'] = contents_res.data or [] return file_data except HTTPException: raise except Exception as e: logger.error(f"Get file details error: {e}") raise HTTPException(status_code=500, detail=str(e)) @router.delete("/files/{file_id}") def delete_file(file_id: str, payload: Dict[str, Any] = Depends(auth)): """Delete a file or directory and its contents.""" try: client = SupabaseServiceRoleClient() storage = StorageAdmin() # Get file info res = client.supabase.table('files').select('*').eq('id', file_id).single().execute() if not res.data: raise HTTPException(status_code=404, detail="File not found") file_data = res.data # If it's a directory, delete all contents first if file_data.get('is_directory'): contents_res = client.supabase.table('files').select('*').eq('parent_directory_id', file_id).execute() # Delete each file in the directory for content_file in contents_res.data or []: try: # Delete from storage storage.delete_file(content_file['bucket'], content_file['path']) except Exception as e: logger.warning(f"Failed to delete file from storage: {content_file['path']}: {e}") # Delete all directory contents from database client.supabase.table('files').delete().eq('parent_directory_id', file_id).execute() else: # Delete single file from storage try: storage.delete_file(file_data['bucket'], file_data['path']) except Exception as e: logger.warning(f"Failed to delete file from storage: {file_data['path']}: {e}") # Delete the main record delete_res = client.supabase.table('files').delete().eq('id', file_id).execute() logger.info(f"🗑️ Deleted {'directory' if file_data.get('is_directory') else 'file'}: {file_id}") return { 'status': 'success', 'message': f"{'Directory' if file_data.get('is_directory') else 'File'} deleted successfully" } except HTTPException: raise except Exception as e: logger.error(f"Delete file error: {e}") raise HTTPException(status_code=500, detail=str(e))