release(main): 同步 dev 最新候选改动
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
This commit is contained in:
@@ -6,11 +6,12 @@ import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
from urllib.parse import quote
|
||||
|
||||
import aiofiles
|
||||
from fastapi import HTTPException, UploadFile, status
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy import and_, delete as sa_delete, or_, select, update as sa_update
|
||||
from sqlalchemy import String, and_, cast, delete as sa_delete, or_, select, update as sa_update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_cra_site_scope
|
||||
@@ -31,6 +32,7 @@ from app.models.distribution import Distribution, DistributionStatus, Distributi
|
||||
from app.models.document import Document, DocumentScopeType, DocumentStatus
|
||||
from app.models.document_version import DocumentVersion, DocumentVersionStatus
|
||||
from app.models.desktop_notification import DesktopNotificationDelivery
|
||||
from app.models.notification import Notification
|
||||
from app.models.study import Study
|
||||
from app.schemas.acknowledgement import AcknowledgementCreate
|
||||
from app.schemas.distribution import DistributionCreate, DistributionRead, DistributionStats
|
||||
@@ -38,6 +40,7 @@ from app.schemas.document import DocumentCreate, DocumentDetail, DocumentSummary
|
||||
from app.schemas.document_version import DocumentVersionRead, DocumentVersionSummary
|
||||
from app.schemas.notification import NotificationItem
|
||||
from app.schemas.user import UserDisplay
|
||||
from app.services import notification_service
|
||||
|
||||
UPLOAD_ROOT = Path(__file__).resolve().parent.parent / "uploads" / "documents"
|
||||
|
||||
@@ -52,6 +55,29 @@ DOCUMENT_ACTION_PERMISSIONS = {
|
||||
}
|
||||
|
||||
|
||||
def _safe_original_filename(value: str | None) -> str:
|
||||
filename = (value or "").replace("\\", "/").rsplit("/", 1)[-1].strip()
|
||||
filename = "".join(character for character in filename if ord(character) >= 32 and ord(character) != 127)
|
||||
return filename[:255] or "document"
|
||||
|
||||
|
||||
def _content_disposition(filename: str, disposition: str = "attachment") -> str:
|
||||
fallback = "".join(character if 32 <= ord(character) < 127 and character not in {'"', "\\"} else "_" for character in filename)
|
||||
fallback = fallback or "download"
|
||||
encoded = quote(filename, safe="")
|
||||
return f'{disposition}; filename="{fallback}"; filename*=UTF-8\'\'{encoded}'
|
||||
|
||||
|
||||
def _legacy_download_filename(version: DocumentVersion, document: Document) -> str:
|
||||
"""Return a readable fallback for rows created before original_filename existed."""
|
||||
stored_name = Path(version.file_uri).name
|
||||
suffix = Path(stored_name).suffix
|
||||
title = _safe_original_filename(document.title)
|
||||
if suffix and title.lower().endswith(suffix.lower()):
|
||||
return title
|
||||
return f"{title}{suffix}"
|
||||
|
||||
|
||||
def _audit_detail(before: dict | None, after: dict | None) -> str:
|
||||
payload = {"before": before, "after": after}
|
||||
return json.dumps(payload, ensure_ascii=True)
|
||||
@@ -356,7 +382,8 @@ async def create_version(
|
||||
file_hash = hashlib.sha256(content).hexdigest()
|
||||
dest_dir = UPLOAD_ROOT / str(document_id)
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
unique_name = f"{uuid.uuid4()}{Path(file.filename).suffix}"
|
||||
original_filename = _safe_original_filename(file.filename)
|
||||
unique_name = f"{uuid.uuid4()}{Path(original_filename).suffix}"
|
||||
dest_path = dest_dir / unique_name
|
||||
async with aiofiles.open(dest_path, "wb") as out_file:
|
||||
await out_file.write(content)
|
||||
@@ -377,6 +404,7 @@ async def create_version(
|
||||
status=DocumentVersionStatus.EFFECTIVE,
|
||||
effective_at=effective_at,
|
||||
file_uri=str(dest_path),
|
||||
original_filename=original_filename,
|
||||
file_hash=file_hash,
|
||||
file_size=len(content),
|
||||
mime_type=file.content_type,
|
||||
@@ -552,12 +580,11 @@ async def get_version_download_response(
|
||||
file_path = Path(version.file_uri)
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文件不存在")
|
||||
filename = file_path.name
|
||||
filename = version.original_filename or _legacy_download_filename(version, doc)
|
||||
return FileResponse(
|
||||
path=str(file_path),
|
||||
filename=filename,
|
||||
media_type=version.mime_type or "application/octet-stream",
|
||||
headers={"Content-Disposition": f'inline; filename="{filename}"'},
|
||||
headers={"Content-Disposition": _content_disposition(filename)},
|
||||
)
|
||||
|
||||
|
||||
@@ -663,6 +690,10 @@ async def create_acknowledgement(
|
||||
if distribution.target_type == DistributionTargetType.ROLE:
|
||||
if distribution.target_id not in ("ADMIN" if is_system_admin(current_user) else "", getattr(membership, "role_in_study", "")):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不在分发范围内")
|
||||
if distribution.target_type == DistributionTargetType.SITE and not is_system_admin(current_user):
|
||||
site_ids = await site_crud.list_ids_by_contact_user(db, doc.trial_id, current_user.id)
|
||||
if distribution.target_id not in {str(site_id) for site_id in site_ids}:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不在分发中心范围内")
|
||||
|
||||
if payload.ack_type != AcknowledgementType.RECEIVED:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="仅支持已接收回执")
|
||||
@@ -697,6 +728,12 @@ async def create_acknowledgement(
|
||||
operator_role=await get_operator_role_label(db, doc.trial_id, current_user),
|
||||
)
|
||||
)
|
||||
await notification_service.resolve_source_notifications(
|
||||
db,
|
||||
source_type="DOCUMENT_DISTRIBUTION",
|
||||
source_id=str(distribution.id),
|
||||
recipient_id=current_user.id,
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(ack)
|
||||
return ack
|
||||
@@ -787,10 +824,18 @@ async def list_distribution_notifications(
|
||||
.join(DocumentVersion, Distribution.version_id == DocumentVersion.id)
|
||||
.join(Document, Distribution.document_id == Document.id)
|
||||
.join(Study, Document.trial_id == Study.id)
|
||||
.outerjoin(
|
||||
Notification,
|
||||
and_(
|
||||
Notification.source_type == "DOCUMENT_DISTRIBUTION",
|
||||
Notification.source_id == cast(Distribution.id, String),
|
||||
Notification.recipient_id == current_user.id,
|
||||
),
|
||||
)
|
||||
.outerjoin(
|
||||
DesktopNotificationDelivery,
|
||||
and_(
|
||||
DesktopNotificationDelivery.distribution_id == Distribution.id,
|
||||
DesktopNotificationDelivery.notification_id == Notification.id,
|
||||
DesktopNotificationDelivery.user_id == current_user.id,
|
||||
),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user