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:
382
backend/app/api/attachments/router.py
Normal file
382
backend/app/api/attachments/router.py
Normal file
@@ -0,0 +1,382 @@
|
||||
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}
|
||||
Reference in New Issue
Block a user