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}