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:
@@ -77,6 +77,7 @@ FAQ_REPLY_ATTACHMENT_PERMISSION_BY_ACTION = {
|
||||
"read": "faq:read",
|
||||
"delete": "faq_attachments:delete",
|
||||
}
|
||||
SHARED_LIBRARY_ENTITY_TYPES = {"precaution", "faq_replies"}
|
||||
STARTUP_AUTH_ATTACHMENT_ENTITY_TYPES = {
|
||||
"startup_kickoff",
|
||||
"startup_kickoff_minutes",
|
||||
@@ -239,16 +240,17 @@ async def upload_attachment(
|
||||
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.filename}",
|
||||
operator_id=current_user.id,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
)
|
||||
if entity_type not in SHARED_LIBRARY_ENTITY_TYPES:
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
action="UPLOAD_FILE",
|
||||
detail=f"文件已上传:{file.filename}",
|
||||
operator_id=current_user.id,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
)
|
||||
return AttachmentRead(
|
||||
id=attachment.id,
|
||||
filename=attachment.filename,
|
||||
@@ -442,16 +444,17 @@ async def global_delete_attachment(
|
||||
if not can_delete:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限删除附件")
|
||||
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"文件已删除:{attachment.filename}",
|
||||
operator_id=user.id,
|
||||
operator_role=await get_operator_role_label(db, attachment.study_id, user),
|
||||
)
|
||||
if attachment.entity_type not in SHARED_LIBRARY_ENTITY_TYPES:
|
||||
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"文件已删除:{attachment.filename}",
|
||||
operator_id=user.id,
|
||||
operator_role=await get_operator_role_label(db, attachment.study_id, user),
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
@@ -494,13 +497,14 @@ async def delete_attachment(
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限删除附件")
|
||||
|
||||
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"文件已删除:{attachment.filename}",
|
||||
operator_id=current_user.id,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
)
|
||||
if entity_type not in SHARED_LIBRARY_ENTITY_TYPES:
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
action="DELETE_ATTACHMENT",
|
||||
detail=f"文件已删除:{attachment.filename}",
|
||||
operator_id=current_user.id,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
)
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import json
|
||||
import uuid
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_operator_role_label, get_current_user, get_db_session, require_api_permission
|
||||
from app.crud import audit as audit_crud
|
||||
from app.crud import study as study_crud
|
||||
from app.models.user import User
|
||||
from app.schemas.audit import AuditEventCreate, AuditLogRead
|
||||
from app.services.ip_location import resolve_ip_location
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -40,6 +45,10 @@ async def list_audit_logs(
|
||||
entity_id: uuid.UUID | None = None,
|
||||
action: str | None = None,
|
||||
operator_id: uuid.UUID | None = None,
|
||||
client_ip: str | None = None,
|
||||
client_type: str | None = None,
|
||||
start_time: datetime | None = None,
|
||||
end_time: datetime | None = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
@@ -51,10 +60,51 @@ async def list_audit_logs(
|
||||
entity_id=entity_id,
|
||||
action=action,
|
||||
operator_id=operator_id,
|
||||
client_ip=client_ip,
|
||||
client_type=client_type,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
return list(logs)
|
||||
operator_ids = {log.operator_id for log in logs}
|
||||
operator_map: dict[uuid.UUID, User] = {}
|
||||
if operator_ids:
|
||||
result = await db.execute(select(User).where(User.id.in_(operator_ids)))
|
||||
operator_map = {user.id: user for user in result.scalars().all()}
|
||||
|
||||
items: list[AuditLogRead] = []
|
||||
for log in logs:
|
||||
ip_location = resolve_ip_location(log.client_ip)
|
||||
operator = operator_map.get(log.operator_id)
|
||||
items.append(
|
||||
AuditLogRead(
|
||||
id=log.id,
|
||||
study_id=log.study_id,
|
||||
entity_type=log.entity_type,
|
||||
entity_id=log.entity_id,
|
||||
action=log.action,
|
||||
detail=log.detail,
|
||||
operator_id=log.operator_id,
|
||||
operator_name=operator.full_name if operator else None,
|
||||
operator_email=operator.email if operator else None,
|
||||
operator_role=log.operator_role,
|
||||
client_ip=log.client_ip,
|
||||
ip_location=ip_location.location,
|
||||
ip_country=ip_location.country,
|
||||
ip_province=ip_location.province,
|
||||
ip_city=ip_location.city,
|
||||
ip_isp=ip_location.isp,
|
||||
user_agent=log.user_agent,
|
||||
client_type=log.client_type,
|
||||
client_version=log.client_version,
|
||||
client_platform=log.client_platform,
|
||||
build_channel=log.build_channel,
|
||||
build_commit=log.build_commit,
|
||||
created_at=log.created_at,
|
||||
)
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
@router.post(
|
||||
|
||||
+108
-12
@@ -1,5 +1,6 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from dataclasses import dataclass
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
|
||||
from fastapi import File, UploadFile
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -8,7 +9,8 @@ import uuid
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.login_crypto import create_login_challenge, decrypt_login_payload, get_public_key_pem
|
||||
from app.core.security import create_access_token, decode_token_allow_expired, oauth2_scheme, verify_password
|
||||
from app.core.request_context import resolve_client_ip, resolve_ctms_client_type
|
||||
from app.core.security import create_access_token, decode_token, decode_token_allow_expired, oauth2_scheme, verify_password
|
||||
from app.core.deps import get_current_user, get_db_session
|
||||
from app.crud import user as user_crud
|
||||
from app.models.user import UserStatus
|
||||
@@ -25,6 +27,12 @@ from app.schemas.email_settings import (
|
||||
)
|
||||
from app.schemas.user import Token, UserRead, UserRegisterRequest, UserSelfUpdate, UserUpdate
|
||||
from app.services import email_service
|
||||
from app.services.user_login_sessions import (
|
||||
create_login_session,
|
||||
end_login_session,
|
||||
session_id_from_payload,
|
||||
touch_login_session,
|
||||
)
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
|
||||
@@ -70,12 +78,59 @@ AVATAR_ALLOWED_CONTENT_TYPES = {
|
||||
}
|
||||
|
||||
|
||||
def issue_user_token(db_user) -> Token:
|
||||
@dataclass(frozen=True)
|
||||
class SessionPolicy:
|
||||
access_minutes: int
|
||||
absolute_max_seconds: int
|
||||
|
||||
|
||||
def normalize_session_client_type(value: str | None) -> str:
|
||||
return "desktop" if (value or "").strip().lower() == "desktop" else "web"
|
||||
|
||||
|
||||
def get_session_policy_for_client_type(client_type: str) -> SessionPolicy:
|
||||
if client_type == "desktop":
|
||||
max_seconds = settings.DESKTOP_SESSION_MAX_DAYS * 24 * 3600
|
||||
return SessionPolicy(
|
||||
access_minutes=settings.DESKTOP_SESSION_MAX_DAYS * 24 * 60,
|
||||
absolute_max_seconds=max_seconds,
|
||||
)
|
||||
return SessionPolicy(
|
||||
access_minutes=settings.JWT_EXPIRE_MINUTES,
|
||||
absolute_max_seconds=settings.ABSOLUTE_SESSION_MAX_HOURS * 3600,
|
||||
)
|
||||
|
||||
|
||||
def get_request_session_client_type(request: Request) -> str:
|
||||
return normalize_session_client_type(resolve_ctms_client_type(request.headers))
|
||||
|
||||
|
||||
def policy_expires_at(issued_at: datetime, session_start: datetime, policy: SessionPolicy) -> datetime:
|
||||
access_expires_at = issued_at + timedelta(minutes=policy.access_minutes)
|
||||
session_expires_at = session_start + timedelta(seconds=policy.absolute_max_seconds)
|
||||
return min(access_expires_at, session_expires_at)
|
||||
|
||||
|
||||
async def issue_user_token(db_user, request: Request, db: AsyncSession) -> Token:
|
||||
session_start = datetime.now(timezone.utc)
|
||||
session_id = uuid.uuid4()
|
||||
client_type = get_request_session_client_type(request)
|
||||
policy = get_session_policy_for_client_type(client_type)
|
||||
access_token = create_access_token(
|
||||
user_id=str(db_user.id),
|
||||
expires_minutes=None,
|
||||
expires_minutes=policy.access_minutes,
|
||||
session_start=session_start,
|
||||
max_age_seconds=policy.absolute_max_seconds,
|
||||
issued_at=session_start,
|
||||
client_type=client_type,
|
||||
session_id=str(session_id),
|
||||
)
|
||||
await create_login_session(
|
||||
db,
|
||||
session_id=session_id,
|
||||
user_id=db_user.id,
|
||||
request=request,
|
||||
login_at=session_start,
|
||||
)
|
||||
return Token(access_token=access_token, token_type="bearer")
|
||||
|
||||
@@ -240,23 +295,23 @@ async def get_login_key() -> LoginKeyResponse:
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
async def login_for_access_token(
|
||||
payload: LoginRequest, db: AsyncSession = Depends(get_db_session)
|
||||
payload: LoginRequest, request: Request, db: AsyncSession = Depends(get_db_session)
|
||||
) -> Token:
|
||||
db_user = await authenticate_encrypted_password(payload, db)
|
||||
ensure_user_active(db_user)
|
||||
|
||||
return issue_user_token(db_user)
|
||||
return await issue_user_token(db_user, request, db)
|
||||
|
||||
|
||||
@router.post("/dev-login", response_model=Token)
|
||||
async def dev_login_for_access_token(
|
||||
payload: DevLoginRequest, db: AsyncSession = Depends(get_db_session)
|
||||
payload: DevLoginRequest, request: Request, db: AsyncSession = Depends(get_db_session)
|
||||
) -> Token:
|
||||
if settings.ENV != "development":
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
db_user = await authenticate_plain_password(payload, db)
|
||||
ensure_user_active(db_user)
|
||||
return issue_user_token(db_user)
|
||||
return await issue_user_token(db_user, request, db)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserRead)
|
||||
@@ -285,22 +340,63 @@ async def extend_access_token(
|
||||
if db_user.status != UserStatus.ACTIVE:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已停用")
|
||||
session_start_ts = payload.get("orig_iat") or payload.get("iat")
|
||||
policy = get_session_policy_for_client_type(normalize_session_client_type(payload.get("client_type")))
|
||||
if session_start_ts:
|
||||
max_seconds = settings.ABSOLUTE_SESSION_MAX_HOURS * 3600
|
||||
if now_ts - int(session_start_ts) > max_seconds:
|
||||
if now_ts - int(session_start_ts) > policy.absolute_max_seconds:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="会话已到期,请重新登录")
|
||||
session_start = datetime.fromtimestamp(int(session_start_ts), tz=timezone.utc)
|
||||
else:
|
||||
session_start = datetime.now(timezone.utc)
|
||||
issued_at = datetime.now(timezone.utc)
|
||||
new_token = create_access_token(
|
||||
user_id=str(db_user.id),
|
||||
expires_minutes=None,
|
||||
expires_minutes=policy.access_minutes,
|
||||
session_start=session_start,
|
||||
max_age_seconds=policy.absolute_max_seconds,
|
||||
issued_at=issued_at,
|
||||
client_type=normalize_session_client_type(payload.get("client_type")),
|
||||
session_id=str(session_id_from_payload(payload)),
|
||||
)
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(minutes=settings.JWT_EXPIRE_MINUTES)
|
||||
expires_at = policy_expires_at(issued_at, session_start, policy)
|
||||
return ExtendResponse(accessToken=new_token, expiresAt=expires_at)
|
||||
|
||||
|
||||
@router.post("/session/heartbeat")
|
||||
async def heartbeat_login_session(
|
||||
request: Request,
|
||||
token: str = Depends(oauth2_scheme),
|
||||
current_user=Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
session = await touch_login_session(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
payload=decode_token(token),
|
||||
request=request,
|
||||
)
|
||||
if session is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录会话已结束")
|
||||
return {
|
||||
"status": "online",
|
||||
"last_seen_at": session.last_seen_at.isoformat(),
|
||||
"client_ip": resolve_client_ip(request),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/session/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def logout_login_session(
|
||||
token: str = Depends(oauth2_scheme),
|
||||
current_user=Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> Response:
|
||||
await end_login_session(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
payload=decode_token(token),
|
||||
)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.patch("/me", response_model=UserRead)
|
||||
async def update_me(
|
||||
payload: UserSelfUpdate,
|
||||
|
||||
@@ -0,0 +1,629 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, Header, Request, Response, UploadFile, status
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_user, get_db_session, require_api_permission
|
||||
from app.schemas.collaboration import (
|
||||
CollaborationCallbackPayload,
|
||||
CollaborationCandidateRead,
|
||||
CollaborationEditorConfigRead,
|
||||
CollaborationEditRequestRead,
|
||||
CollaborationEditRequestResolve,
|
||||
CollaborationExportRecord,
|
||||
CollaborationFileCreate,
|
||||
CollaborationFileRead,
|
||||
CollaborationFileUpdate,
|
||||
CollaborationFolderCreate,
|
||||
CollaborationFolderRead,
|
||||
CollaborationFolderUpdate,
|
||||
CollaborationMemberRead,
|
||||
CollaborationMemberUpsert,
|
||||
CollaborationOwnershipTransferRequest,
|
||||
CollaborationPublicEditorConfigRequest,
|
||||
CollaborationPublicShareMetadata,
|
||||
CollaborationRestoreRequest,
|
||||
CollaborationRevisionCopyRequest,
|
||||
CollaborationRevisionRead,
|
||||
CollaborationRevisionUpdate,
|
||||
CollaborationShareAccessGrant,
|
||||
CollaborationShareLinkRead,
|
||||
CollaborationShareLinkUpdate,
|
||||
CollaborationSharePasswordRequest,
|
||||
)
|
||||
from app.schemas.onlyoffice import OnlyOfficePreviewConfigRead
|
||||
from app.services import collaboration_service, collaboration_share_service, onlyoffice_collaboration_service, onlyoffice_service
|
||||
|
||||
router = APIRouter()
|
||||
public_router = APIRouter()
|
||||
internal_router = APIRouter(include_in_schema=False)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/folders",
|
||||
response_model=list[CollaborationFolderRead],
|
||||
dependencies=[Depends(require_api_permission("collaboration:read"))],
|
||||
)
|
||||
async def list_folders(study_id: uuid.UUID, db: AsyncSession = Depends(get_db_session)):
|
||||
return await collaboration_service.list_folders(db, study_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/folders",
|
||||
response_model=CollaborationFolderRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(require_api_permission("collaboration:manage"))],
|
||||
)
|
||||
async def create_folder(
|
||||
study_id: uuid.UUID,
|
||||
payload: CollaborationFolderCreate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
):
|
||||
return await collaboration_service.create_folder(db, study_id, payload, current_user)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/folders/{folder_id}",
|
||||
response_model=CollaborationFolderRead,
|
||||
dependencies=[Depends(require_api_permission("collaboration:manage"))],
|
||||
)
|
||||
async def update_folder(
|
||||
study_id: uuid.UUID,
|
||||
folder_id: uuid.UUID,
|
||||
payload: CollaborationFolderUpdate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
return await collaboration_service.update_folder(db, study_id, folder_id, payload)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/folders/{folder_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(require_api_permission("collaboration:manage"))],
|
||||
)
|
||||
async def delete_folder(study_id: uuid.UUID, folder_id: uuid.UUID, db: AsyncSession = Depends(get_db_session)):
|
||||
await collaboration_service.delete_folder(db, study_id, folder_id)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/files",
|
||||
response_model=list[CollaborationFileRead],
|
||||
dependencies=[Depends(require_api_permission("collaboration:read"))],
|
||||
)
|
||||
async def list_files(
|
||||
study_id: uuid.UUID,
|
||||
folder_id: uuid.UUID | None = None,
|
||||
keyword: str | None = None,
|
||||
deleted: bool = False,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
):
|
||||
return await collaboration_service.list_files(
|
||||
db, study_id, current_user, folder_id=folder_id, keyword=keyword, deleted=deleted
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/files",
|
||||
response_model=CollaborationFileRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(require_api_permission("collaboration:create"))],
|
||||
)
|
||||
async def create_file(
|
||||
study_id: uuid.UUID,
|
||||
payload: CollaborationFileCreate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
):
|
||||
item = await collaboration_service.create_blank_file(db, study_id, payload, current_user)
|
||||
return await collaboration_service.file_read(db, item, current_user)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/files/import",
|
||||
response_model=CollaborationFileRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(require_api_permission("collaboration:create"))],
|
||||
)
|
||||
async def import_file(
|
||||
study_id: uuid.UUID,
|
||||
file: UploadFile = File(...),
|
||||
folder_id: uuid.UUID | None = Form(None),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
):
|
||||
item = await collaboration_service.import_file(db, study_id, folder_id, file, current_user)
|
||||
return await collaboration_service.file_read(db, item, current_user)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/files/{file_id}/copy",
|
||||
response_model=CollaborationFileRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(require_api_permission("collaboration:create"))],
|
||||
)
|
||||
async def copy_file(
|
||||
study_id: uuid.UUID,
|
||||
file_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
):
|
||||
source = await collaboration_service.get_file_or_404(db, study_id, file_id)
|
||||
item = await collaboration_service.copy_file(db, source, current_user)
|
||||
return await collaboration_service.file_read(db, item, current_user)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/files/{file_id}",
|
||||
response_model=CollaborationFileRead,
|
||||
dependencies=[Depends(require_api_permission("collaboration:read"))],
|
||||
)
|
||||
async def get_file(
|
||||
study_id: uuid.UUID,
|
||||
file_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
):
|
||||
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
|
||||
return await collaboration_service.file_read(db, item, current_user)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/files/{file_id}/download",
|
||||
dependencies=[Depends(require_api_permission("collaboration:read"))],
|
||||
)
|
||||
async def download_file(
|
||||
study_id: uuid.UUID,
|
||||
file_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
):
|
||||
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
|
||||
revision = await collaboration_service.prepare_download(db, item, current_user)
|
||||
return FileResponse(
|
||||
path=revision.file_uri,
|
||||
media_type=revision.mime_type,
|
||||
filename=item.title,
|
||||
content_disposition_type="attachment",
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/files/{file_id}",
|
||||
response_model=CollaborationFileRead,
|
||||
dependencies=[Depends(require_api_permission("collaboration:read"))],
|
||||
)
|
||||
async def update_file(
|
||||
study_id: uuid.UUID,
|
||||
file_id: uuid.UUID,
|
||||
payload: CollaborationFileUpdate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
):
|
||||
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
|
||||
item = await collaboration_service.update_file(db, item, payload, current_user)
|
||||
return await collaboration_service.file_read(db, item, current_user)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/files/{file_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(require_api_permission("collaboration:read"))],
|
||||
)
|
||||
async def trash_file(
|
||||
study_id: uuid.UUID,
|
||||
file_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
):
|
||||
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
|
||||
await collaboration_service.move_to_trash(db, item, current_user)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/files/{file_id}/restore",
|
||||
response_model=CollaborationFileRead,
|
||||
dependencies=[Depends(require_api_permission("collaboration:read"))],
|
||||
)
|
||||
async def restore_file(
|
||||
study_id: uuid.UUID,
|
||||
file_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
):
|
||||
item = await collaboration_service.get_file_or_404(db, study_id, file_id, include_deleted=True)
|
||||
item = await collaboration_service.restore_file(db, item, current_user)
|
||||
return await collaboration_service.file_read(db, item, current_user)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/files/{file_id}/members",
|
||||
response_model=list[CollaborationMemberRead],
|
||||
dependencies=[Depends(require_api_permission("collaboration:read"))],
|
||||
)
|
||||
async def list_members(study_id: uuid.UUID, file_id: uuid.UUID, db: AsyncSession = Depends(get_db_session)):
|
||||
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
|
||||
return await collaboration_service.list_members(db, item)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/files/{file_id}/members",
|
||||
response_model=CollaborationMemberRead,
|
||||
dependencies=[Depends(require_api_permission("collaboration:read"))],
|
||||
)
|
||||
async def upsert_member(
|
||||
study_id: uuid.UUID,
|
||||
file_id: uuid.UUID,
|
||||
payload: CollaborationMemberUpsert,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
):
|
||||
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
|
||||
return await collaboration_service.upsert_member(db, item, payload, current_user)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/files/{file_id}/members/{user_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(require_api_permission("collaboration:read"))],
|
||||
)
|
||||
async def remove_member(
|
||||
study_id: uuid.UUID,
|
||||
file_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
):
|
||||
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
|
||||
await collaboration_service.remove_member(db, item, user_id, current_user)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/files/{file_id}/edit-requests",
|
||||
response_model=CollaborationEditRequestRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(require_api_permission("collaboration:read"))],
|
||||
)
|
||||
async def create_edit_request(
|
||||
study_id: uuid.UUID,
|
||||
file_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
):
|
||||
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
|
||||
return await collaboration_service.create_edit_request(db, item, current_user)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/files/{file_id}/edit-requests",
|
||||
response_model=list[CollaborationEditRequestRead],
|
||||
dependencies=[Depends(require_api_permission("collaboration:read"))],
|
||||
)
|
||||
async def list_edit_requests(
|
||||
study_id: uuid.UUID,
|
||||
file_id: uuid.UUID,
|
||||
pending_only: bool = True,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
):
|
||||
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
|
||||
return await collaboration_service.list_edit_requests(db, item, current_user, pending_only=pending_only)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/files/{file_id}/edit-requests/{request_id}/resolve",
|
||||
response_model=CollaborationEditRequestRead,
|
||||
dependencies=[Depends(require_api_permission("collaboration:read"))],
|
||||
)
|
||||
async def resolve_edit_request(
|
||||
study_id: uuid.UUID,
|
||||
file_id: uuid.UUID,
|
||||
request_id: uuid.UUID,
|
||||
payload: CollaborationEditRequestResolve,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
):
|
||||
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
|
||||
return await collaboration_service.resolve_edit_request(db, item, request_id, payload, current_user)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/files/{file_id}/transfer-ownership",
|
||||
response_model=CollaborationFileRead,
|
||||
dependencies=[Depends(require_api_permission("collaboration:read"))],
|
||||
)
|
||||
async def transfer_ownership(
|
||||
study_id: uuid.UUID,
|
||||
file_id: uuid.UUID,
|
||||
payload: CollaborationOwnershipTransferRequest,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
):
|
||||
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
|
||||
item = await collaboration_service.transfer_ownership(db, item, payload, current_user)
|
||||
return await collaboration_service.file_read(db, item, current_user)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/files/{file_id}/share-link",
|
||||
response_model=CollaborationShareLinkRead,
|
||||
dependencies=[Depends(require_api_permission("collaboration:read"))],
|
||||
)
|
||||
async def get_share_link(
|
||||
study_id: uuid.UUID,
|
||||
file_id: uuid.UUID,
|
||||
response: Response,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
):
|
||||
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
|
||||
result = await collaboration_share_service.get_share_link(db, item, current_user)
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return result
|
||||
|
||||
|
||||
@router.put(
|
||||
"/files/{file_id}/share-link",
|
||||
response_model=CollaborationShareLinkRead,
|
||||
dependencies=[Depends(require_api_permission("collaboration:read"))],
|
||||
)
|
||||
async def update_share_link(
|
||||
study_id: uuid.UUID,
|
||||
file_id: uuid.UUID,
|
||||
payload: CollaborationShareLinkUpdate,
|
||||
response: Response,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
):
|
||||
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
|
||||
result = await collaboration_share_service.update_share_link(db, item, payload, current_user)
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return result
|
||||
|
||||
|
||||
@router.get(
|
||||
"/member-candidates",
|
||||
response_model=list[CollaborationCandidateRead],
|
||||
dependencies=[Depends(require_api_permission("collaboration:read"))],
|
||||
)
|
||||
async def list_member_candidates(study_id: uuid.UUID, db: AsyncSession = Depends(get_db_session)):
|
||||
return await collaboration_service.list_candidates(db, study_id)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/files/{file_id}/revisions",
|
||||
response_model=list[CollaborationRevisionRead],
|
||||
dependencies=[Depends(require_api_permission("collaboration:read"))],
|
||||
)
|
||||
async def list_revisions(study_id: uuid.UUID, file_id: uuid.UUID, db: AsyncSession = Depends(get_db_session)):
|
||||
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
|
||||
return await collaboration_service.list_revisions(db, item)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/files/{file_id}/revisions/{revision_id}",
|
||||
response_model=CollaborationRevisionRead,
|
||||
dependencies=[Depends(require_api_permission("collaboration:read"))],
|
||||
)
|
||||
async def update_revision(
|
||||
study_id: uuid.UUID,
|
||||
file_id: uuid.UUID,
|
||||
revision_id: uuid.UUID,
|
||||
payload: CollaborationRevisionUpdate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
):
|
||||
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
|
||||
return await collaboration_service.update_revision(db, item, revision_id, payload, current_user)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/files/{file_id}/revisions/{revision_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(require_api_permission("collaboration:read"))],
|
||||
)
|
||||
async def delete_revision(
|
||||
study_id: uuid.UUID,
|
||||
file_id: uuid.UUID,
|
||||
revision_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
):
|
||||
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
|
||||
await collaboration_service.delete_revision(db, item, revision_id, current_user)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/files/{file_id}/revisions/{revision_id}/copy",
|
||||
response_model=CollaborationFileRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[
|
||||
Depends(require_api_permission("collaboration:create")),
|
||||
Depends(require_api_permission("collaboration:read")),
|
||||
],
|
||||
)
|
||||
async def copy_revision(
|
||||
study_id: uuid.UUID,
|
||||
file_id: uuid.UUID,
|
||||
revision_id: uuid.UUID,
|
||||
payload: CollaborationRevisionCopyRequest,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
):
|
||||
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
|
||||
copied = await collaboration_service.copy_revision(db, item, revision_id, payload, current_user)
|
||||
return await collaboration_service.file_read(db, copied, current_user)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/files/{file_id}/revisions/{revision_id}/preview-config",
|
||||
response_model=OnlyOfficePreviewConfigRead,
|
||||
dependencies=[Depends(require_api_permission("collaboration:read"))],
|
||||
)
|
||||
async def get_revision_preview_config(
|
||||
study_id: uuid.UUID,
|
||||
file_id: uuid.UUID,
|
||||
revision_id: uuid.UUID,
|
||||
response: Response,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
):
|
||||
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
|
||||
revision = await collaboration_service.prepare_revision_preview(db, item, revision_id, current_user)
|
||||
await onlyoffice_service.ensure_onlyoffice_available()
|
||||
result = onlyoffice_service.build_preview_config(
|
||||
resource_type="collaboration_revision",
|
||||
resource_id=revision.id,
|
||||
file_name=item.title,
|
||||
file_hash=revision.file_hash,
|
||||
user_id=current_user.id,
|
||||
user_name=current_user.full_name,
|
||||
)
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return result
|
||||
|
||||
|
||||
@router.post(
|
||||
"/files/{file_id}/revisions/{revision_id}/restore",
|
||||
response_model=CollaborationRevisionRead,
|
||||
dependencies=[Depends(require_api_permission("collaboration:read"))],
|
||||
)
|
||||
async def restore_revision(
|
||||
study_id: uuid.UUID,
|
||||
file_id: uuid.UUID,
|
||||
revision_id: uuid.UUID,
|
||||
payload: CollaborationRestoreRequest,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
):
|
||||
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
|
||||
return await collaboration_service.restore_revision(
|
||||
db, item, revision_id, current_user, payload.change_summary
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/files/{file_id}/editor-config",
|
||||
response_model=CollaborationEditorConfigRead,
|
||||
dependencies=[Depends(require_api_permission("collaboration:read"))],
|
||||
)
|
||||
async def get_editor_config(
|
||||
study_id: uuid.UUID,
|
||||
file_id: uuid.UUID,
|
||||
response: Response,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
):
|
||||
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
|
||||
result = await onlyoffice_collaboration_service.build_editor_config(db, item, current_user)
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return result
|
||||
|
||||
|
||||
@router.post(
|
||||
"/files/{file_id}/exports",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(require_api_permission("collaboration:read"))],
|
||||
)
|
||||
async def record_export(
|
||||
study_id: uuid.UUID,
|
||||
file_id: uuid.UUID,
|
||||
payload: CollaborationExportRecord,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
):
|
||||
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
|
||||
await collaboration_service.record_export(db, item, current_user, payload.file_type)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/files/{file_id}/downloads",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(require_api_permission("collaboration:read"))],
|
||||
)
|
||||
async def record_download(
|
||||
study_id: uuid.UUID,
|
||||
file_id: uuid.UUID,
|
||||
payload: CollaborationExportRecord,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
):
|
||||
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
|
||||
await collaboration_service.record_download(db, item, current_user, payload.file_type)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@public_router.get("/metadata", response_model=CollaborationPublicShareMetadata)
|
||||
async def get_public_share_metadata(
|
||||
response: Response,
|
||||
x_ctms_share_token: str | None = Header(default=None, alias="X-CTMS-Share-Token"),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
result = await collaboration_share_service.public_metadata(db, x_ctms_share_token)
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return result
|
||||
|
||||
|
||||
@public_router.post("/access", response_model=CollaborationShareAccessGrant)
|
||||
async def verify_public_share_password(
|
||||
payload: CollaborationSharePasswordRequest,
|
||||
response: Response,
|
||||
x_ctms_share_token: str | None = Header(default=None, alias="X-CTMS-Share-Token"),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
result = await collaboration_share_service.verify_share_password(
|
||||
db, x_ctms_share_token, payload.password
|
||||
)
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return result
|
||||
|
||||
|
||||
@public_router.post("/editor-config", response_model=CollaborationEditorConfigRead)
|
||||
async def get_public_share_editor_config(
|
||||
payload: CollaborationPublicEditorConfigRequest,
|
||||
response: Response,
|
||||
x_ctms_share_token: str | None = Header(default=None, alias="X-CTMS-Share-Token"),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
link, item = await collaboration_share_service.resolve_active_share(db, x_ctms_share_token)
|
||||
collaboration_share_service.validate_access_grant(link, payload.access_token)
|
||||
result = await onlyoffice_collaboration_service.build_shared_editor_config(
|
||||
db,
|
||||
item,
|
||||
link,
|
||||
client_id=payload.client_id,
|
||||
display_name=payload.display_name,
|
||||
)
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return result
|
||||
|
||||
|
||||
@internal_router.get("/internal/onlyoffice/collaboration/sessions/{session_id}/content")
|
||||
async def get_session_content(session_id: uuid.UUID, request: Request, db: AsyncSession = Depends(get_db_session)):
|
||||
revision, item = await onlyoffice_collaboration_service.get_session_content(
|
||||
db, session_id, request.headers.get("AuthorizationJwt")
|
||||
)
|
||||
return FileResponse(
|
||||
path=revision.file_uri,
|
||||
media_type=revision.mime_type,
|
||||
filename=item.title,
|
||||
content_disposition_type="inline",
|
||||
)
|
||||
|
||||
|
||||
@internal_router.post("/internal/onlyoffice/collaboration/sessions/{session_id}/callback")
|
||||
async def collaboration_callback(
|
||||
session_id: uuid.UUID,
|
||||
payload: CollaborationCallbackPayload,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
onlyoffice_collaboration_service.validate_callback_token(
|
||||
request.headers.get("AuthorizationJwt"), payload
|
||||
)
|
||||
return await onlyoffice_collaboration_service.process_callback(db, session_id, payload)
|
||||
@@ -75,12 +75,12 @@ async def acknowledge_notifications(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{distribution_id}/read", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@router.post("/{notification_id}/read", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def mark_notification_read(
|
||||
distribution_id: uuid.UUID,
|
||||
notification_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> None:
|
||||
await desktop_notification_service.mark_notification_read(
|
||||
db, current_user.id, distribution_id
|
||||
db, current_user.id, notification_id
|
||||
)
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import uuid
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_user, get_db_session, get_operator_role_label, is_system_admin, require_study_not_locked, require_api_permission
|
||||
from app.core.project_permissions import role_has_api_permission
|
||||
from app.crud import audit as audit_crud
|
||||
from app.core.deps import get_current_user, get_db_session, is_system_admin, require_study_not_locked, require_api_permission
|
||||
from app.crud import faq_category as category_crud
|
||||
from app.crud import faq_item as item_crud
|
||||
from app.crud import member as member_crud
|
||||
from app.schemas.common import PaginatedResponse
|
||||
from app.schemas.faq import CategoryCreate, CategoryRead, CategoryUpdate
|
||||
from app.utils.pagination import paginate
|
||||
@@ -47,16 +43,6 @@ async def create_category(
|
||||
if dup:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="该图标已被其他分类使用")
|
||||
category = await category_crud.create_category(db, payload)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=payload.study_id,
|
||||
entity_type="faq_category",
|
||||
entity_id=category.id,
|
||||
action="CREATE_FAQ_CATEGORY",
|
||||
detail=json.dumps({"targetName": category.name, "description": f"创建“{category.name}”分类"}, ensure_ascii=False),
|
||||
operator_id=current_user.id,
|
||||
operator_role=await get_operator_role_label(db, payload.study_id, current_user),
|
||||
)
|
||||
return CategoryRead.model_validate(category)
|
||||
|
||||
|
||||
@@ -112,16 +98,6 @@ async def update_category(
|
||||
if not target_study_id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="必须提供项目 ID")
|
||||
updated = await category_crud.update_category(db, category, payload)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=updated.study_id,
|
||||
entity_type="faq_category",
|
||||
entity_id=category_id,
|
||||
action="UPDATE_FAQ_CATEGORY",
|
||||
detail=json.dumps({"targetName": updated.name, "description": f"更新“{updated.name}”分类"}, ensure_ascii=False),
|
||||
operator_id=current_user.id,
|
||||
operator_role=await get_operator_role_label(db, updated.study_id, current_user),
|
||||
)
|
||||
return CategoryRead.model_validate(updated)
|
||||
|
||||
|
||||
@@ -148,16 +124,5 @@ async def delete_category(
|
||||
item_count = await item_crud.count_items_by_category(db, category_id)
|
||||
if item_count > 0:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类下存在 FAQ,无法删除")
|
||||
category_name = category.name
|
||||
await db.delete(category)
|
||||
await db.commit()
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=category.study_id,
|
||||
entity_type="faq_category",
|
||||
entity_id=category_id,
|
||||
action="DELETE_FAQ_CATEGORY",
|
||||
detail=json.dumps({"targetName": category_name, "description": f"删除“{category_name}”分类"}, ensure_ascii=False),
|
||||
operator_id=current_user.id,
|
||||
operator_role=await get_operator_role_label(db, category.study_id, current_user),
|
||||
)
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import json
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_user, get_db_session, get_operator_role_label, is_system_admin, require_study_not_locked, require_api_permission
|
||||
from app.core.project_permissions import role_has_api_permission
|
||||
from app.crud import audit as audit_crud
|
||||
from app.core.deps import get_current_user, get_db_session, is_system_admin, require_study_not_locked, require_api_permission
|
||||
from app.crud import faq_category as category_crud
|
||||
from app.crud import faq_item as faq_crud
|
||||
from app.crud import faq_reply as reply_crud
|
||||
@@ -31,13 +28,6 @@ def _is_system_admin(current_user) -> bool:
|
||||
return is_system_admin(current_user)
|
||||
|
||||
|
||||
def _compact_text(value: str | None, max_length: int = 40) -> str:
|
||||
text = " ".join(str(value or "").split())
|
||||
if len(text) <= max_length:
|
||||
return text
|
||||
return f"{text[:max_length]}..."
|
||||
|
||||
|
||||
@router.post(
|
||||
"/",
|
||||
response_model=FaqRead,
|
||||
@@ -74,17 +64,6 @@ async def create_faq(
|
||||
reply_in=FaqReplyCreate(content=payload.answer),
|
||||
)
|
||||
await faq_crud.set_status(db, item.id, "PROCESSING")
|
||||
question_name = _compact_text(item.question)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=payload.study_id,
|
||||
entity_type="faq_item",
|
||||
entity_id=item.id,
|
||||
action="CREATE_FAQ_ITEM",
|
||||
detail=json.dumps({"targetName": question_name, "description": f"创建医学咨询问题“{question_name}”"}, ensure_ascii=False),
|
||||
operator_id=current_user.id,
|
||||
operator_role=await get_operator_role_label(db, payload.study_id, current_user),
|
||||
)
|
||||
return FaqRead.model_validate(item)
|
||||
|
||||
|
||||
@@ -178,19 +157,6 @@ async def update_faq(
|
||||
if not item:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
|
||||
updated = await faq_crud.update_item(db, item, payload)
|
||||
action = "UPDATE_FAQ_ITEM"
|
||||
question_name = _compact_text(updated.question)
|
||||
detail = json.dumps({"targetName": question_name, "description": f"更新医学咨询问题“{question_name}”"}, ensure_ascii=False)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=item.study_id,
|
||||
entity_type="faq_item",
|
||||
entity_id=item_id,
|
||||
action=action,
|
||||
detail=detail,
|
||||
operator_id=current_user.id,
|
||||
operator_role=await get_operator_role_label(db, item.study_id, current_user),
|
||||
)
|
||||
return FaqRead.model_validate(updated)
|
||||
|
||||
|
||||
@@ -337,17 +303,6 @@ async def create_reply(
|
||||
if item.status != "RESOLVED":
|
||||
await faq_crud.set_status(db, item.id, "PROCESSING")
|
||||
await faq_crud.touch_item(db, item.id)
|
||||
question_name = _compact_text(item.question)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=item.study_id,
|
||||
entity_type="faq_reply",
|
||||
entity_id=reply.id,
|
||||
action="CREATE_FAQ_REPLY",
|
||||
detail=json.dumps({"targetName": question_name, "description": f"回复医学咨询问题“{question_name}”"}, ensure_ascii=False),
|
||||
operator_id=current_user.id,
|
||||
operator_role=await get_operator_role_label(db, item.study_id, current_user),
|
||||
)
|
||||
data = FaqReplyRead.model_validate(reply)
|
||||
if quote:
|
||||
if quote.is_deleted:
|
||||
@@ -380,20 +335,9 @@ async def delete_faq(
|
||||
item = await faq_crud.get_item(db, item_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
|
||||
question_name = _compact_text(item.question)
|
||||
await reply_crud.delete_replies_by_faq_id(db, item.id)
|
||||
await db.delete(item)
|
||||
await db.commit()
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=item.study_id,
|
||||
entity_type="faq_item",
|
||||
entity_id=item_id,
|
||||
action="DELETE_FAQ_ITEM",
|
||||
detail=json.dumps({"targetName": question_name, "description": f"删除医学咨询问题“{question_name}”"}, ensure_ascii=False),
|
||||
operator_id=current_user.id,
|
||||
operator_role=await get_operator_role_label(db, item.study_id, current_user),
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
@@ -415,7 +359,6 @@ async def delete_reply(
|
||||
item = await faq_crud.get_item(db, item_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
|
||||
question_name = _compact_text(item.question)
|
||||
reply = await reply_crud.get_reply(db, reply_id)
|
||||
if not reply or reply.faq_id != item.id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="回复不存在")
|
||||
@@ -438,13 +381,3 @@ async def delete_reply(
|
||||
resolved_by_confirm=False,
|
||||
)
|
||||
await faq_crud.touch_item(db, item.id)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=item.study_id,
|
||||
entity_type="faq_reply",
|
||||
entity_id=reply_id,
|
||||
action="DELETE_FAQ_REPLY",
|
||||
detail=json.dumps({"targetName": question_name, "description": f"删除医学咨询问题“{question_name}”的回复"}, ensure_ascii=False),
|
||||
operator_id=current_user.id,
|
||||
operator_role=await get_operator_role_label(db, item.study_id, current_user),
|
||||
)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, Response, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_db_session, get_current_user, require_study_member
|
||||
from app.schemas.notification import NotificationItem
|
||||
from app.services import document_service
|
||||
from app.schemas.notification import GeneralNotificationFeed, GeneralNotificationRead, NotificationItem
|
||||
from app.services import document_service, notification_service, project_reminder_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -29,3 +29,65 @@ async def list_notifications(
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/notifications/feed",
|
||||
response_model=GeneralNotificationFeed,
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def list_general_notifications(
|
||||
study_id: uuid.UUID,
|
||||
skip: int = 0,
|
||||
limit: int = 10,
|
||||
category: str | None = None,
|
||||
unread_only: bool = False,
|
||||
requires_action: bool | None = None,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> GeneralNotificationFeed:
|
||||
# Fail closed: stale reminders may contain details for a permission that was just revoked.
|
||||
await project_reminder_service.sync_project_reminders(db, study_id, current_user)
|
||||
return await notification_service.list_feed(
|
||||
db,
|
||||
study_id=study_id,
|
||||
recipient_id=current_user.id,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
category=category,
|
||||
unread_only=unread_only,
|
||||
requires_action=requires_action,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/notifications/{notification_id}/read",
|
||||
response_model=GeneralNotificationRead,
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def mark_general_notification_read(
|
||||
study_id: uuid.UUID,
|
||||
notification_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> GeneralNotificationRead:
|
||||
return await notification_service.mark_read(
|
||||
db,
|
||||
study_id=study_id,
|
||||
recipient_id=current_user.id,
|
||||
notification_id=notification_id,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/notifications/read-all",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def mark_all_general_notifications_read(
|
||||
study_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> Response:
|
||||
await notification_service.mark_all_read(db, study_id=study_id, recipient_id=current_user.id)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import APIRouter, Depends, Request, Response, status
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.v1.attachments import _ensure_attachment_permission, _ensure_study_exists
|
||||
from app.core.deps import get_current_user, get_db_session
|
||||
from app.crud import attachment as attachment_crud
|
||||
from app.crud import document as document_crud
|
||||
from app.crud import document_version as version_crud
|
||||
from app.models.collaboration import CollaborationRevision
|
||||
from app.schemas.onlyoffice import OnlyOfficePreviewConfigRead
|
||||
from app.services import document_service, onlyoffice_service
|
||||
|
||||
router = APIRouter()
|
||||
internal_router = APIRouter(include_in_schema=False)
|
||||
|
||||
|
||||
def _content_disposition(filename: str) -> str:
|
||||
fallback = "".join(
|
||||
character if 32 <= ord(character) < 127 and character not in {'"', "\\"} else "_"
|
||||
for character in filename
|
||||
) or "document"
|
||||
encoded = quote(filename, safe="")
|
||||
return f'inline; filename="{fallback}"; filename*=UTF-8\'\'{encoded}'
|
||||
|
||||
|
||||
@router.get(
|
||||
"/attachments/{attachment_id}/config",
|
||||
response_model=OnlyOfficePreviewConfigRead,
|
||||
)
|
||||
async def get_attachment_preview_config(
|
||||
attachment_id: uuid.UUID,
|
||||
response: Response,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> OnlyOfficePreviewConfigRead:
|
||||
attachment = await attachment_crud.get_attachment(db, attachment_id)
|
||||
if not attachment:
|
||||
raise onlyoffice_service.onlyoffice_error(
|
||||
"ATTACHMENT_NOT_FOUND", "附件不存在", status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
await _ensure_study_exists(db, attachment.study_id)
|
||||
await _ensure_attachment_permission(
|
||||
db,
|
||||
attachment.study_id,
|
||||
attachment.entity_type,
|
||||
attachment.entity_id,
|
||||
"read",
|
||||
current_user,
|
||||
)
|
||||
if not os.path.exists(attachment.file_path):
|
||||
raise onlyoffice_service.onlyoffice_error(
|
||||
"ATTACHMENT_FILE_NOT_FOUND", "服务器未找到文件", status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
if not onlyoffice_service.office_format_for_filename(attachment.filename):
|
||||
raise onlyoffice_service.onlyoffice_error(
|
||||
"ONLYOFFICE_FORMAT_UNSUPPORTED",
|
||||
"该文件格式不支持 Office 在线预览",
|
||||
status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
|
||||
)
|
||||
await onlyoffice_service.ensure_onlyoffice_available()
|
||||
result = onlyoffice_service.build_preview_config(
|
||||
resource_type="attachment",
|
||||
resource_id=attachment.id,
|
||||
file_name=attachment.filename,
|
||||
user_id=current_user.id,
|
||||
user_name=current_user.full_name,
|
||||
)
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return result
|
||||
|
||||
|
||||
@router.get(
|
||||
"/versions/{version_id}/config",
|
||||
response_model=OnlyOfficePreviewConfigRead,
|
||||
)
|
||||
async def get_version_preview_config(
|
||||
version_id: uuid.UUID,
|
||||
response: Response,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> OnlyOfficePreviewConfigRead:
|
||||
version = await version_crud.get(db, version_id)
|
||||
if not version:
|
||||
raise onlyoffice_service.onlyoffice_error(
|
||||
"DOCUMENT_VERSION_NOT_FOUND", "版本不存在", status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
document = await document_crud.get(db, version.document_id)
|
||||
if not document:
|
||||
raise onlyoffice_service.onlyoffice_error(
|
||||
"DOCUMENT_NOT_FOUND", "文档不存在", status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
await document_service._ensure_study_access(db, document.trial_id, current_user, action="view")
|
||||
file_path = Path(version.file_uri)
|
||||
if not file_path.exists():
|
||||
raise onlyoffice_service.onlyoffice_error(
|
||||
"DOCUMENT_FILE_NOT_FOUND", "文件不存在", status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
file_name = version.original_filename or document_service._legacy_download_filename(version, document)
|
||||
if not onlyoffice_service.office_format_for_filename(file_name):
|
||||
raise onlyoffice_service.onlyoffice_error(
|
||||
"ONLYOFFICE_FORMAT_UNSUPPORTED",
|
||||
"该文件格式不支持 Office 在线预览",
|
||||
status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
|
||||
)
|
||||
await onlyoffice_service.ensure_onlyoffice_available()
|
||||
result = onlyoffice_service.build_preview_config(
|
||||
resource_type="version",
|
||||
resource_id=version.id,
|
||||
file_name=file_name,
|
||||
file_hash=version.file_hash,
|
||||
user_id=current_user.id,
|
||||
user_name=current_user.full_name,
|
||||
)
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return result
|
||||
|
||||
|
||||
def _authorize_internal_file_request(request: Request, expected_url: str) -> None:
|
||||
onlyoffice_service.validate_outbox_token(request.headers.get("AuthorizationJwt"), expected_url)
|
||||
|
||||
|
||||
@internal_router.get("/internal/onlyoffice/attachments/{attachment_id}/content")
|
||||
async def get_internal_attachment_content(
|
||||
attachment_id: uuid.UUID,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> FileResponse:
|
||||
expected_url = onlyoffice_service.onlyoffice_content_url("attachment", attachment_id)
|
||||
_authorize_internal_file_request(request, expected_url)
|
||||
attachment = await attachment_crud.get_attachment(db, attachment_id)
|
||||
if not attachment or not os.path.exists(attachment.file_path):
|
||||
raise onlyoffice_service.onlyoffice_error(
|
||||
"ATTACHMENT_FILE_NOT_FOUND", "文件不存在", status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
return FileResponse(
|
||||
path=attachment.file_path,
|
||||
media_type=attachment.content_type or "application/octet-stream",
|
||||
headers={"Content-Disposition": _content_disposition(attachment.filename)},
|
||||
)
|
||||
|
||||
|
||||
@internal_router.get("/internal/onlyoffice/versions/{version_id}/content")
|
||||
async def get_internal_version_content(
|
||||
version_id: uuid.UUID,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> FileResponse:
|
||||
expected_url = onlyoffice_service.onlyoffice_content_url("version", version_id)
|
||||
_authorize_internal_file_request(request, expected_url)
|
||||
version = await version_crud.get(db, version_id)
|
||||
if not version:
|
||||
raise onlyoffice_service.onlyoffice_error(
|
||||
"DOCUMENT_VERSION_NOT_FOUND", "版本不存在", status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
document = await document_crud.get(db, version.document_id)
|
||||
file_path = Path(version.file_uri)
|
||||
if not document or not file_path.exists():
|
||||
raise onlyoffice_service.onlyoffice_error(
|
||||
"DOCUMENT_FILE_NOT_FOUND", "文件不存在", status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
file_name = version.original_filename or document_service._legacy_download_filename(version, document)
|
||||
return FileResponse(
|
||||
path=str(file_path),
|
||||
media_type=version.mime_type or "application/octet-stream",
|
||||
headers={"Content-Disposition": _content_disposition(file_name)},
|
||||
)
|
||||
|
||||
|
||||
@internal_router.get("/internal/onlyoffice/collaboration-revisions/{revision_id}/content")
|
||||
async def get_internal_collaboration_revision_content(
|
||||
revision_id: uuid.UUID,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> FileResponse:
|
||||
expected_url = onlyoffice_service.onlyoffice_content_url("collaboration_revision", revision_id)
|
||||
_authorize_internal_file_request(request, expected_url)
|
||||
revision = await db.get(CollaborationRevision, revision_id)
|
||||
file_path = Path(revision.file_uri) if revision else None
|
||||
if not revision or getattr(revision, "deleted_at", None) is not None or not file_path or not file_path.exists():
|
||||
raise onlyoffice_service.onlyoffice_error(
|
||||
"COLLABORATION_REVISION_NOT_FOUND", "协作修订不存在", status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
return FileResponse(
|
||||
path=str(file_path),
|
||||
media_type=revision.mime_type or "application/octet-stream",
|
||||
headers={"Content-Disposition": _content_disposition(revision.original_filename)},
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,9 @@
|
||||
import uuid
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_operator_role_label, get_current_user, get_db_session, require_study_not_locked, require_api_permission
|
||||
from app.crud import audit as audit_crud
|
||||
from app.core.deps import get_current_user, get_db_session, require_study_not_locked, require_api_permission
|
||||
from app.crud import precaution as precaution_crud
|
||||
from app.crud import site as site_crud
|
||||
from app.crud import study as study_crud
|
||||
@@ -29,11 +27,6 @@ async def _ensure_site_name_active(db: AsyncSession, study_id: uuid.UUID, site_n
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="中心已停用")
|
||||
|
||||
|
||||
def _precaution_audit_detail(action: str, precaution) -> str:
|
||||
title = str(precaution.title or "").strip() or "注意事项"
|
||||
return json.dumps({"targetName": title, "description": f"{action}注意事项“{title}”"}, ensure_ascii=False)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/precautions",
|
||||
response_model=PrecautionRead,
|
||||
@@ -49,16 +42,6 @@ async def create_precaution(
|
||||
await _ensure_study_exists(db, study_id)
|
||||
await _ensure_site_name_active(db, study_id, precaution_in.site_name)
|
||||
precaution = await precaution_crud.create_precaution(db, study_id, precaution_in, created_by=current_user.id)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="precaution",
|
||||
entity_id=precaution.id,
|
||||
action="CREATE_PRECAUTION",
|
||||
detail=_precaution_audit_detail("创建", precaution),
|
||||
operator_id=current_user.id,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
)
|
||||
return PrecautionRead.model_validate(precaution)
|
||||
|
||||
|
||||
@@ -115,16 +98,6 @@ async def update_precaution(
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="注意事项不存在")
|
||||
await _ensure_site_name_active(db, study_id, precaution.site_name)
|
||||
precaution = await precaution_crud.update_precaution(db, precaution, precaution_in)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="precaution",
|
||||
entity_id=precaution_id,
|
||||
action="UPDATE_PRECAUTION",
|
||||
detail=_precaution_audit_detail("更新", precaution),
|
||||
operator_id=current_user.id,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
)
|
||||
return PrecautionRead.model_validate(precaution)
|
||||
|
||||
|
||||
@@ -144,15 +117,4 @@ async def delete_precaution(
|
||||
if not precaution or precaution.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="注意事项不存在")
|
||||
await _ensure_site_name_active(db, study_id, precaution.site_name)
|
||||
precaution_detail = _precaution_audit_detail("删除", precaution)
|
||||
await precaution_crud.delete_precaution(db, precaution)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="precaution",
|
||||
entity_id=precaution_id,
|
||||
action="DELETE_PRECAUTION",
|
||||
detail=precaution_detail,
|
||||
operator_id=current_user.id,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1 import auth, users, admin_email_settings, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, fees_contracts, drug_shipments, material_equipments, project_milestones, startup, precautions, subject_histories, subject_pds, study_subject_pds, faq_categories, faqs, documents, etmf, overview, notifications, desktop_notifications, monitoring_visit_issues, api_permissions, permission_monitoring, permission_templates, system_permissions, study_active_roles
|
||||
from app.api.v1 import auth, users, admin_email_settings, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, fees_contracts, drug_shipments, material_equipments, project_milestones, startup, precautions, subject_histories, subject_pds, study_subject_pds, faq_categories, faqs, documents, etmf, overview, notifications, desktop_notifications, monitoring_visit_issues, api_permissions, permission_monitoring, permission_templates, system_permissions, study_active_roles, onlyoffice, collaboration
|
||||
|
||||
|
||||
api_router = APIRouter()
|
||||
@@ -17,6 +17,7 @@ api_router.include_router(api_permissions.router, tags=["api-permissions"])
|
||||
api_router.include_router(api_permissions.study_router, prefix="/studies/{study_id}", tags=["api-permissions"])
|
||||
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(onlyoffice.router, prefix="/onlyoffice", tags=["onlyoffice"])
|
||||
api_router.include_router(audit_logs.router, prefix="/studies/{study_id}/audit-logs", tags=["audit-logs"])
|
||||
api_router.include_router(dashboard.router, prefix="/studies/{study_id}/dashboard", tags=["dashboard"])
|
||||
api_router.include_router(subjects.router, prefix="/studies/{study_id}/subjects", tags=["subjects"])
|
||||
@@ -29,6 +30,8 @@ api_router.include_router(material_equipments.router, prefix="/studies/{study_id
|
||||
api_router.include_router(project_milestones.router, prefix="/studies/{study_id}/project", tags=["project-milestones"])
|
||||
api_router.include_router(startup.router, prefix="/studies/{study_id}/startup", tags=["startup"])
|
||||
api_router.include_router(precautions.router, prefix="/studies/{study_id}/shared-library", tags=["precautions"])
|
||||
api_router.include_router(collaboration.router, prefix="/studies/{study_id}/collaboration", tags=["collaboration"])
|
||||
api_router.include_router(collaboration.public_router, prefix="/collaboration/shares", tags=["collaboration-shares"])
|
||||
api_router.include_router(monitoring_visit_issues.router, prefix="/studies/{study_id}/monitoring", tags=["monitoring-visit-issues"])
|
||||
api_router.include_router(subject_histories.router, prefix="/studies/{study_id}/subjects/{subject_id}", tags=["subject-histories"])
|
||||
api_router.include_router(subject_pds.router, prefix="/studies/{study_id}/subjects/{subject_id}", tags=["subject-pds"])
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -9,7 +9,8 @@ from app.schemas.common import PaginatedResponse
|
||||
from app.crud import user as user_crud
|
||||
from app.crud import member as member_crud
|
||||
from app.utils.pagination import paginate
|
||||
from app.schemas.user import UserCreate, UserRead, UserStatus, UserUpdate
|
||||
from app.schemas.user import LoginStatus, UserCreate, UserLoginActivityRead, UserRead, UserStatus, UserUpdate
|
||||
from app.services.user_login_sessions import get_login_summaries, list_login_activities, login_activity_payload
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -20,12 +21,48 @@ async def list_users(
|
||||
limit: int = 100,
|
||||
keyword: str | None = Query(default=None),
|
||||
user_status: UserStatus | None = Query(default=None, alias="status"),
|
||||
login_status: LoginStatus | None = Query(default=None),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(require_roles(["ADMIN"])),
|
||||
) -> PaginatedResponse[UserRead]:
|
||||
users = await user_crud.list_users(db, skip=skip, limit=limit, keyword=keyword, status=user_status)
|
||||
total_users = await user_crud.count_users(db, keyword=keyword, status=user_status)
|
||||
return paginate(list(users), total=total_users)
|
||||
users = await user_crud.list_users(
|
||||
db, skip=skip, limit=limit, keyword=keyword, status=user_status, login_status=login_status
|
||||
)
|
||||
total_users = await user_crud.count_users(
|
||||
db, keyword=keyword, status=user_status, login_status=login_status
|
||||
)
|
||||
summaries = await get_login_summaries(db, [user.id for user in users])
|
||||
items = []
|
||||
for user in users:
|
||||
summary = summaries.get(user.id)
|
||||
item = UserRead.model_validate(user).model_dump()
|
||||
if summary:
|
||||
item.update(
|
||||
{
|
||||
"login_status": summary.status,
|
||||
"last_login_at": summary.last_login_at,
|
||||
"last_seen_at": summary.last_seen_at,
|
||||
"last_client_type": summary.client_type,
|
||||
"active_session_count": summary.active_session_count,
|
||||
}
|
||||
)
|
||||
items.append(item)
|
||||
return paginate(items, total=total_users)
|
||||
|
||||
|
||||
@router.get("/{user_id}/login-activities", response_model=list[UserLoginActivityRead])
|
||||
async def read_user_login_activities(
|
||||
user_id: uuid.UUID,
|
||||
response: Response,
|
||||
limit: int = Query(default=30, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(require_roles(["ADMIN"])),
|
||||
) -> list[UserLoginActivityRead]:
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
if not await user_crud.get_by_id(db, user_id):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||
activities = await list_login_activities(db, user_id=user_id, limit=limit)
|
||||
return [login_activity_payload(activity) for activity in activities]
|
||||
|
||||
|
||||
@router.post("/", response_model=UserRead, status_code=status.HTTP_201_CREATED)
|
||||
|
||||
@@ -12,6 +12,7 @@ from app.crud import study as study_crud
|
||||
from app.crud import visit as visit_crud
|
||||
from app.schemas.subject import SubjectUpdate
|
||||
from app.schemas.visit import EarlyTerminationCreate, VisitCreate, VisitRead, VisitUpdate
|
||||
from app.services import notification_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -42,6 +43,17 @@ async def _ensure_subject_active(db: AsyncSession, subject) -> None:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="中心已停用")
|
||||
|
||||
|
||||
async def _resolve_visit_reminders(db: AsyncSession, visit_ids: list[uuid.UUID]) -> None:
|
||||
for visit_id in set(visit_ids):
|
||||
await notification_service.resolve_source_notifications(
|
||||
db,
|
||||
source_type="SUBJECT_VISIT_WINDOW",
|
||||
source_id=str(visit_id),
|
||||
)
|
||||
if visit_ids:
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/",
|
||||
response_model=list[VisitRead],
|
||||
@@ -145,6 +157,8 @@ async def create_early_termination(
|
||||
if subject.baseline_date and termination_in.termination_date < subject.baseline_date:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="提前终止日期不能早于基线/治疗日期")
|
||||
|
||||
existing_visit_ids = [visit.id for visit in await visit_crud.list_visits(db, subject_id)]
|
||||
|
||||
try:
|
||||
visit = await visit_crud.create_early_termination_visit(
|
||||
db,
|
||||
@@ -164,6 +178,7 @@ async def create_early_termination(
|
||||
drop_reason=reason,
|
||||
),
|
||||
)
|
||||
await _resolve_visit_reminders(db, existing_visit_ids)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
@@ -209,6 +224,8 @@ async def update_visit(
|
||||
old_status = visit.status
|
||||
updated = await visit_crud.update_visit(db, visit, visit_in)
|
||||
await subject_crud.sync_subject_status(db, subject)
|
||||
if updated.actual_date is not None or updated.status in {"DONE", "CANCELLED"}:
|
||||
await _resolve_visit_reminders(db, [updated.id])
|
||||
detail = None
|
||||
if visit_in.status:
|
||||
detail = json.dumps(
|
||||
@@ -253,6 +270,7 @@ async def delete_visit(
|
||||
visit_detail = _visit_audit_detail("删除", subject, visit)
|
||||
await visit_crud.delete_visit(db, visit)
|
||||
await subject_crud.sync_subject_status(db, subject)
|
||||
await _resolve_visit_reminders(db, [visit_id])
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
|
||||
Reference in New Issue
Block a user