Files
app-service/backend/app/api/v1/files.py
matteoscrugli fc605f03c9 Improve file listing and fix notification metadata field
Backend:
- Optimize file listing for non-superusers with dedicated CRUD methods
- Add get_visible_for_user and count_visible_for_user for efficient queries
- Move /allowed-types/ and /max-size/ routes before /{file_id} for proper matching
- Rename notification 'metadata' field to 'extra_data' for clarity
- Fix settings export to use get_value() method

Frontend:
- Update NotificationItem interface to use extra_data field

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 22:27:08 +01:00

374 lines
10 KiB
Python

"""File storage endpoints."""
import json
from typing import Any, List, Optional
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Query
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from app.dependencies import get_db, get_current_user, get_current_superuser
from app.models.user import User
from app import crud
from app.schemas.file import (
StoredFile as StoredFileSchema,
FileCreate,
FileUpdate,
FileUploadResponse,
FileListResponse,
ALLOWED_CONTENT_TYPES,
MAX_FILE_SIZE,
)
router = APIRouter()
def file_to_schema(db_file) -> dict:
"""Convert a StoredFile model to schema dict."""
return {
"id": db_file.id,
"original_filename": db_file.original_filename,
"content_type": db_file.content_type,
"size_bytes": db_file.size_bytes,
"storage_type": db_file.storage_type,
"description": db_file.description,
"tags": json.loads(db_file.tags) if db_file.tags else None,
"is_public": db_file.is_public,
"uploaded_by": db_file.uploaded_by,
"file_hash": db_file.file_hash,
"created_at": db_file.created_at,
"updated_at": db_file.updated_at,
}
@router.post("/upload", response_model=FileUploadResponse, status_code=status.HTTP_201_CREATED)
async def upload_file(
file: UploadFile = File(...),
description: Optional[str] = None,
tags: Optional[str] = None, # Comma-separated tags
is_public: bool = False,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Upload a file.
Returns file metadata with download URL.
"""
# Read file content
content = await file.read()
size = len(content)
# Validate upload
is_valid, error = crud.file_storage.validate_upload(
content_type=file.content_type,
size_bytes=size
)
if not is_valid:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error
)
# Parse tags
tag_list = None
if tags:
tag_list = [t.strip() for t in tags.split(",") if t.strip()]
# Create file metadata
metadata = FileCreate(
description=description,
tags=tag_list,
is_public=is_public
)
# Reset file position for reading
await file.seek(0)
# Save file
import io
stored_file = crud.file_storage.create(
db,
file=io.BytesIO(content),
filename=file.filename,
content_type=file.content_type,
size_bytes=size,
uploaded_by=current_user.id,
metadata=metadata
)
# Log the action
crud.audit_log.log_action(
db,
user_id=current_user.id,
username=current_user.username,
action="upload",
resource_type="file",
resource_id=stored_file.id,
details={"filename": file.filename, "size": size},
status="success"
)
return {
"id": stored_file.id,
"original_filename": stored_file.original_filename,
"content_type": stored_file.content_type,
"size_bytes": stored_file.size_bytes,
"download_url": f"/api/v1/files/{stored_file.id}/download"
}
@router.get("/", response_model=FileListResponse)
def list_files(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
mine_only: bool = False,
is_public: Optional[bool] = None,
content_type: Optional[str] = None,
):
"""
List files with pagination and filtering.
Regular users can only see their own files and public files.
Superusers can see all files.
"""
skip = (page - 1) * page_size
# Filter by ownership for non-superusers
if not current_user.is_superuser:
if mine_only:
files = crud.file_storage.get_multi(
db,
skip=skip,
limit=page_size,
uploaded_by=current_user.id,
is_public=is_public,
content_type=content_type
)
total = crud.file_storage.count(
db,
uploaded_by=current_user.id,
is_public=is_public,
content_type=content_type
)
else:
files = crud.file_storage.get_visible_for_user(
db,
user_id=current_user.id,
skip=skip,
limit=page_size,
is_public=is_public,
content_type=content_type
)
total = crud.file_storage.count_visible_for_user(
db,
user_id=current_user.id,
is_public=is_public,
content_type=content_type
)
else:
uploaded_by = current_user.id if mine_only else None
files = crud.file_storage.get_multi(
db,
skip=skip,
limit=page_size,
uploaded_by=uploaded_by,
is_public=is_public,
content_type=content_type
)
total = crud.file_storage.count(
db,
uploaded_by=uploaded_by,
is_public=is_public,
content_type=content_type
)
return {
"files": [file_to_schema(f) for f in files],
"total": total,
"page": page,
"page_size": page_size
}
@router.get("/allowed-types/", response_model=List[str])
def get_allowed_types(
current_user: User = Depends(get_current_user),
):
"""
Get list of allowed file types for upload.
"""
return ALLOWED_CONTENT_TYPES
@router.get("/max-size/", response_model=dict)
def get_max_size(
current_user: User = Depends(get_current_user),
):
"""
Get maximum allowed file size.
"""
return {
"max_size_bytes": MAX_FILE_SIZE,
"max_size_mb": MAX_FILE_SIZE / (1024 * 1024)
}
@router.get("/{file_id}", response_model=StoredFileSchema)
def get_file(
file_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Get file metadata.
Users can only access their own files or public files.
"""
stored_file = crud.file_storage.get(db, id=file_id)
if not stored_file:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found"
)
# Check access
if not stored_file.is_public and stored_file.uploaded_by != current_user.id and not current_user.is_superuser:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied"
)
return file_to_schema(stored_file)
@router.get("/{file_id}/download")
def download_file(
file_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Download a file.
Users can only download their own files or public files.
"""
stored_file = crud.file_storage.get(db, id=file_id)
if not stored_file:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found"
)
# Check access
if not stored_file.is_public and stored_file.uploaded_by != current_user.id and not current_user.is_superuser:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied"
)
# Get file path
file_path = crud.file_storage.get_file_content(stored_file)
if not file_path:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File content not found"
)
return FileResponse(
path=file_path,
filename=stored_file.original_filename,
media_type=stored_file.content_type
)
@router.put("/{file_id}", response_model=StoredFileSchema)
def update_file(
file_id: str,
file_in: FileUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Update file metadata.
Users can only update their own files.
"""
stored_file = crud.file_storage.get(db, id=file_id)
if not stored_file:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found"
)
# Check ownership
if stored_file.uploaded_by != current_user.id and not current_user.is_superuser:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied"
)
updated = crud.file_storage.update(db, db_obj=stored_file, obj_in=file_in)
# Log the action
crud.audit_log.log_action(
db,
user_id=current_user.id,
username=current_user.username,
action="update",
resource_type="file",
resource_id=file_id,
details={"filename": stored_file.original_filename},
status="success"
)
return file_to_schema(updated)
@router.delete("/{file_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_file(
file_id: str,
permanent: bool = False,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Delete a file.
Users can only delete their own files.
Superusers can permanently delete files.
"""
stored_file = crud.file_storage.get(db, id=file_id)
if not stored_file:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found"
)
# Check ownership
if stored_file.uploaded_by != current_user.id and not current_user.is_superuser:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied"
)
# Log the action
crud.audit_log.log_action(
db,
user_id=current_user.id,
username=current_user.username,
action="delete",
resource_type="file",
resource_id=file_id,
details={
"filename": stored_file.original_filename,
"permanent": permanent
},
status="success"
)
if permanent and current_user.is_superuser:
crud.file_storage.hard_delete(db, id=file_id)
else:
crud.file_storage.soft_delete(db, id=file_id)
return None