Step 11:百问百答(FAQ / Knowledge Base)
This commit is contained in:
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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"])
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
|
)
|
||||||
@@ -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.
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user