feat: implement document management module

- Backend (FastAPI):
  - Attachment and AttachmentVersion models with migration
  - FileStorageService with SHA-256 checksum validation
  - File type validation (whitelist/blacklist)
  - Full CRUD API with version control support
  - Audit trail integration for upload/download/delete
  - Configurable upload directory and file size limit

- Frontend (React + Vite):
  - AttachmentUpload component with drag & drop
  - AttachmentList component with download/delete
  - TaskAttachments combined component
  - Attachments service for API calls

- Testing:
  - 12 tests for storage service and API endpoints

- OpenSpec:
  - add-document-management change archived

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
beabigegg
2025-12-29 22:03:05 +08:00
parent 0ef78e13ff
commit 3108fe1dff
21 changed files with 2027 additions and 1 deletions

View File

@@ -0,0 +1,180 @@
import os
import hashlib
import shutil
from pathlib import Path
from typing import BinaryIO, Optional, Tuple
from fastapi import UploadFile, HTTPException
from app.core.config import settings
class FileStorageService:
"""Service for handling file storage operations."""
def __init__(self):
self.base_dir = Path(settings.UPLOAD_DIR)
self._ensure_base_dir()
def _ensure_base_dir(self):
"""Ensure the base upload directory exists."""
self.base_dir.mkdir(parents=True, exist_ok=True)
def _get_file_path(self, project_id: str, task_id: str, attachment_id: str, version: int) -> Path:
"""Generate the file path for an attachment version."""
return self.base_dir / project_id / task_id / attachment_id / str(version)
@staticmethod
def calculate_checksum(file: BinaryIO) -> str:
"""Calculate SHA-256 checksum of a file."""
sha256_hash = hashlib.sha256()
# Read in chunks to handle large files
for chunk in iter(lambda: file.read(8192), b""):
sha256_hash.update(chunk)
file.seek(0) # Reset file position
return sha256_hash.hexdigest()
@staticmethod
def get_extension(filename: str) -> str:
"""Get file extension in lowercase."""
return filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
@staticmethod
def validate_file(file: UploadFile) -> Tuple[str, str]:
"""
Validate file size and type.
Returns (extension, mime_type) if valid.
Raises HTTPException if invalid.
"""
# Check file size
file.file.seek(0, 2) # Seek to end
file_size = file.file.tell()
file.file.seek(0) # Reset
if file_size > settings.MAX_FILE_SIZE:
raise HTTPException(
status_code=400,
detail=f"File too large. Maximum size is {settings.MAX_FILE_SIZE_MB}MB"
)
if file_size == 0:
raise HTTPException(status_code=400, detail="Empty file not allowed")
# Get extension
extension = FileStorageService.get_extension(file.filename or "")
# Check blocked extensions
if extension in settings.BLOCKED_EXTENSIONS:
raise HTTPException(
status_code=400,
detail=f"File type '.{extension}' is not allowed for security reasons"
)
# Check allowed extensions (if whitelist is enabled)
if settings.ALLOWED_EXTENSIONS and extension not in settings.ALLOWED_EXTENSIONS:
raise HTTPException(
status_code=400,
detail=f"File type '.{extension}' is not supported"
)
mime_type = file.content_type or "application/octet-stream"
return extension, mime_type
async def save_file(
self,
file: UploadFile,
project_id: str,
task_id: str,
attachment_id: str,
version: int
) -> Tuple[str, int, str]:
"""
Save uploaded file to storage.
Returns (file_path, file_size, checksum).
"""
# Validate file
extension, _ = self.validate_file(file)
# Calculate checksum first
checksum = self.calculate_checksum(file.file)
# Create directory structure
dir_path = self._get_file_path(project_id, task_id, attachment_id, version)
dir_path.mkdir(parents=True, exist_ok=True)
# Save file with original extension
filename = f"file.{extension}" if extension else "file"
file_path = dir_path / filename
# Get file size
file.file.seek(0, 2)
file_size = file.file.tell()
file.file.seek(0)
# Write file in chunks (streaming)
with open(file_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
return str(file_path), file_size, checksum
def get_file(
self,
project_id: str,
task_id: str,
attachment_id: str,
version: int
) -> Optional[Path]:
"""
Get the file path for an attachment version.
Returns None if file doesn't exist.
"""
dir_path = self._get_file_path(project_id, task_id, attachment_id, version)
if not dir_path.exists():
return None
# Find the file in the directory
files = list(dir_path.iterdir())
if not files:
return None
return files[0]
def get_file_by_path(self, file_path: str) -> Optional[Path]:
"""Get file by stored path."""
path = Path(file_path)
return path if path.exists() else None
def delete_file(
self,
project_id: str,
task_id: str,
attachment_id: str,
version: Optional[int] = None
) -> bool:
"""
Delete file(s) from storage.
If version is None, deletes all versions.
Returns True if successful.
"""
if version is not None:
# Delete specific version
dir_path = self._get_file_path(project_id, task_id, attachment_id, version)
else:
# Delete all versions (attachment directory)
dir_path = self.base_dir / project_id / task_id / attachment_id
if dir_path.exists():
shutil.rmtree(dir_path)
return True
return False
def delete_task_files(self, project_id: str, task_id: str) -> bool:
"""Delete all files for a task."""
dir_path = self.base_dir / project_id / task_id
if dir_path.exists():
shutil.rmtree(dir_path)
return True
return False
# Singleton instance
file_storage_service = FileStorageService()