Files
PROJECT-CONTORL/backend/app/api/attachments/router.py
beabigegg 3108fe1dff 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>
2025-12-29 22:03:05 +08:00

383 lines
12 KiB
Python

import uuid
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from typing import Optional
from app.core.database import get_db
from app.middleware.auth import get_current_user
from app.models import User, Task, Attachment, AttachmentVersion, AuditAction
from app.schemas.attachment import (
AttachmentResponse, AttachmentListResponse, AttachmentDetailResponse,
AttachmentVersionResponse, VersionHistoryResponse
)
from app.services.file_storage_service import file_storage_service
from app.services.audit_service import AuditService
router = APIRouter(prefix="/api", tags=["attachments"])
def get_task_or_404(db: Session, task_id: str) -> Task:
"""Get task or raise 404."""
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
return task
def get_attachment_or_404(db: Session, attachment_id: str) -> Attachment:
"""Get attachment or raise 404."""
attachment = db.query(Attachment).filter(
Attachment.id == attachment_id,
Attachment.is_deleted == False
).first()
if not attachment:
raise HTTPException(status_code=404, detail="Attachment not found")
return attachment
def attachment_to_response(attachment: Attachment) -> AttachmentResponse:
"""Convert Attachment model to response."""
return AttachmentResponse(
id=attachment.id,
task_id=attachment.task_id,
filename=attachment.filename,
original_filename=attachment.original_filename,
mime_type=attachment.mime_type,
file_size=attachment.file_size,
current_version=attachment.current_version,
is_encrypted=attachment.is_encrypted,
uploaded_by=attachment.uploaded_by,
uploader_name=attachment.uploader.name if attachment.uploader else None,
created_at=attachment.created_at,
updated_at=attachment.updated_at
)
def version_to_response(version: AttachmentVersion) -> AttachmentVersionResponse:
"""Convert AttachmentVersion model to response."""
return AttachmentVersionResponse(
id=version.id,
version=version.version,
file_size=version.file_size,
checksum=version.checksum,
uploaded_by=version.uploaded_by,
uploader_name=version.uploader.name if version.uploader else None,
created_at=version.created_at
)
@router.post("/tasks/{task_id}/attachments", response_model=AttachmentResponse)
async def upload_attachment(
task_id: str,
request: Request,
file: UploadFile = File(...),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Upload a file attachment to a task."""
task = get_task_or_404(db, task_id)
# Check if attachment with same filename exists (for versioning in Phase 2)
existing = db.query(Attachment).filter(
Attachment.task_id == task_id,
Attachment.original_filename == file.filename,
Attachment.is_deleted == False
).first()
if existing:
# Phase 2: Create new version
new_version = existing.current_version + 1
# Save file
file_path, file_size, checksum = await file_storage_service.save_file(
file=file,
project_id=task.project_id,
task_id=task_id,
attachment_id=existing.id,
version=new_version
)
# Create version record
version = AttachmentVersion(
id=str(uuid.uuid4()),
attachment_id=existing.id,
version=new_version,
file_path=file_path,
file_size=file_size,
checksum=checksum,
uploaded_by=current_user.id
)
db.add(version)
# Update attachment
existing.current_version = new_version
existing.file_size = file_size
existing.updated_at = version.created_at
db.commit()
db.refresh(existing)
# Audit log
AuditService.log_event(
db=db,
event_type="attachment.upload",
resource_type="attachment",
action=AuditAction.UPDATE,
user_id=current_user.id,
resource_id=existing.id,
changes=[{"field": "version", "old_value": new_version - 1, "new_value": new_version}],
request_metadata=getattr(request.state, "audit_metadata", None)
)
db.commit()
return attachment_to_response(existing)
# Create new attachment
attachment_id = str(uuid.uuid4())
# Save file
file_path, file_size, checksum = await file_storage_service.save_file(
file=file,
project_id=task.project_id,
task_id=task_id,
attachment_id=attachment_id,
version=1
)
# Get mime type from file storage validation
extension = file_storage_service.get_extension(file.filename or "")
mime_type = file.content_type or "application/octet-stream"
# Create attachment record
attachment = Attachment(
id=attachment_id,
task_id=task_id,
filename=file.filename or "unnamed",
original_filename=file.filename or "unnamed",
mime_type=mime_type,
file_size=file_size,
current_version=1,
is_encrypted=False,
uploaded_by=current_user.id
)
db.add(attachment)
# Create version record
version = AttachmentVersion(
id=str(uuid.uuid4()),
attachment_id=attachment_id,
version=1,
file_path=file_path,
file_size=file_size,
checksum=checksum,
uploaded_by=current_user.id
)
db.add(version)
db.commit()
db.refresh(attachment)
# Audit log
AuditService.log_event(
db=db,
event_type="attachment.upload",
resource_type="attachment",
action=AuditAction.CREATE,
user_id=current_user.id,
resource_id=attachment.id,
changes=[{"field": "filename", "old_value": None, "new_value": attachment.filename}],
request_metadata=getattr(request.state, "audit_metadata", None)
)
db.commit()
return attachment_to_response(attachment)
@router.get("/tasks/{task_id}/attachments", response_model=AttachmentListResponse)
async def list_task_attachments(
task_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""List all attachments for a task."""
task = get_task_or_404(db, task_id)
attachments = db.query(Attachment).filter(
Attachment.task_id == task_id,
Attachment.is_deleted == False
).order_by(Attachment.created_at.desc()).all()
return AttachmentListResponse(
attachments=[attachment_to_response(a) for a in attachments],
total=len(attachments)
)
@router.get("/attachments/{attachment_id}", response_model=AttachmentDetailResponse)
async def get_attachment(
attachment_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get attachment details with version history."""
attachment = get_attachment_or_404(db, attachment_id)
versions = db.query(AttachmentVersion).filter(
AttachmentVersion.attachment_id == attachment_id
).order_by(AttachmentVersion.version.desc()).all()
return AttachmentDetailResponse(
id=attachment.id,
task_id=attachment.task_id,
filename=attachment.filename,
original_filename=attachment.original_filename,
mime_type=attachment.mime_type,
file_size=attachment.file_size,
current_version=attachment.current_version,
is_encrypted=attachment.is_encrypted,
uploaded_by=attachment.uploaded_by,
uploader_name=attachment.uploader.name if attachment.uploader else None,
created_at=attachment.created_at,
updated_at=attachment.updated_at,
versions=[version_to_response(v) for v in versions]
)
@router.get("/attachments/{attachment_id}/download")
async def download_attachment(
attachment_id: str,
version: Optional[int] = None,
request: Request = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Download an attachment file."""
attachment = get_attachment_or_404(db, attachment_id)
# Get version to download
target_version = version or attachment.current_version
version_record = db.query(AttachmentVersion).filter(
AttachmentVersion.attachment_id == attachment_id,
AttachmentVersion.version == target_version
).first()
if not version_record:
raise HTTPException(status_code=404, detail=f"Version {target_version} not found")
# Get file path
file_path = file_storage_service.get_file_by_path(version_record.file_path)
if not file_path:
raise HTTPException(status_code=404, detail="File not found on disk")
# Audit log
AuditService.log_event(
db=db,
event_type="attachment.download",
resource_type="attachment",
action=AuditAction.UPDATE, # Using UPDATE as there's no DOWNLOAD action
user_id=current_user.id,
resource_id=attachment.id,
changes=[{"field": "downloaded_version", "old_value": None, "new_value": target_version}],
request_metadata=getattr(request.state, "audit_metadata", None) if request else None
)
db.commit()
return FileResponse(
path=str(file_path),
filename=attachment.original_filename,
media_type=attachment.mime_type
)
@router.delete("/attachments/{attachment_id}")
async def delete_attachment(
attachment_id: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Soft delete an attachment."""
attachment = get_attachment_or_404(db, attachment_id)
# Soft delete
attachment.is_deleted = True
db.commit()
# Audit log
AuditService.log_event(
db=db,
event_type="attachment.delete",
resource_type="attachment",
action=AuditAction.DELETE,
user_id=current_user.id,
resource_id=attachment.id,
changes=[{"field": "is_deleted", "old_value": False, "new_value": True}],
request_metadata=getattr(request.state, "audit_metadata", None)
)
db.commit()
return {"message": "Attachment deleted", "id": attachment_id}
@router.get("/attachments/{attachment_id}/versions", response_model=VersionHistoryResponse)
async def get_version_history(
attachment_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get version history for an attachment."""
attachment = get_attachment_or_404(db, attachment_id)
versions = db.query(AttachmentVersion).filter(
AttachmentVersion.attachment_id == attachment_id
).order_by(AttachmentVersion.version.desc()).all()
return VersionHistoryResponse(
attachment_id=attachment.id,
filename=attachment.filename,
versions=[version_to_response(v) for v in versions],
total=len(versions)
)
@router.post("/attachments/{attachment_id}/restore/{version}")
async def restore_version(
attachment_id: str,
version: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Restore an attachment to a specific version."""
attachment = get_attachment_or_404(db, attachment_id)
version_record = db.query(AttachmentVersion).filter(
AttachmentVersion.attachment_id == attachment_id,
AttachmentVersion.version == version
).first()
if not version_record:
raise HTTPException(status_code=404, detail=f"Version {version} not found")
old_version = attachment.current_version
attachment.current_version = version
attachment.file_size = version_record.file_size
db.commit()
# Audit log
AuditService.log_event(
db=db,
event_type="attachment.restore",
resource_type="attachment",
action=AuditAction.RESTORE,
user_id=current_user.id,
resource_id=attachment.id,
changes=[{"field": "current_version", "old_value": old_version, "new_value": version}],
request_metadata=getattr(request.state, "audit_metadata", None)
)
db.commit()
return {"message": f"Restored to version {version}", "current_version": version}