feat(faq): refine category icons and project faq flows
This commit is contained in:
@@ -42,6 +42,10 @@ async def create_category(
|
||||
existing = await category_crud.get_category_by_name(db, payload.study_id, payload.name)
|
||||
if existing:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类名称已存在")
|
||||
if payload.icon:
|
||||
dup = await category_crud.get_category_by_icon(db, payload.study_id, payload.icon)
|
||||
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,
|
||||
@@ -65,13 +69,12 @@ async def create_category(
|
||||
)
|
||||
async def list_categories(
|
||||
study_id: uuid.UUID | None = None,
|
||||
is_active: bool | None = True,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> list[CategoryRead]:
|
||||
if not study_id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="必须提供项目 ID")
|
||||
categories = await category_crud.list_categories(db, study_id, include_global=False, is_active=is_active)
|
||||
categories = await category_crud.list_categories(db, study_id, include_global=False)
|
||||
return paginate([CategoryRead.model_validate(c) for c in categories], total=len(categories))
|
||||
|
||||
|
||||
@@ -102,6 +105,10 @@ async def update_category(
|
||||
existing = await category_crud.get_category_by_name(db, target_study_id, target_name)
|
||||
if existing and existing.id != category.id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类名称已存在")
|
||||
if payload.icon:
|
||||
dup = await category_crud.get_category_by_icon(db, target_study_id, payload.icon)
|
||||
if dup and dup.id != category.id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="该图标已被其他分类使用")
|
||||
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)
|
||||
|
||||
@@ -90,7 +90,6 @@ async def list_faqs(
|
||||
study_id: uuid.UUID | None = None,
|
||||
category_id: uuid.UUID | None = None,
|
||||
keyword: str | None = None,
|
||||
is_active: bool | None = None,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> list[FaqRead]:
|
||||
@@ -111,17 +110,12 @@ async def list_faqs(
|
||||
member = await member_crud.get_member(db, study_id, current_user.id)
|
||||
if not member or not member.is_active:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员")
|
||||
if is_active is False:
|
||||
allowed = await role_has_api_permission(db, study_id, member.role_in_study, "faq:update", check_prerequisites=False)
|
||||
if not allowed:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
|
||||
|
||||
items = await faq_crud.list_items(
|
||||
db,
|
||||
study_id=study_id,
|
||||
category_id=category_id,
|
||||
keyword=keyword,
|
||||
is_active=is_active,
|
||||
study_scope="project",
|
||||
)
|
||||
|
||||
@@ -131,10 +125,6 @@ async def list_faqs(
|
||||
role = await _get_member_role(it.study_id)
|
||||
if not role:
|
||||
continue
|
||||
if not it.is_active and not _is_system_admin(current_user):
|
||||
allowed = await role_has_api_permission(db, it.study_id, role, "faq:update", check_prerequisites=False)
|
||||
if not allowed:
|
||||
continue
|
||||
visible.append(FaqRead.model_validate(it))
|
||||
return paginate(visible, total=len(visible))
|
||||
|
||||
@@ -156,13 +146,6 @@ async def get_faq(
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
|
||||
if not item.study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
|
||||
if not item.is_active and not is_system_admin(current_user):
|
||||
member = await member_crud.get_member(db, item.study_id, current_user.id)
|
||||
if not member or not member.is_active:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员")
|
||||
allowed = await role_has_api_permission(db, item.study_id, member.role_in_study, "faq:update", check_prerequisites=False)
|
||||
if not allowed:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
|
||||
return FaqRead.model_validate(item)
|
||||
|
||||
|
||||
@@ -185,13 +168,9 @@ async def update_faq(
|
||||
item = await faq_crud.get_item(db, item_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
|
||||
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,
|
||||
|
||||
@@ -14,7 +14,7 @@ async def create_category(db: AsyncSession, category_in: CategoryCreate) -> FaqC
|
||||
name=category_in.name,
|
||||
description=category_in.description,
|
||||
sort_order=category_in.sort_order,
|
||||
is_active=category_in.is_active,
|
||||
icon=category_in.icon,
|
||||
)
|
||||
db.add(category)
|
||||
await db.commit()
|
||||
@@ -26,15 +26,12 @@ 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()
|
||||
@@ -59,6 +56,22 @@ async def get_category_by_name(
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_category_by_icon(
|
||||
db: AsyncSession,
|
||||
study_id: uuid.UUID | None,
|
||||
icon: str,
|
||||
) -> FaqCategory | None:
|
||||
if not icon:
|
||||
return None
|
||||
stmt = select(FaqCategory).where(FaqCategory.icon == icon)
|
||||
if study_id is None:
|
||||
stmt = stmt.where(FaqCategory.study_id.is_(None))
|
||||
else:
|
||||
stmt = stmt.where(FaqCategory.study_id == study_id)
|
||||
result = await db.execute(stmt)
|
||||
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:
|
||||
|
||||
@@ -26,7 +26,6 @@ async def create_item(db: AsyncSession, item_in: FaqCreate, *, created_by: uuid.
|
||||
answer=item_in.answer or "",
|
||||
status="PENDING",
|
||||
version=1,
|
||||
is_active=True,
|
||||
created_by=created_by,
|
||||
)
|
||||
db.add(item)
|
||||
@@ -46,7 +45,6 @@ async def list_items(
|
||||
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)
|
||||
@@ -69,8 +67,6 @@ async def list_items(
|
||||
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()
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import Optional
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
@@ -20,7 +20,7 @@ class FaqCategory(Base):
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
description: Mapped[Optional[str]] = 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)
|
||||
icon: Mapped[Optional[str]] = mapped_column(String(30), nullable=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,7 +26,6 @@ class FaqItem(Base):
|
||||
answer: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
keywords: Mapped[Optional[str]] = 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(
|
||||
|
||||
@@ -10,7 +10,7 @@ class CategoryCreate(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
sort_order: int = 0
|
||||
is_active: bool = True
|
||||
icon: Optional[str] = None
|
||||
|
||||
|
||||
class CategoryUpdate(BaseModel):
|
||||
@@ -18,7 +18,7 @@ class CategoryUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
sort_order: Optional[int] = None
|
||||
is_active: Optional[bool] = None
|
||||
icon: Optional[str] = None
|
||||
|
||||
|
||||
class CategoryRead(BaseModel):
|
||||
@@ -27,7 +27,7 @@ class CategoryRead(BaseModel):
|
||||
name: str
|
||||
description: Optional[str]
|
||||
sort_order: int
|
||||
is_active: bool
|
||||
icon: Optional[str]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@@ -44,7 +44,6 @@ class FaqCreate(BaseModel):
|
||||
class FaqUpdate(BaseModel):
|
||||
question: Optional[str] = None
|
||||
answer: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class FaqStatusUpdate(BaseModel):
|
||||
@@ -94,7 +93,6 @@ class FaqRead(BaseModel):
|
||||
answer: str
|
||||
keywords: Optional[str]
|
||||
version: int
|
||||
is_active: bool
|
||||
created_by: uuid.UUID
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
Reference in New Issue
Block a user