管理后台前后端逻辑、UI美化

This commit is contained in:
Cheng Zhou
2026-01-16 13:50:08 +08:00
parent 05c1f9579a
commit 7fdcfdaadd
82 changed files with 3210 additions and 549 deletions
+6
View File
@@ -24,11 +24,17 @@ async def _validate_site_subject(db: AsyncSession, study_id: uuid.UUID, site_id:
site = result.scalar_one_or_none()
if not site or site.study_id != study_id:
raise ValueError("分中心不属于当前项目")
if not site.is_active:
raise ValueError("中心已停用")
if subject_id:
result = await db.execute(select(Subject).where(Subject.id == subject_id))
subj = result.scalar_one_or_none()
if not subj or subj.study_id != study_id:
raise ValueError("参与者不属于当前项目")
result = await db.execute(select(Site).where(Site.id == subj.site_id))
subj_site = result.scalar_one_or_none()
if subj_site and not subj_site.is_active:
raise ValueError("中心已停用")
async def create_ae(
+4 -1
View File
@@ -43,7 +43,10 @@ async def list_contracts(
skip: int = 0,
limit: int = 100,
) -> Sequence[FinanceContract]:
stmt = select(FinanceContract).where(FinanceContract.study_id == study_id)
stmt = (
select(FinanceContract)
.where(FinanceContract.study_id == study_id)
)
if site_name:
stmt = stmt.where(FinanceContract.site_name.ilike(f"%{site_name}%"))
if contract_no:
+4 -1
View File
@@ -43,7 +43,10 @@ async def list_specials(
skip: int = 0,
limit: int = 100,
) -> Sequence[FinanceSpecial]:
stmt = select(FinanceSpecial).where(FinanceSpecial.study_id == study_id)
stmt = (
select(FinanceSpecial)
.where(FinanceSpecial.study_id == study_id)
)
if site_name:
stmt = stmt.where(FinanceSpecial.site_name.ilike(f"%{site_name}%"))
if fee_type:
+4 -1
View File
@@ -41,7 +41,10 @@ async def list_notes(
skip: int = 0,
limit: int = 100,
) -> Sequence[KnowledgeNote]:
stmt = select(KnowledgeNote).where(KnowledgeNote.study_id == study_id)
stmt = (
select(KnowledgeNote)
.where(KnowledgeNote.study_id == study_id)
)
if site_name:
stmt = stmt.where(KnowledgeNote.site_name.ilike(f"%{site_name}%"))
if keyword:
+2 -7
View File
@@ -1,7 +1,7 @@
import uuid
from typing import Sequence
from sqlalchemy import select, update
from sqlalchemy import delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.study_member import StudyMember
@@ -54,13 +54,8 @@ async def update_member(db: AsyncSession, member: StudyMember, member_in: StudyM
async def remove_member(db: AsyncSession, member: StudyMember) -> StudyMember:
await db.execute(
update(StudyMember)
.where(StudyMember.id == member.id)
.values(is_active=False)
)
await db.execute(delete(StudyMember).where(StudyMember.id == member.id))
await db.commit()
await db.refresh(member)
return member
+312 -6
View File
@@ -1,12 +1,42 @@
import uuid
from typing import Sequence
from pathlib import Path
from typing import Iterable, Sequence
from sqlalchemy import select, update
from sqlalchemy import delete, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.acknowledgement import Acknowledgement
from app.models.ae import AdverseEvent
from app.models.attachment import Attachment
from app.models.contract_fee import ContractFee
from app.models.contract_fee_payment import ContractFeePayment
from app.models.document import Document
from app.models.document_version import DocumentVersion
from app.models.distribution import Distribution
from app.models.drug_shipment import DrugShipment
from app.models.fee_attachment import FeeAttachment
from app.models.finance import FinanceItem
from app.models.finance_contract import FinanceContract
from app.models.finance_special import FinanceSpecial
from app.models.kickoff_meeting import KickoffMeeting
from app.models.knowledge_note import KnowledgeNote
from app.models.milestone import Milestone
from app.models.site import Site
from app.models.special_expense import SpecialExpense
from app.models.startup_ethics import StartupEthics
from app.models.startup_feasibility import StartupFeasibility
from app.models.subject import Subject
from app.models.subject_history import SubjectHistory
from app.models.training_authorization import TrainingAuthorization
from app.models.version_workflow import VersionWorkflow
from app.models.visit import Visit
from app.models.workflow_action import WorkflowAction
from app.schemas.site import SiteCreate, SiteUpdate
ATTACHMENT_ROOT = Path(__file__).resolve().parent.parent / "uploads"
FEE_ATTACHMENT_ROOT = ATTACHMENT_ROOT / "fees"
DOCUMENT_ATTACHMENT_ROOT = ATTACHMENT_ROOT / "documents"
async def create_site(db: AsyncSession, study_id: uuid.UUID, site_in: SiteCreate) -> Site:
site = Site(
@@ -41,10 +71,17 @@ async def update_site(db: AsyncSession, site: Site, site_in: SiteUpdate) -> Site
return site
async def list_by_study(db: AsyncSession, study_id: uuid.UUID, skip: int = 0, limit: int = 100) -> Sequence[Site]:
result = await db.execute(
select(Site).where(Site.study_id == study_id).offset(skip).limit(limit)
)
async def list_by_study(
db: AsyncSession,
study_id: uuid.UUID,
skip: int = 0,
limit: int = 100,
include_inactive: bool = False,
) -> Sequence[Site]:
stmt = select(Site).where(Site.study_id == study_id)
if not include_inactive:
stmt = stmt.where(Site.is_active.is_(True))
result = await db.execute(stmt.offset(skip).limit(limit))
return result.scalars().all()
@@ -54,3 +91,272 @@ async def get_sites_by_ids(db: AsyncSession, ids: set[uuid.UUID]) -> dict[uuid.U
result = await db.execute(select(Site).where(Site.id.in_(ids)))
sites = result.scalars().all()
return {s.id: s for s in sites}
async def list_active_names(db: AsyncSession, study_id: uuid.UUID) -> set[str]:
result = await db.execute(
select(Site.name).where(Site.study_id == study_id, Site.is_active.is_(True))
)
return {row[0] for row in result.all() if row[0]}
async def delete_site_and_related(db: AsyncSession, site: Site) -> None:
site_id = site.id
study_id = site.study_id
site_name = site.name
subject_ids = (
await db.execute(select(Subject.id).where(Subject.site_id == site_id))
).scalars().all()
contract_fee_ids = (
await db.execute(select(ContractFee.id).where(ContractFee.center_id == site_id))
).scalars().all()
special_expense_ids = (
await db.execute(select(SpecialExpense.id).where(SpecialExpense.center_id == site_id))
).scalars().all()
drug_shipment_ids = (
await db.execute(select(DrugShipment.id).where(DrugShipment.center_id == site_id))
).scalars().all()
kickoff_ids = (
await db.execute(select(KickoffMeeting.id).where(KickoffMeeting.site_id == site_id))
).scalars().all()
feasibility_ids = (
await db.execute(select(StartupFeasibility.id).where(StartupFeasibility.site_id == site_id))
).scalars().all()
ethics_ids = (
await db.execute(select(StartupEthics.id).where(StartupEthics.site_id == site_id))
).scalars().all()
training_ids = (
await db.execute(
select(TrainingAuthorization.id).where(
TrainingAuthorization.study_id == study_id,
TrainingAuthorization.site_name == site_name,
)
)
).scalars().all()
finance_contract_ids = (
await db.execute(
select(FinanceContract.id).where(
FinanceContract.study_id == study_id,
FinanceContract.site_name == site_name,
)
)
).scalars().all()
finance_special_ids = (
await db.execute(
select(FinanceSpecial.id).where(
FinanceSpecial.study_id == study_id,
FinanceSpecial.site_name == site_name,
)
)
).scalars().all()
knowledge_note_ids = (
await db.execute(
select(KnowledgeNote.id).where(
KnowledgeNote.study_id == study_id,
KnowledgeNote.site_name == site_name,
)
)
).scalars().all()
document_ids = (
await db.execute(select(Document.id).where(Document.site_id == site_id))
).scalars().all()
version_ids = []
if document_ids:
version_ids = (
await db.execute(select(DocumentVersion.id).where(DocumentVersion.document_id.in_(document_ids)))
).scalars().all()
workflow_ids = []
workflow_conditions = []
if document_ids:
workflow_conditions.append(VersionWorkflow.document_id.in_(document_ids))
if version_ids:
workflow_conditions.append(VersionWorkflow.version_id.in_(version_ids))
if workflow_conditions:
workflow_ids = (
await db.execute(select(VersionWorkflow.id).where(or_(*workflow_conditions)))
).scalars().all()
distribution_conditions = []
if document_ids:
distribution_conditions.append(Distribution.document_id.in_(document_ids))
if version_ids:
distribution_conditions.append(Distribution.version_id.in_(version_ids))
distribution_conditions.append((Distribution.target_type == "SITE") & (Distribution.target_id == str(site_id)))
distribution_ids = (
await db.execute(select(Distribution.id).where(or_(*distribution_conditions)))
).scalars().all()
attachment_groups: dict[str, list[uuid.UUID]] = {
"startup_feasibility": feasibility_ids,
"startup_ethics": ethics_ids,
"startup_kickoff": kickoff_ids,
"startup_kickoff_minutes": kickoff_ids,
"startup_kickoff_signin": kickoff_ids,
"startup_kickoff_ppt": kickoff_ids,
"training_authorization": training_ids,
"finance_contract": finance_contract_ids,
"finance_special": finance_special_ids,
"knowledge_note": knowledge_note_ids,
"drug_shipment": drug_shipment_ids,
}
attachment_paths: list[str] = []
for entity_type, ids in attachment_groups.items():
if not ids:
continue
attachment_paths.extend(
(
await db.execute(
select(Attachment.file_path).where(
Attachment.study_id == study_id,
Attachment.entity_type == entity_type,
Attachment.entity_id.in_(ids),
)
)
).scalars().all()
)
await db.execute(
delete(Attachment).where(
Attachment.study_id == study_id,
Attachment.entity_type == entity_type,
Attachment.entity_id.in_(ids),
)
)
fee_attachment_paths: list[str] = []
if contract_fee_ids:
fee_attachment_paths.extend(
(
await db.execute(
select(FeeAttachment.storage_key).where(
FeeAttachment.entity_type == "contract_fee",
FeeAttachment.entity_id.in_(contract_fee_ids),
)
)
).scalars().all()
)
await db.execute(
delete(FeeAttachment).where(
FeeAttachment.entity_type == "contract_fee",
FeeAttachment.entity_id.in_(contract_fee_ids),
)
)
await db.execute(delete(ContractFeePayment).where(ContractFeePayment.contract_fee_id.in_(contract_fee_ids)))
if special_expense_ids:
fee_attachment_paths.extend(
(
await db.execute(
select(FeeAttachment.storage_key).where(
FeeAttachment.entity_type == "special_expense",
FeeAttachment.entity_id.in_(special_expense_ids),
)
)
).scalars().all()
)
await db.execute(
delete(FeeAttachment).where(
FeeAttachment.entity_type == "special_expense",
FeeAttachment.entity_id.in_(special_expense_ids),
)
)
if workflow_ids:
await db.execute(delete(WorkflowAction).where(WorkflowAction.workflow_id.in_(workflow_ids)))
await db.execute(delete(VersionWorkflow).where(VersionWorkflow.id.in_(workflow_ids)))
if distribution_ids:
await db.execute(delete(Acknowledgement).where(Acknowledgement.distribution_id.in_(distribution_ids)))
await db.execute(delete(Distribution).where(Distribution.id.in_(distribution_ids)))
await db.execute(delete(Acknowledgement).where(Acknowledgement.site_id == site_id))
version_file_paths: list[str] = []
if version_ids:
version_file_paths = (
await db.execute(select(DocumentVersion.file_uri).where(DocumentVersion.id.in_(version_ids)))
).scalars().all()
await db.execute(delete(DocumentVersion).where(DocumentVersion.id.in_(version_ids)))
if document_ids:
await db.execute(delete(Document).where(Document.id.in_(document_ids)))
if subject_ids:
await db.execute(delete(SubjectHistory).where(SubjectHistory.subject_id.in_(subject_ids)))
await db.execute(delete(Visit).where(Visit.subject_id.in_(subject_ids)))
await db.execute(delete(AdverseEvent).where(AdverseEvent.subject_id.in_(subject_ids)))
await db.execute(delete(FinanceItem).where(FinanceItem.subject_id.in_(subject_ids)))
await db.execute(delete(AdverseEvent).where(AdverseEvent.site_id == site_id))
await db.execute(delete(FinanceItem).where(FinanceItem.site_id == site_id))
await db.execute(delete(Subject).where(Subject.site_id == site_id))
await db.execute(delete(Milestone).where(Milestone.site_id == site_id))
await db.execute(delete(KickoffMeeting).where(KickoffMeeting.site_id == site_id))
await db.execute(delete(StartupFeasibility).where(StartupFeasibility.site_id == site_id))
await db.execute(delete(StartupEthics).where(StartupEthics.site_id == site_id))
await db.execute(delete(ContractFee).where(ContractFee.center_id == site_id))
await db.execute(delete(SpecialExpense).where(SpecialExpense.center_id == site_id))
await db.execute(delete(DrugShipment).where(DrugShipment.center_id == site_id))
await db.execute(
delete(TrainingAuthorization).where(
TrainingAuthorization.study_id == study_id,
TrainingAuthorization.site_name == site_name,
)
)
await db.execute(
delete(FinanceContract).where(
FinanceContract.study_id == study_id,
FinanceContract.site_name == site_name,
)
)
await db.execute(
delete(FinanceSpecial).where(
FinanceSpecial.study_id == study_id,
FinanceSpecial.site_name == site_name,
)
)
await db.execute(
delete(KnowledgeNote).where(
KnowledgeNote.study_id == study_id,
KnowledgeNote.site_name == site_name,
)
)
await db.execute(delete(Site).where(Site.id == site_id))
await _remove_files(attachment_paths, ATTACHMENT_ROOT)
await _remove_files(fee_attachment_paths, FEE_ATTACHMENT_ROOT)
await _remove_files(version_file_paths, DOCUMENT_ATTACHMENT_ROOT)
await db.commit()
async def _remove_files(paths: Iterable[str | None], root: Path) -> None:
for raw in paths:
if not raw:
continue
file_path = Path(str(raw))
try:
if file_path.exists():
file_path.unlink()
except OSError:
pass
_prune_empty_parents(file_path.parent, root)
def _prune_empty_parents(start: Path, root: Path) -> None:
try:
root_resolved = root.resolve()
except OSError:
return
try:
current = start.resolve()
except OSError:
return
if root_resolved not in current.parents and current != root_resolved:
return
while True:
if current == root_resolved:
break
try:
current.rmdir()
except OSError:
break
current = current.parent
+132 -3
View File
@@ -1,7 +1,7 @@
import uuid
from typing import Sequence
from sqlalchemy import select, update as sa_update
from sqlalchemy import delete as sa_delete, select, update as sa_update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.study import Study
@@ -9,10 +9,22 @@ from app.schemas.study import StudyCreate, StudyUpdate
async def create(db: AsyncSession, study_in: StudyCreate, *, created_by: uuid.UUID | None = None) -> Study:
import uuid as uuid_lib # Import uuid to generate unique code if needed
code = study_in.code
if not code:
# Generate a unique code if not provided
# Use first 8 chars of a new UUID, uppercase
code = f"P-{str(uuid_lib.uuid4())[:8].upper()}"
study = Study(
code=study_in.code,
code=code,
name=study_in.name,
sponsor=study_in.sponsor,
# sponsor is removed from schema, but model has it. Set to None/empty or keep existing value if schema had it (which it doesn't now)
# If we removed it from schema, study_in.sponsor will fail if we try to access it via .sponsor attribute directly if it's not in the pydantic model anymore.
# Since we removed `sponsor` field from StudyCreate, `study_in` object won't have `sponsor` attribute unless we access dict or use default.
# However, the Study MODEL still has sponsor. We should set it to None.
sponsor=None,
protocol_no=study_in.protocol_no,
phase=study_in.phase,
status=study_in.status,
@@ -73,3 +85,120 @@ async def list_studies_for_user(
)
result = await db.execute(stmt)
return result.scalars().all()
async def delete(db: AsyncSession, study_id: uuid.UUID) -> None:
"""硬删除项目及其所有关联数据"""
# 导入所有关联的模型
from app.models.study_member import StudyMember
from app.models.audit_log import AuditLog
from app.models.site import Site
from app.models.subject import Subject
from app.models.subject_history import SubjectHistory
from app.models.visit import Visit
from app.models.ae import AdverseEvent
from app.models.finance import FinanceItem
from app.models.finance_contract import FinanceContract
from app.models.finance_special import FinanceSpecial
from app.models.contract_fee import ContractFee
from app.models.special_expense import SpecialExpense
from app.models.milestone import Milestone
from app.models.document import Document
from app.models.attachment import Attachment
from app.models.drug_shipment import DrugShipment
from app.models.training_authorization import TrainingAuthorization
from app.models.startup_feasibility import StartupFeasibility
from app.models.startup_ethics import StartupEthics
from app.models.kickoff_meeting import KickoffMeeting
from app.models.knowledge_note import KnowledgeNote
from app.models.faq_category import FaqCategory
from app.models.faq_item import FaqItem
from app.models.faq_reply import FaqReply
from app.models.workflow_template import WorkflowTemplate
# 按依赖关系顺序删除关联数据
# 1. 删除审计日志
await db.execute(sa_delete(AuditLog).where(AuditLog.study_id == study_id))
# 2. 删除FAQ相关(可能有依赖关系)
await db.execute(sa_delete(FaqReply).where(FaqReply.study_id == study_id))
await db.execute(sa_delete(FaqItem).where(FaqItem.study_id == study_id))
await db.execute(sa_delete(FaqCategory).where(FaqCategory.study_id == study_id))
# 3. 删除知识库
await db.execute(sa_delete(KnowledgeNote).where(KnowledgeNote.study_id == study_id))
# 4. 删除工作流模板
await db.execute(sa_delete(WorkflowTemplate).where(WorkflowTemplate.trial_id == study_id))
# 5. 删除启动相关
await db.execute(sa_delete(KickoffMeeting).where(KickoffMeeting.study_id == study_id))
await db.execute(sa_delete(StartupEthics).where(StartupEthics.study_id == study_id))
await db.execute(sa_delete(StartupFeasibility).where(StartupFeasibility.study_id == study_id))
await db.execute(sa_delete(TrainingAuthorization).where(TrainingAuthorization.study_id == study_id))
# 6. 删除药物配送
await db.execute(sa_delete(DrugShipment).where(DrugShipment.study_id == study_id))
# 7. 删除附件和文档
await db.execute(sa_delete(Attachment).where(Attachment.study_id == study_id))
await db.execute(sa_delete(Document).where(Document.trial_id == study_id))
# 8. 删除里程碑
await db.execute(sa_delete(Milestone).where(Milestone.study_id == study_id))
# 9. 删除财务相关
await db.execute(sa_delete(SpecialExpense).where(SpecialExpense.project_id == study_id))
await db.execute(sa_delete(ContractFee).where(ContractFee.project_id == study_id))
await db.execute(sa_delete(FinanceSpecial).where(FinanceSpecial.study_id == study_id))
await db.execute(sa_delete(FinanceContract).where(FinanceContract.study_id == study_id))
await db.execute(sa_delete(FinanceItem).where(FinanceItem.study_id == study_id))
# 10. 删除不良事件
await db.execute(sa_delete(AdverseEvent).where(AdverseEvent.study_id == study_id))
# 11. 删除访视
await db.execute(sa_delete(Visit).where(Visit.study_id == study_id))
# 12. 删除受试者相关
await db.execute(sa_delete(SubjectHistory).where(SubjectHistory.study_id == study_id))
await db.execute(sa_delete(Subject).where(Subject.study_id == study_id))
# 13. 删除站点
await db.execute(sa_delete(Site).where(Site.study_id == study_id))
# 14. 删除成员
await db.execute(sa_delete(StudyMember).where(StudyMember.study_id == study_id))
# 15. 最后删除项目本身
await db.execute(sa_delete(Study).where(Study.id == study_id))
await db.commit()
async def lock(db: AsyncSession, study_id: uuid.UUID) -> Study | None:
"""锁定项目"""
await db.execute(
sa_update(Study)
.where(Study.id == study_id)
.values(is_locked=True)
)
await db.commit()
return await get(db, study_id)
async def unlock(db: AsyncSession, study_id: uuid.UUID) -> Study | None:
"""解锁项目"""
await db.execute(
sa_update(Study)
.where(Study.id == study_id)
.values(is_locked=False)
)
await db.commit()
return await get(db, study_id)
async def is_locked(db: AsyncSession, study_id: uuid.UUID) -> bool:
"""检查项目是否已锁定"""
study = await get(db, study_id)
return study.is_locked if study else False
+2 -6
View File
@@ -18,6 +18,8 @@ async def _validate_site(db: AsyncSession, study_id: uuid.UUID, site_id: uuid.UU
site = result.scalar_one_or_none()
if not site or site.study_id != study_id:
raise ValueError("分中心不属于当前项目")
if not site.is_active:
raise ValueError("中心已停用")
async def create_subject(db: AsyncSession, study_id: uuid.UUID, subject_in: SubjectCreate) -> Subject:
@@ -58,16 +60,10 @@ async def list_subjects(
db: AsyncSession,
study_id: uuid.UUID,
site_id: uuid.UUID | None = None,
status: str | None = None,
subject_no: str | None = None,
) -> Sequence[Subject]:
stmt = select(Subject).where(Subject.study_id == study_id)
if site_id:
stmt = stmt.where(Subject.site_id == site_id)
if status:
stmt = stmt.where(Subject.status == status)
if subject_no:
stmt = stmt.where(Subject.subject_no.ilike(f"%{subject_no}%"))
result = await db.execute(stmt)
return result.scalars().all()