UX-3 附件上传删除功能

This commit is contained in:
Cheng Zhou
2025-12-17 16:16:47 +08:00
parent 9ca30d12f8
commit 1e6cae6b6e
39 changed files with 514 additions and 15 deletions
+133 -2
View File
@@ -3,17 +3,21 @@ import uuid
from pathlib import Path
import aiofiles
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
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
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"
@@ -111,3 +115,130 @@ async def download_attachment(
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,
)