Step 11:百问百答(FAQ / Knowledge Base)

This commit is contained in:
Cheng Zhou
2025-12-16 19:50:44 +08:00
parent 96f7e35713
commit a54d7faf19
68 changed files with 553 additions and 1 deletions
+104
View File
@@ -0,0 +1,104 @@
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
from app.crud import audit as audit_crud
from app.crud import faq_category as category_crud
from app.crud import member as member_crud
from app.schemas.faq import CategoryCreate, CategoryRead, CategoryUpdate
router = APIRouter()
def _check_permission_for_scope(study_id: uuid.UUID | None, current_user, member_role: str | None):
if study_id is None:
if current_user.role != "ADMIN":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only admin can manage global FAQ")
else:
if current_user.role != "ADMIN" and member_role != "PM":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
@router.post(
"/",
response_model=CategoryRead,
status_code=status.HTTP_201_CREATED,
)
async def create_category(
payload: CategoryCreate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> CategoryRead:
member_role = None
if payload.study_id:
member = await member_crud.get_member(db, payload.study_id, current_user.id)
if not member:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a study member")
member_role = member.role_in_study
_check_permission_for_scope(payload.study_id, current_user, member_role)
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=f"FAQ category {category.name} created",
operator_id=current_user.id,
operator_role=current_user.role,
)
return CategoryRead.model_validate(category)
@router.get(
"/",
response_model=list[CategoryRead],
)
async def list_categories(
study_id: uuid.UUID | None = None,
include_global: bool = True,
is_active: bool | None = True,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[CategoryRead]:
if study_id:
member = await member_crud.get_member(db, study_id, current_user.id)
if not member and current_user.role != "ADMIN":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a study member")
categories = await category_crud.list_categories(db, study_id, include_global=include_global, is_active=is_active)
return [CategoryRead.model_validate(c) for c in categories]
@router.patch(
"/{category_id}",
response_model=CategoryRead,
)
async def update_category(
category_id: uuid.UUID,
payload: CategoryUpdate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> CategoryRead:
category = await category_crud.get_category(db, category_id)
if not category:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Category not found")
member_role = None
if category.study_id:
from app.crud import member as member_crud
member = await member_crud.get_member(db, category.study_id, current_user.id)
member_role = member.role_in_study if member else None
_check_permission_for_scope(category.study_id, current_user, member_role)
updated = await category_crud.update_category(db, category, payload)
await audit_crud.log_action(
db,
study_id=category.study_id,
entity_type="faq_category",
entity_id=category_id,
action="UPDATE_FAQ_CATEGORY",
detail=f"FAQ category {category_id} updated",
operator_id=current_user.id,
operator_role=current_user.role,
)
return CategoryRead.model_validate(updated)
+182
View File
@@ -0,0 +1,182 @@
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
from app.crud import audit as audit_crud
from app.crud import faq_category as category_crud
from app.crud import faq_item as faq_crud
from app.crud import member as member_crud
from app.schemas.faq import FaqCreate, FaqRead, FaqUpdate
router = APIRouter()
def _check_write_permission(study_id: uuid.UUID | None, current_user, member_role: str | None):
if study_id is None:
if current_user.role != "ADMIN":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only admin can manage global FAQ")
else:
if current_user.role != "ADMIN" and member_role != "PM":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
@router.post(
"/",
response_model=FaqRead,
status_code=status.HTTP_201_CREATED,
)
async def create_faq(
payload: FaqCreate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> FaqRead:
cat = await category_crud.get_category(db, payload.category_id)
if not cat:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Category not found")
if cat.study_id != payload.study_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Category scope mismatch")
member_role = None
if payload.study_id:
member = await member_crud.get_member(db, payload.study_id, current_user.id)
member_role = member.role_in_study if member else None
_check_write_permission(payload.study_id, current_user, member_role)
try:
item = await faq_crud.create_item(db, payload, created_by=current_user.id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
await audit_crud.log_action(
db,
study_id=payload.study_id,
entity_type="faq_item",
entity_id=item.id,
action="CREATE_FAQ_ITEM",
detail="FAQ created",
operator_id=current_user.id,
operator_role=current_user.role,
)
return FaqRead.model_validate(item)
@router.get(
"/",
response_model=list[FaqRead],
)
async def list_faqs(
study_id: uuid.UUID | None = None,
category_id: uuid.UUID | None = None,
keyword: str | None = None,
is_active: bool | None = True,
study_scope: str | None = "project",
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[FaqRead]:
membership_cache: dict[uuid.UUID, str | None] = {}
async def _get_member_role(sid: uuid.UUID) -> str | None:
if sid in membership_cache:
return membership_cache[sid]
member = await member_crud.get_member(db, sid, current_user.id)
role = member.role_in_study if member else None
membership_cache[sid] = role
return role
if study_id:
if current_user.role != "ADMIN":
role = await _get_member_role(study_id)
if not role:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a study member")
if is_active is False and current_user.role != "ADMIN":
role = None
if study_id:
role = await _get_member_role(study_id)
if role != "PM":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
items = await faq_crud.list_items(
db,
study_id=study_id,
category_id=category_id,
keyword=keyword,
is_active=is_active,
study_scope=study_scope,
)
visible: list[FaqRead] = []
for it in items:
if it.study_id and current_user.role != "ADMIN":
role = await _get_member_role(it.study_id)
if not role:
continue
if not it.is_active and current_user.role != "ADMIN":
role = await _get_member_role(it.study_id) if it.study_id else None
if role != "PM":
continue
visible.append(FaqRead.model_validate(it))
return visible
@router.get(
"/{item_id}",
response_model=FaqRead,
)
async def get_faq(
item_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> FaqRead:
item = await faq_crud.get_item(db, item_id)
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ not found")
if item.study_id and current_user.role != "ADMIN":
member = await member_crud.get_member(db, item.study_id, current_user.id)
if not member:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a study member")
if not item.is_active and current_user.role not in {"ADMIN"}:
member_role = None
if item.study_id:
member = await member_crud.get_member(db, item.study_id, current_user.id)
member_role = member.role_in_study if member else None
if member_role != "PM":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Inactive FAQ")
return FaqRead.model_validate(item)
@router.patch(
"/{item_id}",
response_model=FaqRead,
)
async def update_faq(
item_id: uuid.UUID,
payload: FaqUpdate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> FaqRead:
item = await faq_crud.get_item(db, item_id)
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ not found")
member_role = None
if item.study_id:
member = await member_crud.get_member(db, item.study_id, current_user.id)
member_role = member.role_in_study if member else None
_check_write_permission(item.study_id, current_user, member_role)
old_active = item.is_active
updated = await faq_crud.update_item(db, item, payload)
action = "UPDATE_FAQ_ITEM"
detail = "FAQ updated"
if payload.is_active is not None and payload.is_active != old_active:
action = "FAQ_STATUS_CHANGE"
detail = "FAQ disabled" if not payload.is_active else "FAQ enabled"
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=current_user.role,
)
return FaqRead.model_validate(updated)
+3 -1
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api.v1 import auth, users, studies, sites, members, comments, attachments, audit_logs, milestones, tasks, dashboard, subjects, visits, aes, issues, data_queries, verifications, imp_products, imp_batches, imp_inventory, imp_transactions, finance, finance_dashboard from app.api.v1 import auth, users, studies, sites, members, comments, attachments, audit_logs, milestones, tasks, dashboard, subjects, visits, aes, issues, data_queries, verifications, imp_products, imp_batches, imp_inventory, imp_transactions, finance, finance_dashboard, faq_categories, faqs
api_router = APIRouter() api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
@@ -26,3 +26,5 @@ api_router.include_router(imp_inventory.router, prefix="/studies/{study_id}/imp/
api_router.include_router(imp_transactions.router, prefix="/studies/{study_id}/imp/transactions", tags=["imp-transactions"]) api_router.include_router(imp_transactions.router, prefix="/studies/{study_id}/imp/transactions", tags=["imp-transactions"])
api_router.include_router(finance.router, prefix="/studies/{study_id}/finance", tags=["finance"]) api_router.include_router(finance.router, prefix="/studies/{study_id}/finance", tags=["finance"])
api_router.include_router(finance_dashboard.router, prefix="/studies/{study_id}/finance", tags=["finance"]) api_router.include_router(finance_dashboard.router, prefix="/studies/{study_id}/finance", tags=["finance"])
api_router.include_router(faq_categories.router, prefix="/faqs/categories", tags=["faq-categories"])
api_router.include_router(faqs.router, prefix="/faqs/items", tags=["faqs"])
+58
View File
@@ -0,0 +1,58 @@
import uuid
from typing import Sequence
from sqlalchemy import select, update as sa_update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.faq_category import FaqCategory
from app.schemas.faq import CategoryCreate, CategoryUpdate
async def create_category(db: AsyncSession, category_in: CategoryCreate) -> FaqCategory:
category = FaqCategory(
study_id=category_in.study_id,
name=category_in.name,
description=category_in.description,
sort_order=category_in.sort_order,
is_active=category_in.is_active,
)
db.add(category)
await db.commit()
await db.refresh(category)
return category
async def list_categories(
db: AsyncSession,
study_id: uuid.UUID | None,
include_global: bool = True,
is_active: bool | None = True,
) -> Sequence[FaqCategory]:
stmt = select(FaqCategory)
if include_global:
stmt = stmt.where((FaqCategory.study_id == study_id) | (FaqCategory.study_id.is_(None)))
else:
stmt = stmt.where(FaqCategory.study_id == study_id)
if is_active is not None:
stmt = stmt.where(FaqCategory.is_active == is_active)
stmt = stmt.order_by(FaqCategory.sort_order, FaqCategory.name)
result = await db.execute(stmt)
return result.scalars().all()
async def get_category(db: AsyncSession, category_id: uuid.UUID) -> FaqCategory | None:
result = await db.execute(select(FaqCategory).where(FaqCategory.id == category_id))
return result.scalar_one_or_none()
async def update_category(db: AsyncSession, category: FaqCategory, category_in: CategoryUpdate) -> FaqCategory:
update_data = category_in.model_dump(exclude_unset=True)
if update_data:
await db.execute(
sa_update(FaqCategory)
.where(FaqCategory.id == category.id)
.values(**update_data)
)
await db.commit()
await db.refresh(category)
return category
+90
View File
@@ -0,0 +1,90 @@
import uuid
from typing import Sequence
from sqlalchemy import or_, select, update as sa_update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.faq_category import FaqCategory
from app.models.faq_item import FaqItem
from app.schemas.faq import FaqCreate, FaqUpdate
async def create_item(db: AsyncSession, item_in: FaqCreate, *, created_by: uuid.UUID) -> FaqItem:
# ensure category exists and matches study scope
category = await db.get(FaqCategory, item_in.category_id)
if not category:
raise ValueError("Category not found")
if category.study_id != item_in.study_id:
# allow global category if item is global (None) or project if matches
if not (category.study_id is None and item_in.study_id is None):
raise ValueError("Category scope mismatch")
item = FaqItem(
study_id=item_in.study_id,
category_id=item_in.category_id,
question=item_in.question,
answer=item_in.answer,
keywords=item_in.keywords,
version=1,
is_active=True,
created_by=created_by,
)
db.add(item)
await db.commit()
await db.refresh(item)
return item
async def get_item(db: AsyncSession, item_id: uuid.UUID) -> FaqItem | None:
result = await db.execute(select(FaqItem).where(FaqItem.id == item_id))
return result.scalar_one_or_none()
async def list_items(
db: AsyncSession,
*,
study_id: uuid.UUID | None,
category_id: uuid.UUID | None = None,
keyword: str | None = None,
is_active: bool | None = True,
study_scope: str | None = "project",
) -> Sequence[FaqItem]:
stmt = select(FaqItem).join(FaqCategory, FaqCategory.id == FaqItem.category_id)
if study_scope == "all":
pass
elif study_scope == "global":
stmt = stmt.where(FaqItem.study_id.is_(None))
else: # project (default)
stmt = stmt.where((FaqItem.study_id == study_id) | (FaqItem.study_id.is_(None)))
if category_id:
stmt = stmt.where(FaqItem.category_id == category_id)
if keyword:
like_pattern = f"%{keyword}%"
stmt = stmt.where(
or_(
FaqItem.question.ilike(like_pattern),
FaqItem.answer.ilike(like_pattern),
FaqItem.keywords.ilike(like_pattern),
)
)
if is_active is not None:
stmt = stmt.where(FaqItem.is_active == is_active)
result = await db.execute(stmt)
return result.scalars().all()
async def update_item(db: AsyncSession, item: FaqItem, item_in: FaqUpdate) -> FaqItem:
update_data = item_in.model_dump(exclude_unset=True)
if "question" in update_data or "answer" in update_data or "keywords" in update_data:
update_data["version"] = item.version + 1
if update_data:
await db.execute(
sa_update(FaqItem)
.where(FaqItem.id == item.id)
.values(**update_data)
)
await db.commit()
await db.refresh(item)
return item
+2
View File
@@ -21,3 +21,5 @@ from app.models.imp_batch import ImpBatch # noqa: F401
from app.models.imp_inventory import ImpInventory # noqa: F401 from app.models.imp_inventory import ImpInventory # noqa: F401
from app.models.imp_transaction import ImpTransaction # noqa: F401 from app.models.imp_transaction import ImpTransaction # noqa: F401
from app.models.finance import FinanceItem # noqa: F401 from app.models.finance import FinanceItem # noqa: F401
from app.models.faq_category import FaqCategory # noqa: F401
from app.models.faq_item import FaqItem # noqa: F401
+24
View File
@@ -0,0 +1,24 @@
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base_class import Base
class FaqCategory(Base):
__tablename__ = "faq_categories"
__table_args__ = (UniqueConstraint("study_id", "name", name="uq_faq_category_name"),)
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, index=True)
name: Mapped[str] = mapped_column(String(100), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
sort_order: Mapped[int] = mapped_column(nullable=False, default=0)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
+26
View File
@@ -0,0 +1,26 @@
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base_class import Base
class FaqItem(Base):
__tablename__ = "faq_items"
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, index=True)
category_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("faq_categories.id"), index=True, nullable=False)
question: Mapped[str] = mapped_column(Text, nullable=False)
answer: Mapped[str] = mapped_column(Text, nullable=False)
keywords: Mapped[str | None] = mapped_column(String(255), nullable=True)
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
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())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
+64
View File
@@ -0,0 +1,64 @@
import uuid
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict
class CategoryCreate(BaseModel):
study_id: Optional[uuid.UUID] = None
name: str
description: Optional[str] = None
sort_order: int = 0
is_active: bool = True
class CategoryUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
sort_order: Optional[int] = None
is_active: Optional[bool] = None
class CategoryRead(BaseModel):
id: uuid.UUID
study_id: Optional[uuid.UUID]
name: str
description: Optional[str]
sort_order: int
is_active: bool
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class FaqCreate(BaseModel):
study_id: Optional[uuid.UUID] = None
category_id: uuid.UUID
question: str
answer: str
keywords: Optional[str] = None
class FaqUpdate(BaseModel):
question: Optional[str] = None
answer: Optional[str] = None
keywords: Optional[str] = None
is_active: Optional[bool] = None
class FaqRead(BaseModel):
id: uuid.UUID
study_id: Optional[uuid.UUID]
category_id: uuid.UUID
question: str
answer: str
keywords: Optional[str]
version: int
is_active: bool
created_by: uuid.UUID
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
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.
View File
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.
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.