import uuid from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request from fastapi.responses import FileResponse, Response from sqlalchemy.orm import Session from typing import Optional from app.core.database import get_db from app.middleware.auth import get_current_user, check_task_access, check_task_edit_access from app.models import User, Task, Project, 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 from app.services.watermark_service import watermark_service router = APIRouter(prefix="/api", tags=["attachments"]) def get_task_with_access_check(db: Session, task_id: str, current_user: User, require_edit: bool = False) -> Task: """Get task and verify access permissions.""" task = db.query(Task).filter(Task.id == task_id).first() if not task: raise HTTPException(status_code=404, detail="Task not found") # Get project for access check project = db.query(Project).filter(Project.id == task.project_id).first() if not project: raise HTTPException(status_code=404, detail="Project not found") # Check access permission if not check_task_access(current_user, task, project): raise HTTPException(status_code=403, detail="Access denied to this task") # Check edit permission if required if require_edit and not check_task_edit_access(current_user, task, project): raise HTTPException(status_code=403, detail="Edit access denied to this task") return task def get_attachment_with_access_check( db: Session, attachment_id: str, current_user: User, require_edit: bool = False ) -> Attachment: """Get attachment and verify access permissions.""" 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") # Get task and project for access check task = db.query(Task).filter(Task.id == attachment.task_id).first() if not task: raise HTTPException(status_code=404, detail="Task not found") project = db.query(Project).filter(Project.id == task.project_id).first() if not project: raise HTTPException(status_code=404, detail="Project not found") # Check access permission if not check_task_access(current_user, task, project): raise HTTPException(status_code=403, detail="Access denied to this attachment") # Check edit permission if required if require_edit and not check_task_edit_access(current_user, task, project): raise HTTPException(status_code=403, detail="Edit access denied to this attachment") 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_with_access_check(db, task_id, current_user, require_edit=True) # 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 # 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() db.refresh(existing) 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) # 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() db.refresh(attachment) 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_with_access_check(db, task_id, current_user, require_edit=False) 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_with_access_check(db, attachment_id, current_user, require_edit=False) 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 with dynamic watermark.""" attachment = get_attachment_with_access_check(db, attachment_id, current_user, require_edit=False) # 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 download_time = datetime.now() 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() # Check if watermark should be applied mime_type = attachment.mime_type or "" if watermark_service.supports_watermark(mime_type): try: # Read the original file with open(file_path, "rb") as f: file_bytes = f.read() # Apply watermark based on file type if watermark_service.is_supported_image(mime_type): watermarked_bytes, output_format = watermark_service.add_image_watermark( image_bytes=file_bytes, user_name=current_user.name, employee_id=current_user.employee_id, download_time=download_time ) # Update mime type based on output format output_mime_type = f"image/{output_format}" # Update filename extension if format changed original_filename = attachment.original_filename if output_format == "png" and not original_filename.lower().endswith(".png"): original_filename = original_filename.rsplit(".", 1)[0] + ".png" return Response( content=watermarked_bytes, media_type=output_mime_type, headers={ "Content-Disposition": f'attachment; filename="{original_filename}"' } ) elif watermark_service.is_supported_pdf(mime_type): watermarked_bytes = watermark_service.add_pdf_watermark( pdf_bytes=file_bytes, user_name=current_user.name, employee_id=current_user.employee_id, download_time=download_time ) return Response( content=watermarked_bytes, media_type="application/pdf", headers={ "Content-Disposition": f'attachment; filename="{attachment.original_filename}"' } ) except Exception as e: # If watermarking fails, log the error but still return the original file # This ensures users can still download files even if watermarking has issues import logging logging.getLogger(__name__).warning( f"Watermarking failed for attachment {attachment_id}: {str(e)}. " "Returning original file." ) # Return original file without watermark for unsupported types or on error 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_with_access_check(db, attachment_id, current_user, require_edit=True) # Soft delete attachment.is_deleted = True # 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 {"detail": "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_with_access_check(db, attachment_id, current_user, require_edit=False) 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_with_access_check(db, attachment_id, current_user, require_edit=True) 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 # 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 {"detail": f"Restored to version {version}", "current_version": version}