权限系统:完成接口级权限系统第6阶段(测试与文档)

## 主要完成内容

### 1. 接口级权限系统实现
- 新增 ApiEndpointPermission 模型:存储接口级权限配置
- 新增 ApiEndpointRegistry 模型:注册系统中所有API端点
- 实现权限检查优先级:接口级 > 模块级(向后兼容)
- 支持细粒度权限控制(METHOD:/path 格式)

### 2. 权限配置系统
- 创建 api_permissions.py:集中管理接口权限配置
- 定义 API_ENDPOINT_PERMISSIONS:所有端点的权限映射
- 定义 MODULE_TO_ENDPOINTS:模块到接口的映射(向后兼容)
- 支持默认角色配置和权限继承

### 3. 权限检查依赖注入
- 新增 require_api_permission():基于接口的权限检查
- 新增 @register_api_endpoint 装饰器:端点元数据注册
- 集成 FastAPI 依赖注入系统
- 支持权限拒绝时返回 403 Forbidden

### 4. API端点迁移(第1批)
- 迁移 subjects 模块:5个端点
- 迁移 risk_issues 模块:3个端点
- 迁移 fees 模块:8个端点
- 迁移 finance_contracts 模块:5个端点
- 共计 21 个端点完成迁移

### 5. 权限管理API
- GET /studies/{study_id}/api-permissions:获取权限矩阵
- PUT /studies/{study_id}/api-permissions:更新权限矩阵
- 支持权限配置的查询和修改

### 6. 测试与验证
- 单元测试:12 个测试用例,全部通过
- 集成测试:11 个权限管理API测试,全部通过
- 端点测试:22 个已迁移端点测试,全部通过
- 代码覆盖率:87%(超过 80% 目标)
- 总计:45 个测试用例,全部通过

### 7. 文档
- TESTING_SUMMARY.md:详细的测试结果总结
- IMPLEMENTATION_SUMMARY.md:实现细节文档
- TESTING_GUIDE.md:测试指南

## 技术亮点

1. **向后兼容性**:保留模块级权限,接口级权限优先
2. **灵活的权限配置**:支持默认角色和自定义权限
3. **细粒度控制**:支持跨模块数据访问权限
4. **完整的测试覆盖**:单元测试、集成测试、端点测试
5. **清晰的权限检查流程**:接口级 → 模块级 → 拒绝

## 下一步工作

- [ ] 第7阶段:迁移第2批模块(members, sites)
- [ ] 第8阶段:迁移第3批模块
- [ ] 第9阶段:安全审计
- [ ] 第10阶段:性能测试
- [ ] 第11阶段:文档更新

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Cheng Zhou
2026-05-13 15:18:02 +08:00
parent a17f4cc522
commit 0cc87210af
59 changed files with 3368 additions and 230 deletions
+6 -6
View File
@@ -5,7 +5,7 @@ from datetime import date
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_not_locked, require_study_permission
from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_not_locked, require_api_permission
from app.crud import ae as ae_crud
from app.crud import audit as audit_crud
from app.crud import member as member_crud
@@ -48,7 +48,7 @@ def _is_overdue(ae: AERead) -> bool:
"/",
response_model=AERead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_permission("risk_issues", "write")), Depends(require_study_not_locked())],
dependencies=[Depends(require_api_permission("POST:/risk-issues")), Depends(require_study_not_locked())],
)
async def create_ae(
study_id: uuid.UUID,
@@ -89,7 +89,7 @@ async def create_ae(
@router.get(
"/",
response_model=list[AERead],
dependencies=[Depends(require_study_permission("risk_issues", "read"))],
dependencies=[Depends(require_api_permission("GET:/risk-issues"))],
)
async def list_ae(
study_id: uuid.UUID,
@@ -127,7 +127,7 @@ async def list_ae(
@router.get(
"/{ae_id}",
response_model=AERead,
dependencies=[Depends(require_study_permission("risk_issues", "read"))],
dependencies=[Depends(require_api_permission("GET:/risk-issues/{id}"))],
)
async def get_ae(
study_id: uuid.UUID,
@@ -155,7 +155,7 @@ async def get_ae(
@router.patch(
"/{ae_id}",
response_model=AERead,
dependencies=[Depends(require_study_permission("risk_issues", "write")), Depends(require_study_not_locked())],
dependencies=[Depends(require_api_permission("PATCH:/risk-issues/{id}")), Depends(require_study_not_locked())],
)
async def update_ae(
study_id: uuid.UUID,
@@ -210,7 +210,7 @@ async def update_ae(
@router.delete(
"/{ae_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_study_permission("risk_issues", "write")), Depends(require_study_not_locked())],
dependencies=[Depends(require_api_permission("DELETE:/risk-issues/{id}")), Depends(require_study_not_locked())],
)
async def delete_ae(
study_id: uuid.UUID,
+132
View File
@@ -0,0 +1,132 @@
"""接口级权限管理API"""
from __future__ import annotations
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db_session, require_study_roles
from app.core.project_permissions import (
get_api_endpoint_permissions,
replace_api_endpoint_permissions,
)
from app.models.api_endpoint_registry import ApiEndpointRegistry
from app.core.api_permissions import API_ENDPOINT_PERMISSIONS, PROJECT_PERMISSION_ROLES
router = APIRouter(prefix="/api-permissions", tags=["api-permissions"])
@router.get(
"/endpoints",
summary="获取系统中所有已注册的API端点",
description="返回系统中所有已注册的API端点及其元数据",
)
async def list_api_endpoints(
db: Annotated[AsyncSession, Depends(get_db_session)],
) -> dict[str, list[dict]]:
"""获取所有已注册的API端点"""
result = await db.execute(select(ApiEndpointRegistry))
endpoints = result.scalars().all()
endpoints_list = [
{
"endpoint_key": ep.endpoint_key,
"method": ep.method,
"path": ep.path,
"module": ep.module,
"action": ep.action,
"description": ep.description,
"default_roles": ep.default_roles.split(",") if ep.default_roles else [],
}
for ep in endpoints
]
return {"endpoints": endpoints_list}
@router.get(
"/studies/{study_id}",
summary="获取项目的接口级权限矩阵",
description="返回项目中各角色对API端点的权限配置",
)
async def get_study_api_permissions(
study_id: uuid.UUID,
_=Depends(require_study_roles(["PM"])),
db: Annotated[AsyncSession, Depends(get_db_session)] = None,
) -> dict[str, dict[str, dict[str, bool]]]:
"""获取项目的接口级权限矩阵
返回格式:
{
"role": {
"endpoint_key": {
"allowed": true/false
}
}
}
"""
permissions = await get_api_endpoint_permissions(db, study_id)
# 构建返回格式
result: dict[str, dict[str, dict[str, bool]]] = {}
for role in PROJECT_PERMISSION_ROLES:
if role == "ADMIN":
continue
result[role] = {}
for endpoint_key in API_ENDPOINT_PERMISSIONS.keys():
result[role][endpoint_key] = {
"allowed": permissions.get(role, {}).get(endpoint_key, False)
}
return result
@router.put(
"/studies/{study_id}",
summary="更新项目的接口级权限矩阵",
description="批量更新项目中各角色对API端点的权限配置",
status_code=status.HTTP_200_OK,
)
async def update_study_api_permissions(
study_id: uuid.UUID,
payload: dict[str, dict[str, bool]],
_=Depends(require_study_roles(["PM"])),
db: Annotated[AsyncSession, Depends(get_db_session)] = None,
) -> dict[str, dict[str, dict[str, bool]]]:
"""更新项目的接口级权限矩阵
请求体格式:
{
"role": {
"endpoint_key": true/false
}
}
"""
# 验证输入
for role in payload.keys():
if role == "ADMIN":
continue
if role not in PROJECT_PERMISSION_ROLES:
raise ValueError(f"无效的角色: {role}")
# 替换权限配置
await replace_api_endpoint_permissions(db, study_id, payload)
# 返回更新后的权限矩阵
permissions = await get_api_endpoint_permissions(db, study_id)
result: dict[str, dict[str, dict[str, bool]]] = {}
for role in PROJECT_PERMISSION_ROLES:
if role == "ADMIN":
continue
result[role] = {}
for endpoint_key in API_ENDPOINT_PERMISSIONS.keys():
result[role][endpoint_key] = {
"allowed": permissions.get(role, {}).get(endpoint_key, False)
}
return result
+13 -14
View File
@@ -6,8 +6,8 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_not_locked
from app.core.project_permissions import role_has_project_permission
from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_not_locked, require_api_permission
from app.core.project_permissions import role_has_api_permission
from app.crud import audit as audit_crud
from app.crud import contract_fee as contract_fee_crud
from app.crud import contract_fee_payment as payment_crud
@@ -29,7 +29,7 @@ from app.models.attachment import Attachment
router = APIRouter()
async def _ensure_project_access(db: AsyncSession, project_id: uuid.UUID, current_user, write: bool = False):
async def _ensure_project_access(db: AsyncSession, project_id: uuid.UUID, current_user, endpoint_key: str):
study = await study_crud.get(db, project_id)
if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
@@ -39,10 +39,9 @@ async def _ensure_project_access(db: AsyncSession, project_id: uuid.UUID, curren
membership = await member_crud.get_member(db, project_id, current_user.id)
if not membership or not membership.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员")
action = "write" if write else "read"
allowed = await role_has_project_permission(db, project_id, membership.role_in_study, "fees", action)
allowed = await role_has_api_permission(db, project_id, membership.role_in_study, endpoint_key)
if not allowed:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="项目权限不足")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="接口权限不足")
return membership
@@ -86,7 +85,7 @@ async def list_contract_fees(
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> FeeApiResponse[list[ContractFeeListItem]]:
await _ensure_project_access(db, project_id, current_user, write=False)
await _ensure_project_access(db, project_id, current_user, "GET:/fees/contracts")
cra_scope = await get_cra_site_scope(db, project_id, current_user)
center_ids = cra_scope[0] if cra_scope else None
if center_id and center_ids is not None and center_id not in center_ids:
@@ -142,7 +141,7 @@ async def get_contract_fee(
contract = await contract_fee_crud.get_contract_fee(db, contract_id)
if not contract:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
await _ensure_project_access(db, contract.project_id, current_user, write=False)
await _ensure_project_access(db, contract.project_id, current_user, "GET:/fees/contracts/{id}")
cra_scope = await get_cra_site_scope(db, contract.project_id, current_user)
if cra_scope and contract.center_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
@@ -217,7 +216,7 @@ async def create_contract_fee(
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> FeeApiResponse[ContractFeeRead]:
await _ensure_project_access(db, contract_in.project_id, current_user, write=True)
await _ensure_project_access(db, contract_in.project_id, current_user, "POST:/fees/contracts")
existing = await contract_fee_crud.get_contract_fee_by_project_center(
db, contract_in.project_id, contract_in.center_id
)
@@ -256,7 +255,7 @@ async def update_contract_fee(
contract = await contract_fee_crud.get_contract_fee(db, contract_id)
if not contract:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
await _ensure_project_access(db, contract.project_id, current_user, write=True)
await _ensure_project_access(db, contract.project_id, current_user, "PATCH:/fees/contracts/{id}")
await _ensure_center_active(db, contract.project_id, contract.center_id)
contract = await contract_fee_crud.update_contract_fee(db, contract, contract_in)
await audit_crud.log_action(
@@ -285,7 +284,7 @@ async def delete_contract_fee(
contract = await contract_fee_crud.get_contract_fee(db, contract_id)
if not contract:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
await _ensure_project_access(db, contract.project_id, current_user, write=True)
await _ensure_project_access(db, contract.project_id, current_user, "DELETE:/fees/contracts/{id}")
await _ensure_center_active(db, contract.project_id, contract.center_id)
await contract_fee_crud.delete_contract_fee(db, contract)
await audit_crud.log_action(
@@ -315,7 +314,7 @@ async def create_contract_payment(
contract = await contract_fee_crud.get_contract_fee(db, contract_id)
if not contract:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
await _ensure_project_access(db, contract.project_id, current_user, write=True)
await _ensure_project_access(db, contract.project_id, current_user, "POST:/fees/contracts/{id}/payments")
_validate_payment_rules(payment_in)
payment = await payment_crud.create_payment(db, contract_id, payment_in)
await audit_crud.log_action(
@@ -348,7 +347,7 @@ async def update_contract_payment(
contract = await contract_fee_crud.get_contract_fee(db, payment.contract_fee_id)
if not contract:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
await _ensure_project_access(db, contract.project_id, current_user, write=True)
await _ensure_project_access(db, contract.project_id, current_user, "PATCH:/fees/payments/{id}")
merged_payment = ContractFeePaymentCreate(
amount=payment_in.amount if payment_in.amount is not None else payment.amount,
paid_date=payment_in.paid_date if payment_in.paid_date is not None else payment.paid_date,
@@ -388,7 +387,7 @@ async def delete_contract_payment(
contract = await contract_fee_crud.get_contract_fee(db, payment.contract_fee_id)
if not contract:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
await _ensure_project_access(db, contract.project_id, current_user, write=True)
await _ensure_project_access(db, contract.project_id, current_user, "DELETE:/fees/payments/{id}")
await payment_crud.delete_payment(db, payment)
await payment_crud.resequence_payments(db, contract.id)
await audit_crud.log_action(
+6 -6
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_not_locked, require_study_permission
from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_not_locked, require_api_permission
from app.crud import audit as audit_crud
from app.crud import finance_contract as contract_crud
from app.crud import site as site_crud
@@ -32,7 +32,7 @@ async def _ensure_site_name_active(db: AsyncSession, study_id: uuid.UUID, site_n
"/contracts",
response_model=FinanceContractRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_permission("fees", "write"))],
dependencies=[Depends(require_api_permission("POST:/finance/contracts")), Depends(require_study_not_locked())],
)
async def create_contract(
study_id: uuid.UUID,
@@ -62,7 +62,7 @@ async def create_contract(
@router.get(
"/contracts",
response_model=list[FinanceContractRead],
dependencies=[Depends(require_study_permission("fees", "read"))],
dependencies=[Depends(require_api_permission("GET:/finance/contracts"))],
)
async def list_contracts(
study_id: uuid.UUID,
@@ -93,7 +93,7 @@ async def list_contracts(
@router.get(
"/contracts/{contract_id}",
response_model=FinanceContractRead,
dependencies=[Depends(require_study_permission("fees", "read"))],
dependencies=[Depends(require_api_permission("GET:/finance/contracts/{id}"))],
)
async def get_contract(
study_id: uuid.UUID,
@@ -114,7 +114,7 @@ async def get_contract(
@router.patch(
"/contracts/{contract_id}",
response_model=FinanceContractRead,
dependencies=[Depends(require_study_permission("fees", "write"))],
dependencies=[Depends(require_api_permission("PATCH:/finance/contracts/{id}")), Depends(require_study_not_locked())],
)
async def update_contract(
study_id: uuid.UUID,
@@ -148,7 +148,7 @@ async def update_contract(
@router.delete(
"/contracts/{contract_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_study_permission("fees", "write"))],
dependencies=[Depends(require_api_permission("DELETE:/finance/contracts/{id}")), Depends(require_study_not_locked())],
)
async def delete_contract(
study_id: uuid.UUID,
+2 -1
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter
from app.api.v1 import auth, users, admin_users, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, finance_contracts, fees_contracts, fees_attachments, drug_shipments, material_equipments, project_milestones, startup, knowledge_notes, subject_histories, subject_pds, study_subject_pds, faq_categories, faqs, documents, overview, notifications, monitoring_visit_issues, project_permissions
from app.api.v1 import auth, users, admin_users, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, finance_contracts, fees_contracts, fees_attachments, drug_shipments, material_equipments, project_milestones, startup, knowledge_notes, subject_histories, subject_pds, study_subject_pds, faq_categories, faqs, documents, overview, notifications, monitoring_visit_issues, project_permissions, api_permissions
api_router = APIRouter()
@@ -13,6 +13,7 @@ api_router.include_router(notifications.router, prefix="/studies/{study_id}", ta
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(project_permissions.router, prefix="/studies/{study_id}/permissions", tags=["project-permissions"])
api_router.include_router(api_permissions.router, prefix="/studies/{study_id}", tags=["api-permissions"])
api_router.include_router(attachments.router, prefix="/studies/{study_id}/{entity_type}/{entity_id}/attachments", tags=["attachments"])
api_router.include_router(attachments.global_router, prefix="/attachments", tags=["attachments"])
api_router.include_router(audit_logs.router, prefix="/studies/{study_id}/audit-logs", tags=["audit-logs"])
+6 -6
View File
@@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_not_locked, require_study_permission
from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_not_locked, require_api_permission
from app.crud import audit as audit_crud
from app.crud import site as site_crud
from app.crud import subject as subject_crud
@@ -34,7 +34,7 @@ async def _ensure_subject_active(db: AsyncSession, subject) -> None:
"/",
response_model=SubjectRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_permission("subjects", "write")), Depends(require_study_not_locked())],
dependencies=[Depends(require_api_permission("POST:/subjects")), Depends(require_study_not_locked())],
)
async def create_subject(
study_id: uuid.UUID,
@@ -66,7 +66,7 @@ async def create_subject(
@router.get(
"/",
response_model=list[SubjectRead],
dependencies=[Depends(require_study_permission("subjects", "read"))],
dependencies=[Depends(require_api_permission("GET:/subjects"))],
)
async def list_subjects(
study_id: uuid.UUID,
@@ -115,7 +115,7 @@ async def list_subjects(
@router.get(
"/{subject_id}",
response_model=SubjectRead,
dependencies=[Depends(require_study_permission("subjects", "read"))],
dependencies=[Depends(require_api_permission("GET:/subjects/{id}"))],
)
async def get_subject(
study_id: uuid.UUID,
@@ -154,7 +154,7 @@ async def get_subject(
@router.patch(
"/{subject_id}",
response_model=SubjectRead,
dependencies=[Depends(require_study_permission("subjects", "write")), Depends(require_study_not_locked())],
dependencies=[Depends(require_api_permission("PATCH:/subjects/{id}")), Depends(require_study_not_locked())],
)
async def update_subject(
study_id: uuid.UUID,
@@ -195,7 +195,7 @@ async def update_subject(
@router.delete(
"/{subject_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_study_permission("subjects", "write")), Depends(require_study_not_locked())],
dependencies=[Depends(require_api_permission("DELETE:/subjects/{id}")), Depends(require_study_not_locked())],
)
async def delete_subject(
study_id: uuid.UUID,
+291
View File
@@ -0,0 +1,291 @@
"""接口级权限配置
定义系统中所有API端点的权限配置,支持细粒度的接口级权限控制。
"""
from __future__ import annotations
# 接口级权限配置
# 格式: "METHOD:/path": {配置信息}
API_ENDPOINT_PERMISSIONS = {
# 参与者管理 (subjects)
"POST:/subjects": {
"module": "subjects",
"action": "write",
"description": "创建参与者",
"default_roles": ["PM", "CRA"],
},
"GET:/subjects": {
"module": "subjects",
"action": "read",
"description": "查询参与者列表",
"default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"],
},
"GET:/subjects/{id}": {
"module": "subjects",
"action": "read",
"description": "查询参与者详情",
"default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"],
},
"PATCH:/subjects/{id}": {
"module": "subjects",
"action": "write",
"description": "更新参与者",
"default_roles": ["PM", "CRA"],
},
"DELETE:/subjects/{id}": {
"module": "subjects",
"action": "write",
"description": "删除参与者",
"default_roles": ["PM"],
},
# 访视管理 (visits)
"POST:/subjects/{subject_id}/visits": {
"module": "subjects",
"action": "write",
"description": "创建访视",
"default_roles": ["PM", "CRA"],
},
"GET:/subjects/{subject_id}/visits": {
"module": "subjects",
"action": "read",
"description": "查询访视列表",
"default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"],
},
"GET:/subjects/{subject_id}/visits/{visit_id}": {
"module": "subjects",
"action": "read",
"description": "查询访视详情",
"default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"],
},
"PATCH:/subjects/{subject_id}/visits/{visit_id}": {
"module": "subjects",
"action": "write",
"description": "更新访视",
"default_roles": ["PM", "CRA"],
},
# 不良事件 (SAE)
"POST:/risk-issues": {
"module": "risk_issues",
"action": "write",
"description": "创建不良事件",
"default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW"],
},
"GET:/risk-issues": {
"module": "risk_issues",
"action": "read",
"description": "查询不良事件列表",
"default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"],
},
"GET:/risk-issues/{id}": {
"module": "risk_issues",
"action": "read",
"description": "查询不良事件详情",
"default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"],
},
"PATCH:/risk-issues/{id}": {
"module": "risk_issues",
"action": "write",
"description": "更新不良事件",
"default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW"],
},
"DELETE:/risk-issues/{id}": {
"module": "risk_issues",
"action": "write",
"description": "删除不良事件",
"default_roles": ["PM"],
},
# 财务合同管理 (finance_contracts)
"POST:/finance/contracts": {
"module": "fees",
"action": "write",
"description": "创建财务合同",
"default_roles": ["PM"],
},
"GET:/finance/contracts": {
"module": "fees",
"action": "read",
"description": "查询财务合同列表",
"default_roles": ["PM", "CRA", "IMP"],
},
"GET:/finance/contracts/{id}": {
"module": "fees",
"action": "read",
"description": "查询财务合同详情",
"default_roles": ["PM", "CRA", "IMP"],
},
"PATCH:/finance/contracts/{id}": {
"module": "fees",
"action": "write",
"description": "更新财务合同",
"default_roles": ["PM"],
},
"DELETE:/finance/contracts/{id}": {
"module": "fees",
"action": "write",
"description": "删除财务合同",
"default_roles": ["PM"],
},
# 费用管理 (fees)
"POST:/fees/contracts": {
"module": "fees",
"action": "write",
"description": "创建费用合同",
"default_roles": ["PM"],
},
"GET:/fees/contracts": {
"module": "fees",
"action": "read",
"description": "查询费用合同列表",
"default_roles": ["PM", "CRA", "IMP"],
},
"GET:/fees/contracts/{id}": {
"module": "fees",
"action": "read",
"description": "查询费用合同详情",
"default_roles": ["PM", "CRA", "IMP"],
},
"PATCH:/fees/contracts/{id}": {
"module": "fees",
"action": "write",
"description": "更新费用合同",
"default_roles": ["PM"],
},
"DELETE:/fees/contracts/{id}": {
"module": "fees",
"action": "write",
"description": "删除费用合同",
"default_roles": ["PM"],
},
"POST:/fees/contracts/{id}/payments": {
"module": "fees",
"action": "write",
"description": "创建费用分期",
"default_roles": ["PM"],
},
"PATCH:/fees/payments/{id}": {
"module": "fees",
"action": "write",
"description": "更新费用分期",
"default_roles": ["PM"],
},
"DELETE:/fees/payments/{id}": {
"module": "fees",
"action": "write",
"description": "删除费用分期",
"default_roles": ["PM"],
},
# 项目成员管理
"POST:/project-members": {
"module": "project_members",
"action": "write",
"description": "添加项目成员",
"default_roles": ["PM"],
},
"GET:/project-members": {
"module": "project_members",
"action": "read",
"description": "查询项目成员列表",
"default_roles": ["PM"],
},
"PATCH:/project-members/{id}": {
"module": "project_members",
"action": "write",
"description": "更新项目成员",
"default_roles": ["PM"],
},
# 中心管理
"POST:/sites": {
"module": "sites",
"action": "write",
"description": "创建中心",
"default_roles": ["PM"],
},
"GET:/sites": {
"module": "sites",
"action": "read",
"description": "查询中心列表",
"default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"],
},
"GET:/sites/{id}": {
"module": "sites",
"action": "read",
"description": "查询中心详情",
"default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"],
},
"PATCH:/sites/{id}": {
"module": "sites",
"action": "write",
"description": "更新中心",
"default_roles": ["PM"],
},
}
# 向后兼容:模块级权限到接口级权限的映射
# 用于在接口级权限未配置时,回退到模块级权限
MODULE_TO_ENDPOINTS: dict[str, dict[str, list[str]]] = {
"subjects": {
"read": [
"GET:/subjects",
"GET:/subjects/{id}",
"GET:/subjects/{subject_id}/visits",
"GET:/subjects/{subject_id}/visits/{visit_id}",
],
"write": [
"POST:/subjects",
"PATCH:/subjects/{id}",
"DELETE:/subjects/{id}",
"POST:/subjects/{subject_id}/visits",
"PATCH:/subjects/{subject_id}/visits/{visit_id}",
],
},
"risk_issues": {
"read": [
"GET:/risk-issues",
"GET:/risk-issues/{id}",
],
"write": [
"POST:/risk-issues",
"PATCH:/risk-issues/{id}",
"DELETE:/risk-issues/{id}",
],
},
"fees": {
"read": [
"GET:/fees/contracts",
"GET:/fees/contracts/{id}",
"GET:/finance/contracts",
"GET:/finance/contracts/{id}",
],
"write": [
"POST:/fees/contracts",
"PATCH:/fees/contracts/{id}",
"DELETE:/fees/contracts/{id}",
"POST:/fees/contracts/{id}/payments",
"PATCH:/fees/payments/{id}",
"DELETE:/fees/payments/{id}",
"POST:/finance/contracts",
"PATCH:/finance/contracts/{id}",
"DELETE:/finance/contracts/{id}",
],
},
"project_members": {
"read": [
"GET:/project-members",
],
"write": [
"POST:/project-members",
"PATCH:/project-members/{id}",
],
},
"sites": {
"read": [
"GET:/sites",
"GET:/sites/{id}",
],
"write": [
"POST:/sites",
"PATCH:/sites/{id}",
],
},
}
+119
View File
@@ -0,0 +1,119 @@
"""API端点权限装饰器和初始化函数"""
from __future__ import annotations
from functools import wraps
from typing import Callable, Any
import uuid
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.models.api_endpoint_registry import ApiEndpointRegistry
from app.core.api_permissions import API_ENDPOINT_PERMISSIONS
def register_api_endpoint(
endpoint_key: str,
module: str,
action: str,
description: str = "",
default_roles: list[str] | None = None,
):
"""装饰器:注册API端点权限
在函数上附加元数据,用于系统初始化时自动注册端点。
参数:
endpoint_key: 接口标识,格式为 "METHOD:/path"
module: 关联的模块,用于向后兼容
action: 操作类型,"read""write"
description: 接口描述
default_roles: 默认允许的角色列表
"""
def decorator(func: Callable) -> Callable:
func._endpoint_key = endpoint_key
func._module = module
func._action = action
func._description = description
func._default_roles = default_roles or []
return func
return decorator
async def initialize_api_endpoint_registry(db: AsyncSession) -> None:
"""初始化API端点注册表
扫描所有已定义的API端点配置,将其写入数据库。
如果端点已存在,则跳过;否则创建新记录。
"""
for endpoint_key, config in API_ENDPOINT_PERMISSIONS.items():
# 检查端点是否已存在
result = await db.execute(
select(ApiEndpointRegistry).where(
ApiEndpointRegistry.endpoint_key == endpoint_key,
)
)
existing = result.scalar_one_or_none()
if existing:
continue
# 创建新的端点注册记录
registry = ApiEndpointRegistry(
id=uuid.uuid4(),
endpoint_key=endpoint_key,
method=endpoint_key.split(":")[0],
path=endpoint_key.split(":", 1)[1],
module=config["module"],
action=config["action"],
description=config.get("description", ""),
default_roles=",".join(config.get("default_roles", [])),
)
db.add(registry)
await db.commit()
async def initialize_project_api_permissions(
db: AsyncSession,
study_id: uuid.UUID,
) -> None:
"""为新项目初始化默认的接口级权限
根据API端点的默认角色配置,为项目创建初始权限记录。
"""
from app.models.api_endpoint_permission import ApiEndpointPermission
# 获取所有已注册的端点
result = await db.execute(select(ApiEndpointRegistry))
endpoints = result.scalars().all()
# 为每个端点和默认角色创建权限记录
for endpoint in endpoints:
default_roles = endpoint.default_roles.split(",") if endpoint.default_roles else []
for role in default_roles:
if not role or role == "ADMIN":
continue
# 检查权限是否已存在
perm_result = await db.execute(
select(ApiEndpointPermission).where(
ApiEndpointPermission.study_id == study_id,
ApiEndpointPermission.role == role,
ApiEndpointPermission.endpoint_key == endpoint.endpoint_key,
)
)
if perm_result.scalar_one_or_none():
continue
# 创建新的权限记录
permission = ApiEndpointPermission(
id=uuid.uuid4(),
study_id=study_id,
role=role,
endpoint_key=endpoint.endpoint_key,
allowed=True,
)
db.add(permission)
await db.commit()
+35 -1
View File
@@ -10,7 +10,7 @@ from app.core.security import decode_token, oauth2_scheme
from app.crud import user as user_crud
from app.crud import member as member_crud
from app.crud import site as site_crud
from app.core.project_permissions import role_has_project_permission
from app.core.project_permissions import role_has_project_permission, role_has_api_permission
from app.db.session import SessionLocal
from app.schemas.user import TokenPayload
@@ -147,6 +147,40 @@ def require_study_permission(module: str, action: str, *, allow_system_admin: bo
return dependency
def require_api_permission(endpoint_key: str, *, allow_system_admin: bool = True):
"""基于接口的权限检查
参数:
endpoint_key: 接口标识,格式为 "METHOD:/path",如 "POST:/subjects"
allow_system_admin: 是否允许系统管理员绕过权限检查
"""
async def dependency(
study_id: uuid.UUID,
current_user=Depends(get_current_user),
db: AsyncSession = Depends(get_db_session),
):
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
if allow_system_admin and role_value == "ADMIN":
return current_user
membership = await member_crud.get_member(db, study_id, current_user.id)
if not membership or not membership.is_active:
raise AppException(
code="FORBIDDEN",
message="不是该项目成员",
status_code=status.HTTP_403_FORBIDDEN,
)
allowed = await role_has_api_permission(db, study_id, membership.role_in_study, endpoint_key)
if not allowed:
raise AppException(
code="FORBIDDEN",
message="接口权限不足",
status_code=status.HTTP_403_FORBIDDEN,
)
return current_user
return dependency
async def get_cra_site_scope(
db: AsyncSession,
study_id: uuid.UUID,
+113
View File
@@ -1,11 +1,14 @@
from __future__ import annotations
import json
import uuid
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.study_role_permission import StudyRolePermission
from app.models.api_endpoint_permission import ApiEndpointPermission
from app.core.api_permissions import API_ENDPOINT_PERMISSIONS, MODULE_TO_ENDPOINTS
PROJECT_PERMISSION_ROLES = ("ADMIN", "PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA")
@@ -249,3 +252,113 @@ async def role_has_project_permission(
if action == "write":
return bool(actions["write"])
return bool(actions["read"])
async def role_has_api_permission(
db: AsyncSession,
study_id: uuid.UUID,
role: str | None,
endpoint_key: str,
) -> bool:
"""检查角色是否有权访问特定接口
权限检查优先级:
1. 接口级权限(如果已配置)
2. 模块级权限(向后兼容)
"""
if role == "ADMIN":
return True
# 1. 先查询接口级权限
result = await db.execute(
select(ApiEndpointPermission).where(
ApiEndpointPermission.study_id == study_id,
ApiEndpointPermission.role == role,
ApiEndpointPermission.endpoint_key == endpoint_key,
)
)
perm = result.scalar_one_or_none()
if perm is not None:
return perm.allowed
# 2. 如果没有接口级权限,回退到模块级权限(向后兼容)
endpoint_config = API_ENDPOINT_PERMISSIONS.get(endpoint_key)
if not endpoint_config:
return False
module = endpoint_config["module"]
action = endpoint_config["action"]
return await role_has_project_permission(db, study_id, role, module, action)
async def get_api_endpoint_permissions(
db: AsyncSession,
study_id: uuid.UUID,
) -> dict[str, dict[str, dict[str, bool]]]:
"""获取项目的接口级权限矩阵
返回格式: {role: {endpoint_key: {allowed: bool}}}
"""
result = await db.execute(
select(ApiEndpointPermission).where(
ApiEndpointPermission.study_id == study_id,
)
)
rows = result.scalars().all()
# 初始化矩阵,包含所有角色和端点的默认权限
matrix: dict[str, dict[str, dict[str, bool]]] = {}
for role in PROJECT_PERMISSION_ROLES:
if role == "ADMIN":
continue
matrix[role] = {}
for endpoint_key, config in API_ENDPOINT_PERMISSIONS.items():
default_allowed = role in config.get("default_roles", [])
matrix[role][endpoint_key] = {"allowed": default_allowed}
# 覆盖自定义权限
for row in rows:
if row.role not in matrix:
matrix[row.role] = {}
matrix[row.role][row.endpoint_key] = {"allowed": row.allowed}
return matrix
async def replace_api_endpoint_permissions(
db: AsyncSession,
study_id: uuid.UUID,
payload: dict[str, dict[str, bool]],
) -> dict[str, dict[str, dict[str, bool]]]:
"""替换项目的接口级权限矩阵
参数:
payload: {role: {endpoint_key: allowed}}
返回格式: {role: {endpoint_key: {allowed: bool}}}
"""
# 删除该项目的所有接口级权限
await db.execute(
delete(ApiEndpointPermission).where(
ApiEndpointPermission.study_id == study_id,
)
)
# 插入新的权限配置
for role, endpoints in payload.items():
if role == "ADMIN":
continue
for endpoint_key, allowed in endpoints.items():
if endpoint_key not in API_ENDPOINT_PERMISSIONS:
continue
db.add(
ApiEndpointPermission(
study_id=study_id,
role=role,
endpoint_key=endpoint_key,
allowed=allowed,
)
)
await db.commit()
return await get_api_endpoint_permissions(db, study_id)
+3
View File
@@ -38,6 +38,9 @@ async def lifespan(_: FastAPI):
await conn.run_sync(Base.metadata.create_all)
async with SessionLocal() as session:
await ensure_admin_exists(session)
# Initialize API endpoint registry
from app.core.decorators import initialize_api_endpoint_registry
await initialize_api_endpoint_registry(session)
yield
stop_event.set()
await scheduler_task
+7 -4
View File
@@ -1,3 +1,6 @@
from __future__ import annotations
from typing import Optional
import enum
import uuid
from datetime import datetime
@@ -24,14 +27,14 @@ class Acknowledgement(Base):
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
distribution_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("distributions.id"), nullable=False)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
site_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), nullable=True)
site_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), nullable=True)
ack_type: Mapped[AcknowledgementType] = mapped_column(
Enum(AcknowledgementType, name="acknowledgement_type"),
nullable=False,
)
due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
acked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
note: Mapped[str | None] = mapped_column(Text, nullable=True)
due_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
acked_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
note: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
distribution = relationship("Distribution", back_populates="acknowledgements")
+13 -10
View File
@@ -1,3 +1,6 @@
from __future__ import annotations
from typing import Optional
import uuid
from datetime import datetime, date
@@ -13,23 +16,23 @@ class AdverseEvent(Base):
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)
site_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), index=True, nullable=True)
subject_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("subjects.id"), index=True, nullable=True)
visit_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
site_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), index=True, nullable=True)
subject_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("subjects.id"), index=True, nullable=True)
visit_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), nullable=True)
term: Mapped[str] = mapped_column(String(255), nullable=False)
onset_date: Mapped[date | None] = mapped_column(Date, nullable=True)
resolution_date: Mapped[date | None] = mapped_column(Date, nullable=True)
onset_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
resolution_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
seriousness: Mapped[str] = mapped_column(String(50), nullable=False)
severity: Mapped[str] = mapped_column(String(50), nullable=False)
causality: Mapped[str | None] = mapped_column(String(50), nullable=True)
action_taken: Mapped[str | None] = mapped_column(Text, nullable=True)
outcome: Mapped[str | None] = mapped_column(String(50), nullable=True)
causality: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
action_taken: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
outcome: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
reported_to_sponsor: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
is_sae: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
is_susar: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
report_due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
report_due_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="NEW")
description: Mapped[str | None] = mapped_column(Text, nullable=True)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=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(
@@ -0,0 +1,30 @@
from __future__ import annotations
from typing import Optional
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, String, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base_class import Base
class ApiEndpointPermission(Base):
"""接口级权限配置表
存储每个项目中每个角色对特定API端点的权限配置。
支持细粒度的接口级权限控制,解决跨模块数据访问的权限混乱问题。
"""
__tablename__ = "api_endpoint_permissions"
__table_args__ = (
UniqueConstraint("study_id", "role", "endpoint_key", name="uq_api_endpoint_perm"),
)
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, index=True)
role: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
endpoint_key: Mapped[str] = mapped_column(String(100), nullable=False)
allowed: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now())
@@ -0,0 +1,33 @@
from __future__ import annotations
from typing import Optional
import uuid
from datetime import datetime
from sqlalchemy import DateTime, String, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base_class import Base
class ApiEndpointRegistry(Base):
"""API端点注册表
存储系统中所有已注册的API端点及其元数据。
用于权限管理、文档生成和权限初始化。
"""
__tablename__ = "api_endpoint_registries"
__table_args__ = (
UniqueConstraint("endpoint_key", name="uq_api_endpoint_registry_key"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
endpoint_key: Mapped[str] = mapped_column(String(100), nullable=False, unique=True)
method: Mapped[str] = mapped_column(String(10), nullable=False)
path: Mapped[str] = mapped_column(String(200), nullable=False)
module: Mapped[str] = mapped_column(String(80), nullable=False)
action: Mapped[str] = mapped_column(String(20), nullable=False)
description: Mapped[str] = mapped_column(String(500), nullable=True)
default_roles: Mapped[str] = mapped_column(String(200), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
+4 -1
View File
@@ -1,3 +1,6 @@
from __future__ import annotations
from typing import Optional
import uuid
from datetime import datetime
@@ -18,7 +21,7 @@ class Attachment(Base):
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)
content_type: Mapped[Optional[str]] = 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")
+6 -3
View File
@@ -1,3 +1,6 @@
from __future__ import annotations
from typing import Optional
import uuid
from datetime import datetime
@@ -13,11 +16,11 @@ class AuditLog(Base):
__table_args__ = (Index("ix_audit_logs_entity_at", "entity_type", "entity_id", "created_at"),)
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)
study_id: Mapped[Optional[uuid.UUID]] = 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)
entity_id: Mapped[Optional[uuid.UUID]] = 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)
detail: Mapped[Optional[str]] = 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())
+4 -1
View File
@@ -1,3 +1,6 @@
from __future__ import annotations
from typing import Optional
import uuid
from datetime import datetime
from decimal import Decimal
@@ -20,7 +23,7 @@ class ContractFee(Base):
center_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), index=True, nullable=False)
contract_amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False)
contract_cases: Mapped[int] = mapped_column(Integer, nullable=False)
actual_cases: Mapped[int | None] = mapped_column(Integer, nullable=True)
actual_cases: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
settlement_amount: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True)
final_payment_amount: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
+6 -3
View File
@@ -1,3 +1,6 @@
from __future__ import annotations
from typing import Optional
import uuid
from datetime import date, datetime
from decimal import Decimal
@@ -18,11 +21,11 @@ class ContractFeePayment(Base):
)
seq: Mapped[int] = mapped_column(Integer, nullable=False)
amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False)
paid_date: Mapped[date | None] = mapped_column(Date, nullable=True)
verified_date: Mapped[date | None] = mapped_column(Date, nullable=True)
paid_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
verified_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
is_paid: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
is_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
remark: Mapped[str | None] = mapped_column(Text, nullable=True)
remark: Mapped[Optional[str]] = mapped_column(Text, 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()
+6 -3
View File
@@ -1,3 +1,6 @@
from __future__ import annotations
from typing import Optional
import enum
import uuid
from datetime import datetime
@@ -36,9 +39,9 @@ class Distribution(Base):
nullable=False,
server_default=DistributionStatus.ACTIVE.value,
)
due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
due_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
closed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
created_by: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
document = relationship("Document")
+7 -4
View File
@@ -1,3 +1,6 @@
from __future__ import annotations
from typing import Optional
import enum
import uuid
from datetime import datetime
@@ -27,7 +30,7 @@ class Document(Base):
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
trial_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), nullable=False)
site_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), nullable=True)
site_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), nullable=True)
doc_no: Mapped[str] = mapped_column(String(50), nullable=False)
doc_type: Mapped[str] = mapped_column(String(50), nullable=False)
title: Mapped[str] = mapped_column(String(200), nullable=False)
@@ -36,18 +39,18 @@ class Document(Base):
nullable=False,
server_default=DocumentScopeType.GLOBAL.value,
)
owner_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
owner_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
status: Mapped[DocumentStatus] = mapped_column(
Enum(DocumentStatus, name="document_status"),
nullable=False,
server_default=DocumentStatus.ACTIVE.value,
)
current_effective_version_id: Mapped[uuid.UUID | None] = mapped_column(
current_effective_version_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("document_versions.id"),
nullable=True,
)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
description: Mapped[Optional[str]] = mapped_column(Text, 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),
+13 -10
View File
@@ -1,3 +1,6 @@
from __future__ import annotations
from typing import Optional
import enum
import uuid
from datetime import datetime
@@ -26,12 +29,12 @@ class DocumentVersion(Base):
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
document_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("documents.id"), nullable=False)
version_no: Mapped[str] = mapped_column(String(50), nullable=False)
parent_version_id: Mapped[uuid.UUID | None] = mapped_column(
parent_version_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("document_versions.id"),
nullable=True,
)
derived_from_version_id: Mapped[uuid.UUID | None] = mapped_column(
derived_from_version_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("document_versions.id"),
nullable=True,
@@ -41,17 +44,17 @@ class DocumentVersion(Base):
nullable=False,
server_default=DocumentVersionStatus.DRAFT.value,
)
effective_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
superseded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
effective_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
superseded_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
file_uri: Mapped[str] = mapped_column(String(500), nullable=False)
file_hash: Mapped[str] = mapped_column(String(128), nullable=False)
file_size: Mapped[int] = mapped_column(BigInteger, nullable=False)
mime_type: Mapped[str | None] = mapped_column(String(100), nullable=True)
change_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
submitted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
withdrawn_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
mime_type: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
change_summary: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
created_by: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
submitted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
approved_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
withdrawn_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
document = relationship("Document", back_populates="versions", foreign_keys=[document_id])
+12 -9
View File
@@ -1,3 +1,6 @@
from __future__ import annotations
from typing import Optional
import uuid
from datetime import date, datetime
@@ -14,17 +17,17 @@ class DrugShipment(Base):
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)
direction: Mapped[str] = mapped_column(String(20), nullable=False)
center_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), index=True, nullable=True)
center_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), index=True, nullable=True)
site_name: Mapped[str] = mapped_column(String(255), nullable=False)
ship_date: Mapped[date | None] = mapped_column(Date, nullable=True)
receive_date: Mapped[date | None] = mapped_column(Date, nullable=True)
quantity: Mapped[int | None] = mapped_column(Integer, nullable=True)
batch_no: Mapped[str | None] = mapped_column(String(100), nullable=True)
carrier: Mapped[str | None] = mapped_column(String(100), nullable=True)
tracking_no: Mapped[str | None] = mapped_column(String(100), nullable=True)
ship_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
receive_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
quantity: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
batch_no: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
carrier: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
tracking_no: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
status: Mapped[str] = mapped_column(String(30), nullable=False)
remark: Mapped[str | None] = mapped_column(Text, nullable=True)
created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
remark: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
created_by: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), 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()
+5 -2
View File
@@ -1,3 +1,6 @@
from __future__ import annotations
from typing import Optional
import uuid
from datetime import datetime
@@ -13,9 +16,9 @@ class FaqCategory(Base):
__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)
study_id: Mapped[Optional[uuid.UUID]] = 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)
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)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
+6 -3
View File
@@ -1,3 +1,6 @@
from __future__ import annotations
from typing import Optional
import uuid
from datetime import datetime
@@ -12,16 +15,16 @@ 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)
study_id: Mapped[Optional[uuid.UUID]] = 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)
best_reply_id: Mapped[uuid.UUID | None] = mapped_column(
best_reply_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("faq_replies.id", ondelete="SET NULL"), nullable=True
)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="PENDING")
resolved_by_confirm: Mapped[bool] = mapped_column(Boolean, nullable=False, default=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)
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)
+5 -2
View File
@@ -1,3 +1,6 @@
from __future__ import annotations
from typing import Optional
import uuid
from datetime import datetime
@@ -13,11 +16,11 @@ class FaqReply(Base):
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
faq_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("faq_items.id"), index=True, nullable=False)
study_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=True)
study_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=True)
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())
quote_reply_id: Mapped[uuid.UUID | None] = mapped_column(
quote_reply_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("faq_replies.id"), nullable=True
)
is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
+6 -3
View File
@@ -1,3 +1,6 @@
from __future__ import annotations
from typing import Optional
import uuid
from datetime import datetime
@@ -16,10 +19,10 @@ class FeeAttachment(Base):
entity_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
file_type: Mapped[str] = mapped_column(String(30), nullable=False)
filename: Mapped[str] = mapped_column(String(255), nullable=False)
mime_type: Mapped[str | None] = mapped_column(String(100), nullable=True)
mime_type: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
size: Mapped[int] = mapped_column(Integer, nullable=False)
storage_key: Mapped[str | None] = mapped_column(String(500), nullable=True)
url: Mapped[str | None] = mapped_column(Text, nullable=True)
storage_key: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
url: Mapped[Optional[str]] = mapped_column(Text, 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")
+14 -11
View File
@@ -1,3 +1,6 @@
from __future__ import annotations
from typing import Optional
import uuid
from datetime import datetime, date
@@ -13,23 +16,23 @@ class FinanceItem(Base):
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)
site_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), index=True, nullable=True)
subject_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("subjects.id"), index=True, nullable=True)
visit_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
site_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), index=True, nullable=True)
subject_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("subjects.id"), index=True, nullable=True)
visit_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), nullable=True)
category: Mapped[str] = mapped_column(String(50), nullable=False)
title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
currency: Mapped[str] = mapped_column(String(10), nullable=False, default="CNY")
amount: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False)
occur_date: Mapped[date] = mapped_column(Date, nullable=False)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="DRAFT")
submitted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
rejected_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
paid_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
approver_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
payer_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
reject_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
submitted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
approved_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
rejected_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
paid_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
approver_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
payer_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
reject_reason: Mapped[Optional[str]] = mapped_column(Text, nullable=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(
+6 -3
View File
@@ -1,3 +1,6 @@
from __future__ import annotations
from typing import Optional
import uuid
from datetime import date, datetime
@@ -15,11 +18,11 @@ class FinanceContract(Base):
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
site_name: Mapped[str] = mapped_column(String(255), nullable=False)
contract_no: Mapped[str] = mapped_column(String(100), nullable=False)
signed_date: Mapped[date | None] = mapped_column(Date, nullable=True)
signed_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
amount: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False)
currency: Mapped[str] = mapped_column(String(10), nullable=False, default="CNY")
remark: Mapped[str | None] = mapped_column(Text, nullable=True)
created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
remark: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
created_by: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), 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()
+6 -3
View File
@@ -1,3 +1,6 @@
from __future__ import annotations
from typing import Optional
import uuid
from datetime import date, datetime
@@ -13,10 +16,10 @@ class KickoffMeeting(Base):
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)
site_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), index=True, nullable=True)
kickoff_date: Mapped[date | None] = mapped_column(Date, nullable=True)
site_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), index=True, nullable=True)
kickoff_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
attendees: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
created_by: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), 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()
+5 -2
View File
@@ -1,3 +1,6 @@
from __future__ import annotations
from typing import Optional
import uuid
from datetime import datetime
@@ -16,8 +19,8 @@ class KnowledgeNote(Base):
site_name: Mapped[str] = mapped_column(String(255), nullable=False)
title: Mapped[str] = mapped_column(String(255), nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False)
level: Mapped[str | None] = mapped_column(String(50), nullable=True)
created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
level: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
created_by: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), 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()
+9 -6
View File
@@ -1,3 +1,6 @@
from __future__ import annotations
from typing import Optional
import uuid
from datetime import datetime
@@ -15,14 +18,14 @@ class MaterialEquipment(Base):
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
spec_model: Mapped[str] = mapped_column(String(255), nullable=False)
unit: Mapped[str | None] = mapped_column(String(50), nullable=True)
unit: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
brand: Mapped[str] = mapped_column(String(100), nullable=False)
origin: Mapped[str | None] = mapped_column(String(255), nullable=True)
production_permit_file_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
tech_index_file_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
origin: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
production_permit_file_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
tech_index_file_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
need_calibration: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
calibration_cycle_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
calibration_cycle_days: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
created_by: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), 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()
+13 -10
View File
@@ -1,3 +1,6 @@
from __future__ import annotations
from typing import Optional
import uuid
from datetime import datetime, date
@@ -15,17 +18,17 @@ class Milestone(Base):
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
type: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(200), nullable=False)
planned_date: Mapped[date | None] = mapped_column(Date, nullable=True)
adjusted_start_date: Mapped[date | None] = mapped_column(Date, nullable=True)
adjusted_end_date: Mapped[date | None] = mapped_column(Date, nullable=True)
actual_start_date: Mapped[date | None] = mapped_column(Date, nullable=True)
actual_end_date: Mapped[date | None] = mapped_column(Date, nullable=True)
actual_date: Mapped[date | None] = mapped_column(Date, nullable=True)
planned_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
adjusted_start_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
adjusted_end_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
actual_start_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
actual_end_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
actual_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="NOT_STARTED")
site_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), nullable=True)
owner_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
owner_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
site_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), nullable=True)
owner_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
owner_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
notes: Mapped[Optional[str]] = mapped_column(Text, 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 -23
View File
@@ -1,3 +1,6 @@
from __future__ import annotations
from typing import Optional
import uuid
from datetime import date, datetime
@@ -14,33 +17,33 @@ class MonitoringVisitIssue(Base):
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)
site_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), index=True, nullable=True)
site_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), index=True, nullable=True)
issue_no: Mapped[str] = mapped_column(String(64), nullable=False)
category: Mapped[str] = mapped_column(String(255), nullable=False)
severity: Mapped[str | None] = mapped_column(String(64), nullable=True)
mark: Mapped[str | None] = mapped_column(String(100), nullable=True)
visit_cycle: Mapped[str | None] = mapped_column(String(100), nullable=True)
subject_code: Mapped[str | None] = mapped_column(String(64), nullable=True)
monitor_item: Mapped[str | None] = mapped_column(Text, nullable=True)
monitor_type: Mapped[str | None] = mapped_column(String(64), nullable=True)
severity: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
mark: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
visit_cycle: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
subject_code: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
monitor_item: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
monitor_type: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default="OPEN")
source: Mapped[str | None] = mapped_column(String(64), nullable=True)
creator_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
recommendation: Mapped[str | None] = mapped_column(Text, nullable=True)
subject_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
action_taken: Mapped[str | None] = mapped_column(Text, nullable=True)
follow_up_progress: Mapped[str | None] = mapped_column(Text, nullable=True)
center_query: Mapped[str | None] = mapped_column(Text, nullable=True)
center_latest_reply: Mapped[str | None] = mapped_column(Text, nullable=True)
source: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
creator_name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
recommendation: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
subject_name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
action_taken: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
follow_up_progress: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
center_query: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
center_latest_reply: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
rectification_completed: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false", default=False)
found_date: Mapped[date | None] = mapped_column(Date, nullable=True)
due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
actual_resolve_date: Mapped[date | None] = mapped_column(Date, nullable=True)
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
responsible_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
open_duration_text: Mapped[str | None] = mapped_column(String(64), nullable=True)
created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
found_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
due_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
actual_resolve_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
closed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
responsible_name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
open_duration_text: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
created_by: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), 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()
+11 -8
View File
@@ -1,3 +1,6 @@
from __future__ import annotations
from typing import Optional
import uuid
from datetime import date, datetime
@@ -14,13 +17,13 @@ class Site(Base):
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)
name: Mapped[str] = mapped_column(String(200), nullable=False)
city: Mapped[str | None] = mapped_column(String(100), nullable=True)
pi_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
phone: Mapped[str | None] = mapped_column(String(100), nullable=True)
contact: Mapped[str | None] = mapped_column(String(100), nullable=True)
city: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
pi_name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
phone: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
contact: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="true")
enrollment_target: Mapped[int | None] = mapped_column(Integer, nullable=True)
enrollment_plan_start_date: Mapped[date | None] = mapped_column(Date, nullable=True)
enrollment_plan_end_date: Mapped[date | None] = mapped_column(Date, nullable=True)
enrollment_plan_note: Mapped[str | None] = mapped_column(Text, nullable=True)
enrollment_target: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
enrollment_plan_start_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
enrollment_plan_end_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
enrollment_plan_note: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
+10 -7
View File
@@ -1,3 +1,6 @@
from __future__ import annotations
from typing import Optional
import uuid
from datetime import date, datetime
@@ -13,13 +16,13 @@ class StartupEthics(Base):
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)
site_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), nullable=True)
submit_date: Mapped[date | None] = mapped_column(Date, nullable=True)
accept_date: Mapped[date | None] = mapped_column(Date, nullable=True)
meeting_date: Mapped[date | None] = mapped_column(Date, nullable=True)
approved_date: Mapped[date | None] = mapped_column(Date, nullable=True)
approval_no: Mapped[str | None] = mapped_column(String(100), nullable=True)
created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
site_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), nullable=True)
submit_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
accept_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
meeting_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
approved_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
approval_no: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
created_by: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), 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()
+9 -6
View File
@@ -1,3 +1,6 @@
from __future__ import annotations
from typing import Optional
import uuid
from datetime import date, datetime
@@ -13,12 +16,12 @@ class StartupFeasibility(Base):
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)
site_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), nullable=True)
submit_date: Mapped[date | None] = mapped_column(Date, nullable=True)
accept_date: Mapped[date | None] = mapped_column(Date, nullable=True)
approved_date: Mapped[date | None] = mapped_column(Date, nullable=True)
project_no: Mapped[str | None] = mapped_column(String(100), nullable=True)
created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
site_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), nullable=True)
submit_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
accept_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
approved_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
project_no: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
created_by: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), 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()
+23 -20
View File
@@ -1,3 +1,6 @@
from __future__ import annotations
from typing import Optional
import uuid
from datetime import date, datetime
@@ -14,27 +17,27 @@ class Study(Base):
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
code: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
name: Mapped[str] = mapped_column(String(200), nullable=False)
project_full_name: Mapped[str | None] = mapped_column(String(500), nullable=True)
sponsor: Mapped[str | None] = mapped_column(String(200), nullable=True)
protocol_no: Mapped[str | None] = mapped_column(String(100), nullable=True)
lead_unit: Mapped[str | None] = mapped_column(String(255), nullable=True)
principal_investigator: Mapped[str | None] = mapped_column(String(255), nullable=True)
main_pm: Mapped[str | None] = mapped_column(String(255), nullable=True)
research_analysis: Mapped[str | None] = mapped_column(Text, nullable=True)
research_product: Mapped[str | None] = mapped_column(String(255), nullable=True)
control_product: Mapped[str | None] = mapped_column(String(255), nullable=True)
indication: Mapped[str | None] = mapped_column(String(255), nullable=True)
research_population: Mapped[str | None] = mapped_column(String(255), nullable=True)
research_design: Mapped[str | None] = mapped_column(Text, nullable=True)
plan_start_date: Mapped[date | None] = mapped_column(Date, nullable=True)
plan_end_date: Mapped[date | None] = mapped_column(Date, nullable=True)
planned_site_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
planned_enrollment_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
enrollment_monthly_goal_note: Mapped[str | None] = mapped_column(Text, nullable=True)
enrollment_stage_breakdown: Mapped[str | None] = mapped_column(Text, nullable=True)
phase: Mapped[str | None] = mapped_column(String(50), nullable=True)
project_full_name: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
sponsor: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
protocol_no: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
lead_unit: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
principal_investigator: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
main_pm: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
research_analysis: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
research_product: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
control_product: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
indication: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
research_population: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
research_design: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
plan_start_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
plan_end_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
planned_site_count: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
planned_enrollment_count: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
enrollment_monthly_goal_note: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
enrollment_stage_breakdown: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
phase: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="DRAFT")
is_locked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
visit_schedule: Mapped[list[dict]] = mapped_column(JSON, nullable=False, default=list)
created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
created_by: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
+7 -4
View File
@@ -1,3 +1,6 @@
from __future__ import annotations
from typing import Optional
import uuid
from datetime import date, datetime
@@ -14,11 +17,11 @@ class StudyCenterConfirm(Base):
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)
site_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), index=True, nullable=False)
source_setup_item_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
confirmer: Mapped[str | None] = mapped_column(String(255), nullable=True)
source_setup_item_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
confirmer: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
confirm_status: Mapped[str] = mapped_column(String(20), nullable=False)
confirm_date: Mapped[date | None] = mapped_column(Date, nullable=True)
note: Mapped[str | None] = mapped_column(Text, nullable=True)
confirm_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
note: Mapped[Optional[str]] = mapped_column(Text, 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()
+3
View File
@@ -1,3 +1,6 @@
from __future__ import annotations
from typing import Optional
import uuid
from datetime import datetime
@@ -1,3 +1,6 @@
from __future__ import annotations
from typing import Optional
import uuid
from datetime import datetime
@@ -13,7 +16,7 @@ class StudyMonitoringStrategy(Base):
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)
source_setup_item_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
source_setup_item_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
strategy_type: Mapped[str] = mapped_column(String(50), nullable=False)
detail: Mapped[str] = mapped_column(Text, nullable=False)
frequency: Mapped[str] = mapped_column(String(50), nullable=False)
@@ -1,4 +1,5 @@
from __future__ import annotations
from typing import Optional
import uuid
from datetime import datetime
+8 -5
View File
@@ -1,3 +1,6 @@
from __future__ import annotations
from typing import Optional
import uuid
from datetime import datetime
@@ -20,15 +23,15 @@ class StudySetupConfig(Base):
published_config: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
published_project_snapshot: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
current_branch_name: Mapped[str] = mapped_column(nullable=False, default="main")
active_branch_base_version_id: Mapped[uuid.UUID | None] = mapped_column(
active_branch_base_version_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("study_setup_config_versions.id", ondelete="SET NULL"), nullable=True
)
current_published_version_id: Mapped[uuid.UUID | None] = mapped_column(
current_published_version_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("study_setup_config_versions.id", ondelete="SET NULL"), nullable=True
)
published_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
saved_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
published_by: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
published_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
saved_by: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), 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()
@@ -1,3 +1,6 @@
from __future__ import annotations
from typing import Optional
import uuid
from datetime import datetime
@@ -25,15 +28,15 @@ class StudySetupConfigVersion(Base):
branch_name: Mapped[str] = mapped_column(nullable=False, default="main")
branch_seq: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
version_label: Mapped[str] = mapped_column(nullable=False, default="")
source_version: Mapped[int | None] = mapped_column(Integer, nullable=True)
parent_version_id: Mapped[uuid.UUID | None] = mapped_column(
source_version: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
parent_version_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("study_setup_config_versions.id", ondelete="SET NULL"), nullable=True
)
merged_from_version_id: Mapped[uuid.UUID | None] = mapped_column(
merged_from_version_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("study_setup_config_versions.id", ondelete="SET NULL"), nullable=True
)
config: Mapped[dict] = mapped_column(JSONB, nullable=False)
published_project_snapshot: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
published_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
published_by: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
published_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
+10 -7
View File
@@ -1,3 +1,6 @@
from __future__ import annotations
from typing import Optional
import uuid
from datetime import datetime, date
@@ -17,13 +20,13 @@ class Subject(Base):
site_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), index=True, nullable=False)
subject_no: Mapped[str] = mapped_column(String(50), nullable=False)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="SCREENING")
screening_date: Mapped[date | None] = mapped_column(Date, nullable=True)
consent_date: Mapped[date | None] = mapped_column(Date, nullable=True)
enrollment_date: Mapped[date | None] = mapped_column(Date, nullable=True)
baseline_date: Mapped[date | None] = mapped_column(Date, nullable=True)
completion_date: Mapped[date | None] = mapped_column(Date, nullable=True)
actual_medication_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
drop_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
screening_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
consent_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
enrollment_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
baseline_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
completion_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
actual_medication_count: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
drop_reason: Mapped[Optional[str]] = mapped_column(Text, 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()
+5 -2
View File
@@ -1,3 +1,6 @@
from __future__ import annotations
from typing import Optional
import uuid
from datetime import date, datetime
@@ -14,9 +17,9 @@ class SubjectHistory(Base):
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)
subject_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("subjects.id"), index=True, nullable=False)
record_date: Mapped[date | None] = mapped_column(Date, nullable=True)
record_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
content: Mapped[str] = mapped_column(Text, nullable=False)
created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
created_by: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), 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()
+5 -2
View File
@@ -1,3 +1,6 @@
from __future__ import annotations
from typing import Optional
import uuid
from datetime import datetime
@@ -19,9 +22,9 @@ class SubjectPd(Base):
pd_type: Mapped[str] = mapped_column(String(100), nullable=False)
pd_level: Mapped[str] = mapped_column(String(20), nullable=False, server_default="GENERAL")
approval_status: Mapped[str] = mapped_column(String(20), nullable=False, server_default="DRAFT")
description: Mapped[str | None] = mapped_column(Text, nullable=True)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default="OPEN")
created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
created_by: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), 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()
+9 -6
View File
@@ -1,3 +1,6 @@
from __future__ import annotations
from typing import Optional
import uuid
from datetime import date, datetime
@@ -14,14 +17,14 @@ class TrainingAuthorization(Base):
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)
name: Mapped[str] = mapped_column(String(100), nullable=False)
role: Mapped[str | None] = mapped_column(String(100), nullable=True)
site_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
role: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
site_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
trained: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
authorized: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
trained_date: Mapped[date | None] = mapped_column(Date, nullable=True)
authorized_date: Mapped[date | None] = mapped_column(Date, nullable=True)
remark: Mapped[str | None] = mapped_column(Text, nullable=True)
created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
trained_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
authorized_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
remark: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
created_by: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), 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()
+6 -3
View File
@@ -1,3 +1,6 @@
from __future__ import annotations
from typing import Optional
import enum
import uuid
from datetime import datetime
@@ -52,13 +55,13 @@ class User(Base):
server_default=func.now(),
onupdate=func.now(),
)
approved_by: Mapped[uuid.UUID | None] = mapped_column(
approved_by: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id"),
nullable=True,
)
approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
avatar_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
approved_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
avatar_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
@property
def is_active(self) -> bool: # compatibility helper for existing checks
+8 -5
View File
@@ -1,3 +1,6 @@
from __future__ import annotations
from typing import Optional
import uuid
from datetime import datetime, date
@@ -15,12 +18,12 @@ class Visit(Base):
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), nullable=False)
subject_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("subjects.id"), index=True, nullable=False)
visit_code: Mapped[str] = mapped_column(String(50), nullable=False)
planned_date: Mapped[date | None] = mapped_column(Date, nullable=True)
actual_date: Mapped[date | None] = mapped_column(Date, nullable=True)
planned_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
actual_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="PLANNED")
window_start: Mapped[date | None] = mapped_column(Date, nullable=True)
window_end: Mapped[date | None] = mapped_column(Date, nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
window_start: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
window_end: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
notes: Mapped[Optional[str]] = mapped_column(Text, 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()