544 lines
22 KiB
Python
544 lines
22 KiB
Python
"""
|
|
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))
|