Step 4:评论 / 附件 / 审计日志

This commit is contained in:
Cheng Zhou
2025-12-16 17:02:11 +08:00
parent f3692e1145
commit 31892247bf
69 changed files with 506 additions and 2 deletions
+113
View File
@@ -0,0 +1,113 @@
import os
import uuid
from pathlib import Path
import aiofiles
from fastapi import APIRouter, Depends, File, HTTPException, 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_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.schemas.attachment import AttachmentRead
router = APIRouter()
UPLOAD_ROOT = Path(__file__).resolve().parent.parent.parent / "uploads"
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
study = await study_crud.get(db, study_id)
if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Study not found")
return study
@router.post(
"/",
response_model=AttachmentRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_member())],
)
async def upload_attachment(
study_id: uuid.UUID,
entity_type: str,
entity_id: uuid.UUID,
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> AttachmentRead:
await _ensure_study_exists(db, study_id)
dest_dir = UPLOAD_ROOT / f"study_{study_id}" / f"{entity_type}_{entity_id}"
dest_dir.mkdir(parents=True, exist_ok=True)
unique_name = f"{uuid.uuid4()}{Path(file.filename).suffix}"
dest_path = dest_dir / unique_name
content = await file.read()
async with aiofiles.open(dest_path, "wb") as out_file:
await out_file.write(content)
attachment = await attachment_crud.create_attachment(
db,
study_id=study_id,
entity_type=entity_type,
entity_id=entity_id,
filename=file.filename,
file_path=str(dest_path),
file_size=len(content),
content_type=file.content_type,
uploaded_by=current_user.id,
)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type=entity_type,
entity_id=entity_id,
action="UPLOAD_FILE",
detail=f"File uploaded: {file.filename}",
operator_id=current_user.id,
operator_role=current_user.role,
)
return attachment
@router.get(
"/",
response_model=list[AttachmentRead],
dependencies=[Depends(require_study_member())],
)
async def list_attachments(
study_id: uuid.UUID,
entity_type: str,
entity_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
) -> list[AttachmentRead]:
await _ensure_study_exists(db, study_id)
attachments = await attachment_crud.list_attachments(db, study_id, entity_type, entity_id)
return list(attachments)
@router.get(
"/{attachment_id}/download",
dependencies=[Depends(require_study_member())],
)
async def download_attachment(
study_id: uuid.UUID,
entity_type: str,
entity_id: uuid.UUID,
attachment_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
) -> FileResponse:
await _ensure_study_exists(db, study_id)
attachment = await attachment_crud.get_attachment(db, attachment_id)
if not attachment or attachment.study_id != study_id or attachment.entity_id != entity_id or attachment.entity_type != entity_type:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Attachment not found")
if not os.path.exists(attachment.file_path):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File missing on server")
return FileResponse(
path=attachment.file_path,
filename=attachment.filename,
media_type=attachment.content_type or "application/octet-stream",
)
+36
View File
@@ -0,0 +1,36 @@
import uuid
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db_session, require_study_member
from app.crud import audit as audit_crud
from app.schemas.audit import AuditLogRead
router = APIRouter()
@router.get(
"/",
response_model=list[AuditLogRead],
dependencies=[Depends(require_study_member())],
)
async def list_audit_logs(
study_id: uuid.UUID,
entity_type: str | None = None,
entity_id: uuid.UUID | None = None,
action: str | None = None,
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db_session),
) -> list[AuditLogRead]:
logs = await audit_crud.list_logs(
db,
study_id=study_id,
entity_type=entity_type,
entity_id=entity_id,
action=action,
skip=skip,
limit=limit,
)
return list(logs)
+71
View File
@@ -0,0 +1,71 @@
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, require_study_member
from app.crud import comment as comment_crud
from app.crud import audit as audit_crud
from app.crud import study as study_crud
from app.schemas.comment import CommentCreate, CommentRead
router = APIRouter()
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
study = await study_crud.get(db, study_id)
if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Study not found")
return study
@router.post(
"/",
response_model=CommentRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_member())],
)
async def create_comment(
study_id: uuid.UUID,
entity_type: str,
entity_id: uuid.UUID,
comment_in: CommentCreate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> CommentRead:
await _ensure_study_exists(db, study_id)
comment = await comment_crud.create_comment(
db,
study_id=study_id,
entity_type=entity_type,
entity_id=entity_id,
comment_in=comment_in,
created_by=current_user.id,
)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type=entity_type,
entity_id=entity_id,
action="CREATE_COMMENT",
detail=f"Comment created by {current_user.username}",
operator_id=current_user.id,
operator_role=current_user.role,
)
return comment
@router.get(
"/",
response_model=list[CommentRead],
dependencies=[Depends(require_study_member())],
)
async def list_comments(
study_id: uuid.UUID,
entity_type: str,
entity_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
) -> list[CommentRead]:
await _ensure_study_exists(db, study_id)
comments = await comment_crud.list_comments(db, study_id, entity_type, entity_id)
return list(comments)
+4 -1
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter
from app.api.v1 import auth, users, studies, sites, members
from app.api.v1 import auth, users, studies, sites, members, comments, attachments, audit_logs
api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
@@ -8,3 +8,6 @@ api_router.include_router(users.router, prefix="/users", tags=["users"])
api_router.include_router(studies.router, prefix="/studies", tags=["studies"])
api_router.include_router(sites.router, prefix="/studies/{study_id}/sites", tags=["sites"])
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(audit_logs.router, prefix="/studies/{study_id}/audit-logs", tags=["audit-logs"])
+59
View File
@@ -0,0 +1,59 @@
import uuid
from typing import Sequence
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.attachment import Attachment
async def create_attachment(
db: AsyncSession,
*,
study_id: uuid.UUID,
entity_type: str,
entity_id: uuid.UUID,
filename: str,
file_path: str,
file_size: int,
content_type: str | None,
uploaded_by: uuid.UUID,
) -> Attachment:
attachment = Attachment(
study_id=study_id,
entity_type=entity_type,
entity_id=entity_id,
filename=filename,
file_path=file_path,
file_size=file_size,
content_type=content_type,
uploaded_by=uploaded_by,
)
db.add(attachment)
await db.commit()
await db.refresh(attachment)
return attachment
async def list_attachments(
db: AsyncSession,
study_id: uuid.UUID,
entity_type: str,
entity_id: uuid.UUID,
) -> Sequence[Attachment]:
result = await db.execute(
select(Attachment)
.where(
Attachment.study_id == study_id,
Attachment.entity_type == entity_type,
Attachment.entity_id == entity_id,
Attachment.is_deleted.is_(False),
)
.order_by(Attachment.uploaded_at.desc())
)
return result.scalars().all()
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()
+55
View File
@@ -0,0 +1,55 @@
import uuid
from typing import Sequence
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.audit_log import AuditLog
async def log_action(
db: AsyncSession,
*,
study_id: uuid.UUID | None,
entity_type: str,
entity_id: uuid.UUID | None,
action: str,
detail: str | None,
operator_id: uuid.UUID,
operator_role: str,
) -> AuditLog:
log = AuditLog(
study_id=study_id,
entity_type=entity_type,
entity_id=entity_id,
action=action,
detail=detail,
operator_id=operator_id,
operator_role=operator_role,
)
db.add(log)
await db.commit()
await db.refresh(log)
return log
async def list_logs(
db: AsyncSession,
*,
study_id: uuid.UUID,
entity_type: str | None = None,
entity_id: uuid.UUID | None = None,
action: str | None = None,
skip: int = 0,
limit: int = 100,
) -> Sequence[AuditLog]:
stmt = select(AuditLog).where(AuditLog.study_id == study_id)
if entity_type:
stmt = stmt.where(AuditLog.entity_type == entity_type)
if entity_id:
stmt = stmt.where(AuditLog.entity_id == entity_id)
if action:
stmt = stmt.where(AuditLog.action == action)
stmt = stmt.order_by(AuditLog.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(stmt)
return result.scalars().all()
+48
View File
@@ -0,0 +1,48 @@
import uuid
from typing import Sequence
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.comment import Comment
from app.schemas.comment import CommentCreate
async def create_comment(
db: AsyncSession,
study_id: uuid.UUID,
entity_type: str,
entity_id: uuid.UUID,
comment_in: CommentCreate,
created_by: uuid.UUID,
) -> Comment:
comment = Comment(
study_id=study_id,
entity_type=entity_type,
entity_id=entity_id,
content=comment_in.content,
created_by=created_by,
)
db.add(comment)
await db.commit()
await db.refresh(comment)
return comment
async def list_comments(
db: AsyncSession,
study_id: uuid.UUID,
entity_type: str,
entity_id: uuid.UUID,
) -> Sequence[Comment]:
result = await db.execute(
select(Comment)
.where(
Comment.study_id == study_id,
Comment.entity_type == entity_type,
Comment.entity_id == entity_id,
Comment.is_deleted.is_(False),
)
.order_by(Comment.created_at.asc())
)
return result.scalars().all()
+3
View File
@@ -5,3 +5,6 @@ from app.models.user import User # noqa: F401
from app.models.study import Study # noqa: F401
from app.models.site import Site # noqa: F401
from app.models.study_member import StudyMember # noqa: F401
from app.models.comment import Comment # noqa: F401
from app.models.attachment import Attachment # noqa: F401
from app.models.audit_log import AuditLog # noqa: F401
+24
View File
@@ -0,0 +1,24 @@
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base_class import Base
class Attachment(Base):
__tablename__ = "attachments"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), nullable=False)
entity_type: Mapped[str] = mapped_column(String(50), nullable=False)
entity_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
filename: Mapped[str] = mapped_column(String(255), nullable=False)
file_path: Mapped[str] = mapped_column(String(500), nullable=False)
file_size: Mapped[int] = mapped_column(Integer, nullable=False)
content_type: Mapped[str | None] = mapped_column(String(100), nullable=True)
uploaded_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
uploaded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
+22
View File
@@ -0,0 +1,22 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base_class import Base
class AuditLog(Base):
__tablename__ = "audit_logs"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
study_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), nullable=True)
entity_type: Mapped[str] = mapped_column(String(50), nullable=False)
entity_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
action: Mapped[str] = mapped_column(String(50), nullable=False)
detail: Mapped[str | None] = mapped_column(Text, nullable=True)
operator_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
operator_role: Mapped[str] = mapped_column(String(50), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
+21
View File
@@ -0,0 +1,21 @@
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base_class import Base
class Comment(Base):
__tablename__ = "comments"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
entity_type: Mapped[str] = mapped_column(String(50), nullable=False)
entity_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False)
created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
+14
View File
@@ -0,0 +1,14 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class AttachmentRead(BaseModel):
id: uuid.UUID
filename: str
file_size: int
uploaded_by: uuid.UUID
uploaded_at: datetime
model_config = ConfigDict(from_attributes=True)
+16
View File
@@ -0,0 +1,16 @@
import uuid
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict
class AuditLogRead(BaseModel):
id: uuid.UUID
action: str
detail: Optional[str]
operator_id: uuid.UUID
operator_role: str
created_at: datetime
model_config = ConfigDict(from_attributes=True)
+17
View File
@@ -0,0 +1,17 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
class CommentCreate(BaseModel):
content: str = Field(min_length=1)
class CommentRead(BaseModel):
id: uuid.UUID
content: str
created_by: uuid.UUID
created_at: datetime
model_config = ConfigDict(from_attributes=True)
+1
View File
@@ -7,4 +7,5 @@ python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
bcrypt==4.0.1
python-multipart==0.0.6
aiofiles==23.2.1
debugpy==1.8.0
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -1,6 +1,6 @@
1
/var/lib/postgresql/data
1765872650
1765875461
5432
/var/run/postgresql
*