import os import uuid from pathlib import Path import aiofiles from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status, Request from fastapi.responses import FileResponse from sqlalchemy.ext.asyncio import AsyncSession from app.core.deps import get_current_user, get_db_session, require_study_member, get_study_member from app.crud import attachment as attachment_crud from app.crud import audit as audit_crud from app.crud import study as study_crud from app.crud import user as user_crud from app.crud import member as member_crud from app.core.security import decode_token from app.schemas.attachment import AttachmentRead router = APIRouter() global_router = APIRouter() UPLOAD_ROOT = Path(__file__).resolve().parent.parent.parent / "uploads" async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID): study = await study_crud.get(db, study_id) if not study: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Study not found") return study @router.post( "/", response_model=AttachmentRead, status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_study_member())], ) async def upload_attachment( study_id: uuid.UUID, entity_type: str, entity_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db_session), current_user=Depends(get_current_user), ) -> AttachmentRead: await _ensure_study_exists(db, study_id) dest_dir = UPLOAD_ROOT / f"study_{study_id}" / f"{entity_type}_{entity_id}" dest_dir.mkdir(parents=True, exist_ok=True) unique_name = f"{uuid.uuid4()}{Path(file.filename).suffix}" dest_path = dest_dir / unique_name content = await file.read() async with aiofiles.open(dest_path, "wb") as out_file: await out_file.write(content) attachment = await attachment_crud.create_attachment( db, study_id=study_id, entity_type=entity_type, entity_id=entity_id, filename=file.filename, file_path=str(dest_path), file_size=len(content), content_type=file.content_type, uploaded_by=current_user.id, ) await audit_crud.log_action( db, study_id=study_id, entity_type=entity_type, entity_id=entity_id, action="UPLOAD_FILE", detail=f"File uploaded: {file.filename}", operator_id=current_user.id, operator_role=current_user.role, ) return attachment @router.get( "/", response_model=list[AttachmentRead], dependencies=[Depends(require_study_member())], ) async def list_attachments( study_id: uuid.UUID, entity_type: str, entity_id: uuid.UUID, db: AsyncSession = Depends(get_db_session), ) -> list[AttachmentRead]: await _ensure_study_exists(db, study_id) attachments = await attachment_crud.list_attachments(db, study_id, entity_type, entity_id) return list(attachments) @router.get( "/{attachment_id}/download", dependencies=[Depends(require_study_member())], ) async def download_attachment( study_id: uuid.UUID, entity_type: str, entity_id: uuid.UUID, attachment_id: uuid.UUID, db: AsyncSession = Depends(get_db_session), ) -> FileResponse: await _ensure_study_exists(db, study_id) attachment = await attachment_crud.get_attachment(db, attachment_id) if not attachment or attachment.study_id != study_id or attachment.entity_id != entity_id or attachment.entity_type != entity_type: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Attachment not found") if not os.path.exists(attachment.file_path): raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File missing on server") return FileResponse( path=attachment.file_path, filename=attachment.filename, media_type=attachment.content_type or "application/octet-stream", ) async def _authorize_global(request: Request, db: AsyncSession, study_id: uuid.UUID): token = None auth_header = request.headers.get("Authorization") if auth_header and auth_header.lower().startswith("bearer "): token = auth_header.split(" ", 1)[1] if not token: token = request.query_params.get("token") if not token: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated") payload = decode_token(token) user = await user_crud.get_by_id(db, uuid.UUID(str(payload.get("sub")))) if not user or not user.is_active: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive or missing user") if user.role == "ADMIN": return user, None membership = await member_crud.get_member(db, study_id, user.id) if not membership or not membership.is_active: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not project member") return user, membership @global_router.get( "/{attachment_id}/download", response_class=FileResponse, ) async def global_download_attachment( attachment_id: uuid.UUID, request: Request, db: AsyncSession = Depends(get_db_session), ): attachment = await attachment_crud.get_attachment(db, attachment_id) if not attachment: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Attachment not found") await _ensure_study_exists(db, attachment.study_id) user, _ = await _authorize_global(request, db, attachment.study_id) if not os.path.exists(attachment.file_path): raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File missing on server") return FileResponse( path=attachment.file_path, filename=attachment.filename, media_type=attachment.content_type or "application/octet-stream", ) @global_router.delete( "/{attachment_id}", status_code=status.HTTP_204_NO_CONTENT, ) async def global_delete_attachment( attachment_id: uuid.UUID, request: Request, db: AsyncSession = Depends(get_db_session), ): attachment = await attachment_crud.get_attachment(db, attachment_id) if not attachment: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Attachment not found") await _ensure_study_exists(db, attachment.study_id) user, membership = await _authorize_global(request, db, attachment.study_id) can_delete = ( user.role == "ADMIN" or attachment.uploaded_by == user.id or (membership and getattr(membership, "role_in_study", None) == "PM") ) if not can_delete: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No permission to delete attachment") await attachment_crud.soft_delete_attachment(db, attachment) await audit_crud.log_action( db, study_id=attachment.study_id, entity_type=attachment.entity_type, entity_id=attachment.entity_id, action="DELETE_ATTACHMENT", detail=f"File deleted: {attachment.filename}", operator_id=user.id, operator_role=user.role, ) @router.delete( "/{attachment_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_study_member())], ) async def delete_attachment( study_id: uuid.UUID, entity_type: str, entity_id: uuid.UUID, attachment_id: uuid.UUID, db: AsyncSession = Depends(get_db_session), current_user=Depends(get_current_user), ): await _ensure_study_exists(db, study_id) attachment = await attachment_crud.get_attachment(db, attachment_id) if ( not attachment or attachment.study_id != study_id or attachment.entity_id != entity_id or attachment.entity_type != entity_type or attachment.is_deleted ): raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Attachment not found") membership = None if current_user.role != "ADMIN": membership = await get_study_member(study_id, current_user=current_user, db=db) can_delete = ( current_user.role == "ADMIN" or attachment.uploaded_by == current_user.id or (membership and getattr(membership, "role_in_study", None) == "PM") ) if not can_delete: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No permission to delete attachment") await attachment_crud.soft_delete_attachment(db, attachment) await audit_crud.log_action( db, study_id=study_id, entity_type=entity_type, entity_id=entity_id, action="DELETE_ATTACHMENT", detail=f"File deleted: {attachment.filename}", operator_id=current_user.id, operator_role=current_user.role, )