UX-3 附件上传删除功能
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ api_router.include_router(sites.router, prefix="/studies/{study_id}/sites", tags
|
||||
api_router.include_router(members.router, prefix="/studies/{study_id}/members", tags=["study-members"])
|
||||
api_router.include_router(comments.router, prefix="/studies/{study_id}/{entity_type}/{entity_id}/comments", tags=["comments"])
|
||||
api_router.include_router(attachments.router, prefix="/studies/{study_id}/{entity_type}/{entity_id}/attachments", tags=["attachments"])
|
||||
api_router.include_router(attachments.global_router, prefix="/attachments", tags=["attachments"])
|
||||
api_router.include_router(audit_logs.router, prefix="/studies/{study_id}/audit-logs", tags=["audit-logs"])
|
||||
api_router.include_router(milestones.router, prefix="/studies/{study_id}/milestones", tags=["milestones"])
|
||||
api_router.include_router(tasks.router, prefix="/studies/{study_id}/tasks", tags=["tasks"])
|
||||
|
||||
@@ -57,3 +57,11 @@ async def list_attachments(
|
||||
async def get_attachment(db: AsyncSession, attachment_id: uuid.UUID) -> Attachment | None:
|
||||
result = await db.execute(select(Attachment).where(Attachment.id == attachment_id, Attachment.is_deleted.is_(False)))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def soft_delete_attachment(db: AsyncSession, attachment: Attachment) -> Attachment:
|
||||
attachment.is_deleted = True
|
||||
db.add(attachment)
|
||||
await db.commit()
|
||||
await db.refresh(attachment)
|
||||
return attachment
|
||||
|
||||
BIN
Binary file not shown.
+144
@@ -0,0 +1,144 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>华邦制药 - 临床试验 AI 知识库</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
/* 简单的淡入动画 */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.5s ease-out forwards;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen bg-slate-50 relative overflow-hidden font-sans text-slate-700">
|
||||
|
||||
<div class="absolute inset-0" style="
|
||||
background-image: radial-gradient(#cbd5e1 1px, transparent 1px);
|
||||
background-size: 30px 30px;
|
||||
opacity: 0.5;
|
||||
z-index: 0;
|
||||
"></div>
|
||||
|
||||
<div class="relative z-10 flex justify-between items-center p-6 max-w-7xl mx-auto">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 flex items-center justify-center bg-teal-600 rounded-lg shadow-sm text-white">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="font-bold text-xl text-slate-800 tracking-tight">华邦制药</span>
|
||||
</div>
|
||||
<div class="text-xs font-medium text-slate-500 bg-white/80 backdrop-blur px-3 py-1.5 rounded-md shadow-sm border border-slate-200 cursor-default">
|
||||
⌘ K 智能问答
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main class="relative z-10 flex flex-col items-center mt-24 md:mt-32 px-4 w-full">
|
||||
|
||||
<h1 class="text-3xl md:text-5xl font-bold text-teal-700 mb-12 tracking-wide text-center">
|
||||
欢迎使用 临床试验 AI 知识库
|
||||
</h1>
|
||||
|
||||
<div class="w-full max-w-3xl bg-white rounded-2xl shadow-xl border border-slate-100 p-2 transition-shadow hover:shadow-2xl">
|
||||
<textarea
|
||||
id="questionInput"
|
||||
placeholder="请输入您的问题,例如:该药物的临床不良反应有哪些?"
|
||||
class="w-full h-32 p-5 text-lg text-slate-700 placeholder:text-slate-300 outline-none resize-none rounded-xl bg-transparent"
|
||||
></textarea>
|
||||
|
||||
<div class="flex justify-between items-center px-2 pb-2">
|
||||
<div class="text-slate-300 text-sm px-3">Enter 发送</div>
|
||||
<button
|
||||
id="searchBtn"
|
||||
onclick="simulateSearch()"
|
||||
class="flex items-center gap-2 px-6 py-2 bg-teal-600 hover:bg-teal-700 text-white font-medium rounded-full shadow-md hover:shadow-lg transition-all active:scale-95"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z" />
|
||||
</svg>
|
||||
AI 智能问答
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="loadingState" class="hidden mt-8 flex items-center gap-3 text-teal-600 bg-white/50 px-4 py-2 rounded-full">
|
||||
<svg class="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span class="text-sm font-medium">正在检索华邦制药知识库...</span>
|
||||
</div>
|
||||
|
||||
<div id="resultCard" class="hidden w-full max-w-3xl mt-8 bg-white/90 backdrop-blur rounded-xl p-8 border border-teal-100 shadow-lg animate-fade-in">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="w-10 h-10 rounded-full bg-teal-100 flex items-center justify-center shrink-0">
|
||||
<span class="text-xl">🤖</span>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-lg font-bold text-teal-800">AI 回答:</h3>
|
||||
<p class="leading-relaxed text-slate-700">
|
||||
根据临床试验数据库检索,该药物常见的不良反应主要包括:<br><br>
|
||||
1. <strong>胃肠道反应</strong>:约 15% 的患者报告有轻度恶心、呕吐或腹泻症状,通常在服药后一周内自行缓解。<br>
|
||||
2. <strong>神经系统</strong>:偶见头晕(3%)和嗜睡(2%)。<br>
|
||||
3. <strong>实验室检查异常</strong>:极少数病例出现转氨酶轻度升高,建议用药期间定期监测肝功能。<br><br>
|
||||
<span class="text-sm text-slate-400">数据来源:Ⅲ期临床试验报告文档 (Doc-2023-08)</span>
|
||||
</p>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<button class="text-xs bg-slate-100 hover:bg-slate-200 text-slate-600 px-3 py-1 rounded">复制答案</button>
|
||||
<button class="text-xs bg-slate-100 hover:bg-slate-200 text-slate-600 px-3 py-1 rounded">不满意</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-12">
|
||||
<button class="px-8 py-3 bg-teal-600/5 text-teal-700 rounded-lg hover:bg-teal-600 hover:text-white transition-colors font-medium border border-teal-600/20">
|
||||
查看原始文档库
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
<script>
|
||||
function simulateSearch() {
|
||||
const input = document.getElementById('questionInput');
|
||||
const btn = document.getElementById('searchBtn');
|
||||
const loading = document.getElementById('loadingState');
|
||||
const result = document.getElementById('resultCard');
|
||||
|
||||
if (!input.value.trim()) {
|
||||
input.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. 进入加载状态
|
||||
btn.innerHTML = '思考中...';
|
||||
btn.classList.add('opacity-75', 'cursor-not-allowed');
|
||||
loading.classList.remove('hidden');
|
||||
result.classList.add('hidden'); // 隐藏旧结果
|
||||
|
||||
// 2. 模拟网络延迟 (1.5秒)
|
||||
setTimeout(() => {
|
||||
// 3. 显示结果
|
||||
loading.classList.add('hidden');
|
||||
result.classList.remove('hidden');
|
||||
|
||||
// 恢复按钮
|
||||
btn.innerHTML = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z" />
|
||||
</svg>
|
||||
AI 智能问答
|
||||
`;
|
||||
btn.classList.remove('opacity-75', 'cursor-not-allowed');
|
||||
}, 1500);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user