Merge branch 'dev' into release
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled

# Conflicts:
#	nginx/nginx.conf
This commit is contained in:
Cheng Zhou
2026-06-23 17:22:03 +08:00
481 changed files with 64227 additions and 15291 deletions
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
env = dev
base-url = http://127.0.0.1:8888
+8
View File
@@ -5,6 +5,7 @@ Thumbs.db
# IDE/editor
.idea/
.vscode/
.claude/
*.swp
*.swo
@@ -69,9 +70,16 @@ docs/postman/local.postman_environment.json
docs/postman/*.local.json
!docs/postman/*.example.json
# Role test documents and generated exports
docs/guides/role-test/
output/doc/role-test/
docs/guides/role-uat/
output/doc/role-uat/
# Docker / runtime data
pg_data/
backend/app/uploads/
# Git worktrees
.worktrees/
.install-logs/
+12 -3
View File
@@ -1,5 +1,10 @@
# CTMS 项目快速上手
## 交互安装入口
- 推荐执行 `./install.sh`,通过键盘 `↑/↓` 选择安装、更新、卸载、资源状态等操作;该入口不接受命令行参数。
- 菜单顶部会常驻显示当前 CTMS 容器部署状态,包括已检测到的环境、Compose 项目和容器运行数量。
- 底层脚本位于 `scripts/``install.sh``update.sh``uninstall.sh``status.sh`;状态脚本使用 `docker compose stats --no-stream` 采集资源快照并美化展示。
## 生产部署
- 生产入口:`docker-compose.yaml`
- 初始化方式:`docker compose run --rm backend-init`
@@ -8,6 +13,9 @@
- 对外入口:`nginx` 提供前端静态资源,并同域反代后端 API
- 数据库 schema 来源:Alembic migration,不再依赖 `database/init.sql`
- 默认无任何业务预置数据;生产初始化只确保固定管理员 `admin@huapont.cn / admin123` 存在
- 生产环境必须配置 `LOGIN_RSA_PRIVATE_KEY`;登录密码传输使用 RSA-OAEP 包裹 AES-GCM 密钥,数据库仍只保存 bcrypt 哈希
- 浏览器端加密依赖 WebCrypto 安全上下文;生产访问必须使用 HTTPS,本地 `localhost` 例外
- 当前 challenge 缓存在后端进程内,默认最多保留 1000 个;多实例或多 worker 部署需改为共享缓存或启用粘性会话
- 验证方式:
- `docker compose config`
- `curl -i http://127.0.0.1:8888/`
@@ -34,6 +42,7 @@
## 仓库治理文档
- 分支治理规范:`docs/branch-governance.md`
- 分支环境安装配置:`docs/guides/branch-environment-installation.md`
- 发布检查清单:`docs/guides/release-checklist.md`
## 本地配置
@@ -41,10 +50,10 @@
- 前端环境变量请使用 `frontend/.env`,可从 `frontend/.env.example` 复制。
- 根目录 `.env` 不作为当前默认启动流程的提交配置文件。
- Postman 本地环境请基于 `docs/postman/local.postman_environment.example.json` 自行复制,不提交个人环境文件。
- 文档入口见 `docs/README.md`;当前操作手册集中在 `docs/guides/`审计与治理文档集中在 `docs/audits/`
- 文档入口见 `docs/README.md`;当前约束集中在分支治理、发布清单和治理审计文档,操作手册集中在 `docs/guides/`历史交付记录集中在 `docs/reports/``docs/plans/`
## 常用流程
1. 用管理员账号登录前端(默认 `admin@example.com / admin123`)。
1. 用管理员账号登录前端(默认 `admin@huapont.cn / admin123`)。
2. 进入左侧导航「账号治理」「项目治理」进行用户、项目、中心等全局管理;或在“项目列表”选择项目后进入项目内工作区。
3. Dashboard 查看进度、待办;左侧导航进入“里程碑”“任务”等页面。
4. 退出登录或切换项目可通过顶部右侧下拉。
@@ -57,6 +66,6 @@
> ADMIN 具备所有 PM 权限,项目内操作与 PM 同步放行。
## 注意
- 登录使用邮箱 + 密码,未审核/已拒绝/已停用账号无法登录。
- 登录使用邮箱 + 密码,前端会先获取登录公钥并加密提交;未审核/已拒绝/已停用账号无法登录。
- 令牌与当前项目上下文保存在浏览器 LocalStorage 中,清除后需重新登录/选择项目。
- 若后端重启,确认数据库容器仍健康,前端会弹出错误提示。
+3 -1
View File
@@ -14,4 +14,6 @@ COPY alembic /code/alembic
COPY app /code/app
COPY scripts /code/scripts
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
RUN chmod +x /code/scripts/*.py
CMD sh -c "cd /code && PYTHONPATH=/code alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000"
@@ -19,10 +19,18 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 添加 is_locked 字段到 studies 表
op.add_column('studies', sa.Column('is_locked', sa.Boolean(), nullable=False, server_default='false'))
bind = op.get_bind()
inspector = sa.inspect(bind)
columns = {col["name"] for col in inspector.get_columns("studies")}
if "is_locked" not in columns:
op.add_column("studies", sa.Column("is_locked", sa.Boolean(), nullable=False, server_default="false"))
def downgrade() -> None:
# 删除 is_locked 字段
op.drop_column('studies', 'is_locked')
bind = op.get_bind()
inspector = sa.inspect(bind)
columns = {col["name"] for col in inspector.get_columns("studies")}
if "is_locked" in columns:
op.drop_column("studies", "is_locked")
@@ -19,10 +19,18 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 添加 enrollment_target 字段到 sites 表
op.add_column('sites', sa.Column('enrollment_target', sa.Integer(), nullable=True))
bind = op.get_bind()
inspector = sa.inspect(bind)
columns = {col["name"] for col in inspector.get_columns("sites")}
if "enrollment_target" not in columns:
op.add_column("sites", sa.Column("enrollment_target", sa.Integer(), nullable=True))
def downgrade() -> None:
# 删除 enrollment_target 字段
op.drop_column('sites', 'enrollment_target')
bind = op.get_bind()
inspector = sa.inspect(bind)
columns = {col["name"] for col in inspector.get_columns("sites")}
if "enrollment_target" in columns:
op.drop_column("sites", "enrollment_target")
@@ -20,10 +20,18 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.drop_table("workflow_actions")
op.drop_table("version_workflows")
op.drop_table("workflow_nodes")
op.drop_table("workflow_templates")
bind = op.get_bind()
inspector = sa.inspect(bind)
tables = set(inspector.get_table_names())
if "workflow_actions" in tables:
op.drop_table("workflow_actions")
if "version_workflows" in tables:
op.drop_table("version_workflows")
if "workflow_nodes" in tables:
op.drop_table("workflow_nodes")
if "workflow_templates" in tables:
op.drop_table("workflow_templates")
op.execute("DROP TYPE IF EXISTS workflow_action_type")
op.execute("DROP TYPE IF EXISTS workflow_status")
@@ -19,18 +19,31 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"adverse_events",
sa.Column("is_sae", sa.Boolean(), nullable=False, server_default=sa.false()),
)
op.add_column(
"adverse_events",
sa.Column("is_susar", sa.Boolean(), nullable=False, server_default=sa.false()),
)
op.alter_column("adverse_events", "is_sae", server_default=None)
op.alter_column("adverse_events", "is_susar", server_default=None)
bind = op.get_bind()
inspector = sa.inspect(bind)
columns = {col["name"] for col in inspector.get_columns("adverse_events")}
if "is_sae" not in columns:
op.add_column(
"adverse_events",
sa.Column("is_sae", sa.Boolean(), nullable=False, server_default=sa.false()),
)
if "is_susar" not in columns:
op.add_column(
"adverse_events",
sa.Column("is_susar", sa.Boolean(), nullable=False, server_default=sa.false()),
)
if "is_sae" in columns or "is_susar" in columns:
op.alter_column("adverse_events", "is_sae", server_default=None)
op.alter_column("adverse_events", "is_susar", server_default=None)
def downgrade() -> None:
op.drop_column("adverse_events", "is_susar")
op.drop_column("adverse_events", "is_sae")
bind = op.get_bind()
inspector = sa.inspect(bind)
columns = {col["name"] for col in inspector.get_columns("adverse_events")}
if "is_susar" in columns:
op.drop_column("adverse_events", "is_susar")
if "is_sae" in columns:
op.drop_column("adverse_events", "is_sae")
@@ -0,0 +1,43 @@
"""replace global visit window with per-visit schedule
Revision ID: 20260508_01
Revises: 20260331_01
Create Date: 2026-05-08 11:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "20260508_01"
down_revision: Union[str, None] = "20260331_01"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"studies",
sa.Column(
"visit_schedule",
postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
server_default=sa.text("'[]'::jsonb"),
),
)
op.drop_column("studies", "visit_window_end_offset")
op.drop_column("studies", "visit_window_start_offset")
op.drop_column("studies", "visit_total")
op.drop_column("studies", "visit_interval_days")
def downgrade() -> None:
op.add_column("studies", sa.Column("visit_interval_days", sa.Integer(), nullable=True))
op.add_column("studies", sa.Column("visit_total", sa.Integer(), nullable=True))
op.add_column("studies", sa.Column("visit_window_start_offset", sa.Integer(), nullable=True))
op.add_column("studies", sa.Column("visit_window_end_offset", sa.Integer(), nullable=True))
op.drop_column("studies", "visit_schedule")
@@ -0,0 +1,47 @@
"""remove summary and objective note fields
Revision ID: 20260508_02
Revises: 20260508_01
Create Date: 2026-05-08 15:55:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260508_02"
down_revision: Union[str, None] = "20260508_01"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
bind = op.get_bind()
bind.execute(
sa.text(
"""
UPDATE study_setup_configs
SET published_project_snapshot = published_project_snapshot - 'summary_note' - 'objective_note'
WHERE published_project_snapshot IS NOT NULL
"""
)
)
bind.execute(
sa.text(
"""
UPDATE study_setup_config_versions
SET published_project_snapshot = published_project_snapshot - 'summary_note' - 'objective_note'
WHERE published_project_snapshot IS NOT NULL
"""
)
)
op.drop_column("studies", "objective_note")
op.drop_column("studies", "summary_note")
def downgrade() -> None:
op.add_column("studies", sa.Column("summary_note", sa.Text(), nullable=True))
op.add_column("studies", sa.Column("objective_note", sa.Text(), nullable=True))
@@ -0,0 +1,26 @@
"""add subject baseline date
Revision ID: 20260508_03
Revises: 20260508_02
Create Date: 2026-05-08 16:35:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260508_03"
down_revision: Union[str, None] = "20260508_02"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("subjects", sa.Column("baseline_date", sa.Date(), nullable=True))
def downgrade() -> None:
op.drop_column("subjects", "baseline_date")
@@ -0,0 +1,59 @@
"""add template fields to monitoring_visit_issues
Revision ID: 20260509_01
Revises: 20260508_03
Create Date: 2026-05-09 13:45:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "20260509_01"
down_revision: Union[str, None] = "20260508_03"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
columns = {col["name"] for col in inspector.get_columns("monitoring_visit_issues")}
if "severity" not in columns:
op.add_column("monitoring_visit_issues", sa.Column("severity", sa.String(length=64), nullable=True))
if "mark" not in columns:
op.add_column("monitoring_visit_issues", sa.Column("mark", sa.String(length=100), nullable=True))
if "visit_cycle" not in columns:
op.add_column("monitoring_visit_issues", sa.Column("visit_cycle", sa.String(length=100), nullable=True))
if "center_query" not in columns:
op.add_column("monitoring_visit_issues", sa.Column("center_query", sa.Text(), nullable=True))
if "center_latest_reply" not in columns:
op.add_column("monitoring_visit_issues", sa.Column("center_latest_reply", sa.Text(), nullable=True))
if "rectification_completed" not in columns:
op.add_column(
"monitoring_visit_issues",
sa.Column("rectification_completed", sa.Boolean(), nullable=False, server_default=sa.text("false")),
)
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
columns = {col["name"] for col in inspector.get_columns("monitoring_visit_issues")}
if "rectification_completed" in columns:
op.drop_column("monitoring_visit_issues", "rectification_completed")
if "center_latest_reply" in columns:
op.drop_column("monitoring_visit_issues", "center_latest_reply")
if "center_query" in columns:
op.drop_column("monitoring_visit_issues", "center_query")
if "visit_cycle" in columns:
op.drop_column("monitoring_visit_issues", "visit_cycle")
if "mark" in columns:
op.drop_column("monitoring_visit_issues", "mark")
if "severity" in columns:
op.drop_column("monitoring_visit_issues", "severity")
@@ -0,0 +1,55 @@
"""add site_id to monitoring_visit_issues
Revision ID: 20260509_02
Revises: 20260509_01
Create Date: 2026-05-09 14:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "20260509_02"
down_revision: Union[str, None] = "20260509_01"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
columns = {col["name"] for col in inspector.get_columns("monitoring_visit_issues")}
fks = {fk["name"] for fk in inspector.get_foreign_keys("monitoring_visit_issues")}
indexes = {idx["name"] for idx in inspector.get_indexes("monitoring_visit_issues")}
if "site_id" not in columns:
op.add_column("monitoring_visit_issues", sa.Column("site_id", postgresql.UUID(as_uuid=True), nullable=True))
if "fk_monitoring_visit_issues_site_id" not in fks:
op.create_foreign_key(
"fk_monitoring_visit_issues_site_id",
"monitoring_visit_issues",
"sites",
["site_id"],
["id"],
)
if "ix_monitoring_visit_issues_site_id" not in indexes:
op.create_index("ix_monitoring_visit_issues_site_id", "monitoring_visit_issues", ["site_id"])
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
columns = {col["name"] for col in inspector.get_columns("monitoring_visit_issues")}
fks = {fk["name"] for fk in inspector.get_foreign_keys("monitoring_visit_issues")}
indexes = {idx["name"] for idx in inspector.get_indexes("monitoring_visit_issues")}
if "ix_monitoring_visit_issues_site_id" in indexes:
op.drop_index("ix_monitoring_visit_issues_site_id", table_name="monitoring_visit_issues")
if "fk_monitoring_visit_issues_site_id" in fks:
op.drop_constraint("fk_monitoring_visit_issues_site_id", "monitoring_visit_issues", type_="foreignkey")
if "site_id" in columns:
op.drop_column("monitoring_visit_issues", "site_id")
@@ -0,0 +1,34 @@
"""add subject actual medication count
Revision ID: 20260509_03
Revises: 20260509_02
Create Date: 2026-05-09 14:58:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260509_03"
down_revision: Union[str, None] = "20260509_02"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
columns = {col["name"] for col in inspector.get_columns("subjects")}
if "actual_medication_count" not in columns:
op.add_column("subjects", sa.Column("actual_medication_count", sa.Integer(), nullable=True))
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
columns = {col["name"] for col in inspector.get_columns("subjects")}
if "actual_medication_count" in columns:
op.drop_column("subjects", "actual_medication_count")
@@ -0,0 +1,157 @@
"""backfill project info in setup drafts
Revision ID: 20260511_01
Revises: 20260509_03
Create Date: 2026-05-11 10:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260511_01"
down_revision: Union[str, None] = "20260509_03"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
EMPTY_PROJECT_INFO_JSON = """{
"code": "",
"name": "",
"project_full_name": "",
"sponsor": "",
"protocol_no": "",
"lead_unit": "",
"principal_investigator": "",
"main_pm": "",
"research_analysis": "",
"research_product": "",
"control_product": "",
"indication": "",
"research_population": "",
"research_design": "",
"plan_start_date": "",
"plan_end_date": "",
"planned_site_count": null,
"planned_enrollment_count": null,
"status": "",
"visit_schedule": []
}"""
def _has_table(inspector: sa.Inspector, table_name: str) -> bool:
return table_name in set(inspector.get_table_names())
def _has_columns(inspector: sa.Inspector, table_name: str, column_names: set[str]) -> bool:
existing = {col["name"] for col in inspector.get_columns(table_name)}
return column_names.issubset(existing)
def _backfill_config_column(table_name: str, config_column: str, *, require_existing_setup_content: bool) -> None:
content_filter = ""
if require_existing_setup_content:
content_filter = f"""
AND (
jsonb_array_length(COALESCE(setup.{config_column}->'projectMilestones', '[]'::jsonb)) > 0
OR COALESCE(setup.{config_column}->'enrollmentPlan', '{{}}'::jsonb) <> '{{"totalTarget": 0, "startDate": "", "endDate": "", "monthlyGoalNote": "", "stageBreakdown": ""}}'::jsonb
OR jsonb_array_length(COALESCE(setup.{config_column}->'siteMilestones', '[]'::jsonb)) > 0
OR jsonb_array_length(COALESCE(setup.{config_column}->'siteEnrollmentPlans', '[]'::jsonb)) > 0
OR jsonb_array_length(COALESCE(setup.{config_column}->'monitoringStrategies', '[]'::jsonb)) > 0
OR jsonb_array_length(COALESCE(setup.{config_column}->'centerConfirm', '[]'::jsonb)) > 0
)
"""
op.execute(
sa.text(
f"""
UPDATE {table_name} AS setup
SET {config_column} = jsonb_set(
setup.{config_column},
'{{projectInfo}}',
jsonb_build_object(
'code', COALESCE(studies.code, ''),
'name', COALESCE(studies.name, ''),
'project_full_name', COALESCE(studies.project_full_name, ''),
'sponsor', COALESCE(studies.sponsor, ''),
'protocol_no', COALESCE(studies.protocol_no, ''),
'lead_unit', COALESCE(studies.lead_unit, ''),
'principal_investigator', COALESCE(studies.principal_investigator, ''),
'main_pm', COALESCE(studies.main_pm, ''),
'research_analysis', COALESCE(studies.research_analysis, ''),
'research_product', COALESCE(studies.research_product, ''),
'control_product', COALESCE(studies.control_product, ''),
'indication', COALESCE(studies.indication, ''),
'research_population', COALESCE(studies.research_population, ''),
'research_design', COALESCE(studies.research_design, ''),
'plan_start_date', COALESCE(to_char(studies.plan_start_date, 'YYYY-MM-DD'), ''),
'plan_end_date', COALESCE(to_char(studies.plan_end_date, 'YYYY-MM-DD'), ''),
'planned_site_count', to_jsonb(studies.planned_site_count),
'planned_enrollment_count', to_jsonb(studies.planned_enrollment_count),
'status', COALESCE(studies.status, ''),
'visit_schedule', COALESCE(studies.visit_schedule::jsonb, '[]'::jsonb)
),
true
)
FROM studies
WHERE setup.study_id = studies.id
AND setup.{config_column} IS NOT NULL
AND (
NOT setup.{config_column} ? 'projectInfo'
OR setup.{config_column}->'projectInfo' = CAST(:empty_project_info AS jsonb)
)
{content_filter}
"""
).bindparams(empty_project_info=EMPTY_PROJECT_INFO_JSON)
)
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
required_study_columns = {
"id",
"code",
"name",
"project_full_name",
"sponsor",
"protocol_no",
"lead_unit",
"principal_investigator",
"main_pm",
"research_analysis",
"research_product",
"control_product",
"indication",
"research_population",
"research_design",
"plan_start_date",
"plan_end_date",
"planned_site_count",
"planned_enrollment_count",
"status",
"visit_schedule",
}
if not _has_table(inspector, "studies") or not _has_columns(inspector, "studies", required_study_columns):
return
if _has_table(inspector, "study_setup_configs") and _has_columns(
inspector, "study_setup_configs", {"study_id", "config"}
):
_backfill_config_column("study_setup_configs", "config", require_existing_setup_content=True)
if _has_table(inspector, "study_setup_configs") and _has_columns(
inspector, "study_setup_configs", {"study_id", "published_config"}
):
_backfill_config_column("study_setup_configs", "published_config", require_existing_setup_content=False)
if _has_table(inspector, "study_setup_config_versions") and _has_columns(
inspector, "study_setup_config_versions", {"study_id", "config"}
):
_backfill_config_column("study_setup_config_versions", "config", require_existing_setup_content=False)
def downgrade() -> None:
pass
@@ -0,0 +1,49 @@
"""rename user department to clinical_department
Revision ID: 20260511_02
Revises: 20260511_01
Create Date: 2026-05-11 17:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260511_02"
down_revision: Union[str, None] = "20260511_01"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
TABLE_NAME = "users"
OLD_COLUMN = "department"
NEW_COLUMN = "clinical_department"
def _has_table(inspector: sa.Inspector, table_name: str) -> bool:
return table_name in set(inspector.get_table_names())
def _has_column(inspector: sa.Inspector, table_name: str, column_name: str) -> bool:
return any(column["name"] == column_name for column in inspector.get_columns(table_name))
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if not _has_table(inspector, TABLE_NAME):
return
if _has_column(inspector, TABLE_NAME, OLD_COLUMN) and not _has_column(inspector, TABLE_NAME, NEW_COLUMN):
op.alter_column(TABLE_NAME, OLD_COLUMN, new_column_name=NEW_COLUMN)
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if not _has_table(inspector, TABLE_NAME):
return
if _has_column(inspector, TABLE_NAME, NEW_COLUMN) and not _has_column(inspector, TABLE_NAME, OLD_COLUMN):
op.alter_column(TABLE_NAME, NEW_COLUMN, new_column_name=OLD_COLUMN)
@@ -0,0 +1,36 @@
"""add phone to sites
Revision ID: 20260512_01
Revises: 20260511_02
Create Date: 2026-05-12 09:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260512_01"
down_revision: Union[str, None] = "20260511_02"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
columns = {col["name"] for col in inspector.get_columns("sites")}
if "phone" not in columns:
op.add_column("sites", sa.Column("phone", sa.String(length=100), nullable=True))
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
columns = {col["name"] for col in inspector.get_columns("sites")}
if "phone" in columns:
op.drop_column("sites", "phone")
@@ -0,0 +1,114 @@
"""remove special fee modules
Revision ID: 20260512_02
Revises: 20260512_01
Create Date: 2026-05-12 10:15:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "20260512_02"
down_revision: Union[str, None] = "20260512_01"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _table_exists(inspector: sa.Inspector, table_name: str) -> bool:
return table_name in inspector.get_table_names()
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if _table_exists(inspector, "attachments"):
op.execute(
"""
DELETE FROM attachments
WHERE entity_type IN (
'finance_special',
'special_expense',
'special_expense_voucher',
'special_expense_invoice',
'special_expense_other'
)
"""
)
if _table_exists(inspector, "fee_attachments"):
op.execute(
"""
DELETE FROM fee_attachments
WHERE entity_type = 'special_expense'
"""
)
if _table_exists(inspector, "audit_logs"):
op.execute(
"""
DELETE FROM audit_logs
WHERE entity_type IN ('finance_special', 'special_expense')
"""
)
if _table_exists(inspector, "finance_specials"):
op.drop_table("finance_specials")
if _table_exists(inspector, "special_expenses"):
op.drop_table("special_expenses")
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if not _table_exists(inspector, "special_expenses"):
op.create_table(
"special_expenses",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("project_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("center_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("category", sa.String(length=50), nullable=False),
sa.Column("amount", sa.Numeric(12, 2), nullable=False),
sa.Column("happen_date", sa.Date(), nullable=True),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("is_paid", sa.Boolean(), server_default="false", nullable=False),
sa.Column("paid_date", sa.Date(), nullable=True),
sa.Column("is_verified", sa.Boolean(), server_default="false", nullable=False),
sa.Column("verified_date", sa.Date(), nullable=True),
sa.Column("created_by", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["center_id"], ["sites.id"]),
sa.ForeignKeyConstraint(["created_by"], ["users.id"]),
sa.ForeignKeyConstraint(["project_id"], ["studies.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_special_expenses_project_id", "special_expenses", ["project_id"])
op.create_index("ix_special_expenses_center_id", "special_expenses", ["center_id"])
if not _table_exists(inspector, "finance_specials"):
op.create_table(
"finance_specials",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("study_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("site_name", sa.String(length=255), nullable=False),
sa.Column("fee_type", sa.String(length=50), nullable=False),
sa.Column("amount", sa.Numeric(12, 2), nullable=False),
sa.Column("occur_date", sa.Date(), nullable=True),
sa.Column("staff_name", sa.String(length=100), nullable=True),
sa.Column("remark", sa.Text(), nullable=True),
sa.Column("created_by", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["created_by"], ["users.id"]),
sa.ForeignKeyConstraint(["study_id"], ["studies.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_finance_specials_study_id", "finance_specials", ["study_id"])
@@ -0,0 +1,57 @@
"""add study role permissions
Revision ID: 20260512_03
Revises: 20260512_02
Create Date: 2026-05-12 10:45:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "20260512_03"
down_revision: Union[str, None] = "20260512_02"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _table_exists(inspector: sa.Inspector, table_name: str) -> bool:
return table_name in inspector.get_table_names()
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if _table_exists(inspector, "study_role_permissions"):
return
op.create_table(
"study_role_permissions",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("study_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("role", sa.String(length=20), nullable=False),
sa.Column("module", sa.String(length=80), nullable=False),
sa.Column("can_read", sa.Boolean(), server_default="false", nullable=False),
sa.Column("can_write", sa.Boolean(), server_default="false", nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["study_id"], ["studies.id"]),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("study_id", "role", "module", name="uq_study_role_permissions_study_role_module"),
)
op.create_index("ix_study_role_permissions_study_id", "study_role_permissions", ["study_id"])
op.create_index("ix_study_role_permissions_role", "study_role_permissions", ["role"])
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if not _table_exists(inspector, "study_role_permissions"):
return
op.drop_index("ix_study_role_permissions_role", table_name="study_role_permissions")
op.drop_index("ix_study_role_permissions_study_id", table_name="study_role_permissions")
op.drop_table("study_role_permissions")
@@ -0,0 +1,25 @@
"""add medical review role
Revision ID: 20260513_01
Revises: 20260512_03
Create Date: 2026-05-13 09:15:00.000000
"""
from typing import Sequence, Union
from alembic import op
revision: str = "20260513_01"
down_revision: Union[str, None] = "20260512_03"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute("ALTER TYPE user_role ADD VALUE IF NOT EXISTS 'MEDICAL_REVIEW'")
def downgrade() -> None:
pass
@@ -0,0 +1,81 @@
"""add api endpoint permissions and registry
Revision ID: 20260513_02
Revises: 20260513_01
Create Date: 2026-05-13 10:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "20260513_02"
down_revision: Union[str, None] = "20260513_01"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _table_exists(inspector: sa.Inspector, table_name: str) -> bool:
return table_name in inspector.get_table_names()
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
# Create api_endpoint_registries table
if not _table_exists(inspector, "api_endpoint_registries"):
op.create_table(
"api_endpoint_registries",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("endpoint_key", sa.String(length=100), nullable=False),
sa.Column("method", sa.String(length=10), nullable=False),
sa.Column("path", sa.String(length=200), nullable=False),
sa.Column("module", sa.String(length=80), nullable=False),
sa.Column("action", sa.String(length=20), nullable=False),
sa.Column("description", sa.String(length=500), nullable=True),
sa.Column("default_roles", sa.String(length=200), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("endpoint_key", name="uq_api_endpoint_registry_key"),
)
op.create_index("ix_api_endpoint_registries_endpoint_key", "api_endpoint_registries", ["endpoint_key"])
# Create api_endpoint_permissions table
if not _table_exists(inspector, "api_endpoint_permissions"):
op.create_table(
"api_endpoint_permissions",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("study_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("role", sa.String(length=20), nullable=False),
sa.Column("endpoint_key", sa.String(length=100), nullable=False),
sa.Column("allowed", sa.Boolean(), server_default="false", nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["study_id"], ["studies.id"]),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("study_id", "role", "endpoint_key", name="uq_api_endpoint_perm"),
)
op.create_index("ix_api_endpoint_permissions_study_id", "api_endpoint_permissions", ["study_id"])
op.create_index("ix_api_endpoint_permissions_role", "api_endpoint_permissions", ["role"])
op.create_index("ix_api_endpoint_permissions_endpoint_key", "api_endpoint_permissions", ["endpoint_key"])
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
# Drop api_endpoint_permissions table
if _table_exists(inspector, "api_endpoint_permissions"):
op.drop_index("ix_api_endpoint_permissions_endpoint_key", table_name="api_endpoint_permissions")
op.drop_index("ix_api_endpoint_permissions_role", table_name="api_endpoint_permissions")
op.drop_index("ix_api_endpoint_permissions_study_id", table_name="api_endpoint_permissions")
op.drop_table("api_endpoint_permissions")
# Drop api_endpoint_registries table
if _table_exists(inspector, "api_endpoint_registries"):
op.drop_index("ix_api_endpoint_registries_endpoint_key", table_name="api_endpoint_registries")
op.drop_table("api_endpoint_registries")
@@ -0,0 +1,143 @@
"""add permission templates
Revision ID: 20260514_01
Revises: 20260513_02
Create Date: 2026-05-14 10:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "20260514_01"
down_revision: Union[str, None] = "20260513_02"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 创建 permission_templates 表
op.create_table(
"permission_templates",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("name", sa.String(100), nullable=False),
sa.Column("description", sa.String(500), nullable=True),
sa.Column(
"template_type",
sa.Enum("ROLE", "SCENARIO", "CUSTOM", name="template_type"),
nullable=False,
),
sa.Column("is_system", sa.Boolean(), nullable=False, server_default="false"),
sa.Column("created_by", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.Column("permissions", postgresql.JSON(), nullable=False),
sa.Column("tags", sa.String(200), nullable=True),
sa.Column("category", sa.String(50), nullable=True),
sa.Column("recommended_roles", sa.String(200), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
# 创建 permission_template_versions 表
op.create_table(
"permission_template_versions",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("template_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("version", sa.Integer(), nullable=False),
sa.Column("permissions", postgresql.JSON(), nullable=False),
sa.Column("change_log", sa.String(500), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.ForeignKeyConstraint(
["template_id"],
["permission_templates.id"],
),
sa.PrimaryKeyConstraint("id"),
)
# 创建唯一约束
op.create_unique_constraint(
"uq_template_versions",
"permission_template_versions",
["template_id", "version"],
)
# 插入系统预设模板
_insert_system_templates()
def _insert_system_templates() -> None:
"""插入6个系统预设角色模板"""
templates = [
(
"3078efe6-60d6-4623-b7b3-862aac0084cd",
"PM",
"项目负责人,统筹项目全局,协调进度、资源与关键决策。",
"PM",
"PM",
'{"PM": {"subjects:create": true, "subjects:list": true, "subjects:read": true, "subjects:update": true, "subjects:delete": true, "visits:create": true, "visits:list": true, "visits:read": true, "visits:update": true, "visits:delete": true, "risk_issues:create": true, "risk_issues:list": true, "risk_issues:read": true, "risk_issues:update": true, "risk_issues:delete": true, "finance_contracts:create": true, "finance_contracts:list": true, "finance_contracts:read": true, "finance_contracts:update": true, "finance_contracts:delete": true, "fees_contracts:create": true, "fees_contracts:list": true, "fees_contracts:read": true, "fees_contracts:update": true, "fees_contracts:delete": true, "fees_payments:create": true, "fees_payments:update": true, "fees_payments:delete": true, "project_members:create": true, "project_members:list": true, "project_members:candidates": true, "project_members:update": true, "project_members:delete": true, "sites:create": true, "sites:list": true, "sites:read": true, "sites:update": true, "sites:delete": true, "ethics:create": true, "ethics:list": true, "ethics:read": true, "ethics:update": true, "ethics:delete": true, "feasibility:create": true, "feasibility:list": true, "feasibility:read": true, "feasibility:update": true, "feasibility:delete": true, "permissions:read": true, "permissions:update": true, "overview:read": true, "monitoring_issues:create": true, "monitoring_issues:list": true, "monitoring_issues:read": true, "monitoring_issues:update": true, "monitoring_issues:delete": true, "drug_shipments:create": true, "drug_shipments:list": true, "drug_shipments:read": true, "drug_shipments:update": true, "drug_shipments:delete": true, "materials:create": true, "materials:list": true, "materials:read": true, "materials:update": true, "materials:delete": true, "subject_pds:create": true, "subject_pds:list": true, "subject_pds:read": true, "subject_pds:update": true, "audit_logs:list": true, "audit_logs:read": true, "audit_logs:export": true, "knowledge_notes:create": true, "knowledge_notes:list": true, "knowledge_notes:read": true, "knowledge_notes:update": true, "knowledge_notes:delete": true, "subject_history:list": true, "subject_history:read": true, "subject_history:timeline": true, "subject_history:export": true, "subject_history:search": true, "milestones:list": true, "milestones:update": true, "attachments:create": true, "attachments:read": true, "attachments:update": true, "attachments:delete": true, "fees_attachments:create": true, "fees_attachments:read": true, "fees_attachments:delete": true, "faq:create": true, "faq:read": true, "faq:update": true, "faq:delete": true, "faq_category:create": true, "faq_category:read": true, "faq_category:update": true, "faq_category:delete": true, "faq_reply:create": true, "faq_reply:delete": true, "subject_histories:create": true, "subject_histories:list": true, "subject_histories:read": true, "subject_histories:update": true, "subject_histories:delete": true, "material_equipments:create": true, "material_equipments:list": true, "material_equipments:read": true, "material_equipments:update": true, "material_equipments:delete": true, "documents:create": true, "documents:read": true, "documents:update": true, "documents:delete": true}}',
),
(
"a17e6fd5-f0c6-4723-b166-6c25a595e28d",
"CRA",
"负责各中心临床监查执行,跟进现场质量、数据和问题闭环。",
"CRA",
"CRA",
'{"CRA": {"subjects:create": true, "subjects:list": true, "subjects:read": true, "subjects:update": true, "subjects:delete": false, "visits:create": true, "visits:list": true, "visits:read": true, "visits:update": true, "visits:delete": false, "risk_issues:create": true, "risk_issues:list": true, "risk_issues:read": true, "risk_issues:update": true, "risk_issues:delete": false, "finance_contracts:create": false, "finance_contracts:list": true, "finance_contracts:read": true, "finance_contracts:update": false, "finance_contracts:delete": false, "fees_contracts:create": false, "fees_contracts:list": true, "fees_contracts:read": true, "fees_contracts:update": false, "fees_contracts:delete": false, "fees_payments:create": false, "fees_payments:update": false, "fees_payments:delete": false, "project_members:create": false, "project_members:list": false, "project_members:candidates": false, "project_members:update": false, "project_members:delete": false, "sites:create": false, "sites:list": true, "sites:read": true, "sites:update": false, "sites:delete": false, "ethics:create": false, "ethics:list": true, "ethics:read": true, "ethics:update": false, "ethics:delete": false, "feasibility:create": false, "feasibility:list": true, "feasibility:read": true, "feasibility:update": false, "feasibility:delete": false, "permissions:read": false, "permissions:update": false, "overview:read": true, "monitoring_issues:create": true, "monitoring_issues:list": true, "monitoring_issues:read": true, "monitoring_issues:update": true, "monitoring_issues:delete": false, "drug_shipments:create": false, "drug_shipments:list": true, "drug_shipments:read": true, "drug_shipments:update": false, "drug_shipments:delete": false, "materials:create": false, "materials:list": true, "materials:read": true, "materials:update": false, "materials:delete": false, "subject_pds:create": true, "subject_pds:list": true, "subject_pds:read": true, "subject_pds:update": true, "audit_logs:list": false, "audit_logs:read": false, "audit_logs:export": false, "knowledge_notes:create": true, "knowledge_notes:list": true, "knowledge_notes:read": true, "knowledge_notes:update": true, "knowledge_notes:delete": false, "subject_history:list": true, "subject_history:read": true, "subject_history:timeline": true, "subject_history:export": true, "subject_history:search": true, "milestones:list": true, "milestones:update": false, "attachments:create": true, "attachments:read": true, "attachments:update": true, "attachments:delete": true, "fees_attachments:create": true, "fees_attachments:read": true, "fees_attachments:delete": true, "faq:create": false, "faq:read": true, "faq:update": false, "faq:delete": false, "faq_category:create": false, "faq_category:read": true, "faq_category:update": false, "faq_category:delete": false, "faq_reply:create": true, "faq_reply:delete": false, "subject_histories:create": true, "subject_histories:list": true, "subject_histories:read": true, "subject_histories:update": true, "subject_histories:delete": false, "material_equipments:create": true, "material_equipments:list": true, "material_equipments:read": true, "material_equipments:update": true, "material_equipments:delete": false, "documents:create": true, "documents:read": true, "documents:update": true, "documents:delete": false}}',
),
(
"f45fbfda-faed-4b83-b4a8-24ca4011894b",
"PV",
"负责药物警戒相关工作,跟踪安全性事件并支持风险评估。",
"PV",
"PV",
'{"PV": {"subjects:create": false, "subjects:list": true, "subjects:read": true, "subjects:update": false, "subjects:delete": false, "visits:create": false, "visits:list": true, "visits:read": true, "visits:update": false, "visits:delete": false, "risk_issues:create": true, "risk_issues:list": true, "risk_issues:read": true, "risk_issues:update": true, "risk_issues:delete": false, "finance_contracts:create": false, "finance_contracts:list": false, "finance_contracts:read": false, "finance_contracts:update": false, "finance_contracts:delete": false, "fees_contracts:create": false, "fees_contracts:list": false, "fees_contracts:read": false, "fees_contracts:update": false, "fees_contracts:delete": false, "fees_payments:create": false, "fees_payments:update": false, "fees_payments:delete": false, "project_members:create": false, "project_members:list": false, "project_members:candidates": false, "project_members:update": false, "project_members:delete": false, "sites:create": false, "sites:list": true, "sites:read": true, "sites:update": false, "sites:delete": false, "ethics:create": false, "ethics:list": true, "ethics:read": true, "ethics:update": false, "ethics:delete": false, "feasibility:create": false, "feasibility:list": true, "feasibility:read": true, "feasibility:update": false, "feasibility:delete": false, "permissions:read": false, "permissions:update": false, "overview:read": true, "monitoring_issues:create": false, "monitoring_issues:list": true, "monitoring_issues:read": true, "monitoring_issues:update": false, "monitoring_issues:delete": false, "drug_shipments:create": false, "drug_shipments:list": false, "drug_shipments:read": false, "drug_shipments:update": false, "drug_shipments:delete": false, "materials:create": false, "materials:list": false, "materials:read": false, "materials:update": false, "materials:delete": false, "subject_pds:create": false, "subject_pds:list": true, "subject_pds:read": true, "subject_pds:update": false, "audit_logs:list": false, "audit_logs:read": false, "audit_logs:export": false, "knowledge_notes:create": true, "knowledge_notes:list": true, "knowledge_notes:read": true, "knowledge_notes:update": true, "knowledge_notes:delete": false, "subject_history:list": true, "subject_history:read": true, "subject_history:timeline": true, "subject_history:export": false, "subject_history:search": true, "milestones:list": true, "milestones:update": false, "attachments:create": true, "attachments:read": true, "attachments:update": false, "attachments:delete": false, "fees_attachments:create": false, "fees_attachments:read": true, "fees_attachments:delete": false, "faq:create": false, "faq:read": true, "faq:update": false, "faq:delete": false, "faq_category:create": false, "faq_category:read": true, "faq_category:update": false, "faq_category:delete": false, "faq_reply:create": true, "faq_reply:delete": false, "subject_histories:create": false, "subject_histories:list": true, "subject_histories:read": true, "subject_histories:update": false, "subject_histories:delete": false, "material_equipments:create": false, "material_equipments:list": true, "material_equipments:read": true, "material_equipments:update": false, "material_equipments:delete": false, "documents:create": false, "documents:read": true, "documents:update": false, "documents:delete": false}}',
),
(
"b43d06e4-2ee2-4e80-b733-85964d738f40",
"QA",
"负责医学审核与稽查,关注质量风险、合规性和医学一致性。",
"MEDICAL_REVIEW",
"MEDICAL_REVIEW",
'{"MEDICAL_REVIEW": {"subjects:create": false, "subjects:list": true, "subjects:read": true, "subjects:update": false, "subjects:delete": false, "visits:create": false, "visits:list": true, "visits:read": true, "visits:update": false, "visits:delete": false, "risk_issues:create": true, "risk_issues:list": true, "risk_issues:read": true, "risk_issues:update": true, "risk_issues:delete": false, "finance_contracts:create": false, "finance_contracts:list": false, "finance_contracts:read": false, "finance_contracts:update": false, "finance_contracts:delete": false, "fees_contracts:create": false, "fees_contracts:list": false, "fees_contracts:read": false, "fees_contracts:update": false, "fees_contracts:delete": false, "fees_payments:create": false, "fees_payments:update": false, "fees_payments:delete": false, "project_members:create": false, "project_members:list": false, "project_members:candidates": false, "project_members:update": false, "project_members:delete": false, "sites:create": false, "sites:list": true, "sites:read": true, "sites:update": false, "sites:delete": false, "ethics:create": false, "ethics:list": true, "ethics:read": true, "ethics:update": false, "ethics:delete": false, "feasibility:create": false, "feasibility:list": true, "feasibility:read": true, "feasibility:update": false, "feasibility:delete": false, "permissions:read": false, "permissions:update": false, "overview:read": true, "monitoring_issues:create": false, "monitoring_issues:list": true, "monitoring_issues:read": true, "monitoring_issues:update": false, "monitoring_issues:delete": false, "drug_shipments:create": false, "drug_shipments:list": false, "drug_shipments:read": false, "drug_shipments:update": false, "drug_shipments:delete": false, "materials:create": false, "materials:list": false, "materials:read": false, "materials:update": false, "materials:delete": false, "subject_pds:create": false, "subject_pds:list": true, "subject_pds:read": true, "subject_pds:update": false, "audit_logs:list": false, "audit_logs:read": false, "audit_logs:export": false, "knowledge_notes:create": true, "knowledge_notes:list": true, "knowledge_notes:read": true, "knowledge_notes:update": true, "knowledge_notes:delete": false, "subject_history:list": true, "subject_history:read": true, "subject_history:timeline": true, "subject_history:export": false, "subject_history:search": true, "milestones:list": true, "milestones:update": false, "attachments:create": false, "attachments:read": true, "attachments:update": false, "attachments:delete": false, "fees_attachments:create": false, "fees_attachments:read": false, "fees_attachments:delete": false, "faq:create": false, "faq:read": true, "faq:update": false, "faq:delete": false, "faq_category:create": false, "faq_category:read": true, "faq_category:update": false, "faq_category:delete": false, "faq_reply:create": true, "faq_reply:delete": false, "subject_histories:create": false, "subject_histories:list": true, "subject_histories:read": true, "subject_histories:update": false, "subject_histories:delete": false, "material_equipments:create": false, "material_equipments:list": true, "material_equipments:read": true, "material_equipments:update": false, "material_equipments:delete": false, "documents:create": false, "documents:read": true, "documents:update": false, "documents:delete": false}}',
),
(
"1e38cffb-a7f2-4243-a31c-1a4f8ce66a05",
"CTA",
"负责合同、药品及相关项目事务管理,保障执行支持与物资协同。",
"IMP",
"IMP",
'{"IMP": {"subjects:create": false, "subjects:list": true, "subjects:read": true, "subjects:update": false, "subjects:delete": false, "visits:create": false, "visits:list": true, "visits:read": true, "visits:update": false, "visits:delete": false, "risk_issues:create": false, "risk_issues:list": true, "risk_issues:read": true, "risk_issues:update": false, "risk_issues:delete": false, "finance_contracts:create": false, "finance_contracts:list": true, "finance_contracts:read": true, "finance_contracts:update": false, "finance_contracts:delete": false, "fees_contracts:create": false, "fees_contracts:list": true, "fees_contracts:read": true, "fees_contracts:update": false, "fees_contracts:delete": false, "fees_payments:create": false, "fees_payments:update": false, "fees_payments:delete": false, "project_members:create": false, "project_members:list": false, "project_members:candidates": false, "project_members:update": false, "project_members:delete": false, "sites:create": false, "sites:list": true, "sites:read": true, "sites:update": false, "sites:delete": false, "ethics:create": false, "ethics:list": false, "ethics:read": false, "ethics:update": false, "ethics:delete": false, "feasibility:create": false, "feasibility:list": false, "feasibility:read": false, "feasibility:update": false, "feasibility:delete": false, "permissions:read": false, "permissions:update": false, "overview:read": true, "monitoring_issues:create": false, "monitoring_issues:list": false, "monitoring_issues:read": false, "monitoring_issues:update": false, "monitoring_issues:delete": false, "drug_shipments:create": true, "drug_shipments:list": true, "drug_shipments:read": true, "drug_shipments:update": true, "drug_shipments:delete": false, "materials:create": true, "materials:list": true, "materials:read": true, "materials:update": true, "materials:delete": false, "subject_pds:create": false, "subject_pds:list": false, "subject_pds:read": false, "subject_pds:update": false, "audit_logs:list": false, "audit_logs:read": false, "audit_logs:export": false, "knowledge_notes:create": false, "knowledge_notes:list": true, "knowledge_notes:read": true, "knowledge_notes:update": false, "knowledge_notes:delete": false, "subject_history:list": false, "subject_history:read": false, "subject_history:timeline": false, "subject_history:export": false, "subject_history:search": false, "milestones:list": true, "milestones:update": false, "attachments:create": false, "attachments:read": true, "attachments:update": false, "attachments:delete": false, "fees_attachments:create": false, "fees_attachments:read": false, "fees_attachments:delete": false, "faq:create": false, "faq:read": true, "faq:update": false, "faq:delete": false, "faq_category:create": false, "faq_category:read": true, "faq_category:update": false, "faq_category:delete": false, "faq_reply:create": false, "faq_reply:delete": false, "subject_histories:create": false, "subject_histories:list": true, "subject_histories:read": true, "subject_histories:update": false, "subject_histories:delete": false, "material_equipments:create": false, "material_equipments:list": true, "material_equipments:read": true, "material_equipments:update": false, "material_equipments:delete": false, "documents:create": false, "documents:read": true, "documents:update": false, "documents:delete": false}}',
),
]
for tid, name, desc, category, recommended_roles, perms_json in templates:
op.execute(
f"""INSERT INTO permission_templates
(id, name, description, template_type, is_system, category, recommended_roles, permissions, created_at, updated_at)
VALUES ('{tid}', '{name}', '{desc}', 'ROLE', true, '{category}', '{recommended_roles}',
'{perms_json}'::json, NOW(), NOW())"""
)
def downgrade() -> None:
op.drop_table("permission_template_versions")
op.drop_table("permission_templates")
op.execute("DROP TYPE IF EXISTS template_type")
@@ -0,0 +1,28 @@
"""add active_roles to studies
Revision ID: 20260518_01
Revises: 20260514_01
Create Date: 2026-05-18 16:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "20260518_01"
down_revision: Union[str, None] = "20260514_01"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"studies",
sa.Column("active_roles", sa.JSON(), nullable=False, server_default="[]"),
)
def downgrade() -> None:
op.drop_column("studies", "active_roles")
@@ -0,0 +1,59 @@
"""add permission monitoring tables
Revision ID: 20260519_01
Revises: 20260518_01
Create Date: 2026-05-19 10:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
revision: str = "20260519_01"
down_revision: Union[str, None] = "20260518_01"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"permission_access_logs",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("study_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("studies.id"), nullable=False),
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=False),
sa.Column("endpoint_key", sa.String(100), nullable=False),
sa.Column("role", sa.String(30), nullable=False),
sa.Column("allowed", sa.Boolean(), nullable=False),
sa.Column("elapsed_ms", sa.Float(), nullable=False),
sa.Column("ip_address", sa.String(45), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
)
op.create_index("ix_perm_log_study_created", "permission_access_logs", ["study_id", "created_at"])
op.create_index("ix_perm_log_user_created", "permission_access_logs", ["user_id", "created_at"])
op.create_index("ix_perm_log_endpoint_created", "permission_access_logs", ["endpoint_key", "created_at"])
op.create_index("ix_perm_log_created_at", "permission_access_logs", ["created_at"])
op.create_index("ix_perm_log_allowed", "permission_access_logs", ["allowed", "created_at"])
op.create_table(
"permission_metric_snapshots",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("bucket_time", sa.DateTime(timezone=True), nullable=False),
sa.Column("total_checks", sa.Integer(), nullable=False, server_default="0"),
sa.Column("allowed_checks", sa.Integer(), nullable=False, server_default="0"),
sa.Column("denied_checks", sa.Integer(), nullable=False, server_default="0"),
sa.Column("avg_elapsed_ms", sa.Float(), nullable=False, server_default="0"),
sa.Column("max_elapsed_ms", sa.Float(), nullable=False, server_default="0"),
sa.Column("cache_hits", sa.Integer(), nullable=False, server_default="0"),
sa.Column("cache_misses", sa.Integer(), nullable=False, server_default="0"),
sa.Column("error_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
)
op.create_index("ix_perm_snapshot_bucket", "permission_metric_snapshots", ["bucket_time"])
def downgrade() -> None:
op.drop_table("permission_metric_snapshots")
op.drop_table("permission_access_logs")
@@ -0,0 +1,42 @@
"""add security access logs
Revision ID: 20260520_01
Revises: 20260519_01
Create Date: 2026-05-20 10:40:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
revision: str = "20260520_01"
down_revision: Union[str, None] = "20260519_01"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"security_access_logs",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("method", sa.String(12), nullable=False),
sa.Column("path", sa.String(500), nullable=False),
sa.Column("status_code", sa.Integer(), nullable=False),
sa.Column("elapsed_ms", sa.Float(), nullable=False),
sa.Column("client_ip", sa.String(45), nullable=True),
sa.Column("user_agent", sa.String(500), nullable=True),
sa.Column("auth_status", sa.String(30), nullable=False),
sa.Column("user_identifier", sa.String(80), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
)
op.create_index("ix_security_log_created_at", "security_access_logs", ["created_at"])
op.create_index("ix_security_log_ip_created", "security_access_logs", ["client_ip", "created_at"])
op.create_index("ix_security_log_status_created", "security_access_logs", ["status_code", "created_at"])
op.create_index("ix_security_log_auth_created", "security_access_logs", ["auth_status", "created_at"])
def downgrade() -> None:
op.drop_table("security_access_logs")
@@ -0,0 +1,153 @@
"""remove legacy qa role
Revision ID: 20260521_01
Revises: 20260520_01
Create Date: 2026-05-21 09:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
revision: str = "20260521_01"
down_revision: Union[str, None] = "20260520_01"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
USER_ROLE_VALUES = ("ADMIN", "PM", "CRA", "PV", "IMP", "MEDICAL_REVIEW")
def upgrade() -> None:
op.execute("CREATE TEMP TABLE qa_user_ids AS SELECT id FROM users WHERE role = 'QA'")
op.execute("DELETE FROM permission_access_logs WHERE role = 'QA'")
op.execute("DELETE FROM api_endpoint_permissions WHERE role = 'QA'")
op.execute("DELETE FROM study_members WHERE role_in_study = 'QA'")
op.execute(
"""
UPDATE studies
SET active_roles = (
SELECT COALESCE(json_agg(role), '[]'::json)
FROM json_array_elements_text(active_roles) AS role
WHERE role <> 'QA'
)
WHERE active_roles::text LIKE '%QA%'
"""
)
op.execute(
"""
DELETE FROM permission_template_versions
WHERE template_id IN (
SELECT id FROM permission_templates
WHERE category = 'QA' OR recommended_roles = 'QA' OR (permissions::jsonb ? 'QA')
)
"""
)
op.execute(
"""
DELETE FROM permission_templates
WHERE category = 'QA' OR recommended_roles = 'QA' OR (permissions::jsonb ? 'QA')
"""
)
op.execute(
"""
UPDATE permission_templates
SET created_by = NULL
WHERE created_by IN (SELECT id FROM qa_user_ids)
"""
)
op.execute(
"""
DELETE FROM audit_logs
WHERE operator_role = 'QA'
OR operator_id IN (SELECT id FROM users WHERE role = 'QA')
"""
)
op.execute(
"""
DO $$
DECLARE
rel record;
BEGIN
FOR rel IN
SELECT
quote_ident(ns.nspname) AS schema_name,
quote_ident(cls.relname) AS table_name,
quote_ident(att.attname) AS column_name,
att.attnotnull AS not_null
FROM pg_constraint con
JOIN pg_class cls ON cls.oid = con.conrelid
JOIN pg_namespace ns ON ns.oid = cls.relnamespace
JOIN pg_attribute att ON att.attrelid = con.conrelid AND att.attnum = ANY(con.conkey)
WHERE con.contype = 'f'
AND con.confrelid = 'users'::regclass
AND array_length(con.conkey, 1) = 1
LOOP
IF rel.table_name = 'users' AND rel.column_name = 'id' THEN
CONTINUE;
END IF;
IF rel.not_null THEN
EXECUTE format(
'DELETE FROM %s.%s WHERE %s IN (SELECT id FROM qa_user_ids)',
rel.schema_name,
rel.table_name,
rel.column_name
);
ELSE
EXECUTE format(
'UPDATE %s.%s SET %s = NULL WHERE %s IN (SELECT id FROM qa_user_ids)',
rel.schema_name,
rel.table_name,
rel.column_name,
rel.column_name
);
END IF;
END LOOP;
END $$;
"""
)
# Delete legacy QA users after dependent project-role and audit rows have been removed.
op.execute("DELETE FROM users WHERE id IN (SELECT id FROM qa_user_ids)")
op.execute("DROP TABLE qa_user_ids")
allowed = ", ".join(f"'{role}'" for role in USER_ROLE_VALUES)
op.execute(
f"""
ALTER TABLE users
ALTER COLUMN role TYPE text
USING role::text
"""
)
op.execute("DROP TYPE user_role")
op.execute(f"CREATE TYPE user_role AS ENUM ({allowed})")
op.execute(
"""
ALTER TABLE users
ALTER COLUMN role TYPE user_role
USING role::user_role
"""
)
def downgrade() -> None:
allowed = ", ".join(f"'{role}'" for role in USER_ROLE_VALUES)
op.execute(
"""
ALTER TABLE users
ALTER COLUMN role TYPE text
USING role::text
"""
)
op.execute("DROP TYPE user_role")
op.execute(f"CREATE TYPE user_role AS ENUM ({allowed})")
op.execute(
"""
ALTER TABLE users
ALTER COLUMN role TYPE user_role
USING role::user_role
"""
)
@@ -0,0 +1,58 @@
"""update role template copy
Revision ID: 20260522_01
Revises: 20260521_01
Create Date: 2026-05-22 09:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
revision: str = "20260522_01"
down_revision: Union[str, None] = "20260521_01"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
ROLE_TEMPLATE_COPY = {
"PM": ("PM", "项目负责人,统筹项目全局,协调进度、资源与关键决策。"),
"CRA": ("CRA", "负责各中心临床监查执行,跟进现场质量、数据和问题闭环。"),
"IMP": ("CTA", "负责合同、药品及相关项目事务管理,保障执行支持与物资协同。"),
"MEDICAL_REVIEW": ("QA", "负责医学审核与稽查,关注质量风险、合规性和医学一致性。"),
"PV": ("PV", "负责药物警戒相关工作,跟踪安全性事件并支持风险评估。"),
}
PREVIOUS_ROLE_TEMPLATE_COPY = {
"PM": ("项目经理", "项目管理员,拥有所有权限"),
"CRA": ("临床研究协调员", "数据输入和日常管理人员"),
"IMP": ("物资管理员", "物资和设备管理人员"),
"MEDICAL_REVIEW": ("医学审核", "医学审核人员"),
"PV": ("访视员", "访视和参与者管理人员"),
}
def _update_role_copy(category: str, name: str, description: str) -> None:
op.execute(
f"""
UPDATE permission_templates
SET name = '{name}',
description = '{description}',
updated_at = NOW()
WHERE category = '{category}'
AND template_type = 'ROLE'
AND is_system = true
"""
)
def upgrade() -> None:
for category, (name, description) in ROLE_TEMPLATE_COPY.items():
_update_role_copy(category, name, description)
def downgrade() -> None:
for category, (name, description) in PREVIOUS_ROLE_TEMPLATE_COPY.items():
_update_role_copy(category, name, description)
@@ -0,0 +1,90 @@
"""remove stale unused permissions
Revision ID: 20260526_01
Revises: 20260522_01
Create Date: 2026-05-26 15:40:00.000000
"""
from typing import Sequence, Union
from alembic import op
revision: str = "20260526_01"
down_revision: Union[str, None] = "20260522_01"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
LEGACY_MONITORING_AUDIT_PREFIX = "monitoring" + "_audit"
MONITORING_ISSUE_PREFIX = "monitoring" + "_issues"
STALE_PERMISSION_KEYS = (
"subject_history:list",
"subject_history:read",
"subject_history:timeline",
"subject_history:export",
"subject_history:search",
"subject_pds:read",
"visits:read",
"risk_issues:create",
"risk_issues:list",
"risk_issues:read",
"risk_issues:update",
"risk_issues:delete",
"risk_issue_aes:list",
"risk_issue_pds:list",
f"{MONITORING_ISSUE_PREFIX}:close",
f"{MONITORING_ISSUE_PREFIX}:history",
f"{LEGACY_MONITORING_AUDIT_PREFIX}:create",
f"{LEGACY_MONITORING_AUDIT_PREFIX}:read",
f"{LEGACY_MONITORING_AUDIT_PREFIX}:update",
f"{LEGACY_MONITORING_AUDIT_PREFIX}:delete",
)
def _quoted_keys() -> str:
return ", ".join(f"'{key}'" for key in STALE_PERMISSION_KEYS)
def upgrade() -> None:
op.execute(
f"""
DELETE FROM api_endpoint_permissions
WHERE endpoint_key IN ({_quoted_keys()})
"""
)
op.execute(
f"""
UPDATE permission_templates
SET permissions = (
SELECT jsonb_object_agg(role_key, role_permissions - ARRAY[{_quoted_keys()}])
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
),
updated_at = NOW()
WHERE EXISTS (
SELECT 1
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
WHERE role_permissions ?| ARRAY[{_quoted_keys()}]
)
"""
)
op.execute(
f"""
UPDATE permission_template_versions
SET permissions = (
SELECT jsonb_object_agg(role_key, role_permissions - ARRAY[{_quoted_keys()}])
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
)
WHERE EXISTS (
SELECT 1
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
WHERE role_permissions ?| ARRAY[{_quoted_keys()}]
)
"""
)
def downgrade() -> None:
pass
@@ -0,0 +1,149 @@
"""rename project roles to qa and cta
Revision ID: 20260527_01
Revises: 20260526_01
Create Date: 2026-05-27 08:45:00.000000
"""
from typing import Sequence, Union
from alembic import op
revision: str = "20260527_01"
down_revision: Union[str, None] = "20260526_01"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _replace_role_values(table: str, column: str) -> None:
op.execute(f"UPDATE {table} SET {column} = 'QA' WHERE {column} = 'MEDICAL_REVIEW'")
op.execute(f"UPDATE {table} SET {column} = 'CTA' WHERE {column} = 'IMP'")
def _restore_role_values(table: str, column: str) -> None:
op.execute(f"UPDATE {table} SET {column} = 'MEDICAL_REVIEW' WHERE {column} = 'QA'")
op.execute(f"UPDATE {table} SET {column} = 'IMP' WHERE {column} = 'CTA'")
def _normalize_role_array(table: str, column: str) -> None:
op.execute(
f"""
UPDATE {table}
SET {column} = (
SELECT COALESCE(json_agg(DISTINCT mapped_role), '[]'::json)
FROM (
SELECT CASE role
WHEN 'MEDICAL_REVIEW' THEN 'QA'
WHEN 'IMP' THEN 'CTA'
ELSE role
END AS mapped_role
FROM json_array_elements_text({column}) AS role
) mapped
)
WHERE {column}::text LIKE '%MEDICAL_REVIEW%'
OR {column}::text LIKE '%IMP%'
"""
)
def _normalize_permission_template_roles(table: str) -> None:
op.execute(
f"""
UPDATE {table}
SET permissions = (
SELECT jsonb_object_agg(
CASE role_key
WHEN 'MEDICAL_REVIEW' THEN 'QA'
WHEN 'IMP' THEN 'CTA'
ELSE role_key
END,
role_permissions
)
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
)
WHERE permissions::jsonb ? 'MEDICAL_REVIEW'
OR permissions::jsonb ? 'IMP'
"""
)
def _normalize_recommended_roles() -> None:
op.execute(
"""
UPDATE permission_templates
SET recommended_roles = regexp_replace(
regexp_replace(recommended_roles, '(^|,)MEDICAL_REVIEW(,|$)', '\\1QA\\2', 'g'),
'(^|,)IMP(,|$)', '\\1CTA\\2', 'g'
)
WHERE recommended_roles ~ '(^|,)(MEDICAL_REVIEW|IMP)(,|$)'
"""
)
def _restore_permission_template_roles(table: str) -> None:
op.execute(
f"""
UPDATE {table}
SET permissions = (
SELECT jsonb_object_agg(
CASE role_key
WHEN 'QA' THEN 'MEDICAL_REVIEW'
WHEN 'CTA' THEN 'IMP'
ELSE role_key
END,
role_permissions
)
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
)
WHERE permissions::jsonb ? 'QA'
OR permissions::jsonb ? 'CTA'
"""
)
def _replace_role_enum_values() -> None:
op.execute("ALTER TYPE user_role ADD VALUE IF NOT EXISTS 'QA'")
op.execute("ALTER TYPE user_role ADD VALUE IF NOT EXISTS 'CTA'")
op.execute(
"""
ALTER TABLE users
ALTER COLUMN role TYPE text
USING role::text
"""
)
_replace_role_values("users", "role")
op.execute("DROP TYPE user_role")
op.execute("CREATE TYPE user_role AS ENUM ('ADMIN', 'PM', 'CRA', 'PV', 'QA', 'CTA')")
op.execute(
"""
ALTER TABLE users
ALTER COLUMN role TYPE user_role
USING role::user_role
"""
)
def upgrade() -> None:
_replace_role_enum_values()
_replace_role_values("study_members", "role_in_study")
_replace_role_values("api_endpoint_permissions", "role")
_replace_role_values("permission_access_logs", "role")
_replace_role_values("audit_logs", "operator_role")
_replace_role_values("permission_templates", "category")
_normalize_recommended_roles()
_normalize_role_array("studies", "active_roles")
_normalize_permission_template_roles("permission_templates")
_normalize_permission_template_roles("permission_template_versions")
def downgrade() -> None:
_restore_role_values("study_members", "role_in_study")
_restore_role_values("api_endpoint_permissions", "role")
_restore_role_values("permission_access_logs", "role")
_restore_role_values("audit_logs", "operator_role")
_restore_role_values("permission_templates", "category")
_restore_role_values("permission_templates", "recommended_roles")
_restore_permission_template_roles("permission_templates")
_restore_permission_template_roles("permission_template_versions")
@@ -0,0 +1,51 @@
"""add contract fee basic fields
Revision ID: 20260527_02
Revises: 20260527_01
Create Date: 2026-05-27 10:15:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260527_02"
down_revision: Union[str, None] = "20260527_01"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _column_exists(inspector: sa.Inspector, table_name: str, column_name: str) -> bool:
return column_name in {column["name"] for column in inspector.get_columns(table_name)}
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if "contract_fees" not in inspector.get_table_names():
return
if not _column_exists(inspector, "contract_fees", "contract_no"):
op.add_column("contract_fees", sa.Column("contract_no", sa.String(length=100), nullable=True))
if not _column_exists(inspector, "contract_fees", "signed_date"):
op.add_column("contract_fees", sa.Column("signed_date", sa.Date(), nullable=True))
if not _column_exists(inspector, "contract_fees", "currency"):
op.add_column("contract_fees", sa.Column("currency", sa.String(length=10), server_default="CNY", nullable=False))
if not _column_exists(inspector, "contract_fees", "remark"):
op.add_column("contract_fees", sa.Column("remark", sa.Text(), nullable=True))
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if "contract_fees" not in inspector.get_table_names():
return
for column_name in ("remark", "currency", "signed_date", "contract_no"):
if _column_exists(inspector, "contract_fees", column_name):
op.drop_column("contract_fees", column_name)
@@ -0,0 +1,124 @@
"""remove legacy finance contracts
Revision ID: 20260527_03
Revises: 20260527_02
Create Date: 2026-05-27 10:25:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "20260527_03"
down_revision: Union[str, None] = "20260527_02"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
LEGACY_KEYS = (
"finance_contracts:create",
"finance_contracts:list",
"finance_contracts:read",
"finance_contracts:update",
"finance_contracts:delete",
"fees_payments:create",
"fees_payments:update",
"fees_payments:delete",
"fees_attachments:create",
"fees_attachments:read",
"fees_attachments:delete",
)
def _table_exists(inspector: sa.Inspector, table_name: str) -> bool:
return table_name in inspector.get_table_names()
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if _table_exists(inspector, "finance_contracts") and _table_exists(inspector, "contract_fees"):
op.execute(
"""
UPDATE contract_fees cf
SET
contract_no = COALESCE(cf.contract_no, legacy.contract_no),
signed_date = COALESCE(cf.signed_date, legacy.signed_date),
currency = COALESCE(NULLIF(cf.currency, ''), legacy.currency, 'CNY'),
remark = COALESCE(cf.remark, legacy.remark)
FROM (
SELECT DISTINCT ON (fc.study_id, s.id)
fc.study_id,
s.id AS center_id,
fc.contract_no,
fc.signed_date,
fc.currency,
fc.remark
FROM finance_contracts fc
JOIN sites s
ON s.study_id = fc.study_id
AND s.name = fc.site_name
ORDER BY fc.study_id, s.id, fc.updated_at DESC, fc.created_at DESC
) legacy
WHERE cf.project_id = legacy.study_id
AND cf.center_id = legacy.center_id
"""
)
if _table_exists(inspector, "api_endpoint_permissions"):
quoted_keys = ", ".join(f"'{key}'" for key in LEGACY_KEYS)
op.execute(f"DELETE FROM api_endpoint_permissions WHERE endpoint_key IN ({quoted_keys})")
if _table_exists(inspector, "permission_templates"):
for key in LEGACY_KEYS:
op.execute(f"UPDATE permission_templates SET permissions = permissions::jsonb #- '{{PM,{key}}}'")
op.execute(f"UPDATE permission_templates SET permissions = permissions::jsonb #- '{{CRA,{key}}}'")
op.execute(f"UPDATE permission_templates SET permissions = permissions::jsonb #- '{{PV,{key}}}'")
op.execute(f"UPDATE permission_templates SET permissions = permissions::jsonb #- '{{QA,{key}}}'")
op.execute(f"UPDATE permission_templates SET permissions = permissions::jsonb #- '{{CTA,{key}}}'")
if _table_exists(inspector, "permission_template_versions"):
for key in LEGACY_KEYS:
op.execute(f"UPDATE permission_template_versions SET permissions = permissions::jsonb #- '{{PM,{key}}}'")
op.execute(f"UPDATE permission_template_versions SET permissions = permissions::jsonb #- '{{CRA,{key}}}'")
op.execute(f"UPDATE permission_template_versions SET permissions = permissions::jsonb #- '{{PV,{key}}}'")
op.execute(f"UPDATE permission_template_versions SET permissions = permissions::jsonb #- '{{QA,{key}}}'")
op.execute(f"UPDATE permission_template_versions SET permissions = permissions::jsonb #- '{{CTA,{key}}}'")
if _table_exists(inspector, "attachments"):
op.execute("DELETE FROM attachments WHERE entity_type = 'finance_contract'")
if _table_exists(inspector, "audit_logs"):
op.execute("DELETE FROM audit_logs WHERE entity_type = 'finance_contract'")
if _table_exists(inspector, "finance_contracts"):
op.drop_table("finance_contracts")
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if not _table_exists(inspector, "finance_contracts"):
op.create_table(
"finance_contracts",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("study_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("site_name", sa.String(length=255), nullable=False),
sa.Column("contract_no", sa.String(length=100), nullable=False),
sa.Column("signed_date", sa.Date(), nullable=True),
sa.Column("amount", sa.Numeric(12, 2), nullable=False),
sa.Column("currency", sa.String(length=10), nullable=False),
sa.Column("remark", sa.Text(), nullable=True),
sa.Column("created_by", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["created_by"], ["users.id"]),
sa.ForeignKeyConstraint(["study_id"], ["studies.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_finance_contracts_study_id", "finance_contracts", ["study_id"])
@@ -0,0 +1,81 @@
"""remove stale startup permissions
Revision ID: 20260527_04
Revises: 20260527_03
Create Date: 2026-05-27 11:05:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260527_04"
down_revision: Union[str, None] = "20260527_03"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
STALE_STARTUP_PERMISSION_KEYS = (
"budget:create",
"budget:list",
"budget:read",
"budget:update",
"budget:delete",
"timeline:create",
"timeline:list",
"timeline:read",
"timeline:update",
"timeline:delete",
)
def _quoted_keys() -> str:
return ", ".join(f"'{key}'" for key in STALE_STARTUP_PERMISSION_KEYS)
def _table_exists(inspector: sa.Inspector, table_name: str) -> bool:
return table_name in inspector.get_table_names()
def _remove_template_permission_keys(table_name: str, *, touch_updated_at: bool = False) -> None:
updated_at_assignment = ", updated_at = NOW()" if touch_updated_at else ""
op.execute(
f"""
UPDATE {table_name}
SET permissions = (
SELECT jsonb_object_agg(role_key, role_permissions - ARRAY[{_quoted_keys()}])
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
){updated_at_assignment}
WHERE EXISTS (
SELECT 1
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
WHERE role_permissions ?| ARRAY[{_quoted_keys()}]
)
"""
)
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if _table_exists(inspector, "api_endpoint_permissions"):
op.execute(
f"""
DELETE FROM api_endpoint_permissions
WHERE endpoint_key IN ({_quoted_keys()})
"""
)
if _table_exists(inspector, "permission_templates"):
_remove_template_permission_keys("permission_templates", touch_updated_at=True)
if _table_exists(inspector, "permission_template_versions"):
_remove_template_permission_keys("permission_template_versions")
def downgrade() -> None:
pass
@@ -0,0 +1,81 @@
"""remove legacy startup ethics permission keys
Revision ID: 20260527_05
Revises: 20260527_04
Create Date: 2026-05-27 11:20:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260527_05"
down_revision: Union[str, None] = "20260527_04"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
LEGACY_STARTUP_ETHICS_PERMISSION_KEYS = (
"feasibility:create",
"feasibility:list",
"feasibility:read",
"feasibility:update",
"feasibility:delete",
"ethics:create",
"ethics:list",
"ethics:read",
"ethics:update",
"ethics:delete",
)
def _quoted_keys() -> str:
return ", ".join(f"'{key}'" for key in LEGACY_STARTUP_ETHICS_PERMISSION_KEYS)
def _table_exists(inspector: sa.Inspector, table_name: str) -> bool:
return table_name in inspector.get_table_names()
def _remove_template_permission_keys(table_name: str, *, touch_updated_at: bool = False) -> None:
updated_at_assignment = ", updated_at = NOW()" if touch_updated_at else ""
op.execute(
f"""
UPDATE {table_name}
SET permissions = (
SELECT jsonb_object_agg(role_key, role_permissions - ARRAY[{_quoted_keys()}])
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
){updated_at_assignment}
WHERE EXISTS (
SELECT 1
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
WHERE role_permissions ?| ARRAY[{_quoted_keys()}]
)
"""
)
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if _table_exists(inspector, "api_endpoint_permissions"):
op.execute(
f"""
DELETE FROM api_endpoint_permissions
WHERE endpoint_key IN ({_quoted_keys()})
"""
)
if _table_exists(inspector, "permission_templates"):
_remove_template_permission_keys("permission_templates", touch_updated_at=True)
if _table_exists(inspector, "permission_template_versions"):
_remove_template_permission_keys("permission_template_versions")
def downgrade() -> None:
pass
@@ -0,0 +1,70 @@
"""remove dashboard permission
Revision ID: 20260527_06
Revises: 20260527_05
Create Date: 2026-05-27 16:05:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260527_06"
down_revision: Union[str, None] = "20260527_05"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
LEGACY_DASHBOARD_PERMISSION_KEYS = ("dashboard:read",)
def _quoted_keys() -> str:
return ", ".join(f"'{key}'" for key in LEGACY_DASHBOARD_PERMISSION_KEYS)
def _table_exists(inspector: sa.Inspector, table_name: str) -> bool:
return table_name in inspector.get_table_names()
def _remove_template_permission_keys(table_name: str, *, touch_updated_at: bool = False) -> None:
updated_at_assignment = ", updated_at = NOW()" if touch_updated_at else ""
op.execute(
f"""
UPDATE {table_name}
SET permissions = (
SELECT jsonb_object_agg(role_key, role_permissions - ARRAY[{_quoted_keys()}])
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
){updated_at_assignment}
WHERE EXISTS (
SELECT 1
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
WHERE role_permissions ?| ARRAY[{_quoted_keys()}]
)
"""
)
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if _table_exists(inspector, "api_endpoint_permissions"):
op.execute(
f"""
DELETE FROM api_endpoint_permissions
WHERE endpoint_key IN ({_quoted_keys()})
"""
)
if _table_exists(inspector, "permission_templates"):
_remove_template_permission_keys("permission_templates", touch_updated_at=True)
if _table_exists(inspector, "permission_template_versions"):
_remove_template_permission_keys("permission_template_versions")
def downgrade() -> None:
pass
@@ -0,0 +1,109 @@
"""rename knowledge note permissions to precautions
Revision ID: 20260527_07_permissions
Revises: 20260527_06
Create Date: 2026-05-27 16:35:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260527_07_permissions"
down_revision: Union[str, None] = "20260527_06"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
PERMISSION_KEY_RENAMES = (
("knowledge_notes:create", "precautions:create"),
("knowledge_notes:list", "precautions:list"),
("knowledge_notes:read", "precautions:read"),
("knowledge_notes:update", "precautions:update"),
("knowledge_notes:delete", "precautions:delete"),
("knowledge_notes_attachments:create", "precautions_attachments:create"),
("knowledge_notes_attachments:read", "precautions_attachments:read"),
("knowledge_notes_attachments:delete", "precautions_attachments:delete"),
)
def _table_exists(inspector: sa.Inspector, table_name: str) -> bool:
return table_name in inspector.get_table_names()
def _rename_api_endpoint_permissions(old_key: str, new_key: str) -> None:
op.execute(
f"""
DELETE FROM api_endpoint_permissions AS old_permissions
WHERE old_permissions.endpoint_key = '{old_key}'
AND EXISTS (
SELECT 1
FROM api_endpoint_permissions AS new_permissions
WHERE new_permissions.study_id = old_permissions.study_id
AND new_permissions.role = old_permissions.role
AND new_permissions.endpoint_key = '{new_key}'
)
"""
)
op.execute(
f"""
UPDATE api_endpoint_permissions
SET endpoint_key = '{new_key}'
WHERE endpoint_key = '{old_key}'
"""
)
def _rename_template_permission_key(table_name: str, old_key: str, new_key: str, *, touch_updated_at: bool = False) -> None:
updated_at_assignment = ", updated_at = NOW()" if touch_updated_at else ""
op.execute(
f"""
UPDATE {table_name}
SET permissions = (
SELECT jsonb_object_agg(
role_key,
CASE
WHEN role_permissions ? '{old_key}' AND NOT role_permissions ? '{new_key}'
THEN (role_permissions - '{old_key}') || jsonb_build_object('{new_key}', role_permissions -> '{old_key}')
WHEN role_permissions ? '{old_key}'
THEN role_permissions - '{old_key}'
ELSE role_permissions
END
)
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
){updated_at_assignment}
WHERE EXISTS (
SELECT 1
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
WHERE role_permissions ? '{old_key}'
)
"""
)
def _rename_permission_keys(mapping: tuple[tuple[str, str], ...]) -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if _table_exists(inspector, "api_endpoint_permissions"):
for old_key, new_key in mapping:
_rename_api_endpoint_permissions(old_key, new_key)
if _table_exists(inspector, "permission_templates"):
for old_key, new_key in mapping:
_rename_template_permission_key("permission_templates", old_key, new_key, touch_updated_at=True)
if _table_exists(inspector, "permission_template_versions"):
for old_key, new_key in mapping:
_rename_template_permission_key("permission_template_versions", old_key, new_key)
def upgrade() -> None:
_rename_permission_keys(PERMISSION_KEY_RENAMES)
def downgrade() -> None:
_rename_permission_keys(tuple((new_key, old_key) for old_key, new_key in PERMISSION_KEY_RENAMES))
@@ -0,0 +1,213 @@
"""remove generic attachment permissions
Revision ID: 20260527_08
Revises: 20260527_07_permissions
Create Date: 2026-05-27 17:10:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260527_08"
down_revision: Union[str, None] = "20260527_07_permissions"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
GENERIC_TO_MODULE_KEYS = {
"attachments:create": (
"fees_contracts_attachments:create",
"startup_initiation_attachments:create",
"startup_ethics_attachments:create",
"startup_auth_attachments:create",
"drug_shipments_attachments:create",
"precautions_attachments:create",
"faq_attachments:create",
),
"attachments:read": (
"fees_contracts_attachments:read",
"startup_initiation_attachments:read",
"startup_ethics_attachments:read",
"startup_auth_attachments:read",
"drug_shipments_attachments:read",
"precautions_attachments:read",
"faq_attachments:read",
),
"attachments:delete": (
"fees_contracts_attachments:delete",
"startup_initiation_attachments:delete",
"startup_ethics_attachments:delete",
"startup_auth_attachments:delete",
"drug_shipments_attachments:delete",
"precautions_attachments:delete",
"faq_attachments:delete",
),
}
def _table_exists(inspector: sa.Inspector, table_name: str) -> bool:
return table_name in inspector.get_table_names()
def _expand_api_endpoint_permission(old_key: str, new_key: str) -> None:
op.execute(
f"""
INSERT INTO api_endpoint_permissions (id, study_id, role, endpoint_key, allowed, updated_at)
SELECT (md5(random()::text || clock_timestamp()::text))::uuid, source.study_id, source.role, '{new_key}', source.allowed, NOW()
FROM api_endpoint_permissions AS source
WHERE source.endpoint_key = '{old_key}'
AND NOT EXISTS (
SELECT 1
FROM api_endpoint_permissions AS target
WHERE target.study_id = source.study_id
AND target.role = source.role
AND target.endpoint_key = '{new_key}'
)
"""
)
def _remove_api_endpoint_permissions(keys: tuple[str, ...]) -> None:
quoted_keys = ", ".join(f"'{key}'" for key in keys)
op.execute(f"DELETE FROM api_endpoint_permissions WHERE endpoint_key IN ({quoted_keys})")
def _expand_template_permission_key(table_name: str, old_key: str, new_key: str, *, touch_updated_at: bool = False) -> None:
updated_at_assignment = ", updated_at = NOW()" if touch_updated_at else ""
op.execute(
f"""
UPDATE {table_name}
SET permissions = (
SELECT jsonb_object_agg(
role_key,
CASE
WHEN role_permissions ? '{old_key}' AND NOT role_permissions ? '{new_key}'
THEN role_permissions || jsonb_build_object('{new_key}', role_permissions -> '{old_key}')
ELSE role_permissions
END
)
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
){updated_at_assignment}
WHERE EXISTS (
SELECT 1
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
WHERE role_permissions ? '{old_key}'
)
"""
)
def _remove_template_permission_key(table_name: str, key: str, *, touch_updated_at: bool = False) -> None:
updated_at_assignment = ", updated_at = NOW()" if touch_updated_at else ""
op.execute(
f"""
UPDATE {table_name}
SET permissions = (
SELECT jsonb_object_agg(role_key, role_permissions - '{key}')
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
){updated_at_assignment}
WHERE EXISTS (
SELECT 1
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
WHERE role_permissions ? '{key}'
)
"""
)
def _restore_api_endpoint_permission(old_key: str, new_keys: tuple[str, ...]) -> None:
quoted_new_keys = ", ".join(f"'{key}'" for key in new_keys)
op.execute(
f"""
INSERT INTO api_endpoint_permissions (id, study_id, role, endpoint_key, allowed, updated_at)
SELECT (md5(random()::text || clock_timestamp()::text))::uuid, source.study_id, source.role, '{old_key}', bool_or(source.allowed), NOW()
FROM api_endpoint_permissions AS source
WHERE source.endpoint_key IN ({quoted_new_keys})
GROUP BY source.study_id, source.role
HAVING NOT EXISTS (
SELECT 1
FROM api_endpoint_permissions AS target
WHERE target.study_id = source.study_id
AND target.role = source.role
AND target.endpoint_key = '{old_key}'
)
"""
)
def _restore_template_permission_key(table_name: str, old_key: str, new_keys: tuple[str, ...], *, touch_updated_at: bool = False) -> None:
updated_at_assignment = ", updated_at = NOW()" if touch_updated_at else ""
new_key_checks = " OR ".join(f"role_permissions ? '{key}'" for key in new_keys)
new_key_values = ", ".join(f"role_permissions -> '{key}'" for key in new_keys)
op.execute(
f"""
UPDATE {table_name}
SET permissions = (
SELECT jsonb_object_agg(
role_key,
CASE
WHEN NOT role_permissions ? '{old_key}' AND ({new_key_checks})
THEN role_permissions || jsonb_build_object('{old_key}', COALESCE({new_key_values}))
ELSE role_permissions
END
)
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
){updated_at_assignment}
WHERE EXISTS (
SELECT 1
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
WHERE {new_key_checks}
)
"""
)
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
old_keys = tuple(GENERIC_TO_MODULE_KEYS)
if _table_exists(inspector, "api_endpoint_permissions"):
for old_key, new_keys in GENERIC_TO_MODULE_KEYS.items():
for new_key in new_keys:
_expand_api_endpoint_permission(old_key, new_key)
_remove_api_endpoint_permissions(old_keys)
if _table_exists(inspector, "permission_templates"):
for old_key, new_keys in GENERIC_TO_MODULE_KEYS.items():
for new_key in new_keys:
_expand_template_permission_key("permission_templates", old_key, new_key, touch_updated_at=True)
_remove_template_permission_key("permission_templates", old_key, touch_updated_at=True)
if _table_exists(inspector, "permission_template_versions"):
for old_key, new_keys in GENERIC_TO_MODULE_KEYS.items():
for new_key in new_keys:
_expand_template_permission_key("permission_template_versions", old_key, new_key)
_remove_template_permission_key("permission_template_versions", old_key)
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
module_keys = tuple(key for new_keys in GENERIC_TO_MODULE_KEYS.values() for key in new_keys)
if _table_exists(inspector, "api_endpoint_permissions"):
for old_key, new_keys in GENERIC_TO_MODULE_KEYS.items():
_restore_api_endpoint_permission(old_key, new_keys)
_remove_api_endpoint_permissions(module_keys)
if _table_exists(inspector, "permission_templates"):
for old_key, new_keys in GENERIC_TO_MODULE_KEYS.items():
_restore_template_permission_key("permission_templates", old_key, new_keys, touch_updated_at=True)
for new_key in new_keys:
_remove_template_permission_key("permission_templates", new_key, touch_updated_at=True)
if _table_exists(inspector, "permission_template_versions"):
for old_key, new_keys in GENERIC_TO_MODULE_KEYS.items():
_restore_template_permission_key("permission_template_versions", old_key, new_keys)
for new_key in new_keys:
_remove_template_permission_key("permission_template_versions", new_key)
@@ -0,0 +1,87 @@
"""rename knowledge notes table to precautions
Revision ID: 20260527_08_precautions
Revises: 20260527_08
Create Date: 2026-05-27 17:35:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260527_08_precautions"
down_revision: Union[str, None] = "20260527_08"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _table_exists(inspector: sa.Inspector, table_name: str) -> bool:
return table_name in inspector.get_table_names()
def _column_exists(inspector: sa.Inspector, table_name: str, column_name: str) -> bool:
if not _table_exists(inspector, table_name):
return False
return column_name in {column["name"] for column in inspector.get_columns(table_name)}
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if _table_exists(inspector, "knowledge_notes") and not _table_exists(inspector, "precautions"):
op.rename_table("knowledge_notes", "precautions")
if _column_exists(inspector, "attachments", "entity_type"):
op.execute(
"""
UPDATE attachments
SET entity_type = 'precaution'
WHERE entity_type = 'knowledge_note'
"""
)
if _column_exists(inspector, "audit_logs", "entity_type"):
op.execute(
"""
UPDATE audit_logs
SET entity_type = 'precaution'
WHERE entity_type = 'knowledge_note'
"""
)
# Keep permission data aligned if this migration is applied without the
# earlier permission-key migration in a partial database.
if _table_exists(inspector, "api_endpoint_permissions"):
pass
if _table_exists(inspector, "permission_templates"):
pass
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if _table_exists(inspector, "precautions") and not _table_exists(inspector, "knowledge_notes"):
op.rename_table("precautions", "knowledge_notes")
if _column_exists(inspector, "attachments", "entity_type"):
op.execute(
"""
UPDATE attachments
SET entity_type = 'knowledge_note'
WHERE entity_type = 'precaution'
"""
)
if _column_exists(inspector, "audit_logs", "entity_type"):
op.execute(
"""
UPDATE audit_logs
SET entity_type = 'knowledge_note'
WHERE entity_type = 'precaution'
"""
)
@@ -0,0 +1,88 @@
"""add etmf nodes
Revision ID: 20260527_09
Revises: 20260527_08_precautions
Create Date: 2026-05-27 20:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "20260527_09"
down_revision: Union[str, None] = "20260527_08_precautions"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _table_exists(inspector: sa.Inspector, table_name: str) -> bool:
return table_name in inspector.get_table_names()
def _column_exists(inspector: sa.Inspector, table_name: str, column_name: str) -> bool:
return column_name in {column["name"] for column in inspector.get_columns(table_name)}
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
uuid_type = postgresql.UUID(as_uuid=True)
scope_type = postgresql.ENUM(
"GLOBAL",
"SITE",
"DERIVED",
name="document_scope_type",
create_type=False,
)
if not _table_exists(inspector, "etmf_nodes"):
op.create_table(
"etmf_nodes",
sa.Column("id", uuid_type, nullable=False),
sa.Column("study_id", uuid_type, nullable=False),
sa.Column("parent_id", uuid_type, nullable=True),
sa.Column("code", sa.String(length=50), nullable=False),
sa.Column("name", sa.String(length=200), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("scope_type", scope_type, server_default="GLOBAL", nullable=False),
sa.Column("required", sa.Boolean(), server_default=sa.text("false"), nullable=False),
sa.Column("expected_doc_type", sa.String(length=50), nullable=True),
sa.Column("sort_order", sa.Integer(), server_default="0", nullable=False),
sa.Column("is_active", sa.Boolean(), server_default=sa.text("true"), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["parent_id"], ["etmf_nodes.id"]),
sa.ForeignKeyConstraint(["study_id"], ["studies.id"]),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("study_id", "parent_id", "code", name="uq_etmf_nodes_study_parent_code"),
)
op.create_index("ix_etmf_nodes_study_id", "etmf_nodes", ["study_id"])
op.create_index("ix_etmf_nodes_parent_id", "etmf_nodes", ["parent_id"])
op.create_index("ix_etmf_nodes_scope_type", "etmf_nodes", ["scope_type"])
op.create_index("ix_etmf_nodes_is_active", "etmf_nodes", ["is_active"])
if _table_exists(inspector, "documents") and not _column_exists(inspector, "documents", "etmf_node_id"):
op.add_column("documents", sa.Column("etmf_node_id", uuid_type, nullable=True))
op.create_index("ix_documents_etmf_node_id", "documents", ["etmf_node_id"])
op.create_foreign_key("fk_documents_etmf_node_id", "documents", "etmf_nodes", ["etmf_node_id"], ["id"])
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if _table_exists(inspector, "documents") and _column_exists(inspector, "documents", "etmf_node_id"):
op.drop_constraint("fk_documents_etmf_node_id", "documents", type_="foreignkey")
op.drop_index("ix_documents_etmf_node_id", table_name="documents")
op.drop_column("documents", "etmf_node_id")
if _table_exists(inspector, "etmf_nodes"):
op.drop_index("ix_etmf_nodes_is_active", table_name="etmf_nodes")
op.drop_index("ix_etmf_nodes_scope_type", table_name="etmf_nodes")
op.drop_index("ix_etmf_nodes_parent_id", table_name="etmf_nodes")
op.drop_index("ix_etmf_nodes_study_id", table_name="etmf_nodes")
op.drop_table("etmf_nodes")
@@ -0,0 +1,25 @@
"""drop legacy fee attachments
Revision ID: 20260528_01
Revises: 20260527_09
Create Date: 2026-05-28 17:20:00.000000
"""
from typing import Sequence, Union
from alembic import op
revision: str = "20260528_01"
down_revision: Union[str, None] = "20260527_09"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute("DROP TABLE IF EXISTS fee_attachments")
def downgrade() -> None:
pass
@@ -0,0 +1,44 @@
"""remove user global role
Revision ID: 20260529_01
Revises: 20260528_01
Create Date: 2026-05-29 10:15:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260529_01"
down_revision: Union[str, None] = "20260528_01"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = {column["name"] for column in inspector.get_columns("users")}
if "is_admin" not in columns:
op.add_column("users", sa.Column("is_admin", sa.Boolean(), nullable=False, server_default=sa.false()))
if "role" in columns:
op.execute("UPDATE users SET is_admin = true WHERE role::text = 'ADMIN'")
op.drop_column("users", "role")
op.execute("DROP TYPE IF EXISTS user_role")
def downgrade() -> None:
op.execute("CREATE TYPE user_role AS ENUM ('ADMIN', 'PM', 'CRA', 'PV', 'QA', 'CTA')")
op.add_column(
"users",
sa.Column("role", sa.Enum("ADMIN", "PM", "CRA", "PV", "QA", "CTA", name="user_role"), nullable=True),
)
op.execute("UPDATE users SET role = CASE WHEN is_admin THEN 'ADMIN'::user_role ELSE 'PV'::user_role END")
op.alter_column("users", "role", nullable=False)
op.drop_column("users", "is_admin")
@@ -0,0 +1,132 @@
"""remove contract fee attachment create and read permissions
Revision ID: 20260529_02
Revises: 20260529_01
Create Date: 2026-05-29 15:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260529_02"
down_revision: Union[str, None] = "20260529_01"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
REMOVED_KEYS = (
"fees_contracts_attachments:create",
"fees_contracts_attachments:read",
)
RESTORE_FROM_PARENT = {
"fees_contracts_attachments:create": "fees_contracts:create",
"fees_contracts_attachments:read": "fees_contracts:read",
}
def _table_exists(inspector: sa.Inspector, table_name: str) -> bool:
return table_name in inspector.get_table_names()
def _remove_permission_rows() -> None:
quoted_keys = ", ".join(f"'{key}'" for key in REMOVED_KEYS)
op.execute(f"DELETE FROM api_endpoint_permissions WHERE endpoint_key IN ({quoted_keys})")
def _remove_template_key(table_name: str, key: str, *, touch_updated_at: bool = False) -> None:
updated_at_assignment = ", updated_at = NOW()" if touch_updated_at else ""
op.execute(
f"""
UPDATE {table_name}
SET permissions = (
SELECT jsonb_object_agg(role_key, role_permissions - '{key}')
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
){updated_at_assignment}
WHERE EXISTS (
SELECT 1
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
WHERE role_permissions ? '{key}'
)
"""
)
def _restore_permission_rows(new_key: str, source_key: str) -> None:
op.execute(
f"""
INSERT INTO api_endpoint_permissions (id, study_id, role, endpoint_key, allowed, updated_at)
SELECT (md5(random()::text || clock_timestamp()::text))::uuid, source.study_id, source.role, '{new_key}', source.allowed, NOW()
FROM api_endpoint_permissions AS source
WHERE source.endpoint_key = '{source_key}'
AND NOT EXISTS (
SELECT 1
FROM api_endpoint_permissions AS target
WHERE target.study_id = source.study_id
AND target.role = source.role
AND target.endpoint_key = '{new_key}'
)
"""
)
def _restore_template_key(table_name: str, new_key: str, source_key: str, *, touch_updated_at: bool = False) -> None:
updated_at_assignment = ", updated_at = NOW()" if touch_updated_at else ""
op.execute(
f"""
UPDATE {table_name}
SET permissions = (
SELECT jsonb_object_agg(
role_key,
CASE
WHEN role_permissions ? '{source_key}' AND NOT role_permissions ? '{new_key}'
THEN role_permissions || jsonb_build_object('{new_key}', role_permissions -> '{source_key}')
ELSE role_permissions
END
)
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
){updated_at_assignment}
WHERE EXISTS (
SELECT 1
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
WHERE role_permissions ? '{source_key}'
)
"""
)
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if _table_exists(inspector, "api_endpoint_permissions"):
_remove_permission_rows()
if _table_exists(inspector, "permission_templates"):
for key in REMOVED_KEYS:
_remove_template_key("permission_templates", key, touch_updated_at=True)
if _table_exists(inspector, "permission_template_versions"):
for key in REMOVED_KEYS:
_remove_template_key("permission_template_versions", key)
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if _table_exists(inspector, "api_endpoint_permissions"):
for new_key, source_key in RESTORE_FROM_PARENT.items():
_restore_permission_rows(new_key, source_key)
if _table_exists(inspector, "permission_templates"):
for new_key, source_key in RESTORE_FROM_PARENT.items():
_restore_template_key("permission_templates", new_key, source_key, touch_updated_at=True)
if _table_exists(inspector, "permission_template_versions"):
for new_key, source_key in RESTORE_FROM_PARENT.items():
_restore_template_key("permission_template_versions", new_key, source_key)
@@ -0,0 +1,139 @@
"""remove business attachment create and read permissions
Revision ID: 20260529_03
Revises: 20260529_02
Create Date: 2026-05-29 15:12:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260529_03"
down_revision: Union[str, None] = "20260529_02"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
RESTORE_FROM_PARENT = {
"startup_initiation_attachments:create": "startup_initiation:create",
"startup_initiation_attachments:read": "startup_initiation:read",
"startup_ethics_attachments:create": "startup_ethics:create",
"startup_ethics_attachments:read": "startup_ethics:read",
"startup_auth_attachments:create": "startup_auth:create",
"startup_auth_attachments:read": "startup_auth:read",
"drug_shipments_attachments:create": "drug_shipments:create",
"drug_shipments_attachments:read": "drug_shipments:read",
"precautions_attachments:create": "precautions:create",
"precautions_attachments:read": "precautions:read",
"faq_attachments:create": "faq_reply:create",
"faq_attachments:read": "faq:read",
}
REMOVED_KEYS = tuple(RESTORE_FROM_PARENT)
def _table_exists(inspector: sa.Inspector, table_name: str) -> bool:
return table_name in inspector.get_table_names()
def _remove_permission_rows() -> None:
quoted_keys = ", ".join(f"'{key}'" for key in REMOVED_KEYS)
op.execute(f"DELETE FROM api_endpoint_permissions WHERE endpoint_key IN ({quoted_keys})")
def _remove_template_key(table_name: str, key: str, *, touch_updated_at: bool = False) -> None:
updated_at_assignment = ", updated_at = NOW()" if touch_updated_at else ""
op.execute(
f"""
UPDATE {table_name}
SET permissions = (
SELECT jsonb_object_agg(role_key, role_permissions - '{key}')
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
){updated_at_assignment}
WHERE EXISTS (
SELECT 1
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
WHERE role_permissions ? '{key}'
)
"""
)
def _restore_permission_rows(new_key: str, source_key: str) -> None:
op.execute(
f"""
INSERT INTO api_endpoint_permissions (id, study_id, role, endpoint_key, allowed, updated_at)
SELECT (md5(random()::text || clock_timestamp()::text))::uuid, source.study_id, source.role, '{new_key}', source.allowed, NOW()
FROM api_endpoint_permissions AS source
WHERE source.endpoint_key = '{source_key}'
AND NOT EXISTS (
SELECT 1
FROM api_endpoint_permissions AS target
WHERE target.study_id = source.study_id
AND target.role = source.role
AND target.endpoint_key = '{new_key}'
)
"""
)
def _restore_template_key(table_name: str, new_key: str, source_key: str, *, touch_updated_at: bool = False) -> None:
updated_at_assignment = ", updated_at = NOW()" if touch_updated_at else ""
op.execute(
f"""
UPDATE {table_name}
SET permissions = (
SELECT jsonb_object_agg(
role_key,
CASE
WHEN role_permissions ? '{source_key}' AND NOT role_permissions ? '{new_key}'
THEN role_permissions || jsonb_build_object('{new_key}', role_permissions -> '{source_key}')
ELSE role_permissions
END
)
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
){updated_at_assignment}
WHERE EXISTS (
SELECT 1
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
WHERE role_permissions ? '{source_key}'
)
"""
)
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if _table_exists(inspector, "api_endpoint_permissions"):
_remove_permission_rows()
if _table_exists(inspector, "permission_templates"):
for key in REMOVED_KEYS:
_remove_template_key("permission_templates", key, touch_updated_at=True)
if _table_exists(inspector, "permission_template_versions"):
for key in REMOVED_KEYS:
_remove_template_key("permission_template_versions", key)
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if _table_exists(inspector, "api_endpoint_permissions"):
for new_key, source_key in RESTORE_FROM_PARENT.items():
_restore_permission_rows(new_key, source_key)
if _table_exists(inspector, "permission_templates"):
for new_key, source_key in RESTORE_FROM_PARENT.items():
_restore_template_key("permission_templates", new_key, source_key, touch_updated_at=True)
if _table_exists(inspector, "permission_template_versions"):
for new_key, source_key in RESTORE_FROM_PARENT.items():
_restore_template_key("permission_template_versions", new_key, source_key)
@@ -0,0 +1,119 @@
"""add material equipment attachment delete permission
Revision ID: 20260529_04
Revises: 20260529_03
Create Date: 2026-05-29 15:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260529_04"
down_revision: Union[str, None] = "20260529_03"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
SOURCE_KEY = "material_equipments:update"
TARGET_KEY = "material_equipments_attachments:delete"
def _table_exists(inspector: sa.Inspector, table_name: str) -> bool:
return table_name in inspector.get_table_names()
def _copy_permission_rows() -> None:
op.execute(
f"""
INSERT INTO api_endpoint_permissions (id, study_id, role, endpoint_key, allowed, updated_at)
SELECT (md5(random()::text || clock_timestamp()::text))::uuid, source.study_id, source.role, '{TARGET_KEY}', source.allowed, NOW()
FROM api_endpoint_permissions AS source
WHERE source.endpoint_key = '{SOURCE_KEY}'
AND NOT EXISTS (
SELECT 1
FROM api_endpoint_permissions AS target
WHERE target.study_id = source.study_id
AND target.role = source.role
AND target.endpoint_key = '{TARGET_KEY}'
)
"""
)
def _remove_permission_rows() -> None:
op.execute(f"DELETE FROM api_endpoint_permissions WHERE endpoint_key = '{TARGET_KEY}'")
def _copy_template_key(table_name: str, *, touch_updated_at: bool = False) -> None:
updated_at_assignment = ", updated_at = NOW()" if touch_updated_at else ""
op.execute(
f"""
UPDATE {table_name}
SET permissions = (
SELECT jsonb_object_agg(
role_key,
CASE
WHEN role_permissions ? '{SOURCE_KEY}' AND NOT role_permissions ? '{TARGET_KEY}'
THEN role_permissions || jsonb_build_object('{TARGET_KEY}', role_permissions -> '{SOURCE_KEY}')
ELSE role_permissions
END
)
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
){updated_at_assignment}
WHERE EXISTS (
SELECT 1
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
WHERE role_permissions ? '{SOURCE_KEY}'
)
"""
)
def _remove_template_key(table_name: str, *, touch_updated_at: bool = False) -> None:
updated_at_assignment = ", updated_at = NOW()" if touch_updated_at else ""
op.execute(
f"""
UPDATE {table_name}
SET permissions = (
SELECT jsonb_object_agg(role_key, role_permissions - '{TARGET_KEY}')
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
){updated_at_assignment}
WHERE EXISTS (
SELECT 1
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
WHERE role_permissions ? '{TARGET_KEY}'
)
"""
)
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if _table_exists(inspector, "api_endpoint_permissions"):
_copy_permission_rows()
if _table_exists(inspector, "permission_templates"):
_copy_template_key("permission_templates", touch_updated_at=True)
if _table_exists(inspector, "permission_template_versions"):
_copy_template_key("permission_template_versions")
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if _table_exists(inspector, "api_endpoint_permissions"):
_remove_permission_rows()
if _table_exists(inspector, "permission_templates"):
_remove_template_key("permission_templates", touch_updated_at=True)
if _table_exists(inspector, "permission_template_versions"):
_remove_template_key("permission_template_versions")
@@ -0,0 +1,36 @@
"""add faq category icon column
Revision ID: 20260608_01
Revises: 20260529_04
Create Date: 2026-06-08 14:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260608_01"
down_revision: Union[str, None] = "20260529_04"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _column_exists(inspector: sa.Inspector, table_name: str, column_name: str) -> bool:
return any(column["name"] == column_name for column in inspector.get_columns(table_name))
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if "faq_categories" in inspector.get_table_names() and not _column_exists(inspector, "faq_categories", "icon"):
op.add_column("faq_categories", sa.Column("icon", sa.String(length=30), nullable=True))
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if "faq_categories" in inspector.get_table_names() and _column_exists(inspector, "faq_categories", "icon"):
op.drop_column("faq_categories", "icon")
+6 -6
View File
@@ -3,9 +3,9 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db_session, require_roles
from app.core.deps import get_db_session, is_system_admin, require_roles
from app.crud import user as user_crud
from app.models.user import User, UserRole, UserStatus
from app.models.user import User, UserStatus
from app.schemas.user import AdminUserListResponse, UserAdminReviewRequest, UserResponse
router = APIRouter(prefix="/users")
@@ -15,7 +15,7 @@ router = APIRouter(prefix="/users")
async def list_users_for_review(
status_filter: UserStatus = Query(default=UserStatus.PENDING, alias="status"),
db: AsyncSession = Depends(get_db_session),
current_user=Depends(require_roles([UserRole.ADMIN.value])),
current_user=Depends(require_roles(["ADMIN"])),
) -> AdminUserListResponse:
users = await user_crud.list_users_by_status(db, status=status_filter)
return AdminUserListResponse(items=list(users), total=len(users))
@@ -25,7 +25,7 @@ async def _get_review_user(db: AsyncSession, user_id: uuid.UUID) -> User:
user = await user_crud.get_by_id(db, user_id)
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
if user.role == UserRole.ADMIN:
if is_system_admin(user):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="管理员账号不允许审核")
return user
@@ -35,7 +35,7 @@ async def approve_user(
user_id: uuid.UUID,
review: UserAdminReviewRequest,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(require_roles([UserRole.ADMIN.value])),
current_user=Depends(require_roles(["ADMIN"])),
) -> UserResponse:
if review.action != "approve":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="非法操作")
@@ -51,7 +51,7 @@ async def reject_user(
user_id: uuid.UUID,
review: UserAdminReviewRequest,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(require_roles([UserRole.ADMIN.value])),
current_user=Depends(require_roles(["ADMIN"])),
) -> UserResponse:
if review.action not in ("reject", "approve"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="非法操作")
+92 -51
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_member, require_study_not_locked
from app.core.deps import get_operator_role_label, 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
@@ -16,9 +16,6 @@ from app.schemas.ae import AECreate, AERead, AEUpdate
router = APIRouter()
ALLOWED_CREATE_ROLES = {"PM", "CRA", "PV"}
ALLOWED_UPDATE_ROLES = {"PM", "PV", "CRA"}
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
study = await study_crud.get(db, study_id)
@@ -43,20 +40,24 @@ async def _ensure_ae_visible(db: AsyncSession, study_id: uuid.UUID, ae: AERead |
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="AE 不存在")
async def _get_member_role(db: AsyncSession, study_id: uuid.UUID, user_id: uuid.UUID) -> str | None:
member = await member_crud.get_member(db, study_id, user_id)
return member.role_in_study if member else None
def _is_overdue(ae: AERead) -> bool:
return bool(ae.report_due_date and date.today() > ae.report_due_date and ae.status != "CLOSED")
def _ae_audit_name(ae) -> str:
return str(getattr(ae, "term", "") or "").strip() or "AE 记录"
def _ae_audit_detail(action: str, ae) -> str:
name = _ae_audit_name(ae)
return json.dumps({"targetName": name, "description": f"{action}AE“{name}"}, ensure_ascii=False)
@router.post(
"/",
response_model=AERead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_member()), Depends(require_study_not_locked())],
dependencies=[Depends(require_api_permission("subject_aes:create")), Depends(require_study_not_locked())],
)
async def create_ae(
study_id: uuid.UUID,
@@ -75,9 +76,6 @@ async def create_ae(
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
if ae_in.onset_date and ae_in.onset_date > date.today():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="发生日期不能晚于今天")
member_role = await _get_member_role(db, study_id, current_user.id)
if current_user.role != "ADMIN" and member_role not in ALLOWED_CREATE_ROLES:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
try:
ae = await ae_crud.create_ae(db, study_id, ae_in, created_by=current_user.id)
except ValueError as exc:
@@ -88,29 +86,24 @@ async def create_ae(
entity_type="ae",
entity_id=ae.id,
action="CREATE_AE",
detail=f"AE {ae.id} 已创建",
detail=_ae_audit_detail("创建", ae),
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
data = AERead.model_validate(ae)
data.is_overdue = _is_overdue(data)
return data
@router.get(
"/",
response_model=list[AERead],
dependencies=[Depends(require_study_member())],
)
async def list_ae(
async def _list_ae_records(
study_id: uuid.UUID,
status_filter: str | None = None,
seriousness: str | None = None,
site_id: uuid.UUID | None = None,
subject_id: uuid.UUID | None = None,
overdue: bool | None = None,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
status_filter: str | None,
seriousness: str | None,
site_id: uuid.UUID | None,
subject_id: uuid.UUID | None,
overdue: bool | None,
db: AsyncSession,
current_user,
) -> list[AERead]:
await _ensure_study_exists(db, study_id)
cra_scope = await get_cra_site_scope(db, study_id, current_user)
@@ -135,10 +128,64 @@ async def list_ae(
return result
@router.get(
"/summary",
response_model=list[AERead],
dependencies=[Depends(require_api_permission("subject_aes:read"))],
)
async def list_risk_issue_ae(
study_id: uuid.UUID,
status_filter: str | None = None,
seriousness: str | None = None,
site_id: uuid.UUID | None = None,
subject_id: uuid.UUID | None = None,
overdue: bool | None = None,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[AERead]:
return await _list_ae_records(
study_id,
status_filter,
seriousness,
site_id,
subject_id,
overdue,
db,
current_user,
)
@router.get(
"/",
response_model=list[AERead],
dependencies=[Depends(require_api_permission("subject_aes:read"))],
)
async def list_ae(
study_id: uuid.UUID,
status_filter: str | None = None,
seriousness: str | None = None,
site_id: uuid.UUID | None = None,
subject_id: uuid.UUID | None = None,
overdue: bool | None = None,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[AERead]:
return await _list_ae_records(
study_id,
status_filter,
seriousness,
site_id,
subject_id,
overdue,
db,
current_user,
)
@router.get(
"/{ae_id}",
response_model=AERead,
dependencies=[Depends(require_study_member())],
dependencies=[Depends(require_api_permission("subject_aes:read"))],
)
async def get_ae(
study_id: uuid.UUID,
@@ -166,7 +213,7 @@ async def get_ae(
@router.patch(
"/{ae_id}",
response_model=AERead,
dependencies=[Depends(require_study_member()), Depends(require_study_not_locked())],
dependencies=[Depends(require_api_permission("subject_aes:update")), Depends(require_study_not_locked())],
)
async def update_ae(
study_id: uuid.UUID,
@@ -183,19 +230,6 @@ async def update_ae(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="AE 不存在")
await _ensure_ae_visible(db, study_id, AERead.model_validate(ae))
member_role = await _get_member_role(db, study_id, current_user.id)
if current_user.role == "ADMIN":
pass
elif member_role in ALLOWED_UPDATE_ROLES:
pass
elif member_role == "CRA":
if ae.created_by != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="仅创建人可更新 AE")
if ae_in.status == "CLOSED":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="CRA 无法关闭 AE")
else:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
old_status = ae.status
detail_before = {
"event": ae.term,
@@ -222,9 +256,18 @@ async def update_ae(
entity_type="ae",
entity_id=ae_id,
action=action,
detail=json.dumps({"before": detail_before, "after": detail_after}, ensure_ascii=False, default=str),
detail=json.dumps(
{
"targetName": _ae_audit_name(updated),
"description": f"{'变更' if action == 'AE_STATUS_CHANGE' else '更新'}AE“{_ae_audit_name(updated)}",
"before": detail_before,
"after": detail_after,
},
ensure_ascii=False,
default=str,
),
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
data = AERead.model_validate(updated)
data.is_overdue = _is_overdue(data)
@@ -234,7 +277,7 @@ async def update_ae(
@router.delete(
"/{ae_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_study_member()), Depends(require_study_not_locked())],
dependencies=[Depends(require_api_permission("subject_aes:delete")), Depends(require_study_not_locked())],
)
async def delete_ae(
study_id: uuid.UUID,
@@ -247,9 +290,7 @@ async def delete_ae(
if not ae or ae.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="AE 不存在")
await _ensure_ae_visible(db, study_id, AERead.model_validate(ae))
member_role = await _get_member_role(db, study_id, current_user.id)
if current_user.role != "ADMIN" and member_role not in {"PM", "PV"}:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
ae_detail = _ae_audit_detail("删除", ae)
await ae_crud.delete_ae(db, ae)
await audit_crud.log_action(
db,
@@ -257,7 +298,7 @@ async def delete_ae(
entity_type="ae",
entity_id=ae_id,
action="DELETE_AE",
detail=f"AE {ae_id} 已删除",
detail=ae_detail,
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
+295
View File
@@ -0,0 +1,295 @@
"""接口级权限管理API"""
from __future__ import annotations
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, is_system_admin, require_study_member, require_system_permission
from app.core.project_permissions import (
get_api_endpoint_permissions,
get_effective_api_endpoint_permissions,
PrerequisitePermissionConfirmationRequired,
replace_api_endpoint_permissions,
get_missing_prerequisites,
)
from app.crud import member as member_crud
from app.models.api_endpoint_registry import ApiEndpointRegistry
from app.models.study import Study
from app.core.api_permissions import API_ENDPOINT_PERMISSIONS, PROJECT_PERMISSION_ROLES, OPERATION_PREREQUISITES
router = APIRouter(prefix="/api-permissions", tags=["api-permissions"])
# 项目级路由(需要 study_id,挂载到 /studies/{study_id}/api-permissions
study_router = APIRouter(prefix="/api-permissions", tags=["api-permissions"])
async def _get_configurable_roles(db: AsyncSession, study_id: uuid.UUID) -> list[str]:
result = await db.execute(select(Study).where(Study.id == study_id))
study = result.scalar_one_or_none()
active_roles = [role for role in (study.active_roles if study else []) if isinstance(role, str) and role.strip()]
return list(dict.fromkeys([*PROJECT_PERMISSION_ROLES, *active_roles]))
@router.get(
"/operations",
summary="获取系统中所有权限操作",
description="返回系统中所有权限操作及其描述和前置权限",
)
async def list_api_operations() -> dict[str, list[dict]]:
"""获取所有权限操作"""
operations_list = [
{
"operation_key": key,
"module": config["module"],
"action": config["action"],
"description": config["description"],
"default_roles": config["default_roles"],
"prerequisite_permissions": config.get("prerequisite_permissions", []),
}
for key, config in API_ENDPOINT_PERMISSIONS.items()
]
return {"operations": operations_list}
@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(
"/operations/prerequisites",
summary="获取所有权限操作的前置权限依赖",
description="返回系统中所有权限操作及其前置权限依赖关系",
)
async def list_operation_prerequisites() -> dict[str, dict]:
"""获取所有权限操作的前置权限依赖"""
prerequisites_map = {}
for operation_key, prerequisites in OPERATION_PREREQUISITES.items():
prerequisites_map[operation_key] = {
"prerequisites": prerequisites,
"description": API_ENDPOINT_PERMISSIONS.get(operation_key, {}).get("description", ""),
}
return {"prerequisites": prerequisites_map}
@router.get(
"/{endpoint_key}/prerequisites",
summary="获取特定操作的缺失前置权限",
description="检查指定角色对特定操作的前置权限是否满足",
)
async def check_operation_prerequisites(
study_id: uuid.UUID,
endpoint_key: str,
role: str,
db: Annotated[AsyncSession, Depends(get_db_session)],
) -> dict:
"""检查特定操作的前置权限
返回格式:
{
"endpoint_key": "subjects:create",
"role": "CRA",
"has_main_permission": true,
"prerequisites": ["sites:read"],
"missing_prerequisites": [],
"can_perform": true
}
"""
from app.core.project_permissions import role_has_api_permission
if role == "ADMIN":
return {
"endpoint_key": endpoint_key,
"role": role,
"has_main_permission": True,
"prerequisites": OPERATION_PREREQUISITES.get(endpoint_key, []),
"missing_prerequisites": [],
"can_perform": True,
}
has_main = await role_has_api_permission(
db, study_id, role, endpoint_key, check_prerequisites=False
)
missing = await get_missing_prerequisites(db, study_id, role, endpoint_key)
return {
"endpoint_key": endpoint_key,
"role": role,
"has_main_permission": has_main,
"prerequisites": OPERATION_PREREQUISITES.get(endpoint_key, []),
"missing_prerequisites": missing,
"can_perform": has_main and len(missing) == 0,
}
@study_router.get(
"/me",
summary="获取当前用户在项目内的有效接口权限",
description="返回当前用户项目角色对应的有效权限,用于前端菜单和路由判断",
response_model=None,
)
async def get_my_study_api_permissions(
study_id: uuid.UUID,
_=Depends(require_study_member()),
current_user=Depends(get_current_user),
db: Annotated[AsyncSession, Depends(get_db_session)] = None,
):
"""获取当前用户当前项目角色的有效权限。"""
if is_system_admin(current_user):
return {
"ADMIN": {
endpoint_key: {"allowed": True}
for endpoint_key in API_ENDPOINT_PERMISSIONS.keys()
}
}
membership = await member_crud.get_member(db, study_id, current_user.id)
role = membership.role_in_study if membership and membership.is_active else ""
permissions = await get_effective_api_endpoint_permissions(db, study_id)
role_permissions = permissions.get(role, {})
return {
role: {
endpoint_key: role_permissions.get(endpoint_key, {"allowed": False})
for endpoint_key in API_ENDPOINT_PERMISSIONS.keys()
}
}
@study_router.get(
"",
summary="获取项目的接口级权限矩阵",
description="返回项目中各角色对API端点的权限配置",
response_model=None,
)
async def get_study_api_permissions(
study_id: uuid.UUID,
_=Depends(require_system_permission("system:permissions:project_config")),
db: Annotated[AsyncSession, Depends(get_db_session)] = None,
):
"""获取项目的接口级权限矩阵
返回格式:
{
"role": {
"endpoint_key": {
"allowed": true/false
}
}
}
"""
permissions = await get_api_endpoint_permissions(db, study_id)
# 构建返回格式(get_api_endpoint_permissions 已返回 {role: {key: {"allowed": bool}}}
result: dict[str, dict[str, dict[str, bool]]] = {}
for role in await _get_configurable_roles(db, study_id):
if role == "ADMIN":
continue
result[role] = {}
for endpoint_key in API_ENDPOINT_PERMISSIONS.keys():
perm = permissions.get(role, {}).get(endpoint_key, {"allowed": False})
# perm 已经是 {"allowed": bool},直接使用
allowed = perm["allowed"] if isinstance(perm, dict) else bool(perm)
result[role][endpoint_key] = {"allowed": allowed}
return result
@study_router.put(
"",
summary="更新项目的接口级权限矩阵",
description="批量更新项目中各角色对API端点的权限配置",
status_code=status.HTTP_200_OK,
response_model=None,
)
async def update_study_api_permissions(
study_id: uuid.UUID,
payload: dict[str, dict[str, bool]],
confirm_prerequisite_adjustments: bool = False,
_=Depends(require_system_permission("system:permissions:project_config")),
current_user=Depends(get_current_user),
db: Annotated[AsyncSession, Depends(get_db_session)] = None,
):
"""更新项目的接口级权限矩阵
请求体格式:
{
"role": {
"endpoint_key": true/false
}
}
"""
# 验证输入
configurable_roles = set(await _get_configurable_roles(db, study_id))
for role in payload.keys():
if role == "ADMIN":
continue
if role not in configurable_roles:
raise ValueError(f"无效的角色: {role}")
if role == "PM" and not is_system_admin(current_user):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="仅系统管理员可修改项目负责人权限",
)
# 替换权限配置
try:
await replace_api_endpoint_permissions(
db,
study_id,
payload,
confirm_prerequisite_adjustments=confirm_prerequisite_adjustments,
)
except PrerequisitePermissionConfirmationRequired as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={
"code": "PREREQUISITE_PERMISSION_CONFIRMATION_REQUIRED",
"message": "需要确认前置权限调整",
"adjustments": exc.adjustments,
},
) from exc
# 返回更新后的权限矩阵
permissions = await get_api_endpoint_permissions(db, study_id)
result: dict[str, dict[str, dict[str, bool]]] = {}
for role in await _get_configurable_roles(db, study_id):
if role == "ADMIN":
continue
result[role] = {}
for endpoint_key in API_ENDPOINT_PERMISSIONS.keys():
perm = permissions.get(role, {}).get(endpoint_key, {"allowed": False})
allowed = perm["allowed"] if isinstance(perm, dict) else bool(perm)
result[role][endpoint_key] = {"allowed": allowed}
return result
+200 -18
View File
@@ -8,10 +8,17 @@ from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status,
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_member, get_study_member, require_study_not_locked
from app.core.deps import get_current_user, get_db_session, get_operator_role_label, get_study_member, is_system_admin, require_study_not_locked
from app.core.project_permissions import role_has_api_permission
from app.crud import attachment as attachment_crud
from app.crud import audit as audit_crud
from app.crud import contract_fee as contract_fee_crud
from app.crud import drug_shipment as drug_shipment_crud
from app.crud import faq_reply as faq_reply_crud
from app.crud import material_equipment as material_equipment_crud
from app.crud import precaution as precaution_crud
from app.crud import study as study_crud
from app.crud import startup as startup_crud
from app.crud import user as user_crud
from app.crud import member as member_crud
from app.core.security import decode_token
@@ -22,7 +29,61 @@ router = APIRouter()
global_router = APIRouter()
UPLOAD_ROOT = Path(__file__).resolve().parent.parent.parent / "uploads"
FEE_ATTACHMENT_ENTITY_TYPES = {
"contract_fee_contract",
"contract_fee_voucher",
"contract_fee_invoice",
}
FEE_ATTACHMENT_PERMISSION_BY_ACTION = {
"create": "fees_contracts:create",
"read": "fees_contracts:read",
"delete": "fees_contracts_attachments:delete",
}
STARTUP_INITIATION_ATTACHMENT_PERMISSION_BY_ACTION = {
"create": "startup_initiation:create",
"read": "startup_initiation:read",
"delete": "startup_initiation_attachments:delete",
}
STARTUP_ETHICS_ATTACHMENT_PERMISSION_BY_ACTION = {
"create": "startup_ethics:create",
"read": "startup_ethics:read",
"delete": "startup_ethics_attachments:delete",
}
STARTUP_AUTH_ATTACHMENT_PERMISSION_BY_ACTION = {
"create": "startup_auth:create",
"read": "startup_auth:read",
"delete": "startup_auth_attachments:delete",
}
DRUG_SHIPMENT_ATTACHMENT_PERMISSION_BY_ACTION = {
"create": "drug_shipments:create",
"read": "drug_shipments:read",
"delete": "drug_shipments_attachments:delete",
}
MATERIAL_EQUIPMENT_ATTACHMENT_ENTITY_TYPES = {
"material_equipment",
}
MATERIAL_EQUIPMENT_ATTACHMENT_PERMISSION_BY_ACTION = {
"create": "material_equipments:update",
"read": "material_equipments:read",
"delete": "material_equipments_attachments:delete",
}
PRECAUTION_ATTACHMENT_PERMISSION_BY_ACTION = {
"create": "precautions:create",
"read": "precautions:read",
"delete": "precautions_attachments:delete",
}
FAQ_REPLY_ATTACHMENT_PERMISSION_BY_ACTION = {
"create": "faq_reply:create",
"read": "faq:read",
"delete": "faq_attachments:delete",
}
STARTUP_AUTH_ATTACHMENT_ENTITY_TYPES = {
"startup_kickoff",
"startup_kickoff_minutes",
"startup_kickoff_signin",
"startup_kickoff_ppt",
"training_authorization",
}
def _content_disposition(filename: str, disposition: str = "inline") -> str:
fallback = "".join((ch if ord(ch) < 128 else "_") for ch in filename) or "download"
@@ -37,11 +98,116 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
return study
def _attachment_operation(action: str) -> str:
if action in {"create", "read", "delete"}:
return action
return "read" if action == "read" else "create"
async def _resolve_attachment_parent_permission(
db: AsyncSession,
study_id: uuid.UUID,
entity_type: str,
entity_id: uuid.UUID,
action: str,
) -> str | None:
operation = _attachment_operation(action)
if entity_type in FEE_ATTACHMENT_ENTITY_TYPES:
contract = await contract_fee_crud.get_contract_fee(db, entity_id)
if not contract or contract.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
return FEE_ATTACHMENT_PERMISSION_BY_ACTION[operation]
if entity_type == "startup_feasibility":
record = await startup_crud.get_feasibility(db, entity_id)
if not record or record.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="立项记录不存在")
return STARTUP_INITIATION_ATTACHMENT_PERMISSION_BY_ACTION[operation]
if entity_type == "startup_ethics":
record = await startup_crud.get_ethics(db, entity_id)
if not record or record.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="伦理记录不存在")
return STARTUP_ETHICS_ATTACHMENT_PERMISSION_BY_ACTION[operation]
if entity_type in STARTUP_AUTH_ATTACHMENT_ENTITY_TYPES:
if entity_type == "training_authorization":
record = await startup_crud.get_training_authorization(db, entity_id)
missing_detail = "培训授权不存在"
else:
record = await startup_crud.get_kickoff(db, entity_id)
missing_detail = "启动会不存在"
if not record or record.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=missing_detail)
return STARTUP_AUTH_ATTACHMENT_PERMISSION_BY_ACTION[operation]
if entity_type == "drug_shipment":
shipment = await drug_shipment_crud.get_shipment(db, entity_id)
if not shipment or shipment.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="药物发货不存在")
return DRUG_SHIPMENT_ATTACHMENT_PERMISSION_BY_ACTION[operation]
if entity_type in MATERIAL_EQUIPMENT_ATTACHMENT_ENTITY_TYPES:
equipment = await material_equipment_crud.get_equipment(db, entity_id)
if not equipment or equipment.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="设备记录不存在")
return MATERIAL_EQUIPMENT_ATTACHMENT_PERMISSION_BY_ACTION[operation]
if entity_type == "precaution":
precaution = await precaution_crud.get_precaution(db, entity_id)
if not precaution or precaution.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="注意事项不存在")
return PRECAUTION_ATTACHMENT_PERMISSION_BY_ACTION[operation]
if entity_type == "faq_replies":
reply = await faq_reply_crud.get_reply(db, entity_id)
if not reply or reply.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ回复不存在")
return FAQ_REPLY_ATTACHMENT_PERMISSION_BY_ACTION[operation]
return None
async def _ensure_attachment_permission(
db: AsyncSession,
study_id: uuid.UUID,
entity_type: str,
entity_id: uuid.UUID,
action: str,
current_user,
membership=None,
) -> None:
parent_permission = await _resolve_attachment_parent_permission(db, study_id, entity_type, entity_id, action)
if is_system_admin(current_user):
return
if membership is None:
membership = await member_crud.get_member(db, study_id, current_user.id)
if not membership or not membership.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员")
if not parent_permission:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="附件类型未配置权限")
allowed = await role_has_api_permission(
db,
study_id,
membership.role_in_study,
parent_permission,
check_prerequisites=False,
)
if not allowed:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="项目权限不足")
@router.post(
"/",
response_model=AttachmentRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_member()), Depends(require_study_not_locked())],
dependencies=[
Depends(require_study_not_locked())
],
)
async def upload_attachment(
study_id: uuid.UUID,
@@ -52,6 +218,7 @@ async def upload_attachment(
current_user=Depends(get_current_user),
) -> AttachmentRead:
await _ensure_study_exists(db, study_id)
await _ensure_attachment_permission(db, study_id, entity_type, entity_id, "create", current_user)
dest_dir = UPLOAD_ROOT / f"study_{study_id}" / f"{entity_type}_{entity_id}"
dest_dir.mkdir(parents=True, exist_ok=True)
unique_name = f"{uuid.uuid4()}{Path(file.filename).suffix}"
@@ -80,7 +247,7 @@ async def upload_attachment(
action="UPLOAD_FILE",
detail=f"文件已上传:{file.filename}",
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
return AttachmentRead(
id=attachment.id,
@@ -96,15 +263,16 @@ async def upload_attachment(
@router.get(
"/",
response_model=list[AttachmentRead],
dependencies=[Depends(require_study_member())],
)
async def list_attachments(
study_id: uuid.UUID,
entity_type: str,
entity_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[AttachmentRead]:
await _ensure_study_exists(db, study_id)
await _ensure_attachment_permission(db, study_id, entity_type, entity_id, "read", current_user)
attachments = await attachment_crud.list_attachments(db, study_id, entity_type, entity_id)
user_ids = {a.uploaded_by for a in attachments if a.uploaded_by}
users_map = await user_crud.get_users_by_ids(db, user_ids)
@@ -127,7 +295,6 @@ async def list_attachments(
@router.get(
"/{attachment_id}/download",
dependencies=[Depends(require_study_member())],
)
async def download_attachment(
study_id: uuid.UUID,
@@ -135,8 +302,10 @@ async def download_attachment(
entity_id: uuid.UUID,
attachment_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> FileResponse:
await _ensure_study_exists(db, study_id)
await _ensure_attachment_permission(db, study_id, entity_type, entity_id, "read", current_user)
attachment = await attachment_crud.get_attachment(db, attachment_id)
if not attachment or attachment.study_id != study_id or attachment.entity_id != entity_id or attachment.entity_type != entity_type:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在")
@@ -152,7 +321,6 @@ async def download_attachment(
@router.get(
"/{attachment_id}/preview",
dependencies=[Depends(require_study_member())],
)
async def preview_attachment(
study_id: uuid.UUID,
@@ -160,8 +328,10 @@ async def preview_attachment(
entity_id: uuid.UUID,
attachment_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> FileResponse:
await _ensure_study_exists(db, study_id)
await _ensure_attachment_permission(db, study_id, entity_type, entity_id, "read", current_user)
attachment = await attachment_crud.get_attachment(db, attachment_id)
if not attachment or attachment.study_id != study_id or attachment.entity_id != entity_id or attachment.entity_type != entity_type:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在")
@@ -188,7 +358,7 @@ async def _authorize_global(request: Request, db: AsyncSession, study_id: uuid.U
user = await user_crud.get_by_id(db, uuid.UUID(str(payload.get("sub"))))
if not user or not user.is_active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="账号不存在或已停用")
if user.role == "ADMIN":
if is_system_admin(user):
return user, None
membership = await member_crud.get_member(db, study_id, user.id)
if not membership or not membership.is_active:
@@ -209,7 +379,10 @@ async def global_download_attachment(
if not attachment:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在")
await _ensure_study_exists(db, attachment.study_id)
user, _ = await _authorize_global(request, db, attachment.study_id)
user, membership = await _authorize_global(request, db, attachment.study_id)
await _ensure_attachment_permission(
db, attachment.study_id, attachment.entity_type, attachment.entity_id, "read", user, membership
)
if not os.path.exists(attachment.file_path):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="服务器未找到文件")
return FileResponse(
@@ -233,7 +406,10 @@ async def global_preview_attachment(
if not attachment:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在")
await _ensure_study_exists(db, attachment.study_id)
await _authorize_global(request, db, attachment.study_id)
user, membership = await _authorize_global(request, db, attachment.study_id)
await _ensure_attachment_permission(
db, attachment.study_id, attachment.entity_type, attachment.entity_id, "read", user, membership
)
if not os.path.exists(attachment.file_path):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="服务器未找到文件")
return FileResponse(
@@ -258,10 +434,13 @@ async def global_delete_attachment(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在")
await _ensure_study_exists(db, attachment.study_id)
user, membership = await _authorize_global(request, db, attachment.study_id)
await _ensure_attachment_permission(
db, attachment.study_id, attachment.entity_type, attachment.entity_id, "delete", user, membership
)
can_delete = (
user.role == "ADMIN"
is_system_admin(user)
or attachment.uploaded_by == user.id
or (membership and getattr(membership, "role_in_study", None) == "PM")
or membership is not None
)
if not can_delete:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限删除附件")
@@ -274,14 +453,16 @@ async def global_delete_attachment(
action="DELETE_ATTACHMENT",
detail=f"文件已删除:{attachment.filename}",
operator_id=user.id,
operator_role=user.role,
operator_role=await get_operator_role_label(db, attachment.study_id, user),
)
@router.delete(
"/{attachment_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_study_member()), Depends(require_study_not_locked())],
dependencies=[
Depends(require_study_not_locked())
],
)
async def delete_attachment(
study_id: uuid.UUID,
@@ -292,6 +473,7 @@ async def delete_attachment(
current_user=Depends(get_current_user),
):
await _ensure_study_exists(db, study_id)
await _ensure_attachment_permission(db, study_id, entity_type, entity_id, "delete", current_user)
attachment = await attachment_crud.get_attachment(db, attachment_id)
if (
not attachment
@@ -303,13 +485,13 @@ async def delete_attachment(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在")
membership = None
if current_user.role != "ADMIN":
if not is_system_admin(current_user):
membership = await get_study_member(study_id, current_user=current_user, db=db)
can_delete = (
current_user.role == "ADMIN"
is_system_admin(current_user)
or attachment.uploaded_by == current_user.id
or (membership and getattr(membership, "role_in_study", None) == "PM")
or membership is not None
)
if not can_delete:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限删除附件")
@@ -323,5 +505,5 @@ async def delete_attachment(
action="DELETE_ATTACHMENT",
detail=f"文件已删除:{attachment.filename}",
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
+26 -23
View File
@@ -1,19 +1,38 @@
import json
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_roles, require_study_member
from app.core.deps import get_operator_role_label, get_current_user, get_db_session, require_api_permission
from app.crud import audit as audit_crud
from app.crud import study as study_crud
from app.schemas.audit import AuditEventCreate, AuditLogRead
router = APIRouter()
async def _build_export_audit_detail(db: AsyncSession, study_id: uuid.UUID, action: str) -> str:
study = await study_crud.get(db, study_id)
if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
scope = "system" if action == "AUDIT_EXPORT_SYSTEM" else "project"
description = "导出了系统审计日志" if scope == "system" else f"导出了项目审计日志:{study.name}"
return json.dumps(
{
"targetName": "系统审计日志" if scope == "system" else study.name,
"scope": scope,
"description": description,
"result": "SUCCESS",
},
ensure_ascii=False,
)
@router.get(
"/",
response_model=list[AuditLogRead],
dependencies=[Depends(require_study_member())],
dependencies=[Depends(require_api_permission("audit_logs:read"))],
)
async def list_audit_logs(
study_id: uuid.UUID,
@@ -38,30 +57,14 @@ async def list_audit_logs(
return list(logs)
@router.delete(
"/{log_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_roles(["ADMIN"]))],
)
async def delete_audit_log(
study_id: uuid.UUID,
log_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
):
log = await audit_crud.get_log(db, log_id)
if not log or log.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="审计日志不存在")
await audit_crud.delete_log(db, log)
@router.post(
"/events",
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_member())],
)
async def create_audit_event(
study_id: uuid.UUID,
payload: AuditEventCreate,
_export_permission=Depends(require_api_permission("audit_logs:export")),
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
@@ -71,11 +74,11 @@ async def create_audit_event(
await audit_crud.log_action(
db,
study_id=study_id,
entity_type=payload.entity_type or "audit_log",
entity_id=payload.entity_id,
entity_type="audit_log",
entity_id=None,
action=payload.action,
detail=payload.detail,
detail=await _build_export_audit_detail(db, study_id, payload.action),
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
return {"ok": True}
+112 -62
View File
@@ -1,63 +1,91 @@
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import File, UploadFile
from pydantic import BaseModel, EmailStr, Field
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from pathlib import Path
import uuid
from app.core.config import settings
from app.core.login_crypto import create_login_challenge, decrypt_login_payload, get_public_key_pem
from app.core.security import create_access_token, decode_token_allow_expired, oauth2_scheme, verify_password
from app.core.deps import get_current_user, get_db_session
from app.crud import user as user_crud
from app.models.user import UserRole, UserStatus
from app.models.user import UserStatus
from app.schemas.user import Token, UserRead, UserRegisterRequest, UserSelfUpdate, UserUpdate
from fastapi.responses import FileResponse
class LoginRequest(BaseModel):
email: EmailStr
key_id: str = Field(min_length=1)
challenge: str = Field(min_length=16)
ciphertext: str = Field(min_length=1)
class DevLoginRequest(BaseModel):
email: str = Field(min_length=1)
password: str = Field(min_length=1)
class LoginKeyResponse(BaseModel):
key_id: str
public_key: str
challenge: str
expires_at: datetime
class ExtendResponse(BaseModel):
accessToken: str
expiresAt: datetime
class UnlockRequest(BaseModel):
email: EmailStr
password: str = Field(min_length=1)
class UnlockResponse(BaseModel):
accessToken: str
expiresAt: datetime
router = APIRouter()
AVATAR_ROOT = Path(__file__).resolve().parent.parent.parent / "uploads" / "avatars"
AVATAR_ROOT.mkdir(parents=True, exist_ok=True)
AVATAR_ALLOWED_CONTENT_TYPES = {
"image/png": ".png",
"image/jpeg": ".jpg",
"image/gif": ".gif",
"image/webp": ".webp",
}
@router.post("/register", status_code=status.HTTP_201_CREATED)
async def register(
payload: UserRegisterRequest,
db: AsyncSession = Depends(get_db_session),
):
if payload.role == UserRole.ADMIN.value:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不允许注册管理员账号")
existing = await user_crud.get_by_email(db, payload.email)
if existing:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="邮箱已注册")
await user_crud.create_pending_user(db, payload)
return {"message": "注册成功,等待管理员审核"}
def issue_user_token(db_user) -> Token:
session_start = datetime.now(timezone.utc)
access_token = create_access_token(
user_id=str(db_user.id),
expires_minutes=None,
session_start=session_start,
)
return Token(access_token=access_token, token_type="bearer")
@router.post("/login", response_model=Token)
async def login_for_access_token(
payload: LoginRequest, db: AsyncSession = Depends(get_db_session)
) -> Token:
async def authenticate_encrypted_password(payload: LoginRequest, db: AsyncSession):
decrypted = decrypt_login_payload(
key_id=payload.key_id,
challenge=payload.challenge,
ciphertext=payload.ciphertext,
)
if not decrypted:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无法验证登录凭据",
)
db_user = await user_crud.get_by_email(db, decrypted.email)
if not db_user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="账号不存在",
)
if not verify_password(decrypted.password, db_user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="密码错误",
)
return db_user
async def authenticate_plain_password(payload: DevLoginRequest, db: AsyncSession):
db_user = await user_crud.get_by_email(db, payload.email)
if not db_user:
raise HTTPException(
@@ -69,20 +97,59 @@ async def login_for_access_token(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="密码错误",
)
return db_user
def ensure_user_active(db_user) -> None:
if db_user.status != UserStatus.ACTIVE:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="账号未审核或不可用",
)
session_start = datetime.now(timezone.utc)
access_token = create_access_token(
user_id=str(db_user.id),
role=db_user.role.value if hasattr(db_user.role, "value") else db_user.role,
expires_minutes=None,
session_start=session_start,
@router.post("/register", status_code=status.HTTP_201_CREATED)
async def register(
payload: UserRegisterRequest,
db: AsyncSession = Depends(get_db_session),
):
existing = await user_crud.get_by_email(db, payload.email)
if existing:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="邮箱已注册")
await user_crud.create_pending_user(db, payload)
return {"message": "注册成功,等待管理员审核"}
@router.get("/login-key", response_model=LoginKeyResponse)
async def get_login_key() -> LoginKeyResponse:
challenge = create_login_challenge()
return LoginKeyResponse(
key_id=settings.LOGIN_RSA_KEY_ID,
public_key=get_public_key_pem(),
challenge=challenge.value,
expires_at=challenge.expires_at,
)
return Token(access_token=access_token, token_type="bearer")
@router.post("/login", response_model=Token)
async def login_for_access_token(
payload: LoginRequest, db: AsyncSession = Depends(get_db_session)
) -> Token:
db_user = await authenticate_encrypted_password(payload, db)
ensure_user_active(db_user)
return issue_user_token(db_user)
@router.post("/dev-login", response_model=Token)
async def dev_login_for_access_token(
payload: DevLoginRequest, db: AsyncSession = Depends(get_db_session)
) -> Token:
if settings.ENV != "development":
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
db_user = await authenticate_plain_password(payload, db)
ensure_user_active(db_user)
return issue_user_token(db_user)
@router.get("/me", response_model=UserRead)
@@ -120,7 +187,6 @@ async def extend_access_token(
session_start = datetime.now(timezone.utc)
new_token = create_access_token(
user_id=str(db_user.id),
role=db_user.role.value if hasattr(db_user.role, "value") else db_user.role,
expires_minutes=None,
session_start=session_start,
)
@@ -128,29 +194,6 @@ async def extend_access_token(
return ExtendResponse(accessToken=new_token, expiresAt=expires_at)
@router.post("/unlock", response_model=UnlockResponse)
async def unlock_session(
payload: UnlockRequest,
db: AsyncSession = Depends(get_db_session),
) -> UnlockResponse:
db_user = await user_crud.get_by_email(db, payload.email)
if not db_user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="账号不存在")
if not verify_password(payload.password, db_user.password_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="密码错误")
if db_user.status != UserStatus.ACTIVE:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已停用")
session_start = datetime.now(timezone.utc)
access_token = create_access_token(
user_id=str(db_user.id),
role=db_user.role.value if hasattr(db_user.role, "value") else db_user.role,
expires_minutes=None,
session_start=session_start,
)
expires_at = session_start + timedelta(minutes=settings.JWT_EXPIRE_MINUTES)
return UnlockResponse(accessToken=access_token, expiresAt=expires_at)
@router.patch("/me", response_model=UserRead)
async def update_me(
payload: UserSelfUpdate,
@@ -162,7 +205,9 @@ async def update_me(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="当前密码不正确")
update_data = {
"full_name": payload.full_name if payload.full_name is not None else current_user.full_name,
"department": payload.department if payload.department is not None else current_user.department,
"clinical_department": (
payload.clinical_department if payload.clinical_department is not None else current_user.clinical_department
),
"password": payload.password if payload.password else None,
"avatar_url": payload.avatar_url if payload.avatar_url is not None else current_user.avatar_url,
}
@@ -176,10 +221,15 @@ async def upload_avatar(
current_user=Depends(get_current_user),
db: AsyncSession = Depends(get_db_session),
) -> UserRead:
ext = AVATAR_ALLOWED_CONTENT_TYPES.get(file.content_type or "")
if not ext:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="头像仅支持图片格式",
)
AVATAR_ROOT.mkdir(parents=True, exist_ok=True)
user_dir = AVATAR_ROOT / str(current_user.id)
user_dir.mkdir(parents=True, exist_ok=True)
ext = Path(file.filename).suffix or ".png"
filename = f"{uuid.uuid4()}{ext}"
dest = user_dir / filename
content = await file.read()
+15 -8
View File
@@ -3,7 +3,9 @@ from fastapi import APIRouter, Depends
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_member
from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, is_system_admin, require_api_permission
from app.core.project_permissions import role_has_api_permission
from app.crud import member as member_crud
from app.models.milestone import Milestone
from app.schemas.progress import StudyProgressRead
from app.schemas.visit import VisitLostItem
@@ -14,9 +16,10 @@ from app.crud import overview as overview_crud
router = APIRouter()
@router.get("/progress", response_model=StudyProgressRead, dependencies=[Depends(require_study_member())])
@router.get("/progress", response_model=StudyProgressRead)
async def get_progress(
study_id: uuid.UUID,
_=Depends(require_api_permission("project_overview:read")),
db: AsyncSession = Depends(get_db_session),
) -> StudyProgressRead:
milestone_total_stmt = select(func.count()).select_from(Milestone).where(Milestone.study_id == study_id)
@@ -36,10 +39,11 @@ async def get_progress(
)
@router.get("/lost-visits", response_model=list[VisitLostItem], dependencies=[Depends(require_study_member())])
@router.get("/lost-visits", response_model=list[VisitLostItem])
async def list_lost_visits(
study_id: uuid.UUID,
limit: int = 20,
_=Depends(require_api_permission("project_overview:read")),
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[VisitLostItem]:
@@ -62,15 +66,18 @@ async def list_lost_visits(
return items
@router.get("/center-summary", response_model=list[CenterSummaryItem], dependencies=[Depends(require_study_member())])
@router.get("/center-summary", response_model=list[CenterSummaryItem])
async def get_center_summary(
study_id: uuid.UUID,
_=Depends(require_api_permission("project_overview:read")),
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[CenterSummaryItem]:
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
if role_value not in {"ADMIN", "PM", "CRA"}:
return []
membership = None
if not is_system_admin(current_user):
membership = await member_crud.get_member(db, study_id, current_user.id)
if not membership or not membership.is_active:
return []
cra_scope = await get_cra_site_scope(db, study_id, current_user)
scope_ids = cra_scope[0] if cra_scope else None
scope_id_strs = {str(cid) for cid in scope_ids} if scope_ids is not None else None
@@ -79,7 +86,7 @@ async def get_center_summary(
stage_order = [
("institution_initiation_status", "机构立项"),
("ethics_status", "伦理审批"),
("ethics_status", "伦理记录"),
("contract_sign_status", "合同签署"),
("startup_status", "启动"),
("enrollment_status", "入组"),
+13 -9
View File
@@ -1,7 +1,7 @@
import uuid
from typing import Optional
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile, status
from fastapi import APIRouter, Depends, File, Form, Query, UploadFile, status
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
@@ -9,9 +9,8 @@ from app.core.deps import get_current_user, get_db_session
from app.schemas.acknowledgement import AcknowledgementCreate, AcknowledgementRead
from app.schemas.common import PaginatedResponse
from app.schemas.distribution import DistributionCreate, DistributionRead
from app.schemas.document import DocumentCreate, DocumentDetail, DocumentSummary
from app.schemas.document import DocumentCreate, DocumentDetail, DocumentSummary, DocumentUpdate
from app.schemas.document_version import DocumentVersionRead
from app.models.user import UserRole
from app.services import document_service
from app.utils.pagination import paginate
@@ -67,15 +66,23 @@ async def get_document_detail(
return await document_service.get_document_detail(db, document_id, current_user)
@router.patch("/documents/{document_id}", response_model=DocumentSummary)
async def update_document(
document_id: uuid.UUID,
payload: DocumentUpdate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> DocumentSummary:
doc = await document_service.update_document(db, document_id, payload, current_user)
return DocumentSummary.model_validate(doc)
@router.delete("/documents/{document_id}", response_model=DocumentSummary)
async def delete_document(
document_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> DocumentSummary:
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
if role_value != UserRole.ADMIN.value:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="仅管理员可删除文档")
doc = await document_service.delete_document(db, document_id, current_user)
return DocumentSummary.model_validate(doc)
@@ -123,9 +130,6 @@ async def delete_version(
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> None:
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
if role_value != UserRole.ADMIN.value:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="仅管理员可删除版本")
await document_service.delete_version(db, version_id, current_user)
+49 -13
View File
@@ -1,18 +1,33 @@
import uuid
import json
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_member, require_study_roles, require_study_not_locked
from app.core.deps import get_operator_role_label, 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 drug_shipment as shipment_crud
from app.crud import site as site_crud
from app.crud import study as study_crud
from app.schemas.drug_shipment import DrugShipmentCreate, DrugShipmentRead, DrugShipmentUpdate
from app.schemas.drug_shipment import (
DrugShipmentCreate,
DrugShipmentRead,
DrugShipmentUpdate,
validate_drug_shipment_required_fields,
)
router = APIRouter()
def _shipment_audit_name(shipment) -> str:
return shipment.tracking_no or shipment.batch_no or shipment.site_name or "药品运输记录"
def _shipment_audit_description(action: str, shipment) -> str:
name = _shipment_audit_name(shipment)
return f"{action}药品流向“{name}"
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
study = await study_crud.get(db, study_id)
if not study:
@@ -33,7 +48,7 @@ async def _ensure_center_active(db: AsyncSession, study_id: uuid.UUID, center_id
"/shipments",
response_model=DrugShipmentRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
dependencies=[Depends(require_api_permission("drug_shipments:create")), Depends(require_study_not_locked())],
)
async def create_shipment(
study_id: uuid.UUID,
@@ -54,9 +69,12 @@ async def create_shipment(
entity_type="drug_shipment",
entity_id=shipment.id,
action="CREATE_DRUG_SHIPMENT",
detail=f"药品运输 {shipment.id} 已创建",
detail=json.dumps(
{"targetName": _shipment_audit_name(shipment), "description": _shipment_audit_description("创建", shipment)},
ensure_ascii=False,
),
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
return DrugShipmentRead.model_validate(shipment)
@@ -64,7 +82,7 @@ async def create_shipment(
@router.get(
"/shipments",
response_model=list[DrugShipmentRead],
dependencies=[Depends(require_study_member())],
dependencies=[Depends(require_api_permission("drug_shipments:read"))],
)
async def list_shipments(
study_id: uuid.UUID,
@@ -101,7 +119,7 @@ async def list_shipments(
@router.get(
"/shipments/{shipment_id}",
response_model=DrugShipmentRead,
dependencies=[Depends(require_study_member())],
dependencies=[Depends(require_api_permission("drug_shipments:read"))],
)
async def get_shipment(
study_id: uuid.UUID,
@@ -122,7 +140,7 @@ async def get_shipment(
@router.patch(
"/shipments/{shipment_id}",
response_model=DrugShipmentRead,
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
dependencies=[Depends(require_api_permission("drug_shipments:update")), Depends(require_study_not_locked())],
)
async def update_shipment(
study_id: uuid.UUID,
@@ -143,6 +161,20 @@ async def update_shipment(
shipment_in = shipment_in.model_copy(update={"site_name": site.name})
else:
await _ensure_center_active(db, study_id, shipment.center_id)
update_data = shipment_in.model_dump(exclude_unset=True)
try:
validate_drug_shipment_required_fields(
update_data.get("status", shipment.status),
update_data.get("ship_date", shipment.ship_date),
update_data.get("receive_date", shipment.receive_date),
update_data.get("quantity", shipment.quantity),
update_data.get("batch_no", shipment.batch_no),
update_data.get("carrier", shipment.carrier),
update_data.get("tracking_no", shipment.tracking_no),
update_data.get("remark", shipment.remark),
)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
shipment = await shipment_crud.update_shipment(db, shipment, shipment_in)
await audit_crud.log_action(
db,
@@ -150,9 +182,12 @@ async def update_shipment(
entity_type="drug_shipment",
entity_id=shipment_id,
action="UPDATE_DRUG_SHIPMENT",
detail=f"药品运输 {shipment_id} 已更新",
detail=json.dumps(
{"targetName": _shipment_audit_name(shipment), "description": _shipment_audit_description("更新", shipment)},
ensure_ascii=False,
),
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
return DrugShipmentRead.model_validate(shipment)
@@ -160,7 +195,7 @@ async def update_shipment(
@router.delete(
"/shipments/{shipment_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
dependencies=[Depends(require_api_permission("drug_shipments:delete")), Depends(require_study_not_locked())],
)
async def delete_shipment(
study_id: uuid.UUID,
@@ -176,6 +211,7 @@ async def delete_shipment(
if cra_scope and shipment.center_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_center_active(db, study_id, shipment.center_id)
shipment_name = _shipment_audit_name(shipment)
await shipment_crud.delete_shipment(db, shipment)
await audit_crud.log_action(
db,
@@ -183,7 +219,7 @@ async def delete_shipment(
entity_type="drug_shipment",
entity_id=shipment_id,
action="DELETE_DRUG_SHIPMENT",
detail=f"药品运输 {shipment_id} 已删除",
detail=json.dumps({"targetName": shipment_name, "description": f"删除药品流向“{shipment_name}"}, ensure_ascii=False),
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
+63
View File
@@ -0,0 +1,63 @@
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session
from app.schemas.document import DocumentCreate, DocumentSummary
from app.schemas.etmf import EtmfNodeCreate, EtmfNodeRead, EtmfNodeUpdate, EtmfTreeNode
from app.services import etmf_service
router = APIRouter()
@router.get("/tree", response_model=list[EtmfTreeNode])
async def get_etmf_tree(
study_id: uuid.UUID = Query(...),
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[EtmfTreeNode]:
return await etmf_service.list_etmf_tree(db, study_id=study_id, current_user=current_user)
@router.post("/nodes", response_model=EtmfNodeRead, status_code=status.HTTP_201_CREATED)
async def create_etmf_node(
payload: EtmfNodeCreate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> EtmfNodeRead:
node = await etmf_service.create_node(db, payload, current_user)
return EtmfNodeRead.model_validate(node)
@router.patch("/nodes/{node_id}", response_model=EtmfNodeRead)
async def update_etmf_node(
node_id: uuid.UUID,
payload: EtmfNodeUpdate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> EtmfNodeRead:
node = await etmf_service.update_node(db, node_id, payload, current_user)
return EtmfNodeRead.model_validate(node)
@router.get("/nodes/{node_id}/documents", response_model=list[DocumentSummary])
async def list_etmf_node_documents(
node_id: uuid.UUID,
site_id: uuid.UUID | None = Query(None),
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[DocumentSummary]:
return await etmf_service.list_node_documents(db, node_id=node_id, site_id=site_id, current_user=current_user)
@router.post("/nodes/{node_id}/documents", response_model=DocumentSummary, status_code=status.HTTP_201_CREATED)
async def create_etmf_node_document(
node_id: uuid.UUID,
payload: DocumentCreate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> DocumentSummary:
return await etmf_service.create_node_document(db, node_id=node_id, payload=payload, current_user=current_user)
+40 -37
View File
@@ -1,9 +1,11 @@
import uuid
import json
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_not_locked
from app.core.deps import get_current_user, get_db_session, get_operator_role_label, is_system_admin, 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 faq_category as category_crud
from app.crud import faq_item as item_crud
@@ -15,9 +17,8 @@ from app.utils.pagination import paginate
router = APIRouter()
def _check_permission_for_scope(study_id: uuid.UUID, current_user, member_role: str | None):
if current_user.role != "ADMIN" and member_role != "PM":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
def _is_system_admin(current_user) -> bool:
return is_system_admin(current_user)
@router.post(
@@ -25,8 +26,11 @@ def _check_permission_for_scope(study_id: uuid.UUID, current_user, member_role:
response_model=CategoryRead,
status_code=status.HTTP_201_CREATED,
summary="创建 FAQ 分类",
description="创建全局或项目内 FAQ 分类,项目 PM/ADMIN 可用,全局仅 ADMIN",
dependencies=[Depends(require_study_not_locked())],
description="创建项目内 FAQ 分类,权限由项目级权限矩阵控制",
dependencies=[
Depends(require_api_permission("faq_category:create")),
Depends(require_study_not_locked())
],
)
async def create_category(
payload: CategoryCreate,
@@ -38,12 +42,10 @@ async def create_category(
existing = await category_crud.get_category_by_name(db, payload.study_id, payload.name)
if existing:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类名称已存在")
member_role = None
member = await member_crud.get_member(db, payload.study_id, current_user.id)
if not member and current_user.role != "ADMIN":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是该项目成员")
member_role = member.role_in_study if member else None
_check_permission_for_scope(payload.study_id, current_user, member_role)
if payload.icon:
dup = await category_crud.get_category_by_icon(db, payload.study_id, payload.icon)
if dup:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="该图标已被其他分类使用")
category = await category_crud.create_category(db, payload)
await audit_crud.log_action(
db,
@@ -51,9 +53,9 @@ async def create_category(
entity_type="faq_category",
entity_id=category.id,
action="CREATE_FAQ_CATEGORY",
detail=f"FAQ 分类 {category.name} 已创建",
detail=json.dumps({"targetName": category.name, "description": f"创建“{category.name}”分类"}, ensure_ascii=False),
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, payload.study_id, current_user),
)
return CategoryRead.model_validate(category)
@@ -63,20 +65,16 @@ async def create_category(
response_model=PaginatedResponse[CategoryRead],
summary="FAQ 分类列表",
description="返回全局及项目内 FAQ 分类列表,可按 study_id 过滤。",
dependencies=[Depends(require_api_permission("faq_category:read"))],
)
async def list_categories(
study_id: uuid.UUID | None = None,
is_active: bool | None = True,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[CategoryRead]:
if not study_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="必须提供项目 ID")
if study_id:
member = await member_crud.get_member(db, study_id, current_user.id)
if not member and current_user.role != "ADMIN":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是该项目成员")
categories = await category_crud.list_categories(db, study_id, include_global=False, is_active=is_active)
categories = await category_crud.list_categories(db, study_id, include_global=False)
return paginate([CategoryRead.model_validate(c) for c in categories], total=len(categories))
@@ -84,8 +82,11 @@ async def list_categories(
"/{category_id}",
response_model=CategoryRead,
summary="更新 FAQ 分类",
description="更新分类名称或启停状态,项目 PM/ADMIN 或全局 ADMIN",
dependencies=[Depends(require_study_not_locked())],
description="更新分类名称或启停状态,权限由项目级权限矩阵控制",
dependencies=[
Depends(require_api_permission("faq_category:update")),
Depends(require_study_not_locked())
],
)
async def update_category(
category_id: uuid.UUID,
@@ -96,22 +97,20 @@ async def update_category(
category = await category_crud.get_category(db, category_id)
if not category:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分类不存在")
member_role = None
update_data = payload.model_dump(exclude_unset=True)
target_study_id = update_data.get("study_id", category.study_id)
target_name = update_data.get("name", category.name)
if "study_id" in update_data and update_data["study_id"] != category.study_id and current_user.role != "ADMIN":
if "study_id" in update_data and update_data["study_id"] != category.study_id and not _is_system_admin(current_user):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="仅管理员可修改分类范围")
existing = await category_crud.get_category_by_name(db, target_study_id, target_name)
if existing and existing.id != category.id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类名称已存在")
if payload.icon:
dup = await category_crud.get_category_by_icon(db, target_study_id, payload.icon)
if dup and dup.id != category.id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="该图标已被其他分类使用")
if not target_study_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="必须提供项目 ID")
member = await member_crud.get_member(db, target_study_id, current_user.id)
member_role = member.role_in_study if member else None
if not member and current_user.role != "ADMIN":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是该项目成员")
_check_permission_for_scope(target_study_id, current_user, member_role)
updated = await category_crud.update_category(db, category, payload)
await audit_crud.log_action(
db,
@@ -119,9 +118,9 @@ async def update_category(
entity_type="faq_category",
entity_id=category_id,
action="UPDATE_FAQ_CATEGORY",
detail=f"FAQ 分类 {category_id} 已更新",
detail=json.dumps({"targetName": updated.name, "description": f"更新“{updated.name}”分类"}, ensure_ascii=False),
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, updated.study_id, current_user),
)
return CategoryRead.model_validate(updated)
@@ -130,8 +129,11 @@ async def update_category(
"/{category_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="删除 FAQ 分类",
description="仅 ADMIN 可删除分类,分类下存在 FAQ 时不可删除。",
dependencies=[Depends(require_study_not_locked())],
description="删除分类,权限由项目级权限矩阵控制;分类下存在 FAQ 时不可删除。",
dependencies=[
Depends(require_api_permission("faq_category:delete")),
Depends(require_study_not_locked())
],
)
async def delete_category(
category_id: uuid.UUID,
@@ -141,11 +143,12 @@ async def delete_category(
category = await category_crud.get_category(db, category_id)
if not category:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分类不存在")
if current_user.role != "ADMIN":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="仅管理员可删除 FAQ 分类")
if not category.study_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="必须提供项目 ID")
item_count = await item_crud.count_items_by_category(db, category_id)
if item_count > 0:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类下存在 FAQ,无法删除")
category_name = category.name
await db.delete(category)
await db.commit()
await audit_crud.log_action(
@@ -154,7 +157,7 @@ async def delete_category(
entity_type="faq_category",
entity_id=category_id,
action="DELETE_FAQ_CATEGORY",
detail=f"FAQ 分类 {category_id} 已删除",
detail=json.dumps({"targetName": category_name, "description": f"删除“{category_name}”分类"}, ensure_ascii=False),
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, category.study_id, current_user),
)
+67 -102
View File
@@ -1,9 +1,11 @@
import json
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_not_locked
from app.core.deps import get_current_user, get_db_session, get_operator_role_label, is_system_admin, 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 faq_category as category_crud
from app.crud import faq_item as faq_crud
@@ -25,14 +27,15 @@ from app.utils.pagination import paginate
router = APIRouter()
def _check_write_permission(study_id: uuid.UUID, current_user, member_role: str | None):
if current_user.role != "ADMIN" and member_role != "PM":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
def _is_system_admin(current_user) -> bool:
return is_system_admin(current_user)
def _check_create_permission(current_user, is_member: bool):
if current_user.role != "ADMIN" and not is_member:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是该项目成员")
def _compact_text(value: str | None, max_length: int = 40) -> str:
text = " ".join(str(value or "").split())
if len(text) <= max_length:
return text
return f"{text[:max_length]}..."
@router.post(
@@ -40,8 +43,11 @@ def _check_create_permission(current_user, is_member: bool):
response_model=FaqRead,
status_code=status.HTTP_201_CREATED,
summary="创建 FAQ",
description="创建全局或项目内 FAQ项目 FAQ 需项目 PM/ADMIN 权限。",
dependencies=[Depends(require_study_not_locked())],
description="创建项目内 FAQ,权限由项目级权限矩阵控制",
dependencies=[
Depends(require_api_permission("faq:create")),
Depends(require_study_not_locked())
],
)
async def create_faq(
payload: FaqCreate,
@@ -55,12 +61,6 @@ async def create_faq(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分类不存在")
if cat.study_id != payload.study_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类范围不匹配")
member_role = None
is_member = False
member = await member_crud.get_member(db, payload.study_id, current_user.id)
member_role = member.role_in_study if member else None
is_member = member is not None
_check_create_permission(current_user, is_member)
try:
item = await faq_crud.create_item(db, payload, created_by=current_user.id)
except ValueError as exc:
@@ -74,15 +74,16 @@ async def create_faq(
reply_in=FaqReplyCreate(content=payload.answer),
)
await faq_crud.set_status(db, item.id, "PROCESSING")
question_name = _compact_text(item.question)
await audit_crud.log_action(
db,
study_id=payload.study_id,
entity_type="faq_item",
entity_id=item.id,
action="CREATE_FAQ_ITEM",
detail="FAQ 已创建",
detail=json.dumps({"targetName": question_name, "description": f"创建医学咨询问题“{question_name}"}, ensure_ascii=False),
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, payload.study_id, current_user),
)
return FaqRead.model_validate(item)
@@ -92,12 +93,12 @@ async def create_faq(
response_model=PaginatedResponse[FaqRead],
summary="FAQ 列表",
description="返回全局与项目 FAQ,可按关键词、分类过滤。",
dependencies=[Depends(require_api_permission("faq:read"))],
)
async def list_faqs(
study_id: uuid.UUID | None = None,
category_id: uuid.UUID | None = None,
keyword: str | None = None,
is_active: bool | None = None,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[FaqRead]:
@@ -113,35 +114,26 @@ async def list_faqs(
if not study_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="必须提供项目 ID")
if current_user.role != "ADMIN":
role = await _get_member_role(study_id)
if not role:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是该项目成员")
if is_active is False and current_user.role != "ADMIN":
role = await _get_member_role(study_id)
if role != "PM":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
if not is_system_admin(current_user):
member = await member_crud.get_member(db, study_id, current_user.id)
if not member or not member.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员")
items = await faq_crud.list_items(
db,
study_id=study_id,
category_id=category_id,
keyword=keyword,
is_active=is_active,
study_scope="project",
)
visible: list[FaqRead] = []
for it in items:
if current_user.role != "ADMIN":
if not _is_system_admin(current_user):
role = await _get_member_role(it.study_id)
if not role:
continue
if not it.is_active and current_user.role != "ADMIN":
role = await _get_member_role(it.study_id)
if role != "PM":
continue
visible.append(FaqRead.model_validate(it))
return paginate(visible, total=len(visible))
@@ -150,7 +142,8 @@ async def list_faqs(
"/{item_id}",
response_model=FaqRead,
summary="FAQ 详情",
description="获取单条 FAQ非 PM 不能查看停用 FAQ。",
description="获取单条 FAQ,停用 FAQ 需项目级写权限",
dependencies=[Depends(require_api_permission("faq:read"))],
)
async def get_faq(
item_id: uuid.UUID,
@@ -162,15 +155,6 @@ async def get_faq(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
if not item.study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
if current_user.role != "ADMIN":
member = await member_crud.get_member(db, item.study_id, current_user.id)
if not member:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是该项目成员")
if not item.is_active and current_user.role not in {"ADMIN"}:
member = await member_crud.get_member(db, item.study_id, current_user.id)
member_role = member.role_in_study if member else None
if member_role != "PM":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="FAQ 已停用")
return FaqRead.model_validate(item)
@@ -178,8 +162,11 @@ async def get_faq(
"/{item_id}",
response_model=FaqRead,
summary="更新 FAQ",
description="更新 FAQ 内容或启停状态,项目内需 PM/ADMIN 权限。",
dependencies=[Depends(require_study_not_locked())],
description="更新 FAQ 内容或启停状态,权限由项目级权限矩阵控制",
dependencies=[
Depends(require_api_permission("faq:update")),
Depends(require_study_not_locked())
],
)
async def update_faq(
item_id: uuid.UUID,
@@ -190,19 +177,10 @@ async def update_faq(
item = await faq_crud.get_item(db, item_id)
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
if item.created_by != current_user.id:
member_role = None
if item.study_id:
member = await member_crud.get_member(db, item.study_id, current_user.id)
member_role = member.role_in_study if member else None
_check_write_permission(item.study_id, current_user, member_role)
old_active = item.is_active
updated = await faq_crud.update_item(db, item, payload)
action = "UPDATE_FAQ_ITEM"
detail = "FAQ updated"
if payload.is_active is not None and payload.is_active != old_active:
action = "FAQ_STATUS_CHANGE"
detail = "FAQ disabled" if not payload.is_active else "FAQ enabled"
question_name = _compact_text(updated.question)
detail = json.dumps({"targetName": question_name, "description": f"更新医学咨询问题“{question_name}"}, ensure_ascii=False)
await audit_crud.log_action(
db,
study_id=item.study_id,
@@ -211,7 +189,7 @@ async def update_faq(
action=action,
detail=detail,
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, item.study_id, current_user),
)
return FaqRead.model_validate(updated)
@@ -220,8 +198,11 @@ async def update_faq(
"/{item_id}/status",
response_model=FaqRead,
summary="更新 FAQ 状态",
description="提问者或项目 PM/ADMIN 可确认已解决。",
dependencies=[Depends(require_study_not_locked())],
description="提问者或具备项目级写权限的成员可确认已解决。",
dependencies=[
Depends(require_api_permission("faq:update")),
Depends(require_study_not_locked())
],
)
async def update_faq_status(
item_id: uuid.UUID,
@@ -234,13 +215,6 @@ async def update_faq_status(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
if payload.status != "RESOLVED":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅允许设置为已解决")
if item.created_by != current_user.id and current_user.role != "ADMIN":
member_role = None
if item.study_id:
member = await member_crud.get_member(db, item.study_id, current_user.id)
member_role = member.role_in_study if member else None
if member_role != "PM":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await faq_crud.set_status(db, item.id, "RESOLVED", resolved_by_confirm=True)
updated = await faq_crud.get_item(db, item_id)
return FaqRead.model_validate(updated)
@@ -251,7 +225,10 @@ async def update_faq_status(
response_model=FaqRead,
summary="设置最佳回复",
description="项目成员可设置最佳回复,全局仅 ADMIN。",
dependencies=[Depends(require_study_not_locked())],
dependencies=[
Depends(require_api_permission("faq:update")),
Depends(require_study_not_locked())
],
)
async def set_best_reply(
item_id: uuid.UUID,
@@ -264,10 +241,6 @@ async def set_best_reply(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
if not item.study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
is_member = False
member = await member_crud.get_member(db, item.study_id, current_user.id)
is_member = member is not None
_check_create_permission(current_user, is_member)
if payload.best_reply_id:
reply = await reply_crud.get_reply(db, payload.best_reply_id)
if not reply or reply.faq_id != item.id:
@@ -295,6 +268,7 @@ async def set_best_reply(
response_model=PaginatedResponse[FaqReplyRead],
summary="FAQ 回复列表",
description="获取 FAQ 的回复列表。",
dependencies=[Depends(require_api_permission("faq:read"))],
)
async def list_replies(
item_id: uuid.UUID,
@@ -304,10 +278,6 @@ async def list_replies(
item = await faq_crud.get_item(db, item_id)
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
if item.study_id and current_user.role != "ADMIN":
member = await member_crud.get_member(db, item.study_id, current_user.id)
if not member:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是该项目成员")
replies = await reply_crud.list_replies(db, item_id)
reply_map = {r.id: r for r in replies}
result: list[FaqReplyRead] = []
@@ -334,7 +304,10 @@ async def list_replies(
status_code=status.HTTP_201_CREATED,
summary="创建 FAQ 回复",
description="回复 FAQ,项目内成员可回复,全局仅 ADMIN。",
dependencies=[Depends(require_study_not_locked())],
dependencies=[
Depends(require_api_permission("faq_reply:create")),
Depends(require_study_not_locked())
],
)
async def create_reply(
item_id: uuid.UUID,
@@ -349,10 +322,6 @@ async def create_reply(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
if not payload.content.strip():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="回复内容不能为空")
is_member = False
member = await member_crud.get_member(db, item.study_id, current_user.id)
is_member = member is not None
_check_create_permission(current_user, is_member)
quote = None
if payload.quote_reply_id:
quote = await reply_crud.get_reply(db, payload.quote_reply_id)
@@ -368,15 +337,16 @@ async def create_reply(
if item.status != "RESOLVED":
await faq_crud.set_status(db, item.id, "PROCESSING")
await faq_crud.touch_item(db, item.id)
question_name = _compact_text(item.question)
await audit_crud.log_action(
db,
study_id=item.study_id,
entity_type="faq_reply",
entity_id=reply.id,
action="CREATE_FAQ_REPLY",
detail="FAQ 已回复",
detail=json.dumps({"targetName": question_name, "description": f"回复医学咨询问题“{question_name}"}, ensure_ascii=False),
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, item.study_id, current_user),
)
data = FaqReplyRead.model_validate(reply)
if quote:
@@ -396,8 +366,11 @@ async def create_reply(
"/{item_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="删除 FAQ",
description="删除 FAQ项目内需 PM/ADMIN 权限,全局仅 ADMIN",
dependencies=[Depends(require_study_not_locked())],
description="删除 FAQ权限由项目级权限矩阵控制",
dependencies=[
Depends(require_api_permission("faq:delete")),
Depends(require_study_not_locked())
],
)
async def delete_faq(
item_id: uuid.UUID,
@@ -407,12 +380,7 @@ async def delete_faq(
item = await faq_crud.get_item(db, item_id)
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
if item.created_by != current_user.id:
member_role = None
if item.study_id:
member = await member_crud.get_member(db, item.study_id, current_user.id)
member_role = member.role_in_study if member else None
_check_write_permission(item.study_id, current_user, member_role)
question_name = _compact_text(item.question)
await reply_crud.delete_replies_by_faq_id(db, item.id)
await db.delete(item)
await db.commit()
@@ -422,9 +390,9 @@ async def delete_faq(
entity_type="faq_item",
entity_id=item_id,
action="DELETE_FAQ_ITEM",
detail="FAQ 已删除",
detail=json.dumps({"targetName": question_name, "description": f"删除医学咨询问题“{question_name}"}, ensure_ascii=False),
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, item.study_id, current_user),
)
@@ -432,8 +400,11 @@ async def delete_faq(
"/{item_id}/replies/{reply_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="删除 FAQ 回复",
description="删除 FAQ 回复,管理员、项目 PM 或回复者可删除。",
dependencies=[Depends(require_study_not_locked())],
description="删除 FAQ 回复,回复者或具备项目级写权限的成员可删除。",
dependencies=[
Depends(require_api_permission("faq_reply:delete")),
Depends(require_study_not_locked())
],
)
async def delete_reply(
item_id: uuid.UUID,
@@ -444,16 +415,10 @@ async def delete_reply(
item = await faq_crud.get_item(db, item_id)
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
question_name = _compact_text(item.question)
reply = await reply_crud.get_reply(db, reply_id)
if not reply or reply.faq_id != item.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="回复不存在")
if reply.created_by != current_user.id and current_user.role != "ADMIN":
member_role = None
if item.study_id:
member = await member_crud.get_member(db, item.study_id, current_user.id)
member_role = member.role_in_study if member else None
if member_role != "PM":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
if item.best_reply_id == reply.id:
await faq_crud.set_best_reply(db, item.id, None)
ref_count = await reply_crud.count_quote_references(db, reply.id)
@@ -479,7 +444,7 @@ async def delete_reply(
entity_type="faq_reply",
entity_id=reply_id,
action="DELETE_FAQ_REPLY",
detail="FAQ 回复已删除",
detail=json.dumps({"targetName": question_name, "description": f"删除医学咨询问题“{question_name}”的回复"}, ensure_ascii=False),
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, item.study_id, current_user),
)
-255
View File
@@ -1,255 +0,0 @@
import os
import uuid
from pathlib import Path
import aiofiles
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status, Request, Form
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_not_locked
from app.core.security import decode_token
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
from app.crud import fee_attachment as fee_attachment_crud
from app.crud import member as member_crud
from app.crud import special_expense as special_crud
from app.crud import user as user_crud
from app.schemas.fee_attachment import FeeAttachmentRead
from app.schemas.fee_common import FeeApiResponse
from app.schemas.user import UserDisplay
router = APIRouter()
UPLOAD_ROOT = Path(__file__).resolve().parent.parent.parent / "uploads" / "fees"
ALLOWED_ENTITY_TYPES = {"contract_fee", "contract_payment", "special_expense"}
ALLOWED_FILE_TYPES = {
"contract_fee": {"contract", "voucher", "invoice"},
"contract_payment": {"voucher", "invoice"},
"special_expense": {"voucher", "invoice", "other"},
}
async def _resolve_project_id(db: AsyncSession, entity_type: str, entity_id: uuid.UUID) -> uuid.UUID:
if entity_type == "contract_fee":
contract = await contract_fee_crud.get_contract_fee(db, entity_id)
if not contract:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
return contract.project_id
if entity_type == "contract_payment":
payment = await payment_crud.get_payment(db, entity_id)
if not payment:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分期记录不存在")
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="合同费用不存在")
return contract.project_id
if entity_type == "special_expense":
expense = await special_crud.get_special_expense(db, entity_id)
if not expense:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="特殊费用不存在")
return expense.project_id
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不支持的附件类型")
async def _ensure_project_access(db: AsyncSession, project_id: uuid.UUID, current_user, write: bool = False):
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
if role_value == "ADMIN":
return None
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="不是项目成员")
if write and membership.role_in_study != "PM":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="项目权限不足")
return membership
async def _authorize_download_user(request: Request, db: AsyncSession):
token = None
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.lower().startswith("bearer "):
token = auth_header.split(" ", 1)[1]
if not token:
token = request.query_params.get("token")
if not token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="未登录")
payload = decode_token(token)
user = await user_crud.get_by_id(db, uuid.UUID(str(payload.get("sub"))))
if not user or not user.is_active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="账号不存在或已停用")
return user
@router.post(
"/attachments",
response_model=FeeApiResponse[FeeAttachmentRead],
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(get_current_user), Depends(require_study_not_locked())],
)
async def upload_fee_attachment(
entity_type: str = Form(...),
entity_id: uuid.UUID = Form(...),
file_type: str = Form(...),
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> FeeApiResponse[FeeAttachmentRead]:
if entity_type not in ALLOWED_ENTITY_TYPES:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="附件类型无效")
if file_type not in ALLOWED_FILE_TYPES.get(entity_type, set()):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="附件文件类型无效")
project_id = await _resolve_project_id(db, entity_type, entity_id)
await _ensure_project_access(db, project_id, current_user, write=True)
dest_dir = UPLOAD_ROOT / f"{entity_type}_{entity_id}"
dest_dir.mkdir(parents=True, exist_ok=True)
unique_name = f"{uuid.uuid4()}{Path(file.filename).suffix}"
dest_path = dest_dir / unique_name
content = await file.read()
async with aiofiles.open(dest_path, "wb") as out_file:
await out_file.write(content)
attachment = await fee_attachment_crud.create_attachment(
db,
entity_type=entity_type,
entity_id=entity_id,
file_type=file_type,
filename=file.filename,
mime_type=file.content_type,
size=len(content),
storage_key=str(dest_path),
url=None,
uploaded_by=current_user.id,
)
await audit_crud.log_action(
db,
study_id=project_id,
entity_type=entity_type,
entity_id=entity_id,
action="UPLOAD_FEE_ATTACHMENT",
detail=f"附件已上传:{file.filename}",
operator_id=current_user.id,
operator_role=current_user.role,
)
return FeeApiResponse(
data=FeeAttachmentRead(
id=attachment.id,
entity_type=attachment.entity_type,
entity_id=attachment.entity_id,
file_type=attachment.file_type,
filename=attachment.filename,
mime_type=attachment.mime_type,
size=attachment.size,
storage_key=attachment.storage_key,
url=attachment.url,
uploaded_by=UserDisplay.model_validate(current_user),
uploaded_by_id=attachment.uploaded_by,
uploaded_at=attachment.uploaded_at,
)
)
@router.get(
"/attachments",
response_model=FeeApiResponse[list[FeeAttachmentRead]],
dependencies=[Depends(get_current_user)],
)
async def list_fee_attachments(
entity_type: str,
entity_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> FeeApiResponse[list[FeeAttachmentRead]]:
if entity_type not in ALLOWED_ENTITY_TYPES:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="附件类型无效")
project_id = await _resolve_project_id(db, entity_type, entity_id)
await _ensure_project_access(db, project_id, current_user, write=False)
attachments = await fee_attachment_crud.list_attachments(db, entity_type=entity_type, entity_id=entity_id)
user_ids = {a.uploaded_by for a in attachments if a.uploaded_by}
users_map = await user_crud.get_users_by_ids(db, user_ids)
items: list[FeeAttachmentRead] = []
for attachment in attachments:
user = users_map.get(attachment.uploaded_by)
items.append(
FeeAttachmentRead(
id=attachment.id,
entity_type=attachment.entity_type,
entity_id=attachment.entity_id,
file_type=attachment.file_type,
filename=attachment.filename,
mime_type=attachment.mime_type,
size=attachment.size,
storage_key=attachment.storage_key,
url=attachment.url,
uploaded_by=UserDisplay.model_validate(user) if user else None,
uploaded_by_id=attachment.uploaded_by,
uploaded_at=attachment.uploaded_at,
)
)
return FeeApiResponse(data=items, meta={"total": len(items)})
@router.get(
"/attachments/{attachment_id}/download",
response_class=FileResponse,
)
async def download_fee_attachment(
attachment_id: uuid.UUID,
request: Request,
db: AsyncSession = Depends(get_db_session),
):
attachment = await fee_attachment_crud.get_attachment(db, attachment_id)
if not attachment:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在")
current_user = await _authorize_download_user(request, db)
project_id = await _resolve_project_id(db, attachment.entity_type, attachment.entity_id)
await _ensure_project_access(db, project_id, current_user, write=False)
if not attachment.storage_key or not os.path.exists(attachment.storage_key):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="服务器未找到文件")
return FileResponse(
path=attachment.storage_key,
filename=attachment.filename,
media_type=attachment.mime_type or "application/octet-stream",
headers={"Content-Disposition": f'inline; filename="{attachment.filename}"'},
)
@router.delete(
"/attachments/{attachment_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(get_current_user), Depends(require_study_not_locked())],
)
async def delete_fee_attachment(
attachment_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> None:
attachment = await fee_attachment_crud.get_attachment(db, attachment_id)
if not attachment:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在")
project_id = await _resolve_project_id(db, attachment.entity_type, attachment.entity_id)
membership = await _ensure_project_access(db, project_id, current_user, write=True)
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
can_delete = (
role_value == "ADMIN"
or attachment.uploaded_by == current_user.id
or (membership and getattr(membership, "role_in_study", None) == "PM")
)
if not can_delete:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限删除附件")
await fee_attachment_crud.soft_delete_attachment(db, attachment)
await audit_crud.log_action(
db,
study_id=project_id,
entity_type=attachment.entity_type,
entity_id=attachment.entity_id,
action="DELETE_FEE_ATTACHMENT",
detail=f"附件已删除:{attachment.filename}",
operator_id=current_user.id,
operator_role=current_user.role,
)
+121 -61
View File
@@ -1,12 +1,14 @@
import uuid
import json
from decimal import Decimal
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi import APIRouter, Depends, HTTPException, Request, 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.deps import get_cra_site_scope, get_current_user, get_db_session, get_operator_role_label, is_system_admin, 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
@@ -21,36 +23,87 @@ from app.schemas.contract_fee_payment import (
ContractFeePaymentUpdate,
)
from app.schemas.fee_common import FeeApiResponse
from app.schemas.fee_attachment import FeeAttachmentRead
from app.schemas.attachment import AttachmentRead
from app.schemas.user import UserDisplay
from app.models.attachment import Attachment
router = APIRouter()
async def _ensure_project_access(db: AsyncSession, project_id: uuid.UUID, current_user, write: bool = False):
study = await study_crud.get(db, project_id)
async def _ensure_study_access(db: AsyncSession, study_id: uuid.UUID, current_user, endpoint_key: str):
study = await study_crud.get(db, study_id)
if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
if role_value == "ADMIN":
if is_system_admin(current_user):
return None
membership = await member_crud.get_member(db, project_id, current_user.id)
membership = await member_crud.get_member(db, study_id, current_user.id)
if not membership or not membership.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员")
if write and membership.role_in_study != "PM":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="项目权限不足")
allowed = await role_has_api_permission(db, study_id, membership.role_in_study, endpoint_key)
if not allowed:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="接口权限不足")
return membership
async def _ensure_center_active(db: AsyncSession, project_id: uuid.UUID, center_id: uuid.UUID | None):
async def _ensure_center_active(db: AsyncSession, study_id: uuid.UUID, center_id: uuid.UUID | None):
if not center_id:
return
site = await site_crud.get_site(db, center_id)
if not site or site.study_id != project_id or not site.is_active:
if not site or site.study_id != study_id or not site.is_active:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="中心已停用")
async def _contract_audit_name(db: AsyncSession, contract) -> str:
site = await site_crud.get_site(db, contract.center_id)
center_name = site.name if site else "中心"
contract_no = str(contract.contract_no or "").strip()
return f"{center_name} / {contract_no}" if contract_no else center_name
async def _contract_audit_detail(db: AsyncSession, action: str, contract) -> str:
name = await _contract_audit_name(db, contract)
return json.dumps({"targetName": name, "description": f"{action}合同费用“{name}"}, ensure_ascii=False)
async def _payment_audit_name(db: AsyncSession, contract, payment) -> str:
contract_name = await _contract_audit_name(db, contract)
seq = getattr(payment, "seq", None)
amount = getattr(payment, "amount", None)
if seq:
return f"{contract_name} / 第{seq}"
if amount is not None:
return f"{contract_name} / {amount}"
return contract_name
async def _payment_audit_detail(db: AsyncSession, action: str, contract, payment) -> str:
name = await _payment_audit_name(db, contract, payment)
return json.dumps({"targetName": name, "description": f"{action}合同费用分期“{name}"}, ensure_ascii=False)
def _parse_optional_uuid(value: str | None, detail: str) -> uuid.UUID | None:
if not value:
return None
try:
return uuid.UUID(str(value))
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=detail) from exc
def _resolve_contract_fee_list_query(
request: Request,
study_id: uuid.UUID | None,
center_id: uuid.UUID | None,
) -> tuple[uuid.UUID, uuid.UUID | None]:
query = request.query_params
resolved_study_id = study_id or _parse_optional_uuid(query.get("projectId"), "项目 ID 格式错误")
if not resolved_study_id:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="缺少项目 ID")
resolved_center_id = center_id or _parse_optional_uuid(query.get("centerId"), "中心 ID 格式错误")
return resolved_study_id, resolved_center_id
def _to_decimal(value: Any) -> Decimal:
if isinstance(value, Decimal):
return value
@@ -77,21 +130,23 @@ def _validate_payment_rules(data: ContractFeePaymentCreate | ContractFeePaymentU
dependencies=[Depends(get_current_user)],
)
async def list_contract_fees(
project_id: uuid.UUID = Query(..., alias="projectId"),
center_id: uuid.UUID | None = Query(None, alias="centerId"),
request: Request,
study_id: uuid.UUID | None = None,
center_id: uuid.UUID | None = None,
q: str | None = None,
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)
cra_scope = await get_cra_site_scope(db, project_id, current_user)
resolved_study_id, resolved_center_id = _resolve_contract_fee_list_query(request, study_id, center_id)
await _ensure_study_access(db, resolved_study_id, current_user, "fees_contracts:read")
cra_scope = await get_cra_site_scope(db, resolved_study_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:
if resolved_center_id and center_ids is not None and resolved_center_id not in center_ids:
return FeeApiResponse(data=[], meta={"total": 0})
rows = await contract_fee_crud.list_contract_fees(
db,
project_id,
center_id=center_id,
resolved_study_id,
center_id=resolved_center_id,
center_ids=center_ids,
q=q,
)
@@ -105,9 +160,13 @@ async def list_contract_fees(
items.append(
ContractFeeListItem(
id=contract.id,
project_id=contract.project_id,
study_id=contract.study_id,
center_id=contract.center_id,
contract_no=contract.contract_no,
signed_date=contract.signed_date,
contract_amount=contract_amount_decimal,
currency=contract.currency,
remark=contract.remark,
contract_cases=contract.contract_cases,
actual_cases=contract.actual_cases,
settlement_amount=_to_decimal(contract.settlement_amount) if contract.settlement_amount else None,
@@ -139,8 +198,8 @@ 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)
cra_scope = await get_cra_site_scope(db, contract.project_id, current_user)
await _ensure_study_access(db, contract.study_id, current_user, "fees_contracts:read")
cra_scope = await get_cra_site_scope(db, contract.study_id, current_user)
if cra_scope and contract.center_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
@@ -157,7 +216,7 @@ async def get_contract_fee(
user_ids = {a.uploaded_by for a in attachments if a.uploaded_by}
users_map = await user_crud.get_users_by_ids(db, user_ids)
attachments_map: dict[str, list[FeeAttachmentRead]] = {
attachments_map: dict[str, list[AttachmentRead]] = {
"contract": [],
"voucher": [],
"invoice": [],
@@ -167,16 +226,11 @@ async def get_contract_fee(
attachments_map.setdefault(key, [])
user = users_map.get(attachment.uploaded_by)
attachments_map[key].append(
FeeAttachmentRead(
AttachmentRead(
id=attachment.id,
entity_type="contract_fee",
entity_id=attachment.entity_id,
file_type=key,
filename=attachment.filename,
mime_type=attachment.content_type,
size=attachment.file_size,
storage_key=attachment.file_path,
url=None,
file_size=attachment.file_size,
content_type=attachment.content_type,
uploaded_by_id=attachment.uploaded_by,
uploaded_by=UserDisplay.model_validate(user) if user else None,
uploaded_at=attachment.uploaded_at,
@@ -187,9 +241,13 @@ async def get_contract_fee(
detail = ContractFeeDetail(
id=contract.id,
project_id=contract.project_id,
study_id=contract.study_id,
center_id=contract.center_id,
contract_no=contract.contract_no,
signed_date=contract.signed_date,
contract_amount=_to_decimal(contract.contract_amount),
currency=contract.currency,
remark=contract.remark,
contract_cases=contract.contract_cases,
actual_cases=contract.actual_cases,
settlement_amount=_to_decimal(contract.settlement_amount) if contract.settlement_amount else None,
@@ -214,27 +272,27 @@ 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)
existing = await contract_fee_crud.get_contract_fee_by_project_center(
db, contract_in.project_id, contract_in.center_id
await _ensure_study_access(db, contract_in.study_id, current_user, "fees_contracts:create")
existing = await contract_fee_crud.get_contract_fee_by_study_center(
db, contract_in.study_id, contract_in.center_id
)
if existing:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="该中心已存在合同费用")
site = await site_crud.get_site(db, contract_in.center_id)
if not site or site.study_id != contract_in.project_id or not site.is_active:
if not site or site.study_id != contract_in.study_id or not site.is_active:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="中心不存在或已停用")
contract = await contract_fee_crud.create_contract_fee(db, contract_in)
await audit_crud.log_action(
db,
study_id=contract.project_id,
study_id=contract.study_id,
entity_type="contract_fee",
entity_id=contract.id,
action="CREATE_CONTRACT_FEE",
detail="合同费用已创建",
detail=await _contract_audit_detail(db, "创建", contract),
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, contract.study_id, current_user),
)
return FeeApiResponse(data=ContractFeeRead.model_validate(contract))
@@ -253,18 +311,18 @@ 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_center_active(db, contract.project_id, contract.center_id)
await _ensure_study_access(db, contract.study_id, current_user, "fees_contracts:update")
await _ensure_center_active(db, contract.study_id, contract.center_id)
contract = await contract_fee_crud.update_contract_fee(db, contract, contract_in)
await audit_crud.log_action(
db,
study_id=contract.project_id,
study_id=contract.study_id,
entity_type="contract_fee",
entity_id=contract_id,
action="UPDATE_CONTRACT_FEE",
detail="合同费用已更新",
detail=await _contract_audit_detail(db, "更新", contract),
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, contract.study_id, current_user),
)
return FeeApiResponse(data=ContractFeeRead.model_validate(contract))
@@ -282,18 +340,19 @@ 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_center_active(db, contract.project_id, contract.center_id)
await _ensure_study_access(db, contract.study_id, current_user, "fees_contracts:delete")
await _ensure_center_active(db, contract.study_id, contract.center_id)
contract_detail = await _contract_audit_detail(db, "删除", contract)
await contract_fee_crud.delete_contract_fee(db, contract)
await audit_crud.log_action(
db,
study_id=contract.project_id,
study_id=contract.study_id,
entity_type="contract_fee",
entity_id=contract_id,
action="DELETE_CONTRACT_FEE",
detail="合同费用已删除",
detail=contract_detail,
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, contract.study_id, current_user),
)
@@ -312,18 +371,18 @@ 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_study_access(db, contract.study_id, current_user, "fees_contracts:update")
_validate_payment_rules(payment_in)
payment = await payment_crud.create_payment(db, contract_id, payment_in)
await audit_crud.log_action(
db,
study_id=contract.project_id,
study_id=contract.study_id,
entity_type="contract_fee_payment",
entity_id=payment.id,
action="CREATE_CONTRACT_FEE_PAYMENT",
detail="合同费用分期已创建",
detail=await _payment_audit_detail(db, "创建", contract, payment),
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, contract.study_id, current_user),
)
return FeeApiResponse(data=ContractFeePaymentRead.model_validate(payment))
@@ -345,7 +404,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_study_access(db, contract.study_id, current_user, "fees_contracts:update")
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,
@@ -358,13 +417,13 @@ async def update_contract_payment(
payment = await payment_crud.update_payment(db, payment, payment_in)
await audit_crud.log_action(
db,
study_id=contract.project_id,
study_id=contract.study_id,
entity_type="contract_fee_payment",
entity_id=payment_id,
action="UPDATE_CONTRACT_FEE_PAYMENT",
detail="合同费用分期已更新",
detail=await _payment_audit_detail(db, "更新", contract, payment),
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, contract.study_id, current_user),
)
return FeeApiResponse(data=ContractFeePaymentRead.model_validate(payment))
@@ -385,16 +444,17 @@ 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_study_access(db, contract.study_id, current_user, "fees_contracts:update")
payment_detail = await _payment_audit_detail(db, "删除", contract, payment)
await payment_crud.delete_payment(db, payment)
await payment_crud.resequence_payments(db, contract.id)
await audit_crud.log_action(
db,
study_id=contract.project_id,
study_id=contract.study_id,
entity_type="contract_fee_payment",
entity_id=payment_id,
action="DELETE_CONTRACT_FEE_PAYMENT",
detail="合同费用分期已删除",
detail=payment_detail,
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, contract.study_id, current_user),
)
-245
View File
@@ -1,245 +0,0 @@
import uuid
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query, 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
from app.crud import audit as audit_crud
from app.crud import member as member_crud
from app.crud import special_expense as special_crud
from app.crud import study as study_crud
from app.crud import site as site_crud
from app.schemas.fee_common import FeeApiResponse
from app.schemas.special_expense import (
SpecialExpenseCreate,
SpecialExpenseListItem,
SpecialExpenseRead,
SpecialExpenseUpdate,
)
router = APIRouter()
ALLOWED_CATEGORIES = {"travel", "meal", "meeting", "supplies", "other"}
async def _ensure_project_access(db: AsyncSession, project_id: uuid.UUID, current_user, write: bool = False):
study = await study_crud.get(db, project_id)
if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
if role_value == "ADMIN":
return None
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="不是项目成员")
if write and membership.role_in_study != "PM":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="项目权限不足")
return membership
def _validate_special_rules(payload: SpecialExpenseCreate | SpecialExpenseUpdate):
if payload.is_verified is True and payload.is_paid is False:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="核销需先打款")
if payload.is_paid and not payload.paid_date:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="已打款需填写打款日期")
if payload.is_verified and not payload.verified_date:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="已核销需填写核销日期")
def _validate_category(value: str | None):
if value and value not in ALLOWED_CATEGORIES:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="费用类别无效")
async def _ensure_center_active(db: AsyncSession, project_id: uuid.UUID, center_id: uuid.UUID | None):
if not center_id:
return
site = await site_crud.get_site(db, center_id)
if not site or site.study_id != project_id or not site.is_active:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="中心已停用")
@router.get(
"/special",
response_model=FeeApiResponse[list[SpecialExpenseListItem]],
dependencies=[Depends(get_current_user)],
)
async def list_special_expenses(
project_id: uuid.UUID = Query(..., alias="projectId"),
center_id: uuid.UUID | None = Query(None, alias="centerId"),
category: str | None = None,
date_from: date | None = Query(None, alias="dateFrom"),
date_to: date | None = Query(None, alias="dateTo"),
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> FeeApiResponse[list[SpecialExpenseListItem]]:
await _ensure_project_access(db, project_id, current_user, write=False)
_validate_category(category)
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:
return FeeApiResponse(data=[], meta={"total": 0})
rows = await special_crud.list_special_expenses(
db,
project_id,
center_id=center_id,
center_ids=center_ids,
category=category,
date_from=date_from,
date_to=date_to,
)
items: list[SpecialExpenseListItem] = []
for expense, center_name, attachments_count in rows:
items.append(
SpecialExpenseListItem(
id=expense.id,
project_id=expense.project_id,
center_id=expense.center_id,
category=expense.category,
amount=expense.amount,
happen_date=expense.happen_date,
description=expense.description,
is_paid=expense.is_paid,
paid_date=expense.paid_date,
is_verified=expense.is_verified,
verified_date=expense.verified_date,
created_by=expense.created_by,
created_at=expense.created_at,
updated_at=expense.updated_at,
center_name=center_name,
attachments_count=attachments_count or 0,
)
)
return FeeApiResponse(data=items, meta={"total": len(items)})
@router.get(
"/special/{expense_id}",
response_model=FeeApiResponse[SpecialExpenseRead],
dependencies=[Depends(get_current_user)],
)
async def get_special_expense(
expense_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> FeeApiResponse[SpecialExpenseRead]:
expense = await special_crud.get_special_expense(db, expense_id)
if not expense:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="特殊费用不存在")
await _ensure_project_access(db, expense.project_id, current_user, write=False)
cra_scope = await get_cra_site_scope(db, expense.project_id, current_user)
if cra_scope and expense.center_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
return FeeApiResponse(data=SpecialExpenseRead.model_validate(expense))
@router.post(
"/special",
response_model=FeeApiResponse[SpecialExpenseRead],
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(get_current_user), Depends(require_study_not_locked())],
)
async def create_special_expense(
expense_in: SpecialExpenseCreate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> FeeApiResponse[SpecialExpenseRead]:
await _ensure_project_access(db, expense_in.project_id, current_user, write=True)
_validate_category(expense_in.category)
_validate_special_rules(expense_in)
if expense_in.center_id:
site = await site_crud.get_site(db, expense_in.center_id)
if not site or site.study_id != expense_in.project_id or not site.is_active:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="中心不存在或已停用")
await _ensure_center_active(db, expense_in.project_id, expense_in.center_id)
expense = await special_crud.create_special_expense(db, expense_in, created_by=current_user.id)
await audit_crud.log_action(
db,
study_id=expense.project_id,
entity_type="special_expense",
entity_id=expense.id,
action="CREATE_SPECIAL_EXPENSE",
detail="特殊费用已创建",
operator_id=current_user.id,
operator_role=current_user.role,
)
return FeeApiResponse(data=SpecialExpenseRead.model_validate(expense))
@router.patch(
"/special/{expense_id}",
response_model=FeeApiResponse[SpecialExpenseRead],
dependencies=[Depends(get_current_user), Depends(require_study_not_locked())],
)
async def update_special_expense(
expense_id: uuid.UUID,
expense_in: SpecialExpenseUpdate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> FeeApiResponse[SpecialExpenseRead]:
expense = await special_crud.get_special_expense(db, expense_id)
if not expense:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="特殊费用不存在")
await _ensure_project_access(db, expense.project_id, current_user, write=True)
_validate_category(expense_in.category)
merged = SpecialExpenseCreate(
project_id=expense.project_id,
center_id=expense_in.center_id if expense_in.center_id is not None else expense.center_id,
category=expense_in.category if expense_in.category is not None else expense.category,
amount=expense_in.amount if expense_in.amount is not None else expense.amount,
happen_date=expense_in.happen_date if expense_in.happen_date is not None else expense.happen_date,
description=expense_in.description if expense_in.description is not None else expense.description,
is_paid=expense_in.is_paid if expense_in.is_paid is not None else expense.is_paid,
paid_date=expense_in.paid_date if expense_in.paid_date is not None else expense.paid_date,
is_verified=expense_in.is_verified if expense_in.is_verified is not None else expense.is_verified,
verified_date=expense_in.verified_date if expense_in.verified_date is not None else expense.verified_date,
)
_validate_special_rules(merged)
if expense_in.center_id:
site = await site_crud.get_site(db, expense_in.center_id)
if not site or site.study_id != expense.project_id or not site.is_active:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="中心不存在或已停用")
await _ensure_center_active(db, expense.project_id, expense_in.center_id)
else:
await _ensure_center_active(db, expense.project_id, expense.center_id)
expense = await special_crud.update_special_expense(db, expense, expense_in)
await audit_crud.log_action(
db,
study_id=expense.project_id,
entity_type="special_expense",
entity_id=expense_id,
action="UPDATE_SPECIAL_EXPENSE",
detail="特殊费用已更新",
operator_id=current_user.id,
operator_role=current_user.role,
)
return FeeApiResponse(data=SpecialExpenseRead.model_validate(expense))
@router.delete(
"/special/{expense_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(get_current_user), Depends(require_study_not_locked())],
)
async def delete_special_expense(
expense_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> None:
expense = await special_crud.get_special_expense(db, expense_id)
if not expense:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="特殊费用不存在")
await _ensure_project_access(db, expense.project_id, current_user, write=True)
await _ensure_center_active(db, expense.project_id, expense.center_id)
await special_crud.delete_special_expense(db, expense)
await audit_crud.log_action(
db,
study_id=expense.project_id,
entity_type="special_expense",
entity_id=expense_id,
action="DELETE_SPECIAL_EXPENSE",
detail="特殊费用已删除",
operator_id=current_user.id,
operator_role=current_user.role,
)
-177
View File
@@ -1,177 +0,0 @@
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_member, require_study_roles, require_study_not_locked
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
from app.crud import study as study_crud
from app.schemas.finance_contract import FinanceContractCreate, FinanceContractRead, FinanceContractUpdate
router = APIRouter()
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
study = await study_crud.get(db, study_id)
if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
return study
async def _ensure_site_name_active(db: AsyncSession, study_id: uuid.UUID, site_name: str | None):
if not site_name:
return
active_names = await site_crud.list_active_names(db, study_id)
if site_name not in active_names:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="中心已停用")
@router.post(
"/contracts",
response_model=FinanceContractRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
)
async def create_contract(
study_id: uuid.UUID,
contract_in: FinanceContractCreate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> FinanceContractRead:
await _ensure_study_exists(db, study_id)
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and contract_in.site_name not in cra_scope[1]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_name_active(db, study_id, contract_in.site_name)
contract = await contract_crud.create_contract(db, study_id, contract_in, created_by=current_user.id)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="finance_contract",
entity_id=contract.id,
action="CREATE_FINANCE_CONTRACT",
detail=f"合同费用 {contract.contract_no} 已创建",
operator_id=current_user.id,
operator_role=current_user.role,
)
return FinanceContractRead.model_validate(contract)
@router.get(
"/contracts",
response_model=list[FinanceContractRead],
dependencies=[Depends(require_study_member())],
)
async def list_contracts(
study_id: uuid.UUID,
site_name: str | None = None,
contract_no: str | None = None,
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[FinanceContractRead]:
await _ensure_study_exists(db, study_id)
cra_scope = await get_cra_site_scope(db, study_id, current_user)
site_names = cra_scope[1] if cra_scope else None
if site_name and site_names is not None and site_name not in site_names:
return []
items = await contract_crud.list_contracts(
db,
study_id,
site_name=site_name,
site_names=site_names,
contract_no=contract_no,
skip=skip,
limit=limit,
)
return [FinanceContractRead.model_validate(item) for item in items]
@router.get(
"/contracts/{contract_id}",
response_model=FinanceContractRead,
dependencies=[Depends(require_study_member())],
)
async def get_contract(
study_id: uuid.UUID,
contract_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> FinanceContractRead:
await _ensure_study_exists(db, study_id)
contract = await contract_crud.get_contract(db, contract_id)
if not contract or contract.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and contract.site_name not in cra_scope[1]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
return FinanceContractRead.model_validate(contract)
@router.patch(
"/contracts/{contract_id}",
response_model=FinanceContractRead,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
)
async def update_contract(
study_id: uuid.UUID,
contract_id: uuid.UUID,
contract_in: FinanceContractUpdate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> FinanceContractRead:
await _ensure_study_exists(db, study_id)
contract = await contract_crud.get_contract(db, contract_id)
if not contract or contract.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and contract.site_name not in cra_scope[1]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_name_active(db, study_id, contract.site_name)
contract = await contract_crud.update_contract(db, contract, contract_in)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="finance_contract",
entity_id=contract_id,
action="UPDATE_FINANCE_CONTRACT",
detail=f"合同费用 {contract_id} 已更新",
operator_id=current_user.id,
operator_role=current_user.role,
)
return FinanceContractRead.model_validate(contract)
@router.delete(
"/contracts/{contract_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
)
async def delete_contract(
study_id: uuid.UUID,
contract_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> None:
await _ensure_study_exists(db, study_id)
contract = await contract_crud.get_contract(db, contract_id)
if not contract or contract.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and contract.site_name not in cra_scope[1]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_name_active(db, study_id, contract.site_name)
await contract_crud.delete_contract(db, contract)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="finance_contract",
entity_id=contract_id,
action="DELETE_FINANCE_CONTRACT",
detail=f"合同费用 {contract_id} 已删除",
operator_id=current_user.id,
operator_role=current_user.role,
)
+3 -2
View File
@@ -4,7 +4,7 @@ from datetime import date
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db_session, require_study_member
from app.core.deps import get_db_session, require_api_permission
from app.crud import finance as finance_crud
from app.crud import study as study_crud
from app.schemas.finance import FinanceSummaryRead
@@ -12,11 +12,12 @@ from app.schemas.finance import FinanceSummaryRead
router = APIRouter()
@router.get("/summary", response_model=FinanceSummaryRead, dependencies=[Depends(require_study_member())])
@router.get("/summary", response_model=FinanceSummaryRead)
async def finance_summary(
study_id: uuid.UUID,
date_from: date | None = None,
date_to: date | None = None,
_=Depends(require_api_permission("fees_contracts:read")),
db: AsyncSession = Depends(get_db_session),
) -> FinanceSummaryRead:
# ensure study exists
-177
View File
@@ -1,177 +0,0 @@
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_member, require_study_roles, require_study_not_locked
from app.crud import audit as audit_crud
from app.crud import finance_special as special_crud
from app.crud import site as site_crud
from app.crud import study as study_crud
from app.schemas.finance_special import FinanceSpecialCreate, FinanceSpecialRead, FinanceSpecialUpdate
router = APIRouter()
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
study = await study_crud.get(db, study_id)
if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
return study
async def _ensure_site_name_active(db: AsyncSession, study_id: uuid.UUID, site_name: str | None):
if not site_name:
return
active_names = await site_crud.list_active_names(db, study_id)
if site_name not in active_names:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="中心已停用")
@router.post(
"/specials",
response_model=FinanceSpecialRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
)
async def create_special(
study_id: uuid.UUID,
special_in: FinanceSpecialCreate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> FinanceSpecialRead:
await _ensure_study_exists(db, study_id)
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and special_in.site_name not in cra_scope[1]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_name_active(db, study_id, special_in.site_name)
item = await special_crud.create_special(db, study_id, special_in, created_by=current_user.id)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="finance_special",
entity_id=item.id,
action="CREATE_FINANCE_SPECIAL",
detail=f"特殊费用 {item.id} 已创建",
operator_id=current_user.id,
operator_role=current_user.role,
)
return FinanceSpecialRead.model_validate(item)
@router.get(
"/specials",
response_model=list[FinanceSpecialRead],
dependencies=[Depends(require_study_member())],
)
async def list_specials(
study_id: uuid.UUID,
site_name: str | None = None,
fee_type: str | None = None,
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[FinanceSpecialRead]:
await _ensure_study_exists(db, study_id)
cra_scope = await get_cra_site_scope(db, study_id, current_user)
site_names = cra_scope[1] if cra_scope else None
if site_name and site_names is not None and site_name not in site_names:
return []
items = await special_crud.list_specials(
db,
study_id,
site_name=site_name,
site_names=site_names,
fee_type=fee_type,
skip=skip,
limit=limit,
)
return [FinanceSpecialRead.model_validate(item) for item in items]
@router.get(
"/specials/{special_id}",
response_model=FinanceSpecialRead,
dependencies=[Depends(require_study_member())],
)
async def get_special(
study_id: uuid.UUID,
special_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> FinanceSpecialRead:
await _ensure_study_exists(db, study_id)
item = await special_crud.get_special(db, special_id)
if not item or item.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="特殊费用不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and item.site_name not in cra_scope[1]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
return FinanceSpecialRead.model_validate(item)
@router.patch(
"/specials/{special_id}",
response_model=FinanceSpecialRead,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
)
async def update_special(
study_id: uuid.UUID,
special_id: uuid.UUID,
special_in: FinanceSpecialUpdate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> FinanceSpecialRead:
await _ensure_study_exists(db, study_id)
item = await special_crud.get_special(db, special_id)
if not item or item.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="特殊费用不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and item.site_name not in cra_scope[1]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_name_active(db, study_id, item.site_name)
item = await special_crud.update_special(db, item, special_in)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="finance_special",
entity_id=special_id,
action="UPDATE_FINANCE_SPECIAL",
detail=f"特殊费用 {special_id} 已更新",
operator_id=current_user.id,
operator_role=current_user.role,
)
return FinanceSpecialRead.model_validate(item)
@router.delete(
"/specials/{special_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
)
async def delete_special(
study_id: uuid.UUID,
special_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> None:
await _ensure_study_exists(db, study_id)
item = await special_crud.get_special(db, special_id)
if not item or item.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="特殊费用不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and item.site_name not in cra_scope[1]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_name_active(db, study_id, item.site_name)
await special_crud.delete_special(db, item)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="finance_special",
entity_id=special_id,
action="DELETE_FINANCE_SPECIAL",
detail=f"特殊费用 {special_id} 已删除",
operator_id=current_user.id,
operator_role=current_user.role,
)
-151
View File
@@ -1,151 +0,0 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles, require_study_not_locked
from app.crud import audit as audit_crud
from app.crud import knowledge_note as note_crud
from app.crud import site as site_crud
from app.crud import study as study_crud
from app.schemas.knowledge_note import KnowledgeNoteCreate, KnowledgeNoteRead, KnowledgeNoteUpdate
router = APIRouter()
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
study = await study_crud.get(db, study_id)
if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
return study
async def _ensure_site_name_active(db: AsyncSession, study_id: uuid.UUID, site_name: str | None):
if not site_name:
return
active_names = await site_crud.list_active_names(db, study_id)
if site_name not in active_names:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="中心已停用")
@router.post(
"/notes",
response_model=KnowledgeNoteRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
)
async def create_note(
study_id: uuid.UUID,
note_in: KnowledgeNoteCreate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> KnowledgeNoteRead:
await _ensure_study_exists(db, study_id)
await _ensure_site_name_active(db, study_id, note_in.site_name)
note = await note_crud.create_note(db, study_id, note_in, created_by=current_user.id)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="knowledge_note",
entity_id=note.id,
action="CREATE_KNOWLEDGE_NOTE",
detail=f"注意事项 {note.title} 已创建",
operator_id=current_user.id,
operator_role=current_user.role,
)
return KnowledgeNoteRead.model_validate(note)
@router.get(
"/notes",
response_model=list[KnowledgeNoteRead],
dependencies=[Depends(require_study_member())],
)
async def list_notes(
study_id: uuid.UUID,
site_name: str | None = None,
keyword: str | None = None,
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db_session),
) -> list[KnowledgeNoteRead]:
await _ensure_study_exists(db, study_id)
items = await note_crud.list_notes(db, study_id, site_name=site_name, keyword=keyword, skip=skip, limit=limit)
return [KnowledgeNoteRead.model_validate(item) for item in items]
@router.get(
"/notes/{note_id}",
response_model=KnowledgeNoteRead,
dependencies=[Depends(require_study_member())],
)
async def get_note(
study_id: uuid.UUID,
note_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
) -> KnowledgeNoteRead:
await _ensure_study_exists(db, study_id)
note = await note_crud.get_note(db, note_id)
if not note or note.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="注意事项不存在")
return KnowledgeNoteRead.model_validate(note)
@router.patch(
"/notes/{note_id}",
response_model=KnowledgeNoteRead,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
)
async def update_note(
study_id: uuid.UUID,
note_id: uuid.UUID,
note_in: KnowledgeNoteUpdate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> KnowledgeNoteRead:
await _ensure_study_exists(db, study_id)
note = await note_crud.get_note(db, note_id)
if not note or note.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="注意事项不存在")
await _ensure_site_name_active(db, study_id, note.site_name)
note = await note_crud.update_note(db, note, note_in)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="knowledge_note",
entity_id=note_id,
action="UPDATE_KNOWLEDGE_NOTE",
detail=f"注意事项 {note_id} 已更新",
operator_id=current_user.id,
operator_role=current_user.role,
)
return KnowledgeNoteRead.model_validate(note)
@router.delete(
"/notes/{note_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
)
async def delete_note(
study_id: uuid.UUID,
note_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> None:
await _ensure_study_exists(db, study_id)
note = await note_crud.get_note(db, note_id)
if not note or note.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="注意事项不存在")
await _ensure_site_name_active(db, study_id, note.site_name)
await note_crud.delete_note(db, note)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="knowledge_note",
entity_id=note_id,
action="DELETE_KNOWLEDGE_NOTE",
detail=f"注意事项 {note_id} 已删除",
operator_id=current_user.id,
operator_role=current_user.role,
)
+26 -12
View File
@@ -1,9 +1,10 @@
import uuid
import json
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_not_locked, require_study_roles
from app.core.deps import get_operator_role_label, 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 material_equipment as equipment_crud
from app.crud import study as study_crud
@@ -24,11 +25,23 @@ def _validate_calibration(need_calibration: bool, calibration_cycle_days: int |
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="需要校准时校准周期必须大于0")
def _equipment_audit_name(item) -> str:
parts = [item.name, item.spec_model, item.brand]
return " / ".join(str(part).strip() for part in parts if str(part or "").strip()) or "设备记录"
def _equipment_audit_detail(action: str, item) -> str:
return json.dumps(
{"targetName": _equipment_audit_name(item), "description": f"{action}设备“{_equipment_audit_name(item)}"},
ensure_ascii=False,
)
@router.post(
"/equipment",
response_model=MaterialEquipmentRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
dependencies=[Depends(require_api_permission("material_equipments:create")), Depends(require_study_not_locked())],
)
async def create_equipment(
study_id: uuid.UUID,
@@ -47,9 +60,9 @@ async def create_equipment(
entity_type="material_equipment",
entity_id=item.id,
action="CREATE_MATERIAL_EQUIPMENT",
detail=f"设备 {item.id} 已创建",
detail=_equipment_audit_detail("创建", item),
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
return MaterialEquipmentRead.model_validate(item)
@@ -57,7 +70,7 @@ async def create_equipment(
@router.get(
"/equipment",
response_model=list[MaterialEquipmentRead],
dependencies=[Depends(require_study_member())],
dependencies=[Depends(require_api_permission("material_equipments:read"))],
)
async def list_equipments(
study_id: uuid.UUID,
@@ -74,7 +87,7 @@ async def list_equipments(
@router.get(
"/equipment/{equipment_id}",
response_model=MaterialEquipmentRead,
dependencies=[Depends(require_study_member())],
dependencies=[Depends(require_api_permission("material_equipments:read"))],
)
async def get_equipment(
study_id: uuid.UUID,
@@ -91,7 +104,7 @@ async def get_equipment(
@router.patch(
"/equipment/{equipment_id}",
response_model=MaterialEquipmentRead,
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
dependencies=[Depends(require_api_permission("material_equipments:update")), Depends(require_study_not_locked())],
)
async def update_equipment(
study_id: uuid.UUID,
@@ -118,9 +131,9 @@ async def update_equipment(
entity_type="material_equipment",
entity_id=equipment_id,
action="UPDATE_MATERIAL_EQUIPMENT",
detail=f"设备 {equipment_id} 已更新",
detail=_equipment_audit_detail("更新", item),
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
return MaterialEquipmentRead.model_validate(item)
@@ -128,7 +141,7 @@ async def update_equipment(
@router.delete(
"/equipment/{equipment_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
dependencies=[Depends(require_api_permission("material_equipments:delete")), Depends(require_study_not_locked())],
)
async def delete_equipment(
study_id: uuid.UUID,
@@ -140,6 +153,7 @@ async def delete_equipment(
item = await equipment_crud.get_equipment(db, equipment_id)
if not item or item.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="设备记录不存在")
equipment_detail = _equipment_audit_detail("删除", item)
await equipment_crud.delete_equipment(db, item)
await audit_crud.log_action(
db,
@@ -147,7 +161,7 @@ async def delete_equipment(
entity_type="material_equipment",
entity_id=equipment_id,
action="DELETE_MATERIAL_EQUIPMENT",
detail=f"设备 {equipment_id} 已删除",
detail=equipment_detail,
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
+161 -15
View File
@@ -4,16 +4,26 @@ import json
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles, require_study_not_locked
from app.core.deps import get_current_user, get_db_session, is_system_admin, require_api_permission, require_study_not_locked
from app.core.decorators import register_api_endpoint
from app.crud import audit as audit_crud
from app.crud import member as member_crud
from app.crud import study as study_crud
from app.crud import user as user_crud
from app.schemas.member import StudyMemberCreate, StudyMemberRead, StudyMemberReadWithUser, StudyMemberUpdate
from app.schemas.user import UserDisplay
from app.schemas.user import UserResponse
router = APIRouter()
PROJECT_ROLE_RANK = {
"ADMIN": 100,
"PM": 80,
"PV": 50,
"QA": 50,
"CRA": 40,
"CTA": 40,
}
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
study = await study_crud.get(db, study_id)
@@ -22,11 +32,73 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
return study
async def _operator_project_role(db: AsyncSession, study_id: uuid.UUID, current_user) -> str:
if is_system_admin(current_user):
return "ADMIN"
member = await member_crud.get_member(db, study_id, current_user.id)
return member.role_in_study if member and member.is_active else ""
def _role_rank(role: str | None) -> int:
return PROJECT_ROLE_RANK.get(role or "", 0)
def _user_audit_name(user, user_id: uuid.UUID) -> str:
if user:
for attr in ("full_name", "username", "email"):
value = getattr(user, attr, None)
if value:
return str(value)
return str(user_id)
async def _member_audit_name(db: AsyncSession, user_id: uuid.UUID) -> str:
user = await user_crud.get_by_id(db, user_id)
return _user_audit_name(user, user_id)
async def _ensure_member_mutation_allowed(
db: AsyncSession,
study_id: uuid.UUID,
current_user,
*,
target_member=None,
target_role: str | None = None,
) -> None:
if is_system_admin(current_user):
return
actor_member = await member_crud.get_member(db, study_id, current_user.id)
actor_role = actor_member.role_in_study if actor_member else None
actor_rank = _role_rank(actor_role)
if target_member and target_member.user_id == current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不能修改自己的项目成员权限")
if target_member:
target_user = await user_crud.get_by_id(db, target_member.user_id)
if is_system_admin(target_user):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不能修改系统管理员账号的项目权限")
if target_member and _role_rank(target_member.role_in_study) >= actor_rank:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只能修改下属项目角色成员")
if target_role and _role_rank(target_role) >= actor_rank:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只能授予下属项目角色")
@router.post(
"/",
response_model=StudyMemberRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
dependencies=[Depends(require_api_permission("project_members:create")), Depends(require_study_not_locked())],
)
@register_api_endpoint(
endpoint_key="project_members:create",
module="project_members",
action="write",
description="添加项目成员",
default_roles=["PM"],
)
async def add_member(
study_id: uuid.UUID,
@@ -35,8 +107,16 @@ async def add_member(
current_user=Depends(get_current_user),
) -> StudyMemberRead:
await _ensure_study_exists(db, study_id)
await _ensure_member_mutation_allowed(db, study_id, current_user, target_role=member_in.role_in_study)
existing = await member_crud.get_member(db, study_id, member_in.user_id)
if existing:
await _ensure_member_mutation_allowed(
db,
study_id,
current_user,
target_member=existing,
target_role=member_in.role_in_study,
)
if not existing.is_active:
updated = await member_crud.update_member(
db,
@@ -50,12 +130,12 @@ async def add_member(
entity_id=updated.id,
action="PROJECT_MEMBER_UPDATED",
detail=json.dumps({
"targetName": str(updated.user_id),
"targetName": await _member_audit_name(db, updated.user_id),
"before": {"is_active": existing.is_active, "role_in_study": existing.role_in_study},
"after": {"is_active": updated.is_active, "role_in_study": updated.role_in_study},
}, ensure_ascii=False),
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await _operator_project_role(db, study_id, current_user),
)
return updated
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="成员已存在")
@@ -66,9 +146,12 @@ async def add_member(
entity_type="study_member",
entity_id=member.id,
action="PROJECT_MEMBER_ADDED",
detail=json.dumps({"targetName": str(member.user_id), "after": {"role_in_study": member.role_in_study, "is_active": member.is_active}}, ensure_ascii=False),
detail=json.dumps({
"targetName": await _member_audit_name(db, member.user_id),
"after": {"role_in_study": member.role_in_study, "is_active": member.is_active},
}, ensure_ascii=False),
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await _operator_project_role(db, study_id, current_user),
)
return member
@@ -76,7 +159,14 @@ async def add_member(
@router.get(
"/",
response_model=list[StudyMemberReadWithUser],
dependencies=[Depends(require_study_member())],
dependencies=[Depends(require_api_permission("project_members:read"))],
)
@register_api_endpoint(
endpoint_key="project_members:read",
module="project_members",
action="read",
description="查询项目成员列表",
default_roles=["PM"],
)
async def list_members(
study_id: uuid.UUID,
@@ -106,16 +196,46 @@ async def list_members(
role_in_study=m.role_in_study,
is_active=m.is_active,
added_at=m.added_at,
user=UserDisplay.model_validate(user) if user else None,
user=UserResponse.model_validate(user) if user else None,
)
)
return result
@router.get(
"/candidates",
response_model=list[UserResponse],
dependencies=[Depends(require_api_permission("project_members:read"))],
)
@register_api_endpoint(
endpoint_key="project_members:read",
module="project_members",
action="read",
description="查询项目成员候选人",
default_roles=["PM"],
)
async def list_member_candidates(
study_id: uuid.UUID,
skip: int = 0,
limit: int = 500,
db: AsyncSession = Depends(get_db_session),
) -> list[UserResponse]:
await _ensure_study_exists(db, study_id)
users = await user_crud.list_active_member_candidates_for_study(db, study_id, skip=skip, limit=limit)
return [UserResponse.model_validate(user) for user in users]
@router.patch(
"/{member_id}",
response_model=StudyMemberRead,
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
dependencies=[Depends(require_api_permission("project_members:update")), Depends(require_study_not_locked())],
)
@register_api_endpoint(
endpoint_key="project_members:update",
module="project_members",
action="write",
description="更新项目成员",
default_roles=["PM"],
)
async def update_member(
study_id: uuid.UUID,
@@ -128,6 +248,13 @@ async def update_member(
member = await member_crud.get_member_by_id(db, member_id)
if not member or member.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="成员不存在")
await _ensure_member_mutation_allowed(
db,
study_id,
current_user,
target_member=member,
target_role=member_in.role_in_study,
)
before_data = {"role_in_study": member.role_in_study, "is_active": member.is_active}
updated = await member_crud.update_member(db, member, member_in)
after_data = {"role_in_study": updated.role_in_study, "is_active": updated.is_active}
@@ -137,9 +264,13 @@ async def update_member(
entity_type="study_member",
entity_id=updated.id,
action="PROJECT_MEMBER_UPDATED",
detail=json.dumps({"targetName": str(updated.user_id), "before": before_data, "after": after_data}, ensure_ascii=False),
detail=json.dumps({
"targetName": await _member_audit_name(db, updated.user_id),
"before": before_data,
"after": after_data,
}, ensure_ascii=False),
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await _operator_project_role(db, study_id, current_user),
)
return updated
@@ -147,7 +278,14 @@ async def update_member(
@router.delete(
"/{member_id}",
response_model=StudyMemberRead,
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
dependencies=[Depends(require_api_permission("project_members:delete")), Depends(require_study_not_locked())],
)
@register_api_endpoint(
endpoint_key="project_members:delete",
module="project_members",
action="write",
description="删除项目成员",
default_roles=["PM"],
)
async def remove_member(
study_id: uuid.UUID,
@@ -159,6 +297,10 @@ async def remove_member(
member = await member_crud.get_member_by_id(db, member_id)
if not member or member.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="成员不存在")
target_user = await user_crud.get_by_id(db, member.user_id)
if target_user and is_system_admin(target_user):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="系统管理员不可从项目中移除")
await _ensure_member_mutation_allowed(db, study_id, current_user, target_member=member)
before_data = {"role_in_study": member.role_in_study, "is_active": member.is_active}
removed = await member_crud.remove_member(db, member)
await audit_crud.log_action(
@@ -167,8 +309,12 @@ async def remove_member(
entity_type="study_member",
entity_id=removed.id,
action="PROJECT_MEMBER_REMOVED",
detail=json.dumps({"targetName": str(removed.user_id), "before": before_data, "after": {"is_active": removed.is_active}}, ensure_ascii=False),
detail=json.dumps({
"targetName": _user_audit_name(target_user, removed.user_id),
"before": before_data,
"after": {"is_active": removed.is_active},
}, ensure_ascii=False),
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await _operator_project_role(db, study_id, current_user),
)
return removed
+147 -22
View File
@@ -11,9 +11,10 @@ from openpyxl import Workbook, load_workbook
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_not_locked, require_study_roles
from app.core.deps import get_operator_role_label, 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 monitoring_visit_issue as issue_crud
from app.crud import site as site_crud
from app.crud import study as study_crud
from app.schemas.monitoring_visit_issue import (
MonitoringVisitIssueCreate,
@@ -25,9 +26,13 @@ from app.schemas.monitoring_visit_issue import (
router = APIRouter()
_HEADER_ALIASES: dict[str, list[str]] = {
"site_name": ["项目/中心", "中心", "中心名称", "site_name", "site"],
"issue_no": ["问题编号", "问题编码", "issue_no", "IssueNo"],
"open_duration_text": ["开放时长", "open_duration"],
"category": ["问题分类", "category"],
"severity": ["严重程度", "severity"],
"mark": ["标记", "mark"],
"visit_cycle": ["访视周期", "访视", "visit_cycle"],
"subject_code": ["受试者", "受试者编号", "subject_code"],
"subject_name": ["受试者姓名", "subject_name"],
"monitor_item": ["监查项", "monitor_item"],
@@ -40,6 +45,9 @@ _HEADER_ALIASES: dict[str, list[str]] = {
"description": ["问题描述", "description"],
"action_taken": ["采取措施", "action_taken"],
"follow_up_progress": ["跟进计划及进展", "follow_up_progress"],
"center_query": ["中心质疑", "center_query"],
"center_latest_reply": ["中心最新回复", "中心回复", "center_latest_reply"],
"rectification_completed": ["是否完成整改", "完成整改", "rectification_completed"],
"found_date": ["发现时间", "found_date"],
"due_at": ["截止时间", "截止日期", "超期截止时间", "due_at"],
"actual_resolve_date": ["实际解决日期", "actual_resolve_date"],
@@ -48,17 +56,24 @@ _HEADER_ALIASES: dict[str, list[str]] = {
}
_AUDIT_FIELDS: tuple[str, ...] = (
"site_id",
"issue_no",
"source",
"monitor_type",
"monitor_item",
"category",
"severity",
"mark",
"visit_cycle",
"recommendation",
"subject_name",
"subject_code",
"description",
"action_taken",
"follow_up_progress",
"center_query",
"center_latest_reply",
"rectification_completed",
"found_date",
"due_at",
"actual_resolve_date",
@@ -75,6 +90,14 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
return study
async def _ensure_site_belongs_to_study(db: AsyncSession, study_id: uuid.UUID, site_id: uuid.UUID | None) -> None:
if site_id is None:
return
site = await site_crud.get_site(db, site_id)
if not site or site.study_id != study_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="中心不存在或不属于当前项目")
def _normalize_status(value: str | None) -> str:
text = (value or "").strip().upper()
zh = {
@@ -91,6 +114,21 @@ def _normalize_status(value: str | None) -> str:
return "OPEN"
def _parse_bool(value: object | None) -> bool | None:
if value is None:
return None
if isinstance(value, bool):
return value
text = str(value).strip().lower()
if not text:
return None
if text in {"1", "true", "yes", "y", "", "已完成", "完成"}:
return True
if text in {"0", "false", "no", "n", "", "未完成"}:
return False
return None
def _pick(row: dict[str, object], key: str) -> str | None:
for alias in _HEADER_ALIASES[key]:
if alias not in row:
@@ -167,9 +205,13 @@ def _to_read(item) -> MonitoringVisitIssueRead:
return MonitoringVisitIssueRead(
id=item.id,
study_id=item.study_id,
site_id=item.site_id,
issue_no=item.issue_no,
open_duration=_calc_open_duration(item.created_at, item.status, item.closed_at, item.open_duration_text),
category=item.category,
severity=item.severity,
mark=item.mark,
visit_cycle=item.visit_cycle,
subject_code=item.subject_code,
monitor_item=item.monitor_item,
monitor_type=item.monitor_type,
@@ -183,6 +225,9 @@ def _to_read(item) -> MonitoringVisitIssueRead:
description=item.description,
action_taken=item.action_taken,
follow_up_progress=item.follow_up_progress,
center_query=item.center_query,
center_latest_reply=item.center_latest_reply,
rectification_completed=bool(item.rectification_completed),
found_date=item.found_date,
overdue=overdue,
due_at=item.due_at,
@@ -206,6 +251,8 @@ def _format_datetime(value: datetime | None) -> str:
def _to_audit_value(value: object | None):
if isinstance(value, uuid.UUID):
return str(value)
if isinstance(value, datetime):
parsed = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
return parsed.isoformat()
@@ -285,26 +332,45 @@ def _normalize_file_rows(filename: str, content: bytes) -> list[dict[str, object
@router.get(
"/issues",
response_model=list[MonitoringVisitIssueRead],
dependencies=[Depends(require_study_member())],
dependencies=[Depends(require_api_permission("monitoring_issues:read"))],
)
async def list_monitoring_visit_issues(
study_id: uuid.UUID,
site_id: uuid.UUID | None = None,
category: str | None = None,
severity: str | None = None,
mark: str | None = None,
visit_cycle: str | None = None,
status_value: str | None = Query(default=None, alias="status"),
overdue: bool | None = None,
rectification_completed: bool | None = None,
due_from: date | None = None,
due_to: date | None = None,
created_from: date | None = None,
created_to: date | None = None,
keyword: str | None = None,
skip: int = 0,
limit: int = 500,
db: AsyncSession = Depends(get_db_session),
) -> list[MonitoringVisitIssueRead]:
await _ensure_study_exists(db, study_id)
await _ensure_site_belongs_to_study(db, study_id, site_id)
normalized_status = _normalize_status(status_value) if status_value else None
items = await issue_crud.list_issues(
db,
study_id,
site_id=site_id,
category=(category or None),
severity=(severity or None),
mark=(mark or None),
visit_cycle=(visit_cycle or None),
status=normalized_status,
overdue=overdue,
rectification_completed=rectification_completed,
due_from=due_from,
due_to=due_to,
created_from=created_from,
created_to=created_to,
keyword=(keyword or None),
skip=skip,
limit=min(limit, 2000),
@@ -316,7 +382,7 @@ async def list_monitoring_visit_issues(
"/issues",
response_model=MonitoringVisitIssueRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
dependencies=[Depends(require_api_permission("monitoring_issues:create")), Depends(require_study_not_locked())],
)
async def create_monitoring_visit_issue(
study_id: uuid.UUID,
@@ -325,6 +391,7 @@ async def create_monitoring_visit_issue(
current_user=Depends(get_current_user),
) -> MonitoringVisitIssueRead:
await _ensure_study_exists(db, study_id)
await _ensure_site_belongs_to_study(db, study_id, payload.site_id)
if payload.issue_no:
existing = await issue_crud.get_issue_by_issue_no(db, study_id, payload.issue_no)
@@ -346,7 +413,7 @@ async def create_monitoring_visit_issue(
action="CREATE_MONITORING_VISIT_ISSUE",
detail=f"监查访视问题 {item.issue_no} 已创建",
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
return _to_read(item)
@@ -354,24 +421,43 @@ async def create_monitoring_visit_issue(
@router.get(
"/issues/export",
response_class=StreamingResponse,
dependencies=[Depends(require_study_member())],
dependencies=[Depends(require_api_permission("monitoring_issues:read"))],
)
async def export_monitoring_visit_issues(
study_id: uuid.UUID,
site_id: uuid.UUID | None = None,
category: str | None = None,
severity: str | None = None,
mark: str | None = None,
visit_cycle: str | None = None,
status_value: str | None = Query(default=None, alias="status"),
overdue: bool | None = None,
rectification_completed: bool | None = None,
due_from: date | None = None,
due_to: date | None = None,
created_from: date | None = None,
created_to: date | None = None,
keyword: str | None = None,
db: AsyncSession = Depends(get_db_session),
) -> StreamingResponse:
await _ensure_study_exists(db, study_id)
await _ensure_site_belongs_to_study(db, study_id, site_id)
normalized_status = _normalize_status(status_value) if status_value else None
items = await issue_crud.list_issues(
db,
study_id,
site_id=site_id,
category=(category or None),
severity=(severity or None),
mark=(mark or None),
visit_cycle=(visit_cycle or None),
status=normalized_status,
overdue=overdue,
rectification_completed=rectification_completed,
due_from=due_from,
due_to=due_to,
created_from=created_from,
created_to=created_to,
keyword=(keyword or None),
skip=0,
limit=50000,
@@ -381,15 +467,23 @@ async def export_monitoring_visit_issues(
ws = wb.active
ws.title = "监查访视问题"
headers = [
"项目/中心",
"问题编号",
"受试者筛选号",
"访视周期",
"问题分类",
"严重程度",
"建议措施",
"问题描述",
"中心质疑",
"中心最新回复",
"是否完成整改",
"标记",
"状态",
"是否超期",
"问题来源",
"监查类型",
"监查项",
"问题分类",
"建议措施",
"受试者",
"受试者缩写号",
"问题描述",
"采取措施",
"跟进计划及进展",
"发现时间",
@@ -403,20 +497,31 @@ async def export_monitoring_visit_issues(
"创建时间",
]
ws.append(headers)
site_ids = {item.site_id for item in items if item.site_id}
site_map = await site_crud.get_sites_by_ids(db, site_ids)
for item in items:
view = _to_read(item)
site_name = site_map.get(view.site_id).name if view.site_id in site_map else ""
ws.append(
[
site_name,
view.issue_no or "",
view.subject_code or "",
view.visit_cycle or "",
view.category or "",
view.severity or "",
view.recommendation or "",
view.description or "",
view.center_query or "",
view.center_latest_reply or "",
"" if view.rectification_completed else "",
view.mark or "",
view.progress or "",
"" if view.overdue else "",
view.source or "",
view.monitor_type or "",
view.monitor_item or "",
view.category or "",
view.recommendation or "",
view.subject_name or "",
view.subject_code or "",
view.description or "",
view.action_taken or "",
view.follow_up_progress or "",
_format_date(view.found_date),
@@ -448,7 +553,7 @@ async def export_monitoring_visit_issues(
@router.get(
"/issues/{issue_id}",
response_model=MonitoringVisitIssueRead,
dependencies=[Depends(require_study_member())],
dependencies=[Depends(require_api_permission("monitoring_issues:read"))],
)
async def get_monitoring_visit_issue(
study_id: uuid.UUID,
@@ -465,7 +570,7 @@ async def get_monitoring_visit_issue(
@router.patch(
"/issues/{issue_id}",
response_model=MonitoringVisitIssueRead,
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
dependencies=[Depends(require_api_permission("monitoring_issues:update")), Depends(require_study_not_locked())],
)
async def update_monitoring_visit_issue(
study_id: uuid.UUID,
@@ -486,6 +591,8 @@ async def update_monitoring_visit_issue(
if not payload.model_fields_set:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="至少提供一个更新字段")
if "site_id" in payload.model_fields_set:
await _ensure_site_belongs_to_study(db, study_id, payload.site_id)
before_snapshot = _issue_audit_snapshot(item)
try:
@@ -510,7 +617,7 @@ async def update_monitoring_visit_issue(
action="UPDATE_MONITORING_VISIT_ISSUE",
detail=json.dumps(detail_payload, ensure_ascii=False),
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
return _to_read(updated)
@@ -518,7 +625,7 @@ async def update_monitoring_visit_issue(
@router.delete(
"/issues/{issue_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
dependencies=[Depends(require_api_permission("monitoring_issues:delete")), Depends(require_study_not_locked())],
)
async def delete_monitoring_visit_issue(
study_id: uuid.UUID,
@@ -541,14 +648,14 @@ async def delete_monitoring_visit_issue(
action="DELETE_MONITORING_VISIT_ISSUE",
detail=f"监查访视问题 {issue_no} 已删除",
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
@router.post(
"/issues/import",
response_model=MonitoringVisitIssueImportSummary,
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
dependencies=[Depends(require_api_permission("monitoring_issues:create")), Depends(require_study_not_locked())],
)
async def import_monitoring_visit_issues(
study_id: uuid.UUID,
@@ -568,6 +675,8 @@ async def import_monitoring_visit_issues(
created_count = 0
updated_count = 0
skipped_rows: list[str] = []
sites = await site_crud.list_by_study(db, study_id, limit=5000, include_inactive=True)
site_by_name = {site.name.strip().lower(): site for site in sites if site.name}
for idx, row in enumerate(rows, start=2):
issue_no = _pick(row, "issue_no")
@@ -576,6 +685,15 @@ async def import_monitoring_visit_issues(
skipped_rows.append(f"{idx}行:问题编号或问题分类为空")
continue
site_id = None
site_name = _pick(row, "site_name")
if site_name:
site = site_by_name.get(site_name.strip().lower())
if not site:
skipped_rows.append(f"{idx}行:中心「{site_name}」不存在")
continue
site_id = site.id
status_text = _normalize_status(_pick(row, "status"))
creator_name = _pick(row, "creator_name") or getattr(current_user, "full_name", None)
created_at = _parse_datetime(row.get("创建时间") or row.get("created_at") or _pick(row, "created_at"))
@@ -586,7 +704,11 @@ async def import_monitoring_visit_issues(
try:
payload = MonitoringVisitIssueCreate(
issue_no=issue_no,
site_id=site_id,
category=category,
severity=_pick(row, "severity"),
mark=_pick(row, "mark"),
visit_cycle=_pick(row, "visit_cycle"),
subject_code=_pick(row, "subject_code"),
subject_name=_pick(row, "subject_name"),
monitor_item=_pick(row, "monitor_item"),
@@ -598,6 +720,9 @@ async def import_monitoring_visit_issues(
description=_pick(row, "description"),
action_taken=_pick(row, "action_taken"),
follow_up_progress=_pick(row, "follow_up_progress"),
center_query=_pick(row, "center_query"),
center_latest_reply=_pick(row, "center_latest_reply"),
rectification_completed=_parse_bool(_pick(row, "rectification_completed")) or False,
found_date=_parse_date(row.get("发现时间") or row.get("found_date") or _pick(row, "found_date")),
due_at=due_at,
actual_resolve_date=_parse_date(
@@ -640,7 +765,7 @@ async def import_monitoring_visit_issues(
action="IMPORT_MONITORING_VISIT_ISSUE",
detail=f"导入监查访视问题:新增 {created_count} 条,更新 {updated_count} 条,跳过 {len(skipped_rows)}",
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
return MonitoringVisitIssueImportSummary(
+4 -4
View File
@@ -4,7 +4,7 @@ import uuid
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db_session, require_study_member
from app.core.deps import get_db_session, require_api_permission
from app.crud import overview as overview_crud
from app.schemas.overview import ProjectOverviewResponse
@@ -14,7 +14,7 @@ router = APIRouter()
@router.get(
"/overview",
response_model=ProjectOverviewResponse,
dependencies=[Depends(require_study_member())],
dependencies=[Depends(require_api_permission("project_overview:read"))],
)
async def get_project_overview(
study_id: uuid.UUID,
@@ -22,10 +22,10 @@ async def get_project_overview(
) -> ProjectOverviewResponse:
"""
获取项目概览数据
返回项目各中心的进度情况,包括:
- 机构立项状态
- 伦理审批状态
- 伦理记录状态
- 合同签署状态
- 启动会状态
- 入组进度
+844
View File
@@ -0,0 +1,844 @@
"""权限系统监控API
提供权限系统的监控数据、访问日志、趋势分析和告警信息。
"""
from __future__ import annotations
import uuid
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, select, desc
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, is_system_admin
from app.core.permission_monitor import get_permission_monitor
from app.models.permission_access_log import PermissionAccessLog
from app.models.permission_metric_snapshot import PermissionMetricSnapshot
from app.models.security_access_log import SecurityAccessLog
from app.models.user import User
from app.services.ip_location import IpLocation, resolve_ip_location
router = APIRouter(prefix="/permission-monitoring", tags=["permission-monitoring"])
class MonitoringScope:
def __init__(self, *, is_admin: bool, study_ids: set[uuid.UUID]) -> None:
self.is_admin = is_admin
self.study_ids = study_ids
def can_access_study(self, study_id: uuid.UUID | None) -> bool:
if self.is_admin:
return True
return study_id is not None and study_id in self.study_ids
async def resolve_monitoring_scope(db: AsyncSession, current_user) -> MonitoringScope:
if is_system_admin(current_user):
return MonitoringScope(is_admin=True, study_ids=set())
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
def _apply_monitoring_scope_to_log_query(query, scope: MonitoringScope):
if scope.is_admin:
return query
return query.where(PermissionAccessLog.study_id.in_(scope.study_ids))
# ═══════════════════════════════════════════
# 原有端点(保持兼容)
# ═══════════════════════════════════════════
@router.get("/metrics", status_code=status.HTTP_200_OK)
async def get_permission_metrics(
db: AsyncSession = Depends(get_db_session),
_=Depends(get_current_user),
hours: int = Query(24, ge=1, le=720),
) -> dict:
"""从 permission_access_logs 实时聚合权限检查指标"""
scope = await resolve_monitoring_scope(db, _)
if not scope.is_admin and not scope.study_ids:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
start_time = datetime.now(timezone.utc) - timedelta(hours=hours)
metrics_query = select(
func.count().label("total_checks"),
func.count().filter(PermissionAccessLog.allowed.is_(True)).label("allowed_checks"),
func.count().filter(PermissionAccessLog.allowed.is_(False)).label("denied_checks"),
func.coalesce(func.sum(PermissionAccessLog.elapsed_ms), 0).label("total_time_ms"),
func.coalesce(func.min(PermissionAccessLog.elapsed_ms), 0).label("min_time_ms"),
func.coalesce(func.max(PermissionAccessLog.elapsed_ms), 0).label("max_time_ms"),
func.coalesce(func.avg(PermissionAccessLog.elapsed_ms), 0).label("avg_time_ms"),
).where(PermissionAccessLog.created_at >= start_time)
result = await db.execute(
_apply_monitoring_scope_to_log_query(metrics_query, scope)
)
row = result.one()
total = row.total_checks or 0
monitor = get_permission_monitor()
cache_metrics = monitor.metrics.cache_metrics.to_dict()
return {
"window_hours": hours,
"check_metrics": {
"total_checks": total,
"allowed_checks": row.allowed_checks or 0,
"denied_checks": row.denied_checks or 0,
"total_time": round(float(row.total_time_ms) / 1000, 3),
"min_time": round(float(row.min_time_ms) / 1000, 3),
"max_time": round(float(row.max_time_ms) / 1000, 3),
"avg_time": round(float(row.avg_time_ms) / 1000, 3),
"allow_rate": round(row.allowed_checks / total * 100, 2) if total else 0,
"deny_rate": round(row.denied_checks / total * 100, 2) if total else 0,
"error_rate": 0,
"errors": 0,
},
"cache_metrics": cache_metrics,
"uptime_seconds": monitor.metrics.uptime_seconds,
}
@router.get("/cache-stats", status_code=status.HTTP_200_OK)
async def get_cache_statistics(
_=Depends(get_current_user),
db: AsyncSession = Depends(get_db_session),
) -> dict:
scope = await resolve_monitoring_scope(db, _)
if not scope.is_admin and not scope.study_ids:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
monitor = get_permission_monitor()
return monitor.get_cache_stats()
@router.get("/alerts", status_code=status.HTTP_200_OK)
async def get_alerts(
level: str | None = None,
limit: int = 100,
_=Depends(get_current_user),
db: AsyncSession = Depends(get_db_session),
) -> dict:
scope = await resolve_monitoring_scope(db, _)
if not scope.is_admin and not scope.study_ids:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
monitor = get_permission_monitor()
alerts = monitor.get_alerts(level=level, limit=limit)
return {
"total": len(alerts),
"alerts": alerts,
}
@router.post("/reset-metrics", status_code=status.HTTP_200_OK)
async def reset_metrics(
_=Depends(get_current_user),
db: AsyncSession = Depends(get_db_session),
) -> dict:
scope = await resolve_monitoring_scope(db, _)
if not scope.is_admin and not scope.study_ids:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
monitor = get_permission_monitor()
monitor.reset_metrics()
return {"message": "指标已重置"}
@router.post("/clear-alerts", status_code=status.HTTP_200_OK)
async def clear_alerts(
_=Depends(get_current_user),
db: AsyncSession = Depends(get_db_session),
) -> dict:
scope = await resolve_monitoring_scope(db, _)
if not scope.is_admin and not scope.study_ids:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
monitor = get_permission_monitor()
monitor.clear_alerts()
return {"message": "告警已清除"}
@router.get("/health", status_code=status.HTTP_200_OK)
async def permission_system_health(
db: AsyncSession = Depends(get_db_session),
_=Depends(get_current_user),
) -> dict:
"""从 DB 聚合最近 1 小时数据评估权限系统健康状态"""
scope = await resolve_monitoring_scope(db, _)
if not scope.is_admin and not scope.study_ids:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
start_time = datetime.now(timezone.utc) - timedelta(hours=1)
health_query = select(
func.count().label("total"),
func.count().filter(PermissionAccessLog.allowed.is_(False)).label("denied"),
func.coalesce(func.avg(PermissionAccessLog.elapsed_ms), 0).label("avg_ms"),
).where(PermissionAccessLog.created_at >= start_time)
result = await db.execute(
_apply_monitoring_scope_to_log_query(health_query, scope)
)
row = result.one()
total = row.total or 0
avg_ms = float(row.avg_ms)
deny_rate = round(row.denied / total * 100, 2) if total else 0
monitor = get_permission_monitor()
cache_metrics = monitor.metrics.cache_metrics
health_score = 100
issues = []
if avg_ms > 10:
health_score -= 10
issues.append("权限检查响应时间过长")
if deny_rate > 50:
health_score -= 5
issues.append("权限拒绝率过高")
if (
cache_metrics.total_accesses >= 10
and cache_metrics.hit_rate < 50
):
health_score -= 10
issues.append("缓存命中率过低")
return {
"status": "healthy" if health_score >= 80 else "degraded" if health_score >= 50 else "unhealthy",
"health_score": max(0, health_score),
"issues": issues,
"last_hour": {
"total_checks": total,
"denied_checks": row.denied or 0,
"deny_rate": deny_rate,
"avg_elapsed_ms": round(avg_ms, 2),
},
"cache_stats": monitor.get_cache_stats(),
}
# ═══════════════════════════════════════════
# 新增端点:访问日志
# ═══════════════════════════════════════════
@router.get("/access-logs", status_code=status.HTTP_200_OK)
async def get_access_logs(
db: AsyncSession = Depends(get_db_session),
_=Depends(get_current_user),
study_id: Optional[uuid.UUID] = Query(None),
user_id: Optional[uuid.UUID] = Query(None),
endpoint_key: Optional[str] = Query(None),
role: Optional[str] = Query(None),
allowed: Optional[bool] = Query(None),
start_time: Optional[datetime] = Query(None),
end_time: Optional[datetime] = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
) -> dict:
"""分页查询权限访问日志"""
scope = await resolve_monitoring_scope(db, _)
if not scope.is_admin and not scope.study_ids:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
if study_id and not scope.can_access_study(study_id):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
conditions = []
if study_id:
conditions.append(PermissionAccessLog.study_id == study_id)
elif not scope.is_admin:
conditions.append(PermissionAccessLog.study_id.in_(scope.study_ids))
if user_id:
conditions.append(PermissionAccessLog.user_id == user_id)
if endpoint_key:
conditions.append(PermissionAccessLog.endpoint_key == endpoint_key)
if role:
conditions.append(PermissionAccessLog.role == role)
if allowed is not None:
conditions.append(PermissionAccessLog.allowed == allowed)
if start_time:
conditions.append(PermissionAccessLog.created_at >= start_time)
if end_time:
conditions.append(PermissionAccessLog.created_at <= end_time)
query = (
select(PermissionAccessLog, User.full_name)
.outerjoin(User, PermissionAccessLog.user_id == User.id)
)
count_query = select(func.count()).select_from(PermissionAccessLog)
summary_query = select(
func.count().label("total_count"),
func.count(func.distinct(PermissionAccessLog.user_id)).label("unique_user_count"),
func.count(func.distinct(PermissionAccessLog.ip_address)).label("unique_ip_count"),
func.count().filter(PermissionAccessLog.allowed.is_(False)).label("denied_count"),
func.coalesce(func.avg(PermissionAccessLog.elapsed_ms), 0).label("avg_elapsed_ms"),
).select_from(PermissionAccessLog)
user_stats_query = (
select(
PermissionAccessLog.ip_address.label("sample_ip_address"),
func.count().label("total_count"),
func.count().filter(PermissionAccessLog.allowed.is_(False)).label("denied_count"),
func.count(func.distinct(PermissionAccessLog.ip_address)).label("unique_ip_count"),
func.max(PermissionAccessLog.created_at).label("last_seen_at"),
)
.group_by(PermissionAccessLog.ip_address)
.order_by(desc("total_count"), desc("last_seen_at"))
.limit(10)
)
for condition in conditions:
query = query.where(condition)
count_query = count_query.where(condition)
summary_query = summary_query.where(condition)
user_stats_query = user_stats_query.where(condition)
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
summary_result = await db.execute(summary_query)
summary_row = summary_result.one()
user_stats_result = await db.execute(user_stats_query)
user_stats_rows = user_stats_result.all()
latest_user_by_ip = {}
if user_stats_rows:
ranked_ips = [stat.sample_ip_address for stat in user_stats_rows if stat.sample_ip_address]
latest_user_query = (
select(
PermissionAccessLog.ip_address,
PermissionAccessLog.user_id,
User.full_name,
PermissionAccessLog.role,
PermissionAccessLog.created_at,
)
.outerjoin(User, PermissionAccessLog.user_id == User.id)
.where(PermissionAccessLog.ip_address.in_(ranked_ips))
.order_by(PermissionAccessLog.ip_address, desc(PermissionAccessLog.created_at))
)
for condition in conditions:
latest_user_query = latest_user_query.where(condition)
latest_user_result = await db.execute(latest_user_query)
for row in latest_user_result.all():
latest_user_by_ip.setdefault(row.ip_address, row)
query = query.order_by(desc(PermissionAccessLog.created_at))
query = query.offset((page - 1) * page_size).limit(page_size)
result = await db.execute(query)
rows = result.all()
items = []
for log, full_name in rows:
ip_location = resolve_ip_location(log.ip_address)
items.append(
{
"id": str(log.id),
"study_id": str(log.study_id),
"user_id": str(log.user_id),
"user_name": full_name or "未知用户",
"endpoint_key": log.endpoint_key,
"role": log.role,
"allowed": log.allowed,
"elapsed_ms": round(log.elapsed_ms, 2),
"ip_address": log.ip_address,
"ip_location": ip_location.location,
"ip_country": ip_location.country,
"ip_province": ip_location.province,
"ip_city": ip_location.city,
"ip_isp": ip_location.isp,
"created_at": log.created_at.isoformat(),
}
)
user_stats = []
for stat in user_stats_rows:
sample_location = resolve_ip_location(stat.sample_ip_address)
latest_user = latest_user_by_ip.get(stat.sample_ip_address)
user_stats.append(
{
"user_id": str(latest_user.user_id) if latest_user else "",
"user_name": latest_user.full_name if latest_user and latest_user.full_name else "未知用户",
"role": latest_user.role if latest_user else "",
"total_count": stat.total_count,
"denied_count": stat.denied_count,
"unique_ip_count": stat.unique_ip_count,
"sample_ip_address": stat.sample_ip_address,
"last_seen_at": stat.last_seen_at.isoformat() if stat.last_seen_at else None,
"primary_location": sample_location.location,
}
)
return {
"total": total,
"page": page,
"page_size": page_size,
"summary": {
"total_count": summary_row.total_count,
"unique_user_count": summary_row.unique_user_count,
"unique_ip_count": summary_row.unique_ip_count,
"denied_count": summary_row.denied_count,
"avg_elapsed_ms": round(float(summary_row.avg_elapsed_ms), 2),
},
"user_stats": user_stats,
"items": items,
}
def _security_account_label(auth_status: str, user_identifier: str | None, user_names: dict[str, str] | None = None) -> str:
if auth_status == "AUTHENTICATED" and user_identifier:
return (user_names or {}).get(user_identifier) or user_identifier
if auth_status == "INVALID_TOKEN":
return "无效令牌"
return "未知账号"
SENSITIVE_PROBE_MARKERS = (
"/.env",
".env",
"/.git",
".git/config",
"backup",
"config.php",
"wp-config",
"database.yml",
)
CHINA_IP_COUNTRY_LABELS = {"中国", "China", "Mainland China", "中国香港", "中国澳门", "中国台湾"}
def _is_non_china_ip(ip_location: IpLocation) -> bool:
country = (ip_location.country or "").strip()
return bool(country) and country not in CHINA_IP_COUNTRY_LABELS and not country.startswith("中国")
def _classify_security_access_log(log: SecurityAccessLog, ip_location: IpLocation | None = None) -> dict[str, str]:
path = (log.path or "").lower()
if any(marker in path for marker in SENSITIVE_PROBE_MARKERS):
return {"category": "PROBE", "severity": "CRITICAL"}
if ip_location and _is_non_china_ip(ip_location):
return {"category": "ABNORMAL_IP", "severity": "HIGH"}
if log.status_code >= 500:
return {"category": "SERVER_ERROR", "severity": "HIGH"}
if log.auth_status == "INVALID_TOKEN":
return {"category": "INVALID_TOKEN", "severity": "MEDIUM"}
if log.auth_status == "ANONYMOUS" and log.status_code in {401, 403}:
return {"category": "ANONYMOUS_API", "severity": "MEDIUM"}
if log.status_code == 404:
return {"category": "NOT_FOUND_NOISE", "severity": "LOW"}
return {"category": "OTHER", "severity": "LOW"}
@router.get("/security-logs", status_code=status.HTTP_200_OK)
async def get_security_access_logs(
db: AsyncSession = Depends(get_db_session),
_=Depends(get_current_user),
status_min: Optional[int] = Query(None, ge=100, le=599),
auth_status: Optional[str] = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
) -> dict:
"""查询底层安全访问日志,覆盖匿名、无效令牌和异常状态请求。"""
scope = await resolve_monitoring_scope(db, _)
if not scope.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
conditions = []
if status_min is not None:
conditions.append(SecurityAccessLog.status_code >= status_min)
if auth_status:
conditions.append(SecurityAccessLog.auth_status == auth_status)
query = select(SecurityAccessLog)
count_query = select(func.count()).select_from(SecurityAccessLog)
summary_query = select(
func.count().label("total_count"),
func.count().filter(SecurityAccessLog.auth_status == "ANONYMOUS").label("anonymous_count"),
func.count().filter(SecurityAccessLog.auth_status == "INVALID_TOKEN").label("invalid_token_count"),
func.count().filter(SecurityAccessLog.status_code >= 400).label("error_count"),
).select_from(SecurityAccessLog)
for condition in conditions:
query = query.where(condition)
count_query = count_query.where(condition)
summary_query = summary_query.where(condition)
total_result = await db.execute(count_query)
summary_result = await db.execute(summary_query)
result = await db.execute(
query.order_by(desc(SecurityAccessLog.created_at))
.offset((page - 1) * page_size)
.limit(page_size)
)
summary_row = summary_result.one()
logs = result.scalars().all()
user_ids: list[uuid.UUID] = []
for log in logs:
if log.auth_status != "AUTHENTICATED" or not log.user_identifier:
continue
try:
user_ids.append(uuid.UUID(log.user_identifier))
except ValueError:
continue
user_names: dict[str, str] = {}
if user_ids:
users_result = await db.execute(select(User.id, User.full_name).where(User.id.in_(user_ids)))
user_names = {str(row.id): row.full_name for row in users_result.all()}
items = []
for log in logs:
ip_location = resolve_ip_location(log.client_ip)
items.append(
{
"id": str(log.id),
"method": log.method,
"path": log.path,
"status_code": log.status_code,
"elapsed_ms": round(log.elapsed_ms, 2),
"client_ip": log.client_ip,
"ip_location": ip_location.location,
"ip_country": ip_location.country,
"ip_province": ip_location.province,
"ip_city": ip_location.city,
"ip_isp": ip_location.isp,
"user_agent": log.user_agent,
"auth_status": log.auth_status,
"user_identifier": log.user_identifier,
"account_label": _security_account_label(log.auth_status, log.user_identifier, user_names),
**_classify_security_access_log(log, ip_location),
"created_at": log.created_at.isoformat(),
}
)
return {
"total": total_result.scalar() or 0,
"page": page,
"page_size": page_size,
"summary": {
"total_count": summary_row.total_count,
"anonymous_count": summary_row.anonymous_count,
"invalid_token_count": summary_row.invalid_token_count,
"error_count": summary_row.error_count,
},
"items": items,
}
# ═══════════════════════════════════════════
# 新增端点:趋势数据
# ═══════════════════════════════════════════
@router.get("/trends", status_code=status.HTTP_200_OK)
async def get_trends(
db: AsyncSession = Depends(get_db_session),
_=Depends(get_current_user),
period: str = Query("24h", pattern="^(24h|7d|30d)$"),
) -> dict:
"""获取趋势数据(从快照表或实时聚合)"""
scope = await resolve_monitoring_scope(db, _)
if not scope.is_admin and not scope.study_ids:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
now = datetime.now(timezone.utc)
period_map = {"24h": timedelta(hours=24), "7d": timedelta(days=7), "30d": timedelta(days=30)}
start_time = now - period_map[period]
# 先尝试从快照表获取
snapshot_query = (
select(PermissionMetricSnapshot)
.where(PermissionMetricSnapshot.bucket_time >= start_time)
.order_by(PermissionMetricSnapshot.bucket_time)
)
result = await db.execute(snapshot_query)
snapshots = result.scalars().all()
if snapshots and scope.is_admin:
return {
"period": period,
"data_points": [
{
"bucket_time": s.bucket_time.isoformat(),
"total_checks": s.total_checks,
"allowed_checks": s.allowed_checks,
"denied_checks": s.denied_checks,
"avg_elapsed_ms": round(s.avg_elapsed_ms, 2),
"max_elapsed_ms": round(s.max_elapsed_ms, 2),
"cache_hits": s.cache_hits,
"cache_misses": s.cache_misses,
"cache_hit_rate": round(
s.cache_hits / (s.cache_hits + s.cache_misses) * 100, 1
) if (s.cache_hits + s.cache_misses) > 0 else 0,
"error_count": s.error_count,
}
for s in snapshots
],
}
# 如果没有快照数据,从原始日志实时聚合(适用于刚部署时)。
# 这里使用 Python 分桶,避免 SQLite 测试库不支持 PostgreSQL date_trunc。
trend_query = select(
PermissionAccessLog.created_at,
PermissionAccessLog.allowed,
PermissionAccessLog.elapsed_ms,
).where(PermissionAccessLog.created_at >= start_time)
result = await db.execute(
_apply_monitoring_scope_to_log_query(trend_query, scope)
)
buckets: dict[datetime, dict[str, float | int]] = defaultdict(
lambda: {"total": 0, "allowed": 0, "denied": 0, "elapsed_sum": 0.0, "max_ms": 0.0}
)
for created_at, allowed, elapsed_ms in result.all():
bucket = _trend_bucket_time(created_at, period)
buckets[bucket]["total"] += 1
buckets[bucket]["allowed" if allowed else "denied"] += 1
buckets[bucket]["elapsed_sum"] += float(elapsed_ms or 0)
buckets[bucket]["max_ms"] = max(float(buckets[bucket]["max_ms"]), float(elapsed_ms or 0))
return {
"period": period,
"data_points": [
{
"bucket_time": bucket.isoformat(),
"total_checks": values["total"],
"allowed_checks": values["allowed"],
"denied_checks": values["denied"],
"avg_elapsed_ms": round(float(values["elapsed_sum"]) / int(values["total"]), 2),
"max_elapsed_ms": round(float(values["max_ms"]), 2),
"cache_hits": 0,
"cache_misses": 0,
"cache_hit_rate": 0,
"error_count": 0,
}
for bucket, values in sorted(buckets.items())
],
}
def _trend_bucket_time(value: datetime, period: str) -> datetime:
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
if period == "24h":
return value.replace(minute=0, second=0, microsecond=0)
if period == "7d":
return value.replace(hour=(value.hour // 6) * 6, minute=0, second=0, microsecond=0)
return value.replace(hour=0, minute=0, second=0, microsecond=0)
# ═══════════════════════════════════════════
# 新增端点:被拒绝最多的权限
# ═══════════════════════════════════════════
@router.get("/top-denied", status_code=status.HTTP_200_OK)
async def get_top_denied(
db: AsyncSession = Depends(get_db_session),
_=Depends(get_current_user),
days: int = Query(7, ge=1, le=90),
limit: int = Query(20, ge=1, le=100),
) -> dict:
"""获取被拒绝最多的权限"""
scope = await resolve_monitoring_scope(db, _)
if not scope.is_admin and not scope.study_ids:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
start_time = datetime.now(timezone.utc) - timedelta(days=days)
query = (
select(
PermissionAccessLog.endpoint_key,
PermissionAccessLog.role,
func.count().label("denied_count"),
)
.where(
PermissionAccessLog.created_at >= start_time,
PermissionAccessLog.allowed.is_(False),
)
.group_by(PermissionAccessLog.endpoint_key, PermissionAccessLog.role)
.order_by(desc("denied_count"))
.limit(limit)
)
result = await db.execute(_apply_monitoring_scope_to_log_query(query, scope))
rows = result.all()
return {
"days": days,
"items": [
{
"endpoint_key": row.endpoint_key,
"role": row.role,
"denied_count": row.denied_count,
}
for row in rows
],
}
# ═══════════════════════════════════════════
# 新增端点:IP 属地统计
# ═══════════════════════════════════════════
@router.get("/ip-locations", status_code=status.HTTP_200_OK)
async def get_ip_locations(
db: AsyncSession = Depends(get_db_session),
_=Depends(get_current_user),
days: int = Query(7, ge=1, le=90),
limit: int = Query(20, ge=1, le=100),
) -> dict:
"""获取 IP 省市属地统计。"""
scope = await resolve_monitoring_scope(db, _)
if not scope.is_admin and not scope.study_ids:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
start_time = datetime.now(timezone.utc) - timedelta(days=days)
query = (
select(
PermissionAccessLog.ip_address,
PermissionAccessLog.user_id,
PermissionAccessLog.allowed,
)
.where(
PermissionAccessLog.created_at >= start_time,
PermissionAccessLog.ip_address.is_not(None),
)
)
result = await db.execute(_apply_monitoring_scope_to_log_query(query, scope))
security_result = await db.execute(
select(
SecurityAccessLog.client_ip,
SecurityAccessLog.user_identifier,
SecurityAccessLog.auth_status,
SecurityAccessLog.status_code,
).where(
SecurityAccessLog.created_at >= start_time,
SecurityAccessLog.client_ip.is_not(None),
)
)
buckets: dict[tuple[str, str, str], dict] = {}
all_ip_addresses: set[str] = set()
all_user_ids: set[str] = set()
total_count = 0
allowed_count = 0
denied_count = 0
def add_ip_location_row(ip_address: str, user_id: str | uuid.UUID | None, allowed: bool) -> None:
nonlocal total_count, allowed_count, denied_count
ip_info = resolve_ip_location(ip_address)
key = (ip_info.country, ip_info.province, ip_info.city)
location = " / ".join(part for part in [ip_info.country, ip_info.province, ip_info.city] if part) or ip_info.location or "未知"
bucket = buckets.setdefault(
key,
{
"country": ip_info.country,
"province": ip_info.province,
"city": ip_info.city,
"isp": "",
"location": location,
"total_count": 0,
"allowed_count": 0,
"denied_count": 0,
"ip_addresses": set(),
"user_ids": set(),
},
)
bucket["total_count"] += 1
bucket["allowed_count" if allowed else "denied_count"] += 1
bucket["ip_addresses"].add(ip_address)
if user_id:
bucket["user_ids"].add(str(user_id))
total_count += 1
if allowed:
allowed_count += 1
else:
denied_count += 1
all_ip_addresses.add(ip_address)
if user_id:
all_user_ids.add(str(user_id))
for ip_address, user_id, allowed in result.all():
add_ip_location_row(ip_address, user_id, allowed)
for client_ip, user_identifier, auth_status, status_code in security_result.all():
user_id = None
if auth_status == "AUTHENTICATED" and user_identifier:
try:
user_id = uuid.UUID(user_identifier)
except ValueError:
user_id = user_identifier
add_ip_location_row(client_ip, user_id, status_code < 400)
items = sorted(buckets.values(), key=lambda item: item["total_count"], reverse=True)[:limit]
return {
"days": days,
"summary": {
"total_count": total_count,
"allowed_count": allowed_count,
"denied_count": denied_count,
"unique_ip_count": len(all_ip_addresses),
"unique_user_count": len(all_user_ids),
},
"items": [
{
"country": item["country"],
"province": item["province"],
"city": item["city"],
"isp": item["isp"],
"location": item["location"],
"total_count": item["total_count"],
"allowed_count": item["allowed_count"],
"denied_count": item["denied_count"],
"unique_ip_count": len(item["ip_addresses"]),
"unique_user_count": len(item["user_ids"]),
}
for item in items
],
}
# ═══════════════════════════════════════════
# 新增端点:实时统计摘要(从DB获取)
# ═══════════════════════════════════════════
@router.get("/stats-summary", status_code=status.HTTP_200_OK)
async def get_stats_summary(
db: AsyncSession = Depends(get_db_session),
_=Depends(get_current_user),
) -> dict:
"""获取基于数据库的统计摘要"""
scope = await resolve_monitoring_scope(db, _)
if not scope.is_admin and not scope.study_ids:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
now = datetime.now(timezone.utc)
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
hour_ago = now - timedelta(hours=1)
# 今日统计
today_query = select(
func.count().label("total"),
func.count().filter(PermissionAccessLog.allowed.is_(True)).label("allowed"),
func.count().filter(PermissionAccessLog.allowed.is_(False)).label("denied"),
func.coalesce(func.avg(PermissionAccessLog.elapsed_ms), 0).label("avg_ms"),
func.coalesce(func.max(PermissionAccessLog.elapsed_ms), 0).label("max_ms"),
).where(PermissionAccessLog.created_at >= today_start)
today_result = await db.execute(
_apply_monitoring_scope_to_log_query(today_query, scope)
)
today = today_result.one()
# 最近一小时
hour_query = select(
func.count().label("total"),
func.count().filter(PermissionAccessLog.allowed.is_(True)).label("allowed"),
func.count().filter(PermissionAccessLog.allowed.is_(False)).label("denied"),
).where(PermissionAccessLog.created_at >= hour_ago)
hour_result = await db.execute(
_apply_monitoring_scope_to_log_query(hour_query, scope)
)
hour = hour_result.one()
# 总记录数
total_query = select(func.count()).select_from(PermissionAccessLog)
total_result = await db.execute(
_apply_monitoring_scope_to_log_query(total_query, scope)
)
total_logs = total_result.scalar() or 0
return {
"total_logs": total_logs,
"today": {
"total_checks": today.total,
"allowed_checks": today.allowed,
"denied_checks": today.denied,
"avg_elapsed_ms": round(float(today.avg_ms), 2),
"max_elapsed_ms": round(float(today.max_ms), 2),
"allow_rate": round(today.allowed / today.total * 100, 1) if today.total > 0 else 0,
"deny_rate": round(today.denied / today.total * 100, 1) if today.total > 0 else 0,
},
"last_hour": {
"total_checks": hour.total,
"allowed_checks": hour.allowed,
"denied_checks": hour.denied,
"requests_per_minute": round(hour.total / 60, 1),
},
}
+126
View File
@@ -0,0 +1,126 @@
"""权限模板管理API"""
from __future__ import annotations
import uuid
from typing import Annotated, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import Response
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_roles, require_study_roles
from app.models.permission_template import TemplateType
from app.schemas.permission_template import (
ApplyTemplateRequest,
ApplyTemplateResponse,
PermissionTemplateCreate,
PermissionTemplateRead,
PermissionTemplateUpdate,
)
from app.services.permission_template_service import PermissionTemplateService
# 模板管理路由(不需要 study_id)
router = APIRouter(prefix="/permission-templates", tags=["permission-templates"])
# 项目级模板操作路由(需要 study_id,挂载到 /studies/{study_id}
study_router = APIRouter(prefix="/permission-templates", tags=["permission-templates"])
@router.get("", response_model=list[PermissionTemplateRead])
async def list_templates(
template_type: Optional[str] = None,
category: Optional[str] = None,
skip: int = 0,
limit: int = 100,
db: Annotated[AsyncSession, Depends(get_db_session)] = None,
) -> list[PermissionTemplateRead]:
"""列表权限模板"""
t_type = TemplateType(template_type) if template_type else None
templates = await PermissionTemplateService.list_templates(
db, template_type=t_type, category=category, skip=skip, limit=limit
)
return [PermissionTemplateRead.model_validate(t) for t in templates]
@router.post(
"",
response_model=PermissionTemplateRead,
status_code=status.HTTP_201_CREATED,
)
async def create_template(
payload: PermissionTemplateCreate,
db: Annotated[AsyncSession, Depends(get_db_session)] = None,
current_user=Depends(require_roles(["ADMIN"])),
) -> PermissionTemplateRead:
"""创建权限模板"""
try:
template = await PermissionTemplateService.create_template(db, payload, current_user.id)
return PermissionTemplateRead.model_validate(template)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.get("/{template_id}", response_model=PermissionTemplateRead)
async def get_template(
template_id: uuid.UUID,
db: Annotated[AsyncSession, Depends(get_db_session)] = None,
) -> PermissionTemplateRead:
"""获取权限模板"""
template = await PermissionTemplateService.get_template(db, template_id)
if not template:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模板不存在")
return PermissionTemplateRead.model_validate(template)
@router.put("/{template_id}", response_model=PermissionTemplateRead)
async def update_template(
template_id: uuid.UUID,
payload: PermissionTemplateUpdate,
db: Annotated[AsyncSession, Depends(get_db_session)] = None,
current_user=Depends(require_roles(["ADMIN"])),
) -> PermissionTemplateRead:
"""更新权限模板"""
try:
template = await PermissionTemplateService.update_template(db, template_id, payload)
return PermissionTemplateRead.model_validate(template)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response)
async def delete_template(
template_id: uuid.UUID,
db: Annotated[AsyncSession, Depends(get_db_session)] = None,
current_user=Depends(require_roles(["ADMIN"])),
):
"""删除权限模板"""
try:
await PermissionTemplateService.delete_template(db, template_id)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@study_router.post(
"/{template_id}/apply",
response_model=ApplyTemplateResponse,
)
async def apply_template(
study_id: uuid.UUID,
template_id: uuid.UUID,
payload: ApplyTemplateRequest,
db: Annotated[AsyncSession, Depends(get_db_session)] = None,
_=Depends(require_study_roles(["PM"])),
) -> ApplyTemplateResponse:
"""应用权限模板到项目"""
try:
result = await PermissionTemplateService.apply_template(
db,
study_id,
payload.template_id,
roles=payload.roles,
override=payload.override,
)
return ApplyTemplateResponse(**result)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
+158
View File
@@ -0,0 +1,158 @@
import uuid
import json
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_operator_role_label, 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 precaution as precaution_crud
from app.crud import site as site_crud
from app.crud import study as study_crud
from app.schemas.precaution import PrecautionCreate, PrecautionRead, PrecautionUpdate
router = APIRouter()
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
study = await study_crud.get(db, study_id)
if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
return study
async def _ensure_site_name_active(db: AsyncSession, study_id: uuid.UUID, site_name: str | None):
if not site_name:
return
active_names = await site_crud.list_active_names(db, study_id)
if site_name not in active_names:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="中心已停用")
def _precaution_audit_detail(action: str, precaution) -> str:
title = str(precaution.title or "").strip() or "注意事项"
return json.dumps({"targetName": title, "description": f"{action}注意事项“{title}"}, ensure_ascii=False)
@router.post(
"/precautions",
response_model=PrecautionRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_api_permission("precautions:create"))],
)
async def create_precaution(
study_id: uuid.UUID,
precaution_in: PrecautionCreate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> PrecautionRead:
await _ensure_study_exists(db, study_id)
await _ensure_site_name_active(db, study_id, precaution_in.site_name)
precaution = await precaution_crud.create_precaution(db, study_id, precaution_in, created_by=current_user.id)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="precaution",
entity_id=precaution.id,
action="CREATE_PRECAUTION",
detail=_precaution_audit_detail("创建", precaution),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
return PrecautionRead.model_validate(precaution)
@router.get(
"/precautions",
response_model=list[PrecautionRead],
dependencies=[Depends(require_api_permission("precautions:read"))],
)
async def list_precautions(
study_id: uuid.UUID,
site_name: str | None = None,
keyword: str | None = None,
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db_session),
) -> list[PrecautionRead]:
await _ensure_study_exists(db, study_id)
items = await precaution_crud.list_precautions(db, study_id, site_name=site_name, keyword=keyword, skip=skip, limit=limit)
return [PrecautionRead.model_validate(item) for item in items]
@router.get(
"/precautions/{precaution_id}",
response_model=PrecautionRead,
dependencies=[Depends(require_api_permission("precautions:read"))],
)
async def get_precaution(
study_id: uuid.UUID,
precaution_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
) -> PrecautionRead:
await _ensure_study_exists(db, study_id)
precaution = await precaution_crud.get_precaution(db, precaution_id)
if not precaution or precaution.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="注意事项不存在")
return PrecautionRead.model_validate(precaution)
@router.patch(
"/precautions/{precaution_id}",
response_model=PrecautionRead,
dependencies=[Depends(require_api_permission("precautions:update"))],
)
async def update_precaution(
study_id: uuid.UUID,
precaution_id: uuid.UUID,
precaution_in: PrecautionUpdate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> PrecautionRead:
await _ensure_study_exists(db, study_id)
precaution = await precaution_crud.get_precaution(db, precaution_id)
if not precaution or precaution.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="注意事项不存在")
await _ensure_site_name_active(db, study_id, precaution.site_name)
precaution = await precaution_crud.update_precaution(db, precaution, precaution_in)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="precaution",
entity_id=precaution_id,
action="UPDATE_PRECAUTION",
detail=_precaution_audit_detail("更新", precaution),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
return PrecautionRead.model_validate(precaution)
@router.delete(
"/precautions/{precaution_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_api_permission("precautions:delete"))],
)
async def delete_precaution(
study_id: uuid.UUID,
precaution_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> None:
await _ensure_study_exists(db, study_id)
precaution = await precaution_crud.get_precaution(db, precaution_id)
if not precaution or precaution.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="注意事项不存在")
await _ensure_site_name_active(db, study_id, precaution.site_name)
precaution_detail = _precaution_audit_detail("删除", precaution)
await precaution_crud.delete_precaution(db, precaution)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="precaution",
entity_id=precaution_id,
action="DELETE_PRECAUTION",
detail=precaution_detail,
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
+4 -4
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_current_user, get_db_session, require_study_member, require_study_not_locked, require_study_roles
from app.core.deps import get_operator_role_label, 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 project_milestone as milestone_crud
from app.crud import study as study_crud
@@ -29,7 +29,7 @@ def _validate_date_range(start, end, label: str):
@router.get(
"/milestones",
response_model=list[ProjectMilestoneRead],
dependencies=[Depends(require_study_member())],
dependencies=[Depends(require_api_permission("project_milestones:read"))],
)
async def list_project_milestones(
study_id: uuid.UUID,
@@ -43,7 +43,7 @@ async def list_project_milestones(
@router.patch(
"/milestones/{milestone_id}",
response_model=ProjectMilestoneRead,
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
dependencies=[Depends(require_api_permission("project_milestones:update")), Depends(require_study_not_locked())],
)
async def update_project_milestone(
study_id: uuid.UUID,
@@ -69,6 +69,6 @@ async def update_project_milestone(
action="UPDATE_PROJECT_MILESTONE",
detail=f"项目里程碑 {milestone_id} 已更新",
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
return ProjectMilestoneRead.model_validate(item)
+10 -6
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, finance_specials, fees_contracts, fees_specials, 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
from app.api.v1 import auth, users, admin_users, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, fees_contracts, drug_shipments, material_equipments, project_milestones, startup, precautions, subject_histories, subject_pds, study_subject_pds, faq_categories, faqs, documents, etmf, overview, notifications, monitoring_visit_issues, api_permissions, permission_monitoring, permission_templates, system_permissions, study_active_roles
api_router = APIRouter()
@@ -12,6 +12,8 @@ api_router.include_router(overview.router, prefix="/studies/{study_id}", tags=["
api_router.include_router(notifications.router, prefix="/studies/{study_id}", tags=["notifications"])
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(api_permissions.router, tags=["api-permissions"])
api_router.include_router(api_permissions.study_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"])
@@ -20,16 +22,12 @@ api_router.include_router(subjects.router, prefix="/studies/{study_id}/subjects"
api_router.include_router(visits.router, prefix="/studies/{study_id}/subjects/{subject_id}/visits", tags=["visits"])
api_router.include_router(aes.router, prefix="/studies/{study_id}/aes", tags=["aes"])
api_router.include_router(finance_dashboard.router, prefix="/studies/{study_id}/finance", tags=["finance"])
api_router.include_router(finance_contracts.router, prefix="/studies/{study_id}/finance", tags=["finance-contracts"])
api_router.include_router(finance_specials.router, prefix="/studies/{study_id}/finance", tags=["finance-specials"])
api_router.include_router(fees_contracts.router, prefix="/fees", tags=["fees-contracts"])
api_router.include_router(fees_specials.router, prefix="/fees", tags=["fees-specials"])
api_router.include_router(fees_attachments.router, prefix="/fees", tags=["fees-attachments"])
api_router.include_router(drug_shipments.router, prefix="/studies/{study_id}/drug", tags=["drug-shipments"])
api_router.include_router(material_equipments.router, prefix="/studies/{study_id}/materials", tags=["material-equipments"])
api_router.include_router(project_milestones.router, prefix="/studies/{study_id}/project", tags=["project-milestones"])
api_router.include_router(startup.router, prefix="/studies/{study_id}/startup", tags=["startup"])
api_router.include_router(knowledge_notes.router, prefix="/studies/{study_id}/knowledge", tags=["knowledge"])
api_router.include_router(precautions.router, prefix="/studies/{study_id}/shared-library", tags=["precautions"])
api_router.include_router(monitoring_visit_issues.router, prefix="/studies/{study_id}/monitoring", tags=["monitoring-visit-issues"])
api_router.include_router(subject_histories.router, prefix="/studies/{study_id}/subjects/{subject_id}", tags=["subject-histories"])
api_router.include_router(subject_pds.router, prefix="/studies/{study_id}/subjects/{subject_id}", tags=["subject-pds"])
@@ -37,3 +35,9 @@ api_router.include_router(study_subject_pds.router, prefix="/studies/{study_id}"
api_router.include_router(faq_categories.router, prefix="/faqs/categories", tags=["faq-categories"])
api_router.include_router(faqs.router, prefix="/faqs/items", tags=["faqs"])
api_router.include_router(documents.router, prefix="", tags=["documents"])
api_router.include_router(etmf.router, prefix="/etmf", tags=["etmf"])
api_router.include_router(permission_monitoring.router)
api_router.include_router(permission_templates.router)
api_router.include_router(permission_templates.study_router, prefix="/studies/{study_id}", tags=["permission-templates"])
api_router.include_router(system_permissions.router)
api_router.include_router(study_active_roles.router, prefix="/studies/{study_id}", tags=["active-roles"])
+60 -13
View File
@@ -4,13 +4,16 @@ import json
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_member, require_study_roles, require_study_not_locked
from app.core.deps import get_operator_role_label, get_cra_site_scope, get_current_user, get_db_session, require_study_member, require_api_permission, require_study_not_locked
from app.core.decorators import register_api_endpoint
from app.crud import audit as audit_crud
from app.crud import site as site_crud
from app.crud import startup as startup_crud
from app.crud import study as study_crud
from app.crud import user as user_crud
from app.schemas.site import SiteCreate, SiteRead, SiteUpdate
from app.schemas.startup import StartupEthicsCreate, StartupFeasibilityCreate
from app.schemas.startup import KickoffMeetingCreate, StartupEthicsCreate, StartupFeasibilityCreate
from app.services.site_contact_display import build_contact_display, parse_contact_user_ids
router = APIRouter()
@@ -26,7 +29,14 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
"/",
response_model=SiteRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
dependencies=[Depends(require_api_permission("sites:create")), Depends(require_study_not_locked())],
)
@register_api_endpoint(
endpoint_key="sites:create",
module="sites",
action="write",
description="创建中心",
default_roles=["PM"],
)
async def create_site(
study_id: uuid.UUID,
@@ -48,6 +58,12 @@ async def create_site(
StartupEthicsCreate(site_id=site.id),
created_by=getattr(current_user, "id", None),
)
await startup_crud.create_kickoff(
db,
study_id,
KickoffMeetingCreate(site_id=site.id),
created_by=getattr(current_user, "id", None),
)
await audit_crud.log_action(
db,
study_id=study_id,
@@ -56,7 +72,7 @@ async def create_site(
action="SITE_CREATED",
detail=json.dumps({"targetName": site.name, "after": {"name": site.name, "is_active": site.is_active}}, ensure_ascii=False),
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
return site
@@ -64,7 +80,14 @@ async def create_site(
@router.get(
"/",
response_model=list[SiteRead],
dependencies=[Depends(require_study_member())],
dependencies=[Depends(require_api_permission("sites:read")), Depends(require_study_member())],
)
@register_api_endpoint(
endpoint_key="sites:read",
module="sites",
action="read",
description="查询中心列表",
default_roles=["PM", "CRA", "PV", "QA", "CTA"],
)
async def list_sites(
study_id: uuid.UUID,
@@ -85,13 +108,30 @@ async def list_sites(
include_inactive=include_inactive,
site_ids=site_ids,
)
return list(sites)
contact_user_ids: set[uuid.UUID] = set()
for site in sites:
contact_user_ids.update(parse_contact_user_ids(site.contact))
users_map = await user_crud.get_users_by_ids(db, contact_user_ids)
result: list[SiteRead] = []
for site in sites:
item = SiteRead.model_validate(site)
item.contact_display = build_contact_display(site.contact, users_map)
result.append(item)
return result
@router.patch(
"/{site_id}",
response_model=SiteRead,
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
dependencies=[Depends(require_api_permission("sites:update")), Depends(require_study_not_locked())],
)
@register_api_endpoint(
endpoint_key="sites:update",
module="sites",
action="write",
description="更新中心",
default_roles=["PM"],
)
async def update_site(
study_id: uuid.UUID,
@@ -108,6 +148,7 @@ async def update_site(
"name": site.name,
"city": site.city,
"pi_name": site.pi_name,
"phone": site.phone,
"contact": site.contact,
"is_active": site.is_active,
}
@@ -116,6 +157,7 @@ async def update_site(
"name": updated.name,
"city": updated.city,
"pi_name": updated.pi_name,
"phone": updated.phone,
"contact": updated.contact,
"is_active": updated.is_active,
}
@@ -128,7 +170,7 @@ async def update_site(
action=action,
detail=json.dumps({"targetName": updated.name, "before": before_data, "after": after_data}, ensure_ascii=False),
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
return updated
@@ -136,7 +178,14 @@ async def update_site(
@router.delete(
"/{site_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_study_not_locked())],
dependencies=[Depends(require_api_permission("sites:delete")), Depends(require_study_not_locked())],
)
@register_api_endpoint(
endpoint_key="sites:delete",
module="sites",
action="write",
description="删除中心",
default_roles=["PM"],
)
async def delete_site(
study_id: uuid.UUID,
@@ -145,9 +194,6 @@ async def delete_site(
current_user=Depends(get_current_user),
) -> None:
await _ensure_study_exists(db, study_id)
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
if role_value != "ADMIN":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="仅管理员可删除分中心")
site = await site_crud.get_site(db, site_id)
if not site or site.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分中心不存在")
@@ -155,6 +201,7 @@ async def delete_site(
"name": site.name,
"city": site.city,
"pi_name": site.pi_name,
"phone": site.phone,
"contact": site.contact,
"is_active": site.is_active,
}
@@ -167,6 +214,6 @@ async def delete_site(
action="SITE_DELETED",
detail=json.dumps({"targetName": site.name, "before": before_data, "after": {"deleted": True}}, ensure_ascii=False),
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
return None
+83 -43
View File
@@ -1,9 +1,10 @@
import uuid
import json
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_member
from app.core.deps import get_operator_role_label, get_cra_site_scope, get_current_user, get_db_session, require_api_permission
from app.crud import audit as audit_crud
from app.crud import site as site_crud
from app.crud import startup as startup_crud
@@ -49,11 +50,46 @@ async def _ensure_site_active(db: AsyncSession, site_id: uuid.UUID | None):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="中心已停用")
async def _site_label(db: AsyncSession, site_id: uuid.UUID | None, fallback: str = "项目级") -> str:
if not site_id:
return fallback
site = await site_crud.get_site(db, site_id)
return site.name if site else fallback
async def _feasibility_audit_detail(db: AsyncSession, action: str, record) -> str:
site_name = await _site_label(db, record.site_id)
project_no = str(record.project_no or "").strip()
name = f"{site_name} / {project_no}" if project_no else site_name
return json.dumps({"targetName": name, "description": f"{action}立项记录“{name}"}, ensure_ascii=False)
async def _ethics_audit_detail(db: AsyncSession, action: str, record) -> str:
site_name = await _site_label(db, record.site_id)
approval_no = str(record.approval_no or "").strip()
name = f"{site_name} / {approval_no}" if approval_no else site_name
return json.dumps({"targetName": name, "description": f"{action}伦理记录“{name}"}, ensure_ascii=False)
async def _kickoff_audit_detail(db: AsyncSession, action: str, meeting) -> str:
site_name = await _site_label(db, meeting.site_id)
date_text = meeting.kickoff_date.isoformat() if meeting.kickoff_date else ""
name = f"{site_name} / {date_text}" if date_text else site_name
return json.dumps({"targetName": name, "description": f"{action}启动会记录“{name}"}, ensure_ascii=False)
def _training_audit_detail(action: str, record) -> str:
name = str(record.name or "").strip() or "培训授权人员"
site_name = str(record.site_name or "").strip()
target_name = f"{name} / {site_name}" if site_name else name
return json.dumps({"targetName": target_name, "description": f"{action}培训授权人员“{target_name}"}, ensure_ascii=False)
@router.post(
"/feasibility",
response_model=StartupFeasibilityRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_member())],
dependencies=[Depends(require_api_permission("startup_initiation:create"))],
)
async def create_feasibility(
study_id: uuid.UUID,
@@ -73,9 +109,9 @@ async def create_feasibility(
entity_type="startup_feasibility",
entity_id=record.id,
action="CREATE_STARTUP_FEASIBILITY",
detail="立项记录已创建",
detail=await _feasibility_audit_detail(db, "创建", record),
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
return StartupFeasibilityRead.model_validate(record)
@@ -83,7 +119,7 @@ async def create_feasibility(
@router.get(
"/feasibility",
response_model=list[StartupFeasibilityRead],
dependencies=[Depends(require_study_member())],
dependencies=[Depends(require_api_permission("startup_initiation:read"))],
)
async def list_feasibilities(
study_id: uuid.UUID,
@@ -102,7 +138,7 @@ async def list_feasibilities(
@router.get(
"/feasibility/{record_id}",
response_model=StartupFeasibilityRead,
dependencies=[Depends(require_study_member())],
dependencies=[Depends(require_api_permission("startup_initiation:read"))],
)
async def get_feasibility(
study_id: uuid.UUID,
@@ -123,7 +159,7 @@ async def get_feasibility(
@router.patch(
"/feasibility/{record_id}",
response_model=StartupFeasibilityRead,
dependencies=[Depends(require_study_member())],
dependencies=[Depends(require_api_permission("startup_initiation:update"))],
)
async def update_feasibility(
study_id: uuid.UUID,
@@ -147,9 +183,9 @@ async def update_feasibility(
entity_type="startup_feasibility",
entity_id=record_id,
action="UPDATE_STARTUP_FEASIBILITY",
detail="立项记录已更新",
detail=await _feasibility_audit_detail(db, "更新", record),
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
return StartupFeasibilityRead.model_validate(record)
@@ -157,7 +193,7 @@ async def update_feasibility(
@router.delete(
"/feasibility/{record_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_study_member())],
dependencies=[Depends(require_api_permission("startup_initiation:delete"))],
)
async def delete_feasibility(
study_id: uuid.UUID,
@@ -173,6 +209,7 @@ async def delete_feasibility(
if cra_scope and record.site_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_active(db, record.site_id)
record_detail = await _feasibility_audit_detail(db, "删除", record)
await startup_crud.delete_feasibility(db, record)
await audit_crud.log_action(
db,
@@ -180,9 +217,9 @@ async def delete_feasibility(
entity_type="startup_feasibility",
entity_id=record_id,
action="DELETE_STARTUP_FEASIBILITY",
detail="立项记录已删除",
detail=record_detail,
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
@@ -190,7 +227,7 @@ async def delete_feasibility(
"/ethics",
response_model=StartupEthicsRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_member())],
dependencies=[Depends(require_api_permission("startup_ethics:create"))],
)
async def create_ethics(
study_id: uuid.UUID,
@@ -210,9 +247,9 @@ async def create_ethics(
entity_type="startup_ethics",
entity_id=record.id,
action="CREATE_STARTUP_ETHICS",
detail="伦理记录已创建",
detail=await _ethics_audit_detail(db, "创建", record),
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
return StartupEthicsRead.model_validate(record)
@@ -220,7 +257,7 @@ async def create_ethics(
@router.get(
"/ethics",
response_model=list[StartupEthicsRead],
dependencies=[Depends(require_study_member())],
dependencies=[Depends(require_api_permission("startup_ethics:read"))],
)
async def list_ethics(
study_id: uuid.UUID,
@@ -239,7 +276,7 @@ async def list_ethics(
@router.get(
"/ethics/{record_id}",
response_model=StartupEthicsRead,
dependencies=[Depends(require_study_member())],
dependencies=[Depends(require_api_permission("startup_ethics:read"))],
)
async def get_ethics(
study_id: uuid.UUID,
@@ -260,7 +297,7 @@ async def get_ethics(
@router.patch(
"/ethics/{record_id}",
response_model=StartupEthicsRead,
dependencies=[Depends(require_study_member())],
dependencies=[Depends(require_api_permission("startup_ethics:update"))],
)
async def update_ethics(
study_id: uuid.UUID,
@@ -284,9 +321,9 @@ async def update_ethics(
entity_type="startup_ethics",
entity_id=record_id,
action="UPDATE_STARTUP_ETHICS",
detail="伦理记录已更新",
detail=await _ethics_audit_detail(db, "更新", record),
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
return StartupEthicsRead.model_validate(record)
@@ -294,7 +331,7 @@ async def update_ethics(
@router.delete(
"/ethics/{record_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_study_member())],
dependencies=[Depends(require_api_permission("startup_ethics:delete"))],
)
async def delete_ethics(
study_id: uuid.UUID,
@@ -310,6 +347,7 @@ async def delete_ethics(
if cra_scope and record.site_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_active(db, record.site_id)
record_detail = await _ethics_audit_detail(db, "删除", record)
await startup_crud.delete_ethics(db, record)
await audit_crud.log_action(
db,
@@ -317,9 +355,9 @@ async def delete_ethics(
entity_type="startup_ethics",
entity_id=record_id,
action="DELETE_STARTUP_ETHICS",
detail="伦理记录已删除",
detail=record_detail,
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
@@ -327,7 +365,7 @@ async def delete_ethics(
"/kickoff",
response_model=KickoffMeetingRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_member())],
dependencies=[Depends(require_api_permission("startup_auth:create"))],
)
async def create_kickoff(
study_id: uuid.UUID,
@@ -347,9 +385,9 @@ async def create_kickoff(
entity_type="startup_kickoff",
entity_id=meeting.id,
action="CREATE_KICKOFF_MEETING",
detail="启动会记录已创建",
detail=await _kickoff_audit_detail(db, "创建", meeting),
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
return KickoffMeetingRead.model_validate(meeting)
@@ -357,7 +395,7 @@ async def create_kickoff(
@router.get(
"/kickoff",
response_model=list[KickoffMeetingRead],
dependencies=[Depends(require_study_member())],
dependencies=[Depends(require_api_permission("startup_auth:read"))],
)
async def list_kickoffs(
study_id: uuid.UUID,
@@ -376,7 +414,7 @@ async def list_kickoffs(
@router.get(
"/kickoff/{meeting_id}",
response_model=KickoffMeetingRead,
dependencies=[Depends(require_study_member())],
dependencies=[Depends(require_api_permission("startup_auth:read"))],
)
async def get_kickoff(
study_id: uuid.UUID,
@@ -397,7 +435,7 @@ async def get_kickoff(
@router.patch(
"/kickoff/{meeting_id}",
response_model=KickoffMeetingRead,
dependencies=[Depends(require_study_member())],
dependencies=[Depends(require_api_permission("startup_auth:update"))],
)
async def update_kickoff(
study_id: uuid.UUID,
@@ -421,9 +459,9 @@ async def update_kickoff(
entity_type="startup_kickoff",
entity_id=meeting_id,
action="UPDATE_KICKOFF_MEETING",
detail="启动会记录已更新",
detail=await _kickoff_audit_detail(db, "更新", meeting),
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
return KickoffMeetingRead.model_validate(meeting)
@@ -432,7 +470,7 @@ async def update_kickoff(
"/training-authorizations",
response_model=TrainingAuthorizationRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_member())],
dependencies=[Depends(require_api_permission("startup_auth:create"))],
)
async def create_training_authorization(
study_id: uuid.UUID,
@@ -452,9 +490,9 @@ async def create_training_authorization(
entity_type="training_authorization",
entity_id=record.id,
action="CREATE_TRAINING_AUTH",
detail="培训授权人员已创建",
detail=_training_audit_detail("创建", record),
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
return TrainingAuthorizationRead.model_validate(record)
@@ -462,26 +500,27 @@ async def create_training_authorization(
@router.get(
"/training-authorizations",
response_model=list[TrainingAuthorizationRead],
dependencies=[Depends(require_study_member())],
dependencies=[Depends(require_api_permission("startup_auth:read"))],
)
async def list_training_authorizations(
study_id: uuid.UUID,
skip: int = 0,
limit: int = 200,
site_name: str | None = None,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[TrainingAuthorizationRead]:
await _ensure_study_exists(db, study_id)
cra_scope = await get_cra_site_scope(db, study_id, current_user)
site_names = cra_scope[1] if cra_scope else None
items = await startup_crud.list_training_authorizations(db, study_id, skip=skip, limit=limit, site_names=site_names)
items = await startup_crud.list_training_authorizations(db, study_id, skip=skip, limit=limit, site_names=site_names, site_name=site_name)
return [TrainingAuthorizationRead.model_validate(item) for item in items]
@router.get(
"/training-authorizations/{record_id}",
response_model=TrainingAuthorizationRead,
dependencies=[Depends(require_study_member())],
dependencies=[Depends(require_api_permission("startup_auth:read"))],
)
async def get_training_authorization(
study_id: uuid.UUID,
@@ -502,7 +541,7 @@ async def get_training_authorization(
@router.patch(
"/training-authorizations/{record_id}",
response_model=TrainingAuthorizationRead,
dependencies=[Depends(require_study_member())],
dependencies=[Depends(require_api_permission("startup_auth:update"))],
)
async def update_training_authorization(
study_id: uuid.UUID,
@@ -526,9 +565,9 @@ async def update_training_authorization(
entity_type="training_authorization",
entity_id=record_id,
action="UPDATE_TRAINING_AUTH",
detail="培训授权人员已更新",
detail=_training_audit_detail("更新", record),
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
return TrainingAuthorizationRead.model_validate(record)
@@ -536,7 +575,7 @@ async def update_training_authorization(
@router.delete(
"/training-authorizations/{record_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_study_member())],
dependencies=[Depends(require_api_permission("startup_auth:delete"))],
)
async def delete_training_authorization(
study_id: uuid.UUID,
@@ -552,6 +591,7 @@ async def delete_training_authorization(
if cra_scope and record.site_name not in cra_scope[1]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_name_active(db, study_id, record.site_name)
record_detail = _training_audit_detail("删除", record)
await startup_crud.delete_training_authorization(db, record)
await audit_crud.log_action(
db,
@@ -559,7 +599,7 @@ async def delete_training_authorization(
entity_type="training_authorization",
entity_id=record_id,
action="DELETE_TRAINING_AUTH",
detail="培训授权人员已删除",
detail=record_detail,
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
+200 -111
View File
@@ -10,6 +10,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import (
get_current_user,
get_db_session,
get_operator_role_label,
is_system_admin,
require_api_permission,
require_roles,
require_study_member,
require_study_not_locked,
@@ -42,6 +45,13 @@ from app.utils.pagination import paginate
router = APIRouter()
def _study_read_with_role(study, role_in_study: str | None = None) -> StudyRead:
data = StudyRead.model_validate(study)
data.role_in_study = role_in_study
return data
def _raise_validation_error(errors: list[dict[str, str]]) -> None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
@@ -196,19 +206,6 @@ def _validate_setup_data(
if total_site_target > plan.totalTarget:
errors.append({"field": "siteEnrollmentPlans", "message": "中心计划总例数不能超过项目总入组例数"})
for index, row in enumerate(payload.monitoringStrategies):
row_prefix = f"monitoringStrategies[{index}]"
if not row.id:
errors.append({"field": f"{row_prefix}.id", "message": "ID不能为空"})
if _is_empty_row([row.strategyType, row.detail, row.frequency]):
continue
if row.strategyType not in allowed_strategy_types:
errors.append({"field": f"{row_prefix}.strategyType", "message": "监查类型不合法"})
if not row.detail.strip():
errors.append({"field": f"{row_prefix}.detail", "message": "策略详情不能为空"})
if row.frequency not in {"不限", "按触发", "每月1次"} and re.fullmatch(r"\d+次", row.frequency) is None:
errors.append({"field": f"{row_prefix}.frequency", "message": "监查次数格式应为“不限”/“按触发”/“每月1次”或“N次”"})
for index, row in enumerate(payload.centerConfirm):
row_prefix = f"centerConfirm[{index}]"
if not row.id:
@@ -242,6 +239,7 @@ def _build_default_setup_config_from_study(study, sites: list) -> StudySetupConf
if total_target is None:
total_target = 0
return StudySetupConfigData(
projectInfo=_build_project_publish_snapshot(study),
projectMilestones=[],
enrollmentPlan={
"totalTarget": total_target,
@@ -252,7 +250,6 @@ def _build_default_setup_config_from_study(study, sites: list) -> StudySetupConf
},
siteMilestones=[],
siteEnrollmentPlans=[],
monitoringStrategies=[],
centerConfirm=[],
)
@@ -283,16 +280,64 @@ def _build_project_publish_snapshot(study) -> ProjectPublishSnapshot:
plan_end_date=_to_date_text(getattr(study, "plan_end_date", None)),
planned_site_count=getattr(study, "planned_site_count", None),
planned_enrollment_count=getattr(study, "planned_enrollment_count", None),
summary_note=getattr(study, "summary_note", None) or "",
objective_note=getattr(study, "objective_note", None) or "",
status=getattr(study, "status", None) or "",
visit_interval_days=getattr(study, "visit_interval_days", None),
visit_total=getattr(study, "visit_total", None),
visit_window_start_offset=getattr(study, "visit_window_start_offset", None),
visit_window_end_offset=getattr(study, "visit_window_end_offset", None),
visit_schedule=getattr(study, "visit_schedule", None) or [],
)
def _resolve_setup_project_snapshot(setup_data: StudySetupConfigData, study) -> ProjectPublishSnapshot:
return setup_data.projectInfo
def _parse_optional_snapshot_date(value: str) -> date | None:
text = (value or "").strip()
if not text:
return None
return date.fromisoformat(text)
def _normalize_optional_snapshot_text(value: str | None) -> str | None:
if value is None:
return None
text = value.strip()
return text or None
def _snapshot_visit_schedule(snapshot: ProjectPublishSnapshot) -> list[dict[str, int | str]]:
return [item.model_dump(mode="json") for item in snapshot.visit_schedule]
def _apply_project_publish_snapshot_to_study(study, snapshot: ProjectPublishSnapshot) -> bool:
next_values = {
"code": snapshot.code.strip(),
"name": snapshot.name.strip(),
"project_full_name": _normalize_optional_snapshot_text(snapshot.project_full_name),
"sponsor": _normalize_optional_snapshot_text(snapshot.sponsor),
"protocol_no": _normalize_optional_snapshot_text(snapshot.protocol_no),
"lead_unit": _normalize_optional_snapshot_text(snapshot.lead_unit),
"principal_investigator": _normalize_optional_snapshot_text(snapshot.principal_investigator),
"main_pm": _normalize_optional_snapshot_text(snapshot.main_pm),
"research_analysis": _normalize_optional_snapshot_text(snapshot.research_analysis),
"research_product": _normalize_optional_snapshot_text(snapshot.research_product),
"control_product": _normalize_optional_snapshot_text(snapshot.control_product),
"indication": _normalize_optional_snapshot_text(snapshot.indication),
"research_population": _normalize_optional_snapshot_text(snapshot.research_population),
"research_design": _normalize_optional_snapshot_text(snapshot.research_design),
"plan_start_date": _parse_optional_snapshot_date(snapshot.plan_start_date),
"plan_end_date": _parse_optional_snapshot_date(snapshot.plan_end_date),
"planned_site_count": snapshot.planned_site_count,
"planned_enrollment_count": snapshot.planned_enrollment_count,
"status": snapshot.status or "DRAFT",
"visit_schedule": _snapshot_visit_schedule(snapshot),
}
changed = False
for field, value in next_values.items():
if getattr(study, field, None) != value:
setattr(study, field, value)
changed = True
return changed
def _to_setup_config_read(
record,
*,
@@ -360,17 +405,66 @@ def _ensure_study_timeline_valid(
*,
plan_start_date: date | None,
plan_end_date: date | None,
visit_window_start_offset: int | None,
visit_window_end_offset: int | None,
) -> None:
if plan_start_date and plan_end_date and plan_start_date > plan_end_date:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="项目计划结束日期不能早于开始日期")
if (
visit_window_start_offset is not None
and visit_window_end_offset is not None
and visit_window_start_offset > visit_window_end_offset
):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="访视窗口开始偏移不能晚于结束偏移")
async def _validate_setup_project_snapshot(
db: AsyncSession,
*,
study_id: uuid.UUID,
snapshot: ProjectPublishSnapshot,
strict_required: bool = True,
) -> None:
errors: list[dict[str, str]] = []
code = snapshot.code.strip()
name = snapshot.name.strip()
if strict_required and not code:
errors.append({"field": "projectInfo.code", "message": "项目编号不能为空"})
if strict_required and not name:
errors.append({"field": "projectInfo.name", "message": "项目名称不能为空"})
try:
plan_start = _parse_optional_snapshot_date(snapshot.plan_start_date)
plan_end = _parse_optional_snapshot_date(snapshot.plan_end_date)
except ValueError:
errors.append({"field": "projectInfo.plan_start_date", "message": "项目计划日期格式应为 YYYY-MM-DD"})
plan_start = None
plan_end = None
if plan_start and plan_end and plan_start > plan_end:
errors.append({"field": "projectInfo.plan_end_date", "message": "项目计划结束日期不能早于开始日期"})
seen_visit_codes: set[str] = set()
for index, item in enumerate(snapshot.visit_schedule):
row_prefix = f"projectInfo.visit_schedule[{index}]"
visit_code = item.visit_code.strip()
has_visit_values = bool(visit_code) or any(
value != 0
for value in (item.baseline_offset_days, item.window_before_days, item.window_after_days)
)
if strict_required and not visit_code:
errors.append({"field": f"{row_prefix}.visit_code", "message": "访视编号不能为空"})
if not has_visit_values:
continue
if visit_code:
if len(visit_code) > 50:
errors.append({"field": f"{row_prefix}.visit_code", "message": "访视编号不能超过50个字符"})
if visit_code in seen_visit_codes:
errors.append({"field": f"{row_prefix}.visit_code", "message": f"访视编号重复:{visit_code}"})
seen_visit_codes.add(visit_code)
if item.baseline_offset_days < 0 or item.baseline_offset_days > 3650:
errors.append({"field": f"{row_prefix}.baseline_offset_days", "message": "基线偏移天数应在0到3650之间"})
if item.window_before_days < 0 or item.window_before_days > 365:
errors.append({"field": f"{row_prefix}.window_before_days", "message": "窗口前天数应在0到365之间"})
if item.window_after_days < 0 or item.window_after_days > 365:
errors.append({"field": f"{row_prefix}.window_after_days", "message": "窗口后天数应在0到365之间"})
if errors:
_raise_validation_error(errors)
if code:
existing = await study_crud.get_by_code(db, code)
if existing and existing.id != study_id:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="项目编号已存在")
def _resolve_version_label(record: Any) -> str:
@@ -504,7 +598,6 @@ _SETUP_MODULE_KEYS = (
"enrollmentPlan",
"siteMilestones",
"siteEnrollmentPlans",
"monitoringStrategies",
"centerConfirm",
)
@@ -513,12 +606,10 @@ _SETUP_MODULE_LABELS = {
"enrollmentPlan": "项目入组计划",
"siteMilestones": "中心里程碑",
"siteEnrollmentPlans": "中心入组计划",
"monitoringStrategies": "监查策略",
"centerConfirm": "中心确认",
}
_SETUP_FIELD_LABELS = {
"id": "ID",
"name": "名称",
"milestone": "里程碑",
"planDate": "计划日期",
@@ -537,16 +628,13 @@ _SETUP_FIELD_LABELS = {
"siteName": "中心名称",
"target": "计划例数",
"note": "备注",
"strategyType": "监查类型",
"detail": "策略详情",
"frequency": "监查频次",
"updatedAt": "更新时间",
"enabled": "启用状态",
"confirmer": "确认人",
"confirmStatus": "确认状态",
"confirmDate": "确认日期",
}
_SETUP_TECHNICAL_FIELDS = {"id"}
def _setup_value_text(value: Any) -> str:
if value is None:
@@ -578,8 +666,6 @@ def _setup_row_identity(module_key: str, row: Any, index: int) -> str:
return str(row.get("milestone") or "").strip() or f"{index + 1}"
if module_key in {"siteEnrollmentPlans", "centerConfirm"}:
return str(row.get("siteName") or row.get("siteId") or "").strip() or f"{index + 1}"
if module_key == "monitoringStrategies":
return str(row.get("strategyType") or "").strip() or f"{index + 1}"
return f"{index + 1}"
@@ -605,6 +691,8 @@ def _setup_collect_diff_lines(
new_dict = new_value if isinstance(new_value, dict) else {}
keys = sorted(set(old_dict.keys()) | set(new_dict.keys()))
for key in keys:
if key in _SETUP_TECHNICAL_FIELDS:
continue
_setup_collect_diff_lines(module_key, old_dict.get(key), new_dict.get(key), [*path, key], lines)
return
@@ -633,11 +721,7 @@ def _top_level_diff_summary(old: dict | None, new: dict | None) -> str:
if not detail_lines:
return module_summary
max_lines = 20
visible_lines = detail_lines[:max_lines]
if len(detail_lines) > max_lines:
visible_lines.append(f"其余 {len(detail_lines) - max_lines} 项变更已省略")
return f"{module_summary}; 变更明细:" + "".join(visible_lines)
return f"{module_summary}; 变更明细:" + "".join(detail_lines)
@router.post("/", response_model=StudyRead, status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_roles(["ADMIN"]))])
@@ -649,8 +733,6 @@ async def create_study(
_ensure_study_timeline_valid(
plan_start_date=study_in.plan_start_date,
plan_end_date=study_in.plan_end_date,
visit_window_start_offset=study_in.visit_window_start_offset,
visit_window_end_offset=study_in.visit_window_end_offset,
)
study_in.code = study_in.code.strip()
if not study_in.code:
@@ -685,7 +767,7 @@ async def create_study(
action="CREATE_SETUP_CONFIG",
detail="初始化立项配置(创建项目时回填基础信息)",
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
return study
@@ -698,30 +780,39 @@ async def list_studies(
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> PaginatedResponse[StudyRead]:
if current_user.role == "ADMIN":
if is_system_admin(current_user):
studies = await study_crud.list_studies(db, skip=skip, limit=limit)
total = await study_crud.list_studies(db, skip=0, limit=10_000_000)
items = [_study_read_with_role(study) for study in studies]
else:
studies = await study_crud.list_studies_for_user(db, current_user.id, skip=skip, limit=limit)
total = await study_crud.list_studies_for_user(db, current_user.id, skip=0, limit=10_000_000)
return paginate(list(studies), total=len(total))
items = []
for study in studies:
member = await member_crud.get_member(db, study.id, current_user.id)
items.append(_study_read_with_role(study, member.role_in_study if member else None))
return paginate(items, total=len(total))
@router.get("/{study_id}", response_model=StudyRead, dependencies=[Depends(require_study_member())])
async def get_study(
study_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> StudyRead:
study = await study_crud.get(db, study_id)
if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
return study
if is_system_admin(current_user):
return _study_read_with_role(study)
member = await member_crud.get_member(db, study_id, current_user.id)
return _study_read_with_role(study, member.role_in_study if member else None)
@router.patch(
"/{study_id}",
response_model=StudyRead,
dependencies=[Depends(require_study_roles(["PM"]))],
dependencies=[Depends(require_roles(["ADMIN"]))],
)
async def update_study(
study_id: uuid.UUID,
@@ -746,21 +837,9 @@ async def update_study(
next_plan_start = study_in.plan_start_date if "plan_start_date" in study_in.model_fields_set else study.plan_start_date
next_plan_end = study_in.plan_end_date if "plan_end_date" in study_in.model_fields_set else study.plan_end_date
next_window_start = (
study_in.visit_window_start_offset
if "visit_window_start_offset" in study_in.model_fields_set
else study.visit_window_start_offset
)
next_window_end = (
study_in.visit_window_end_offset
if "visit_window_end_offset" in study_in.model_fields_set
else study.visit_window_end_offset
)
_ensure_study_timeline_valid(
plan_start_date=next_plan_start,
plan_end_date=next_plan_end,
visit_window_start_offset=next_window_start,
visit_window_end_offset=next_window_end,
)
updated = await study_crud.update(db, study, study_in)
@@ -812,7 +891,7 @@ async def lock_study(
action="LOCK_STUDY",
detail=f"项目已锁定:{study.name} ({study.code})",
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
return locked_study
@@ -844,7 +923,7 @@ async def unlock_study(
action="UNLOCK_STUDY",
detail=f"项目已解锁:{study.name} ({study.code})",
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
return unlocked_study
@@ -853,7 +932,7 @@ async def unlock_study(
@router.get(
"/{study_id}/setup-config",
response_model=StudySetupConfigRead,
dependencies=[Depends(require_study_member())],
dependencies=[Depends(require_api_permission("setup_config:read"))],
)
async def get_study_setup_config(
study_id: uuid.UUID,
@@ -883,7 +962,7 @@ async def get_study_setup_config(
action="CREATE_SETUP_CONFIG",
detail="初始化立项配置",
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
saved_by_name = None
@@ -910,7 +989,7 @@ async def get_study_setup_config(
@router.put(
"/{study_id}/setup-config",
response_model=StudySetupConfigRead,
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
dependencies=[Depends(require_api_permission("setup_config:update")), Depends(require_study_not_locked())],
)
async def upsert_study_setup_config(
study_id: uuid.UUID,
@@ -924,18 +1003,23 @@ async def upsert_study_setup_config(
sites = await site_crud.list_by_study(db, study_id, skip=0, limit=1000, include_inactive=True)
site_lookup = {str(site.id): site.name or "" for site in sites}
setup_project_snapshot = _resolve_setup_project_snapshot(payload.data, study)
await _validate_setup_project_snapshot(db, study_id=study_id, snapshot=setup_project_snapshot, strict_required=False)
setup_plan_start = _parse_optional_snapshot_date(setup_project_snapshot.plan_start_date)
setup_plan_end = _parse_optional_snapshot_date(setup_project_snapshot.plan_end_date)
_validate_setup_data(
payload.data,
site_lookup,
project_plan_start=getattr(study, "plan_start_date", None),
project_plan_end=getattr(study, "plan_end_date", None),
project_plan_start=setup_plan_start,
project_plan_end=setup_plan_end,
strict_required=False,
)
existing = await study_setup_config_crud.get_by_study(db, study_id)
old_config = dict(existing.config or {}) if existing else {}
force_draft = bool(payload.force_draft)
if existing and existing.publish_status == "PUBLISHED" and existing.published_project_snapshot:
current_project_snapshot = _build_project_publish_snapshot(study).model_dump(mode="json")
current_project_snapshot = setup_project_snapshot.model_dump(mode="json")
if current_project_snapshot != dict(existing.published_project_snapshot or {}):
force_draft = True
record, conflict = await study_setup_config_crud.upsert(
@@ -961,7 +1045,7 @@ async def upsert_study_setup_config(
action="UPDATE_SETUP_CONFIG",
detail=_top_level_diff_summary(old_config, record.config),
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
saved_by_name = current_user.full_name or current_user.username or current_user.email
@@ -983,7 +1067,7 @@ async def upsert_study_setup_config(
@router.post(
"/{study_id}/setup-config/publish",
response_model=StudySetupConfigRead,
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
dependencies=[Depends(require_api_permission("setup_config:publish")), Depends(require_study_not_locked())],
)
async def publish_study_setup_config(
study_id: uuid.UUID,
@@ -998,7 +1082,12 @@ async def publish_study_setup_config(
record = await study_setup_config_crud.get_by_study(db, study_id)
if not record:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="请先保存立项配置草稿")
current_project_snapshot_for_publish = _build_project_publish_snapshot(study).model_dump(mode="json")
setup_data = StudySetupConfigData.model_validate(record.config or {})
project_snapshot_for_publish = _resolve_setup_project_snapshot(setup_data, study)
await _validate_setup_project_snapshot(db, study_id=study_id, snapshot=project_snapshot_for_publish)
setup_plan_start = _parse_optional_snapshot_date(project_snapshot_for_publish.plan_start_date)
setup_plan_end = _parse_optional_snapshot_date(project_snapshot_for_publish.plan_end_date)
current_project_snapshot_for_publish = project_snapshot_for_publish.model_dump(mode="json")
force_create_snapshot = bool(
(record.published_project_snapshot or {}) != current_project_snapshot_for_publish
)
@@ -1006,10 +1095,10 @@ async def publish_study_setup_config(
sites = await site_crud.list_by_study(db, study_id, skip=0, limit=1000, include_inactive=True)
site_lookup = {str(site.id): site.name or "" for site in sites}
_validate_setup_data(
StudySetupConfigData.model_validate(record.config or {}),
setup_data,
site_lookup,
project_plan_start=getattr(study, "plan_start_date", None),
project_plan_end=getattr(study, "plan_end_date", None),
project_plan_start=setup_plan_start,
project_plan_end=setup_plan_end,
)
published, conflict = await study_setup_config_crud.publish(
@@ -1028,6 +1117,7 @@ async def publish_study_setup_config(
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="配置发布失败")
try:
_apply_project_publish_snapshot_to_study(study, project_snapshot_for_publish)
projection = await apply_setup_projection_on_publish(
db,
study_id=study_id,
@@ -1044,16 +1134,14 @@ async def publish_study_setup_config(
for item in projection.skipped_items
],
)
study_after_publish = await study_crud.get(db, study_id)
if study_after_publish:
project_snapshot = _build_project_publish_snapshot(study_after_publish).model_dump(mode="json")
published.published_project_snapshot = project_snapshot
await study_setup_config_crud.set_version_project_snapshot(
db,
study_id,
version=published.version,
project_snapshot=project_snapshot,
)
project_snapshot = project_snapshot_for_publish.model_dump(mode="json")
published.published_project_snapshot = project_snapshot
await study_setup_config_crud.set_version_project_snapshot(
db,
study_id,
version=published.version,
project_snapshot=project_snapshot,
)
await audit_crud.log_action(
db,
study_id=study_id,
@@ -1069,7 +1157,7 @@ async def publish_study_setup_config(
skipped_items=[{"site_id": item.site_id, "reason": item.reason} for item in projection.skipped_items],
),
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, study_id, current_user),
auto_commit=False,
)
published.config = study_setup_config_crud.empty_draft_payload()
@@ -1098,7 +1186,7 @@ async def publish_study_setup_config(
@router.get(
"/{study_id}/setup-config/versions",
response_model=list[StudySetupConfigVersionRead],
dependencies=[Depends(require_study_member())],
dependencies=[Depends(require_api_permission("setup_config:read"))],
)
async def list_study_setup_config_versions(
study_id: uuid.UUID,
@@ -1132,7 +1220,7 @@ async def list_study_setup_config_versions(
@router.post(
"/{study_id}/setup-config/rollback",
response_model=StudySetupConfigRead,
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
dependencies=[Depends(require_api_permission("setup_config:rollback")), Depends(require_study_not_locked())],
)
async def rollback_study_setup_config(
study_id: uuid.UUID,
@@ -1169,7 +1257,7 @@ async def rollback_study_setup_config(
action="ROLLBACK_SETUP_CONFIG",
detail=f"立项配置已回滚并替换当前发布为 {target_label},草稿分支已切换到对应分支基线",
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
operator_name = current_user.full_name or current_user.username or current_user.email
published_by_name = None
@@ -1190,7 +1278,7 @@ async def rollback_study_setup_config(
@router.post(
"/{study_id}/setup-config/draft/checkout-branch",
response_model=StudySetupConfigRead,
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
dependencies=[Depends(require_api_permission("setup_config:update")), Depends(require_study_not_locked())],
)
async def checkout_study_setup_config_branch_draft(
study_id: uuid.UUID,
@@ -1228,7 +1316,7 @@ async def checkout_study_setup_config_branch_draft(
action="CHECKOUT_SETUP_CONFIG_BRANCH_DRAFT",
detail=f"立项配置草稿已切换到 {target_label} 对应分支",
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
operator_name = current_user.full_name or current_user.username or current_user.email
published_by_name = None
@@ -1249,7 +1337,7 @@ async def checkout_study_setup_config_branch_draft(
@router.post(
"/{study_id}/setup-config/draft/clear",
response_model=StudySetupConfigRead,
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
dependencies=[Depends(require_api_permission("setup_config:update")), Depends(require_study_not_locked())],
)
async def clear_study_setup_config_draft(
study_id: uuid.UUID,
@@ -1280,7 +1368,7 @@ async def clear_study_setup_config_draft(
action="CLEAR_SETUP_CONFIG_DRAFT",
detail="立项配置草稿已清空",
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
operator_name = current_user.full_name or current_user.username or current_user.email
published_by_name = None
@@ -1301,7 +1389,7 @@ async def clear_study_setup_config_draft(
@router.post(
"/{study_id}/setup-config/draft/refill",
response_model=StudySetupConfigRead,
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
dependencies=[Depends(require_api_permission("setup_config:update")), Depends(require_study_not_locked())],
)
async def refill_study_setup_config_draft(
study_id: uuid.UUID,
@@ -1334,7 +1422,7 @@ async def refill_study_setup_config_draft(
action="REFILL_SETUP_CONFIG_DRAFT",
detail="立项配置草稿已从当前发布版本一键回填",
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
operator_name = current_user.full_name or current_user.username or current_user.email
published_by_name = None
@@ -1355,7 +1443,7 @@ async def refill_study_setup_config_draft(
@router.post(
"/{study_id}/setup-config/merge-main",
response_model=StudySetupConfigRead,
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
dependencies=[Depends(require_api_permission("setup_config:publish")), Depends(require_study_not_locked())],
)
async def merge_study_setup_config_to_main(
study_id: uuid.UUID,
@@ -1389,10 +1477,13 @@ async def merge_study_setup_config_to_main(
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="合并主分支失败")
try:
merged_setup_data = StudySetupConfigData.model_validate(merged.published_config or {})
merged_project_snapshot = _resolve_setup_project_snapshot(merged_setup_data, study)
_apply_project_publish_snapshot_to_study(study, merged_project_snapshot)
projection = await apply_setup_projection_on_publish(
db,
study_id=study_id,
setup_data=StudySetupConfigData.model_validate(merged.published_config or {}),
setup_data=merged_setup_data,
operator_id=current_user.id,
)
projection_summary = SetupProjectionSummary(
@@ -1402,16 +1493,14 @@ async def merge_study_setup_config_to_main(
warnings=projection.warnings,
skipped_items=[{"site_id": item.site_id, "reason": item.reason} for item in projection.skipped_items],
)
study_after_publish = await study_crud.get(db, study_id)
if study_after_publish:
project_snapshot = _build_project_publish_snapshot(study_after_publish).model_dump(mode="json")
merged.published_project_snapshot = project_snapshot
await study_setup_config_crud.set_version_project_snapshot(
db,
study_id,
version=merged.version,
project_snapshot=project_snapshot,
)
project_snapshot = merged_project_snapshot.model_dump(mode="json")
merged.published_project_snapshot = project_snapshot
await study_setup_config_crud.set_version_project_snapshot(
db,
study_id,
version=merged.version,
project_snapshot=project_snapshot,
)
await audit_crud.log_action(
db,
study_id=study_id,
@@ -1420,7 +1509,7 @@ async def merge_study_setup_config_to_main(
action="MERGE_SETUP_CONFIG_TO_MAIN",
detail=f"已将发布版本 {source_label} 合并到主分支并生成新主版本",
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, study_id, current_user),
auto_commit=False,
)
merged.config = study_setup_config_crud.empty_draft_payload()
@@ -1449,7 +1538,7 @@ async def merge_study_setup_config_to_main(
@router.delete(
"/{study_id}/setup-config/versions/{target_version}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
dependencies=[Depends(require_api_permission("setup_config:delete_version")), Depends(require_study_not_locked())],
)
async def delete_study_setup_config_version(
study_id: uuid.UUID,
@@ -1487,5 +1576,5 @@ async def delete_study_setup_config_version(
action="DELETE_SETUP_CONFIG_VERSION",
detail=f"删除发布版本快照 v{target_version}",
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
+98
View File
@@ -0,0 +1,98 @@
"""项目角色生效管理API"""
from __future__ import annotations
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db_session, is_system_admin, require_study_roles
from app.models.study import Study
from app.models.study_member import StudyMember
router = APIRouter(prefix="/active-roles", tags=["active-roles"])
def _normalize_active_roles(value: object, *, preserve_pm: bool = False) -> list[str]:
if not isinstance(value, list):
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="active_roles 必须是数组")
roles: list[str] = []
for item in value:
role = str(item or "").strip()
if not role:
continue
if role == "ADMIN":
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="ADMIN 不能作为项目角色")
if len(role) > 20:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="项目角色长度不能超过20个字符")
if role not in roles:
roles.append(role)
if preserve_pm and "PM" not in roles:
roles.insert(0, "PM")
return roles
async def _ensure_removed_roles_unused(
db: AsyncSession,
study_id: uuid.UUID,
current_roles: object,
next_roles: list[str],
) -> None:
if not isinstance(current_roles, list):
return
removed_roles = [role for role in current_roles if isinstance(role, str) and role not in next_roles]
if not removed_roles:
return
result = await db.execute(
select(StudyMember.role_in_study)
.where(
StudyMember.study_id == study_id,
StudyMember.is_active.is_(True),
StudyMember.role_in_study.in_(removed_roles),
)
.order_by(StudyMember.role_in_study)
)
role_in_use = result.scalars().first()
if role_in_use:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"角色 {role_in_use} 仍有成员使用,不能停用",
)
@router.get("", summary="获取项目已生效角色列表")
async def get_active_roles(
study_id: uuid.UUID,
_=Depends(require_study_roles(["PM"])),
db: Annotated[AsyncSession, Depends(get_db_session)] = None,
) -> dict:
result = await db.execute(select(Study).where(Study.id == study_id))
study = result.scalar_one_or_none()
if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
return {"active_roles": study.active_roles or []}
@router.put("", summary="更新项目已生效角色列表")
async def update_active_roles(
study_id: uuid.UUID,
payload: dict,
current_user=Depends(require_study_roles(["PM"])),
db: Annotated[AsyncSession, Depends(get_db_session)] = None,
) -> dict:
result = await db.execute(select(Study).where(Study.id == study_id))
study = result.scalar_one_or_none()
if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
active_roles = _normalize_active_roles(
payload.get("active_roles", []),
preserve_pm=not is_system_admin(current_user),
)
await _ensure_removed_roles_unused(db, study_id, study.active_roles, active_roles)
study.active_roles = active_roles
await db.commit()
return {"active_roles": study.active_roles}
+2 -2
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_member
from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_api_permission
from app.crud import study as study_crud
from app.crud import subject_pd as subject_pd_crud
from app.schemas.subject_pd import SubjectPdSummaryRead
@@ -21,7 +21,7 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
@router.get(
"/subject-pds",
response_model=list[SubjectPdSummaryRead],
dependencies=[Depends(require_study_member())],
dependencies=[Depends(require_api_permission("subject_pds:list"))],
)
async def list_study_subject_pds(
study_id: uuid.UUID,
+34 -12
View File
@@ -1,9 +1,10 @@
import uuid
import json
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles, require_study_not_locked
from app.core.deps import get_operator_role_label, 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
@@ -14,6 +15,26 @@ from app.schemas.subject_history import SubjectHistoryCreate, SubjectHistoryRead
router = APIRouter()
def _compact_text(value: str | None, max_length: int = 30) -> str:
text = " ".join(str(value or "").split())
if len(text) <= max_length:
return text
return f"{text[:max_length]}..."
def _history_audit_name(subject, history) -> str:
subject_no = getattr(subject, "subject_no", None) or "参与者"
record_date = getattr(history, "record_date", None)
content = _compact_text(getattr(history, "content", None))
suffix = record_date.isoformat() if record_date else content
return f"{subject_no} / {suffix}" if suffix else subject_no
def _history_audit_detail(action: str, subject, history) -> str:
name = _history_audit_name(subject, history)
return json.dumps({"targetName": name, "description": f"{action}病史记录“{name}"}, ensure_ascii=False)
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
study = await study_crud.get(db, study_id)
if not study:
@@ -33,7 +54,7 @@ async def _ensure_subject_active(db: AsyncSession, subject) -> None:
"/histories",
response_model=SubjectHistoryRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
dependencies=[Depends(require_api_permission("subject_histories:create"))],
)
async def create_history(
study_id: uuid.UUID,
@@ -56,9 +77,9 @@ async def create_history(
entity_type="subject_history",
entity_id=history.id,
action="CREATE_SUBJECT_HISTORY",
detail="病史记录已创建",
detail=_history_audit_detail("创建", subject, history),
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
return SubjectHistoryRead.model_validate(history)
@@ -66,7 +87,7 @@ async def create_history(
@router.get(
"/histories",
response_model=list[SubjectHistoryRead],
dependencies=[Depends(require_study_member())],
dependencies=[Depends(require_api_permission("subject_histories:read"))],
)
async def list_histories(
study_id: uuid.UUID,
@@ -86,7 +107,7 @@ async def list_histories(
@router.get(
"/histories/{history_id}",
response_model=SubjectHistoryRead,
dependencies=[Depends(require_study_member())],
dependencies=[Depends(require_api_permission("subject_histories:read"))],
)
async def get_history(
study_id: uuid.UUID,
@@ -107,7 +128,7 @@ async def get_history(
@router.patch(
"/histories/{history_id}",
response_model=SubjectHistoryRead,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
dependencies=[Depends(require_api_permission("subject_histories:update"))],
)
async def update_history(
study_id: uuid.UUID,
@@ -131,9 +152,9 @@ async def update_history(
entity_type="subject_history",
entity_id=history_id,
action="UPDATE_SUBJECT_HISTORY",
detail="病史记录已更新",
detail=_history_audit_detail("更新", subject, history),
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
return SubjectHistoryRead.model_validate(history)
@@ -141,7 +162,7 @@ async def update_history(
@router.delete(
"/histories/{history_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
dependencies=[Depends(require_api_permission("subject_histories:delete"))],
)
async def delete_history(
study_id: uuid.UUID,
@@ -157,6 +178,7 @@ async def delete_history(
history = await history_crud.get_history(db, history_id)
if not history or history.study_id != study_id or history.subject_id != subject_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="病史记录不存在")
history_detail = _history_audit_detail("删除", subject, history)
await history_crud.delete_history(db, history)
await audit_crud.log_action(
db,
@@ -164,7 +186,7 @@ async def delete_history(
entity_type="subject_history",
entity_id=history_id,
action="DELETE_SUBJECT_HISTORY",
detail="病史记录已删除",
detail=history_detail,
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
+9 -9
View File
@@ -6,9 +6,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import (
get_current_user,
get_db_session,
require_study_member,
get_operator_role_label,
require_study_not_locked,
require_study_roles,
require_api_permission,
)
from app.crud import audit as audit_crud
from app.crud import site as site_crud
@@ -65,7 +65,7 @@ def _normalize_choice(value: str | None, allowed: set[str], field_name: str) ->
@router.get(
"/pds",
response_model=list[SubjectPdRead],
dependencies=[Depends(require_study_member())],
dependencies=[Depends(require_api_permission("subject_pds:list"))],
)
async def list_subject_pds(
study_id: uuid.UUID,
@@ -84,7 +84,7 @@ async def list_subject_pds(
"/pds",
response_model=SubjectPdRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
dependencies=[Depends(require_api_permission("subject_pds:create")), Depends(require_study_not_locked())],
)
async def create_subject_pd(
study_id: uuid.UUID,
@@ -121,7 +121,7 @@ async def create_subject_pd(
action="CREATE_SUBJECT_PD",
detail=f"PD记录 {item.pd_no} 已创建",
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
return SubjectPdRead.model_validate(item)
@@ -129,7 +129,7 @@ async def create_subject_pd(
@router.patch(
"/pds/{pd_id}",
response_model=SubjectPdRead,
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
dependencies=[Depends(require_api_permission("subject_pds:update")), Depends(require_study_not_locked())],
)
async def update_subject_pd(
study_id: uuid.UUID,
@@ -168,7 +168,7 @@ async def update_subject_pd(
action="UPDATE_SUBJECT_PD",
detail=f"PD记录 {updated.pd_no} 已更新",
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
return SubjectPdRead.model_validate(updated)
@@ -176,7 +176,7 @@ async def update_subject_pd(
@router.delete(
"/pds/{pd_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
dependencies=[Depends(require_api_permission("subject_pds:delete")), Depends(require_study_not_locked())],
)
async def delete_subject_pd(
study_id: uuid.UUID,
@@ -202,5 +202,5 @@ async def delete_subject_pd(
action="DELETE_SUBJECT_PD",
detail=f"PD记录 {item.pd_no} 已删除",
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
+71 -19
View File
@@ -1,10 +1,11 @@
import json
import uuid
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_member, require_study_roles, require_study_not_locked
from app.core.deps import get_operator_role_label, 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
@@ -15,6 +16,29 @@ from app.schemas.subject import SubjectCreate, SubjectRead, SubjectUpdate
router = APIRouter()
def _format_date(value):
return value.isoformat() if value else None
async def _subject_audit_snapshot(db: AsyncSession, subject) -> dict:
site_name = None
if subject.site_id:
site = await site_crud.get_site(db, subject.site_id)
site_name = site.name if site else None
return {
"subject_no": subject.subject_no,
"site_name": site_name,
"status": subject.status,
"screening_date": _format_date(subject.screening_date),
"consent_date": _format_date(subject.consent_date),
"enrollment_date": _format_date(subject.enrollment_date),
"baseline_date": _format_date(subject.baseline_date),
"completion_date": _format_date(subject.completion_date),
"actual_medication_count": subject.actual_medication_count,
"drop_reason": subject.drop_reason,
}
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
study = await study_crud.get(db, study_id)
if not study:
@@ -34,7 +58,7 @@ async def _ensure_subject_active(db: AsyncSession, subject) -> None:
"/",
response_model=SubjectRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
dependencies=[Depends(require_api_permission("subjects:create")), Depends(require_study_not_locked())],
)
async def create_subject(
study_id: uuid.UUID,
@@ -50,15 +74,24 @@ async def create_subject(
subject = await subject_crud.create_subject(db, study_id, subject_in)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
after_snapshot = await _subject_audit_snapshot(db, subject)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="subject",
entity_id=subject.id,
action="CREATE_SUBJECT",
detail=f"参与者 {subject.subject_no} 已创建",
detail=json.dumps(
{
"targetName": subject.subject_no,
"description": f"创建参与者 {subject.subject_no}",
"before": None,
"after": after_snapshot,
},
ensure_ascii=False,
),
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
return subject
@@ -66,7 +99,7 @@ async def create_subject(
@router.get(
"/",
response_model=list[SubjectRead],
dependencies=[Depends(require_study_member())],
dependencies=[Depends(require_api_permission("subjects:read"))],
)
async def list_subjects(
study_id: uuid.UUID,
@@ -115,7 +148,7 @@ async def list_subjects(
@router.get(
"/{subject_id}",
response_model=SubjectRead,
dependencies=[Depends(require_study_member())],
dependencies=[Depends(require_api_permission("subjects:read"))],
)
async def get_subject(
study_id: uuid.UUID,
@@ -154,7 +187,7 @@ async def get_subject(
@router.patch(
"/{subject_id}",
response_model=SubjectRead,
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
dependencies=[Depends(require_api_permission("subjects:update")), Depends(require_study_not_locked())],
)
async def update_subject(
study_id: uuid.UUID,
@@ -172,22 +205,31 @@ async def update_subject(
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_subject_active(db, subject)
old_status = subject.status
before_snapshot = await _subject_audit_snapshot(db, subject)
updated = await subject_crud.update_subject(db, subject, subject_in)
# auto-generate visits when enrolled
if subject_in.status and subject_in.status == "ENROLLED" and (subject_in.enrollment_date or updated.enrollment_date):
await subject_crud.generate_default_visits(db, updated)
detail = None
if subject_in.status and subject_in.status != old_status:
detail = f"参与者 {updated.subject_no} 状态 {old_status} -> {subject_in.status}"
# 基线/治疗日期是访视计划的唯一推算基准,不能用入组日期替代。
if updated.baseline_date:
await subject_crud.sync_visits_from_baseline(db, updated)
updated = await subject_crud.sync_subject_status(db, updated)
after_snapshot = await _subject_audit_snapshot(db, updated)
description = f"变更参与者 {updated.subject_no} 状态" if updated.status != old_status else f"更新参与者 {updated.subject_no}"
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="subject",
entity_id=subject_id,
action="SUBJECT_STATUS_CHANGE" if detail else "UPDATE_SUBJECT",
detail=detail or "参与者已更新",
action="SUBJECT_STATUS_CHANGE" if updated.status != old_status else "UPDATE_SUBJECT",
detail=json.dumps(
{
"targetName": updated.subject_no,
"description": description,
"before": before_snapshot,
"after": after_snapshot,
},
ensure_ascii=False,
),
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
return updated
@@ -195,7 +237,7 @@ async def update_subject(
@router.delete(
"/{subject_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
dependencies=[Depends(require_api_permission("subjects:delete")), Depends(require_study_not_locked())],
)
async def delete_subject(
study_id: uuid.UUID,
@@ -210,6 +252,8 @@ async def delete_subject(
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and subject.site_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
before_snapshot = await _subject_audit_snapshot(db, subject)
target_name = subject.subject_no
await subject_crud.delete_subject(db, subject)
await audit_crud.log_action(
db,
@@ -217,7 +261,15 @@ async def delete_subject(
entity_type="subject",
entity_id=subject_id,
action="DELETE_SUBJECT",
detail=f"参与者 {subject_id} 已删除",
detail=json.dumps(
{
"targetName": target_name,
"description": f"删除参与者 {target_name}",
"before": before_snapshot,
"after": None,
},
ensure_ascii=False,
),
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
+36
View File
@@ -0,0 +1,36 @@
"""系统级权限查询API"""
from __future__ import annotations
from fastapi import APIRouter, Depends
from app.core.deps import require_admin_or_any_project_pm
from app.core.api_permissions import SYSTEM_PERMISSIONS, SYSTEM_MODULE_LABELS
router = APIRouter(prefix="/system-permissions", tags=["system-permissions"])
@router.get(
"",
summary="获取系统级权限定义",
description="返回管理后台所有系统级操作的权限定义,ADMIN 和项目 PM 可访问",
)
async def list_system_permissions(
_=Depends(require_admin_or_any_project_pm()),
) -> dict:
permissions_list = [
{
"permission_key": key,
"module": config["module"],
"module_label": SYSTEM_MODULE_LABELS.get(config["module"], config["module"]),
"action": config["action"],
"description": config["description"],
"roles": config["roles"],
}
for key, config in SYSTEM_PERMISSIONS.items()
]
return {
"permissions": permissions_list,
"module_labels": SYSTEM_MODULE_LABELS,
}
+11 -13
View File
@@ -1,14 +1,14 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db_session, require_roles
from app.core.deps import get_db_session, is_system_admin, require_roles
from app.schemas.common import PaginatedResponse
from app.crud import user as user_crud
from app.crud import member as member_crud
from app.utils.pagination import paginate
from app.schemas.user import UserCreate, UserRead, UserUpdate
from app.schemas.user import UserCreate, UserRead, UserStatus, UserUpdate
router = APIRouter()
@@ -17,12 +17,14 @@ router = APIRouter()
async def list_users(
skip: int = 0,
limit: int = 100,
keyword: str | None = Query(default=None),
user_status: UserStatus | None = Query(default=None, alias="status"),
db: AsyncSession = Depends(get_db_session),
current_user=Depends(require_roles(["ADMIN"])),
) -> PaginatedResponse[UserRead]:
users = await user_crud.list_users(db, skip=skip, limit=limit)
total_users = await user_crud.list_users(db, skip=0, limit=10_000_000)
return paginate(list(users), total=len(total_users))
users = await user_crud.list_users(db, skip=skip, limit=limit, keyword=keyword, status=user_status)
total_users = await user_crud.count_users(db, keyword=keyword, status=user_status)
return paginate(list(users), total=total_users)
@router.post("/", response_model=UserRead, status_code=status.HTTP_201_CREATED)
@@ -54,21 +56,17 @@ async def update_user(
if user_crud.is_protected_admin_user(db_user):
if user_in.email is not None and user_in.email != db_user.email:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="系统管理员邮箱不允许修改")
if user_in.role is not None and user_in.role != "ADMIN":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="系统管理员角色不允许修改")
requested_status = user_in.status
if user_in.is_active is not None:
requested_status = "ACTIVE" if user_in.is_active else "DISABLED"
if requested_status is not None and requested_status != "ACTIVE":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="系统管理员不允许停用")
if db_user.role.value == "ADMIN":
requested_role = user_in.role
if is_system_admin(db_user):
requested_status = user_in.status
if user_in.is_active is not None:
requested_status = "ACTIVE" if user_in.is_active else "DISABLED"
will_leave_admin = requested_role is not None and requested_role != "ADMIN"
will_disable = requested_status is not None and requested_status != "ACTIVE"
if (will_leave_admin or will_disable) and db_user.status.value == "ACTIVE":
if will_disable and db_user.status.value == "ACTIVE":
active_admins = await user_crud.count_active_admins(db)
if active_admins <= 1:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="至少保留一个管理员账号")
@@ -89,7 +87,7 @@ async def delete_user(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="系统管理员账号不允许删除")
if db_user.id == current_user.id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不允许删除自己")
if db_user.role.value == "ADMIN" and db_user.status.value == "ACTIVE":
if is_system_admin(db_user) and db_user.status.value == "ACTIVE":
active_admins = await user_crud.count_active_admins(db)
if active_admins <= 1:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="至少保留一个管理员账号")
+97 -18
View File
@@ -1,19 +1,32 @@
import uuid
import json
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles, require_study_not_locked
from app.core.deps import get_operator_role_label, 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
from app.crud import study as study_crud
from app.crud import visit as visit_crud
from app.schemas.visit import VisitCreate, VisitRead, VisitUpdate
from app.schemas.subject import SubjectUpdate
from app.schemas.visit import EarlyTerminationCreate, VisitCreate, VisitRead, VisitUpdate
router = APIRouter()
def _visit_audit_name(subject, visit) -> str:
subject_no = getattr(subject, "subject_no", None) or "参与者"
visit_code = getattr(visit, "visit_code", None) or "访视"
return f"{subject_no} / {visit_code}"
def _visit_audit_detail(action: str, subject, visit) -> str:
name = _visit_audit_name(subject, visit)
return json.dumps({"targetName": name, "description": f"{action}访视“{name}"}, ensure_ascii=False)
async def _ensure_subject(db: AsyncSession, study_id: uuid.UUID, subject_id: uuid.UUID):
subject = await subject_crud.get_subject(db, subject_id)
if not subject or subject.study_id != study_id:
@@ -32,7 +45,7 @@ async def _ensure_subject_active(db: AsyncSession, subject) -> None:
@router.get(
"/",
response_model=list[VisitRead],
dependencies=[Depends(require_study_member())],
dependencies=[Depends(require_api_permission("visits:list"))],
)
async def list_visits(
study_id: uuid.UUID,
@@ -49,7 +62,7 @@ async def list_visits(
"/",
response_model=VisitRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
dependencies=[Depends(require_api_permission("visits:create")), Depends(require_study_not_locked())],
)
async def create_visit(
study_id: uuid.UUID,
@@ -90,25 +103,79 @@ async def create_visit(
if next_visit_code == "V1" and visit_in.planned_date:
study = await study_crud.get(db, study_id)
if study:
await visit_crud.create_followup_visits(
await visit_crud.create_scheduled_visits(
db,
study_id=study_id,
subject=subject,
base_date=visit_in.planned_date,
visit_total=study.visit_total,
visit_interval_days=study.visit_interval_days,
window_start_offset=study.visit_window_start_offset,
window_end_offset=study.visit_window_end_offset,
visit_schedule=study.visit_schedule,
)
subject = await subject_crud.sync_subject_status(db, subject)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="visit",
entity_id=visit.id,
action="CREATE_VISIT",
detail=f"访视 {visit.visit_code} 已创建",
detail=_visit_audit_detail("创建", subject, visit),
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
return visit
@router.post(
"/early-termination",
response_model=VisitRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_api_permission("visits:create")), Depends(require_study_not_locked())],
)
async def create_early_termination(
study_id: uuid.UUID,
subject_id: uuid.UUID,
termination_in: EarlyTerminationCreate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> VisitRead:
subject = await _ensure_subject(db, study_id, subject_id)
await _ensure_subject_active(db, subject)
reason = termination_in.reason.strip()
if not reason:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="提前终止原因不能为空")
if subject.baseline_date and termination_in.termination_date < subject.baseline_date:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="提前终止日期不能早于基线/治疗日期")
try:
visit = await visit_crud.create_early_termination_visit(
db,
study_id=study_id,
subject=subject,
termination_date=termination_in.termination_date,
reason=reason,
notes=termination_in.notes,
)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
await subject_crud.update_subject(
db,
subject,
SubjectUpdate(
completion_date=termination_in.termination_date,
drop_reason=reason,
),
)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="visit",
entity_id=visit.id,
action="CREATE_EARLY_TERMINATION",
detail=json.dumps(
{"targetName": subject.subject_no, "description": f"创建参与者 {subject.subject_no} 提前终止访视"},
ensure_ascii=False,
),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
return visit
@@ -116,7 +183,7 @@ async def create_visit(
@router.patch(
"/{visit_id}",
response_model=VisitRead,
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
dependencies=[Depends(require_api_permission("visits:update")), Depends(require_study_not_locked())],
)
async def update_visit(
study_id: uuid.UUID,
@@ -139,19 +206,29 @@ async def update_visit(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="实际访视日期不能早于访视窗口开始日期")
if visit_in.actual_date and visit.window_end and visit_in.actual_date > visit.window_end:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="实际访视日期不能晚于访视窗口结束日期")
old_status = visit.status
updated = await visit_crud.update_visit(db, visit, visit_in)
await subject_crud.sync_subject_status(db, subject)
detail = None
if visit_in.status:
detail = f"访视 {visit.visit_code} {visit.status} -> {visit_in.status}"
detail = json.dumps(
{
"targetName": _visit_audit_name(subject, updated),
"description": f"变更访视“{_visit_audit_name(subject, updated)}”状态",
"before": {"status": old_status},
"after": {"status": visit_in.status},
},
ensure_ascii=False,
)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="visit",
entity_id=visit_id,
action="VISIT_STATUS_CHANGE" if detail else "UPDATE_VISIT",
detail=detail or "访视已更新",
detail=detail or _visit_audit_detail("更新", subject, updated),
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
return updated
@@ -159,7 +236,7 @@ async def update_visit(
@router.delete(
"/{visit_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
dependencies=[Depends(require_api_permission("visits:delete")), Depends(require_study_not_locked())],
)
async def delete_visit(
study_id: uuid.UUID,
@@ -173,14 +250,16 @@ async def delete_visit(
visit = await visit_crud.get_visit(db, visit_id)
if not visit or visit.subject_id != subject_id or visit.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="访视不存在")
visit_detail = _visit_audit_detail("删除", subject, visit)
await visit_crud.delete_visit(db, visit)
await subject_crud.sync_subject_status(db, subject)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="visit",
entity_id=visit_id,
action="DELETE_VISIT",
detail=f"访视 {visit_id} 已删除",
detail=visit_detail,
operator_id=current_user.id,
operator_role=current_user.role,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
File diff suppressed because it is too large Load Diff
+9 -2
View File
@@ -1,5 +1,5 @@
from functools import lru_cache
from typing import Literal
from typing import Literal, Optional
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -7,7 +7,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
PROTECTED_ADMIN_EMAIL = "admin@huapont.cn"
PROTECTED_ADMIN_DEFAULT_PASSWORD = "admin123"
PROTECTED_ADMIN_FULL_NAME = "System Admin"
PROTECTED_ADMIN_DEPARTMENT = "SYSTEM"
PROTECTED_ADMIN_CLINICAL_DEPARTMENT = "SYSTEM"
class Settings(BaseSettings):
@@ -19,6 +19,13 @@ class Settings(BaseSettings):
JWT_EXPIRE_MINUTES: int = 60
JWT_EXTEND_GRACE_SECONDS: int = 120
ABSOLUTE_SESSION_MAX_HOURS: int = 8
LOGIN_RSA_PRIVATE_KEY: Optional[str] = None
LOGIN_RSA_PUBLIC_KEY: Optional[str] = None
LOGIN_RSA_KEY_ID: str = "default"
LOGIN_CHALLENGE_TTL_SECONDS: int = 120
LOGIN_CHALLENGE_MAX_ACTIVE: int = 1000
IP2REGION_XDB_PATH: Optional[str] = None
IP2REGION_IPV6_XDB_PATH: Optional[str] = None
@lru_cache
+32
View File
@@ -0,0 +1,32 @@
"""API端点权限注册装饰器"""
from typing import Callable
def register_api_endpoint(
endpoint_key: str,
module: str,
action: str,
description: str = "",
default_roles: list[str] | None = None,
prerequisite_permissions: list[str] | None = None,
):
"""装饰器:注册API端点权限
参数:
endpoint_key: 接口标识格式为 "subjects:create"
module: 关联的模块用于向后兼容
action: 操作类型"read" "write"
description: 接口描述
default_roles: 默认有权限的角色列表
prerequisite_permissions: 前置权限列表
"""
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 []
func._prerequisite_permissions = prerequisite_permissions or []
return func
return decorator
+231 -13
View File
@@ -1,4 +1,7 @@
from __future__ import annotations
from typing import Annotated, AsyncGenerator, Callable, Iterable
import time
import uuid
from fastapi import Depends, HTTPException, Request, status
@@ -9,9 +12,12 @@ from app.core.exceptions import AppException
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.api_permissions import SYSTEM_PERMISSIONS
from app.core.project_permissions import role_has_api_permission, get_missing_prerequisites
from app.db.session import SessionLocal
from app.models.study_member import StudyMember
from app.schemas.user import TokenPayload
from sqlalchemy import select
async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
@@ -48,12 +54,15 @@ async def get_current_user(
return user
def is_system_admin(user) -> bool:
return bool(getattr(user, "is_admin", False))
def require_roles(roles: Iterable[str]) -> Callable:
roles_set = set(roles)
async def dependency(current_user=Depends(get_current_user)):
current_role = current_user.role.value if hasattr(current_user.role, "value") else str(current_user.role)
if current_role not in roles_set:
if "ADMIN" not in roles_set or not is_system_admin(current_user):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="权限不足",
@@ -63,13 +72,87 @@ def require_roles(roles: Iterable[str]) -> Callable:
return dependency
async def list_active_pm_study_ids(db: AsyncSession, user_id: uuid.UUID) -> set[uuid.UUID]:
result = await db.execute(
select(StudyMember.study_id).where(
StudyMember.user_id == user_id,
StudyMember.is_active.is_(True),
StudyMember.role_in_study == "PM",
)
)
return set(result.scalars().all())
async def is_active_project_pm(db: AsyncSession, user_id: uuid.UUID, study_id: uuid.UUID) -> bool:
result = await db.execute(
select(StudyMember.id).where(
StudyMember.study_id == study_id,
StudyMember.user_id == user_id,
StudyMember.is_active.is_(True),
StudyMember.role_in_study == "PM",
)
)
return result.scalar_one_or_none() is not None
async def get_operator_role_label(db: AsyncSession, study_id: uuid.UUID | None, current_user) -> str:
if is_system_admin(current_user):
return "ADMIN"
if not study_id:
return ""
membership = await member_crud.get_member(db, study_id, current_user.id)
return membership.role_in_study if membership and membership.is_active else ""
def require_admin_or_any_project_pm() -> Callable:
async def dependency(
current_user=Depends(get_current_user),
db: AsyncSession = Depends(get_db_session),
):
if is_system_admin(current_user):
return current_user
if await list_active_pm_study_ids(db, current_user.id):
return current_user
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="权限不足",
)
return dependency
def require_system_permission(permission_key: str) -> Callable:
async def dependency(
study_id: uuid.UUID,
current_user=Depends(get_current_user),
db: AsyncSession = Depends(get_db_session),
):
permission = SYSTEM_PERMISSIONS.get(permission_key)
if not permission:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="权限不足",
)
allowed_roles = set(permission.get("roles", []))
if is_system_admin(current_user) and "ADMIN" in allowed_roles:
return current_user
if "PM" in allowed_roles and await is_active_project_pm(db, current_user.id, study_id):
return current_user
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="权限不足",
)
return dependency
async def get_study_member(
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 role_value == "ADMIN":
if is_system_admin(current_user):
return None
return await member_crud.get_member(db, study_id, current_user.id)
@@ -80,8 +163,7 @@ def require_study_member():
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 role_value == "ADMIN":
if is_system_admin(current_user):
return current_user
membership = await member_crud.get_member(db, study_id, current_user.id)
if not membership or not membership.is_active:
@@ -95,7 +177,7 @@ def require_study_member():
return dependency
def require_study_roles(roles: Iterable[str]):
def require_study_roles(roles: Iterable[str], *, allow_system_admin: bool = True):
roles_set = set(roles)
async def dependency(
@@ -103,8 +185,7 @@ def require_study_roles(roles: Iterable[str]):
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 role_value == "ADMIN":
if allow_system_admin and is_system_admin(current_user):
return current_user
membership = await member_crud.get_member(db, study_id, current_user.id)
if not membership or not membership.is_active or membership.role_in_study not in roles_set:
@@ -118,13 +199,114 @@ def require_study_roles(roles: Iterable[str]):
return dependency
def require_api_permission(endpoint_key: str, *, allow_system_admin: bool = True, check_prerequisites: bool = True):
"""基于接口的权限检查(包含前置权限检查)
参数:
endpoint_key: 接口标识格式为 "subjects:create"
allow_system_admin: 是否允许系统管理员绕过权限检查
check_prerequisites: 是否检查前置权限
"""
async def dependency(
request: Request,
study_id: uuid.UUID,
current_user=Depends(get_current_user),
db: AsyncSession = Depends(get_db_session),
):
from app.core.permission_monitor import get_permission_monitor
if allow_system_admin and is_system_admin(current_user):
_enqueue_permission_log(
study_id, current_user.id, endpoint_key, "ADMIN", True, 0.0, request
)
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,
)
start_time = time.perf_counter()
error = None
try:
allowed = await role_has_api_permission(
db, study_id, membership.role_in_study, endpoint_key, check_prerequisites=check_prerequisites
)
except Exception as e:
error = e
allowed = False
raise
finally:
elapsed_ms = (time.perf_counter() - start_time) * 1000
if error is not None or elapsed_ms > 50:
from app.core.permission_monitor import get_permission_monitor
monitor = get_permission_monitor()
if error is not None:
monitor.record_error_alert(error)
else:
monitor.record_slow_check_alert(elapsed_ms)
_enqueue_permission_log(
study_id, current_user.id, endpoint_key,
membership.role_in_study, allowed, elapsed_ms, request
)
if not allowed:
missing_prereqs = await get_missing_prerequisites(
db, study_id, membership.role_in_study, endpoint_key
)
if missing_prereqs:
raise AppException(
code="FORBIDDEN",
message=f"缺失前置权限: {', '.join(missing_prereqs)}",
status_code=status.HTTP_403_FORBIDDEN,
)
else:
raise AppException(
code="FORBIDDEN",
message="接口权限不足",
status_code=status.HTTP_403_FORBIDDEN,
)
return current_user
return dependency
def _enqueue_permission_log(
study_id: uuid.UUID,
user_id: uuid.UUID,
endpoint_key: str,
role: str,
allowed: bool,
elapsed_ms: float,
request: Request,
) -> None:
from app.services.permission_log_writer import get_log_writer
writer = get_log_writer()
if writer:
forwarded = request.headers.get("x-forwarded-for")
ip = forwarded.split(",")[0].strip() if forwarded else (request.client.host if request.client else None)
writer.enqueue({
"study_id": study_id,
"user_id": user_id,
"endpoint_key": endpoint_key,
"role": role,
"allowed": allowed,
"elapsed_ms": elapsed_ms,
"ip_address": ip,
})
async def get_cra_site_scope(
db: AsyncSession,
study_id: uuid.UUID,
current_user,
) -> tuple[set[uuid.UUID], set[str]] | None:
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
if role_value == "ADMIN":
from app.crud import site as site_crud
if is_system_admin(current_user):
return None
membership = await member_crud.get_member(db, study_id, current_user.id)
if not membership or not membership.is_active:
@@ -141,9 +323,14 @@ def require_study_not_locked():
from app.crud import study as study_crud
from app.crud import faq_category as faq_category_crud
from app.crud import faq_item as faq_item_crud
from app.crud import contract_fee as contract_fee_crud
from app.crud import contract_fee_payment as contract_fee_payment_crud
async def resolve_study_id(request: Request, db: AsyncSession) -> uuid.UUID:
raw_study_id = request.path_params.get("study_id") or request.query_params.get("study_id")
raw_study_id = (
request.path_params.get("study_id")
or request.query_params.get("study_id")
)
if raw_study_id:
try:
return uuid.UUID(str(raw_study_id))
@@ -194,6 +381,37 @@ def require_study_not_locked():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
return item.study_id
raw_contract_id = request.path_params.get("contract_id")
if raw_contract_id:
try:
contract_id = uuid.UUID(str(raw_contract_id))
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="合同费用 ID 格式错误",
) from exc
contract = await contract_fee_crud.get_contract_fee(db, contract_id)
if not contract or not contract.study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
return contract.study_id
raw_payment_id = request.path_params.get("payment_id")
if raw_payment_id:
try:
payment_id = uuid.UUID(str(raw_payment_id))
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="分期记录 ID 格式错误",
) from exc
payment = await contract_fee_payment_crud.get_payment(db, payment_id)
if not payment:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分期记录不存在")
contract = await contract_fee_crud.get_contract_fee(db, payment.contract_fee_id)
if not contract or not contract.study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
return contract.study_id
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="缺少项目 ID",
+138
View File
@@ -0,0 +1,138 @@
import base64
import json
import secrets
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding, rsa
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPublicKey
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from pydantic import BaseModel, EmailStr, Field, ValidationError
from app.core.config import settings
class DecryptedLoginPayload(BaseModel):
email: EmailStr
password: str = Field(min_length=1, max_length=72)
challenge: str = Field(min_length=16)
class EncryptedLoginEnvelope(BaseModel):
encrypted_key: str = Field(min_length=1)
iv: str = Field(min_length=1)
data: str = Field(min_length=1)
@dataclass(frozen=True)
class LoginChallenge:
value: str
expires_at: datetime
_private_key: RSAPrivateKey | None = None
_public_key_pem: str | None = None
_challenges: dict[str, LoginChallenge] = {}
def _normalize_pem(value: str) -> str:
return value.replace("\\n", "\n").strip()
def _load_or_create_private_key() -> RSAPrivateKey:
configured_key = settings.LOGIN_RSA_PRIVATE_KEY
if configured_key:
key = serialization.load_pem_private_key(_normalize_pem(configured_key).encode("utf-8"), password=None)
if not isinstance(key, RSAPrivateKey):
raise ValueError("LOGIN_RSA_PRIVATE_KEY 必须是 RSA 私钥")
return key
if settings.ENV == "production":
raise ValueError("生产环境必须配置 LOGIN_RSA_PRIVATE_KEY")
return rsa.generate_private_key(public_exponent=65537, key_size=2048)
def get_private_key() -> RSAPrivateKey:
global _private_key
if _private_key is None:
_private_key = _load_or_create_private_key()
return _private_key
def validate_login_crypto_configuration() -> None:
if settings.ENV == "production" and not settings.LOGIN_RSA_PRIVATE_KEY:
raise ValueError("生产环境必须配置 LOGIN_RSA_PRIVATE_KEY")
def get_public_key_pem() -> str:
global _public_key_pem
if _public_key_pem is not None:
return _public_key_pem
if settings.LOGIN_RSA_PUBLIC_KEY:
if not settings.LOGIN_RSA_PRIVATE_KEY:
raise ValueError("配置 LOGIN_RSA_PUBLIC_KEY 时必须同时配置 LOGIN_RSA_PRIVATE_KEY")
_public_key_pem = _normalize_pem(settings.LOGIN_RSA_PUBLIC_KEY)
return _public_key_pem
public_key: RSAPublicKey = get_private_key().public_key()
_public_key_pem = public_key.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
).decode("utf-8")
return _public_key_pem
def create_login_challenge() -> LoginChallenge:
prune_expired_challenges()
while len(_challenges) >= settings.LOGIN_CHALLENGE_MAX_ACTIVE:
oldest = next(iter(_challenges))
_challenges.pop(oldest, None)
value = secrets.token_urlsafe(32)
expires_at = datetime.now(timezone.utc) + timedelta(seconds=settings.LOGIN_CHALLENGE_TTL_SECONDS)
challenge = LoginChallenge(value=value, expires_at=expires_at)
_challenges[value] = challenge
return challenge
def prune_expired_challenges() -> None:
now = datetime.now(timezone.utc)
expired = [value for value, challenge in _challenges.items() if challenge.expires_at <= now]
for value in expired:
_challenges.pop(value, None)
def consume_login_challenge(value: str) -> bool:
prune_expired_challenges()
challenge = _challenges.pop(value, None)
if not challenge:
return False
return challenge.expires_at > datetime.now(timezone.utc)
def decrypt_login_payload(*, key_id: str, challenge: str, ciphertext: str) -> DecryptedLoginPayload | None:
if key_id != settings.LOGIN_RSA_KEY_ID:
return None
if not consume_login_challenge(challenge):
return None
try:
envelope_data = json.loads(base64.b64decode(ciphertext, validate=True).decode("utf-8"))
envelope = EncryptedLoginEnvelope.model_validate(envelope_data)
encrypted_key = base64.b64decode(envelope.encrypted_key, validate=True)
iv = base64.b64decode(envelope.iv, validate=True)
encrypted_payload = base64.b64decode(envelope.data, validate=True)
aes_key = get_private_key().decrypt(
encrypted_key,
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None,
),
)
decrypted = AESGCM(aes_key).decrypt(iv, encrypted_payload, None)
payload: Any = json.loads(decrypted.decode("utf-8"))
parsed = DecryptedLoginPayload.model_validate(payload)
except (ValueError, TypeError, json.JSONDecodeError, ValidationError):
return None
if parsed.challenge != challenge:
return None
return parsed
+124
View File
@@ -0,0 +1,124 @@
"""权限缓存管理器"""
from __future__ import annotations
import time
import uuid
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
class PermissionCache:
"""权限缓存管理器"""
def __init__(self, default_ttl: int = 300):
self.default_ttl = default_ttl
self._project_permissions_cache: dict[str, tuple[Any, float]] = {}
self._member_role_cache: dict[str, tuple[str | None, float]] = {}
def _is_expired(self, timestamp: float, ttl: int) -> bool:
return time.time() - timestamp > ttl
def _make_project_cache_key(self, study_id: uuid.UUID) -> str:
return f"project_permissions:{study_id}"
def _make_member_cache_key(self, study_id: uuid.UUID, user_id: uuid.UUID) -> str:
return f"member_role:{study_id}:{user_id}"
async def get_member_role(
self,
db: AsyncSession,
study_id: uuid.UUID,
user_id: uuid.UUID,
ttl: int | None = None,
) -> str | None:
"""获取成员角色(带缓存)"""
from app.crud import member as member_crud
if ttl is None:
ttl = self.default_ttl
cache_key = self._make_member_cache_key(study_id, user_id)
if cache_key in self._member_role_cache:
cached_role, timestamp = self._member_role_cache[cache_key]
if not self._is_expired(timestamp, ttl):
return cached_role
membership = await member_crud.get_member(db, study_id, user_id)
role = membership.role_in_study if membership and membership.is_active else None
self._member_role_cache[cache_key] = (role, time.time())
return role
def get_project_permissions(
self,
study_id: uuid.UUID,
ttl: int | None = None,
) -> dict[str, dict[str, bool]] | None:
"""获取项目接口权限覆盖表缓存"""
if ttl is None:
ttl = self.default_ttl
cache_key = self._make_project_cache_key(study_id)
if cache_key not in self._project_permissions_cache:
return None
permissions, timestamp = self._project_permissions_cache[cache_key]
if self._is_expired(timestamp, ttl):
self._project_permissions_cache.pop(cache_key, None)
return None
return permissions
def set_project_permissions(
self,
study_id: uuid.UUID,
permissions: dict[str, dict[str, bool]],
) -> None:
"""缓存项目接口权限覆盖表"""
cache_key = self._make_project_cache_key(study_id)
self._project_permissions_cache[cache_key] = (permissions, time.time())
def invalidate_project_permissions(self, study_id: uuid.UUID) -> None:
cache_key = self._make_project_cache_key(study_id)
self._project_permissions_cache.pop(cache_key, None)
def invalidate_member_role(self, study_id: uuid.UUID, user_id: uuid.UUID) -> None:
cache_key = self._make_member_cache_key(study_id, user_id)
self._member_role_cache.pop(cache_key, None)
def invalidate_all_member_roles(self, study_id: uuid.UUID) -> None:
keys_to_delete = [
key for key in self._member_role_cache.keys()
if key.startswith(f"member_role:{study_id}:")
]
for key in keys_to_delete:
del self._member_role_cache[key]
def clear_all(self) -> None:
self._project_permissions_cache.clear()
self._member_role_cache.clear()
def get_cache_stats(self) -> dict[str, Any]:
return {
"project_permissions_count": len(self._project_permissions_cache),
"member_role_count": len(self._member_role_cache),
"total_count": len(self._project_permissions_cache) + len(self._member_role_cache),
}
_permission_cache: PermissionCache | None = None
def get_permission_cache() -> PermissionCache:
global _permission_cache
if _permission_cache is None:
_permission_cache = PermissionCache()
return _permission_cache
def set_permission_cache(cache: PermissionCache) -> None:
global _permission_cache
_permission_cache = cache
+150
View File
@@ -0,0 +1,150 @@
"""权限系统监控
收集缓存性能指标和告警信息
权限检查的统计数据总次数允许/拒绝耗时已持久化在
permission_access_logs 表中通过 /metrics 端点实时聚合查询
"""
from __future__ import annotations
import time
from dataclasses import dataclass, field
from typing import Any
from app.core.permission_cache import get_permission_cache
CACHE_HIT_RATE_HEALTH_MIN_ACCESSES = 10
@dataclass
class CacheMetrics:
"""缓存指标"""
total_accesses: int = 0
cache_hits: int = 0
cache_misses: int = 0
cache_invalidations: int = 0
@property
def hit_rate(self) -> float:
if self.total_accesses == 0:
return 0.0
return (self.cache_hits / self.total_accesses) * 100
@property
def miss_rate(self) -> float:
if self.total_accesses == 0:
return 0.0
return (self.cache_misses / self.total_accesses) * 100
def to_dict(self) -> dict[str, Any]:
return {
"total_accesses": self.total_accesses,
"cache_hits": self.cache_hits,
"cache_misses": self.cache_misses,
"cache_invalidations": self.cache_invalidations,
"hit_rate": round(self.hit_rate, 2),
"miss_rate": round(self.miss_rate, 2),
}
@dataclass
class PermissionSystemMetrics:
cache_metrics: CacheMetrics = field(default_factory=CacheMetrics)
start_time: float = field(default_factory=time.time)
@property
def uptime_seconds(self) -> float:
return time.time() - self.start_time
def reset(self) -> None:
self.cache_metrics = CacheMetrics()
self.start_time = time.time()
class PermissionMonitor:
"""权限系统监控器(仅缓存指标 + 告警)"""
def __init__(self):
self.metrics = PermissionSystemMetrics()
self._alerts: list[dict[str, Any]] = []
def record_cache_hit(self) -> None:
self.metrics.cache_metrics.total_accesses += 1
self.metrics.cache_metrics.cache_hits += 1
def record_cache_miss(self) -> None:
self.metrics.cache_metrics.total_accesses += 1
self.metrics.cache_metrics.cache_misses += 1
def record_cache_invalidation(self) -> None:
self.metrics.cache_metrics.cache_invalidations += 1
def record_error_alert(self, error: Exception) -> None:
self._add_alert(
level="error",
type="permission_check_error",
message=f"权限检查出错: {str(error)}",
data={"error": str(error)},
)
def record_slow_check_alert(self, elapsed_ms: float) -> None:
if elapsed_ms > 50:
self._add_alert(
level="warning",
type="slow_permission_check",
message=f"权限检查耗时过长: {elapsed_ms:.2f}ms",
data={"elapsed_ms": elapsed_ms},
)
def _add_alert(self, level: str, type: str, message: str, data: dict[str, Any] | None = None) -> None:
self._alerts.append({
"timestamp": time.time(),
"level": level,
"type": type,
"message": message,
"data": data or {},
})
if len(self._alerts) > 1000:
self._alerts = self._alerts[-1000:]
def get_alerts(self, level: str | None = None, limit: int = 100) -> list[dict[str, Any]]:
alerts = self._alerts
if level:
alerts = [a for a in alerts if a["level"] == level]
return alerts[-limit:]
def get_cache_stats(self) -> dict[str, Any]:
cache = get_permission_cache()
return {
"cache_items": cache.get_cache_stats(),
"cache_metrics": self.metrics.cache_metrics.to_dict(),
}
def get_metrics(self) -> dict[str, Any]:
return {
"cache_metrics": self.metrics.cache_metrics.to_dict(),
"uptime_seconds": self.metrics.uptime_seconds,
}
def reset_metrics(self) -> None:
self.metrics.reset()
def clear_alerts(self) -> None:
self._alerts.clear()
_permission_monitor: PermissionMonitor | None = None
def get_permission_monitor() -> PermissionMonitor:
global _permission_monitor
if _permission_monitor is None:
_permission_monitor = PermissionMonitor()
return _permission_monitor
def set_permission_monitor(monitor: PermissionMonitor) -> None:
global _permission_monitor
_permission_monitor = monitor
+364
View File
@@ -0,0 +1,364 @@
from __future__ import annotations
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.api_endpoint_permission import ApiEndpointPermission
from app.models.study import Study
from app.core.api_permissions import (
API_ENDPOINT_PERMISSIONS,
OPERATION_PREREQUISITES,
PROJECT_PERMISSION_ROLES,
canonical_permission_key,
)
from app.core.permission_cache import get_permission_cache
class PrerequisitePermissionConfirmationRequired(ValueError):
"""权限调整需要授权人确认。"""
def __init__(self, adjustments: dict):
super().__init__("需要确认前置权限调整")
self.adjustments = adjustments
async def _get_project_permission_overrides(
db: AsyncSession,
study_id: uuid.UUID,
) -> dict[str, dict[str, bool]]:
cache = get_permission_cache()
cached_permissions = cache.get_project_permissions(study_id)
from app.core.permission_monitor import get_permission_monitor
monitor = get_permission_monitor()
if cached_permissions is not None:
monitor.record_cache_hit()
return cached_permissions
monitor.record_cache_miss()
result = await db.execute(
select(ApiEndpointPermission).where(
ApiEndpointPermission.study_id == study_id,
)
)
rows = result.scalars().all()
permissions: dict[str, dict[str, bool]] = {}
for row in rows:
endpoint_key = canonical_permission_key(row.endpoint_key)
role_permissions = permissions.setdefault(row.role, {})
if row.endpoint_key == endpoint_key or endpoint_key not in role_permissions:
role_permissions[endpoint_key] = row.allowed
cache.set_project_permissions(study_id, permissions)
return permissions
def _empty_role_adjustment() -> dict[str, dict[str, list[str]]]:
return {"add": {}, "remove": {}}
def _flatten_role_permissions(
matrix: dict[str, dict[str, dict[str, bool]]],
role: str,
) -> dict[str, bool]:
permissions: dict[str, bool] = {
endpoint_key: False
for endpoint_key in API_ENDPOINT_PERMISSIONS.keys()
}
if role in matrix:
for endpoint_key, value in matrix[role].items():
endpoint_key = canonical_permission_key(endpoint_key)
if endpoint_key not in API_ENDPOINT_PERMISSIONS:
continue
permissions[endpoint_key] = bool(value["allowed"] if isinstance(value, dict) else value)
return permissions
def _canonicalize_payload(payload: dict[str, dict[str, bool]]) -> dict[str, dict[str, bool]]:
normalized: dict[str, dict[str, bool]] = {}
for role, endpoints in payload.items():
normalized[role] = {}
for endpoint_key, allowed in endpoints.items():
endpoint_key = canonical_permission_key(endpoint_key)
if endpoint_key not in API_ENDPOINT_PERMISSIONS:
continue
normalized[role][endpoint_key] = bool(allowed)
return normalized
def _add_adjustment(target: dict[str, list[str]], key: str, value: str) -> None:
values = target.setdefault(key, [])
if value not in values:
values.append(value)
async def preview_api_permission_prerequisite_adjustments(
db: AsyncSession,
study_id: uuid.UUID,
payload: dict[str, dict[str, bool]],
) -> dict:
"""预检权限草稿需要授权人确认的前置补齐和下游消除项。"""
normalized_payload = _canonicalize_payload(payload)
matrix = await get_api_endpoint_permissions(db, study_id)
roles: dict[str, dict[str, dict[str, list[str]]]] = {}
for role, endpoints in normalized_payload.items():
if role in ("ADMIN", "PM"):
continue
candidate = _flatten_role_permissions(matrix, role)
explicitly_denied = {
endpoint_key
for endpoint_key, allowed in endpoints.items()
if not allowed
}
for endpoint_key, allowed in endpoints.items():
candidate[endpoint_key] = allowed
role_adjustment = _empty_role_adjustment()
denied_sources = set(explicitly_denied)
changed = True
while changed:
changed = False
for endpoint_key, allowed in list(candidate.items()):
if not allowed or endpoint_key in denied_sources:
continue
for prereq in OPERATION_PREREQUISITES.get(endpoint_key, []):
prereq = canonical_permission_key(prereq)
if prereq in denied_sources:
_add_adjustment(role_adjustment["remove"], prereq, endpoint_key)
candidate[endpoint_key] = False
denied_sources.add(endpoint_key)
changed = True
break
changed = True
while changed:
changed = False
for endpoint_key, allowed in list(candidate.items()):
if not allowed:
continue
for prereq in OPERATION_PREREQUISITES.get(endpoint_key, []):
prereq = canonical_permission_key(prereq)
if candidate.get(prereq, False):
continue
_add_adjustment(role_adjustment["add"], endpoint_key, prereq)
candidate[prereq] = True
changed = True
if role_adjustment["add"] or role_adjustment["remove"]:
roles[role] = role_adjustment
return {
"requires_confirmation": bool(roles),
"roles": roles,
}
def apply_prerequisite_adjustments_to_payload(
payload: dict[str, dict[str, bool]],
adjustments: dict,
) -> dict[str, dict[str, bool]]:
"""把已确认的补齐和消除项合并到待保存 payload。"""
normalized = _canonicalize_payload(payload)
for role, role_adjustment in adjustments.get("roles", {}).items():
role_payload = normalized.setdefault(role, {})
for prerequisites in role_adjustment.get("add", {}).values():
for prereq in prerequisites:
role_payload[canonical_permission_key(prereq)] = True
for dependents in role_adjustment.get("remove", {}).values():
for dependent in dependents:
role_payload[canonical_permission_key(dependent)] = False
return normalized
async def role_has_api_permission(
db: AsyncSession,
study_id: uuid.UUID,
role: str | None,
endpoint_key: str,
check_prerequisites: bool = True,
) -> bool:
"""检查角色是否有权访问特定接口"""
endpoint_key = canonical_permission_key(endpoint_key)
if endpoint_key not in API_ENDPOINT_PERMISSIONS:
return False
if role == "ADMIN":
return True
permissions = await _get_project_permission_overrides(db, study_id)
if role == "PM":
override = permissions.get("PM", {}).get(endpoint_key)
if override is not None:
return override
return True
allowed = permissions.get(role or "", {}).get(endpoint_key)
if allowed is None:
config = API_ENDPOINT_PERMISSIONS.get(endpoint_key)
allowed = bool(config and role in config.get("default_roles", []))
if not allowed:
return False
if check_prerequisites:
prerequisites = OPERATION_PREREQUISITES.get(endpoint_key, [])
for prereq_endpoint in prerequisites:
has_prereq = await role_has_api_permission(
db, study_id, role, prereq_endpoint, check_prerequisites=False
)
if not has_prereq:
return False
return True
async def get_missing_prerequisites(
db: AsyncSession,
study_id: uuid.UUID,
role: str | None,
endpoint_key: str,
) -> list[str]:
"""获取缺失的前置权限列表"""
if role == "ADMIN":
return []
missing = []
endpoint_key = canonical_permission_key(endpoint_key)
prerequisites = OPERATION_PREREQUISITES.get(endpoint_key, [])
for prereq_endpoint in prerequisites:
has_prereq = await role_has_api_permission(
db, study_id, role, prereq_endpoint, check_prerequisites=False
)
if not has_prereq:
missing.append(prereq_endpoint)
return missing
async def get_api_endpoint_permissions(
db: AsyncSession,
study_id: uuid.UUID,
) -> dict[str, dict[str, dict[str, bool]]]:
"""获取项目的接口级权限矩阵
返回格式: {role: {endpoint_key: {allowed: bool}}}
"""
overrides = await _get_project_permission_overrides(db, study_id)
study_result = await db.execute(select(Study).where(Study.id == study_id))
study = study_result.scalar_one_or_none()
active_roles = [role for role in (study.active_roles if study else []) if isinstance(role, str) and role.strip()]
roles = list(dict.fromkeys([*PROJECT_PERMISSION_ROLES, *active_roles, *overrides.keys()]))
matrix: dict[str, dict[str, dict[str, bool]]] = {}
for role in roles:
matrix[role] = {}
for endpoint_key, config in API_ENDPOINT_PERMISSIONS.items():
if role == "PM":
matrix[role][endpoint_key] = {"allowed": True}
else:
default_allowed = role in config.get("default_roles", [])
matrix[role][endpoint_key] = {"allowed": default_allowed}
for role, endpoints in overrides.items():
if role not in matrix:
matrix[role] = {}
for endpoint_key, allowed in endpoints.items():
endpoint_key = canonical_permission_key(endpoint_key)
if endpoint_key not in API_ENDPOINT_PERMISSIONS:
continue
matrix[role][endpoint_key] = {"allowed": allowed}
return matrix
async def get_effective_api_endpoint_permissions(
db: AsyncSession,
study_id: uuid.UUID,
) -> dict[str, dict[str, dict[str, bool]]]:
"""获取考虑前置权限后的有效权限矩阵。"""
matrix = await get_api_endpoint_permissions(db, study_id)
effective: dict[str, dict[str, dict[str, bool]]] = {}
for role, endpoints in matrix.items():
effective[role] = {}
for endpoint_key in endpoints.keys():
effective[role][endpoint_key] = {
"allowed": await role_has_api_permission(
db,
study_id,
role,
endpoint_key,
check_prerequisites=True,
)
}
return effective
async def replace_api_endpoint_permissions(
db: AsyncSession,
study_id: uuid.UUID,
payload: dict[str, dict[str, bool]],
*,
confirm_prerequisite_adjustments: bool = False,
) -> dict[str, dict[str, dict[str, bool]]]:
"""更新 payload 中指定的角色权限项。未提交的权限项保持不变。
ADMIN PM 权限不会被持久化ADMIN 始终拥有全部权限PM 默认拥有
全部项目权限应当通过专门的渠道而不是项目权限矩阵调整
"""
normalized_payload = _canonicalize_payload(payload)
adjustments = await preview_api_permission_prerequisite_adjustments(
db,
study_id,
normalized_payload,
)
if adjustments["requires_confirmation"]:
if not confirm_prerequisite_adjustments:
raise PrerequisitePermissionConfirmationRequired(adjustments)
normalized_payload = apply_prerequisite_adjustments_to_payload(
normalized_payload,
adjustments,
)
for role, endpoints in normalized_payload.items():
if role in ("ADMIN", "PM"):
continue
for endpoint_key, allowed in endpoints.items():
if endpoint_key not in API_ENDPOINT_PERMISSIONS:
continue
result = await db.execute(
select(ApiEndpointPermission).where(
ApiEndpointPermission.study_id == study_id,
ApiEndpointPermission.role == role,
ApiEndpointPermission.endpoint_key == endpoint_key,
)
)
existing = result.scalar_one_or_none()
if existing:
existing.allowed = allowed
else:
db.add(
ApiEndpointPermission(
study_id=study_id,
role=role,
endpoint_key=endpoint_key,
allowed=allowed,
)
)
await db.commit()
cache = get_permission_cache()
cache.invalidate_project_permissions(study_id)
cache.invalidate_all_member_roles(study_id)
from app.core.permission_monitor import get_permission_monitor
get_permission_monitor().record_cache_invalidation()
return await get_api_endpoint_permissions(db, study_id)
+3 -6
View File
@@ -22,19 +22,16 @@ ROLE_ACTIONS: dict[str, set[str]] = {
"ack",
"delete_document",
},
"QA": {"view", "create_document", "create_version", "submit", "approve", "distribute", "ack"},
"PM": {"view", "create_document", "create_version", "submit", "approve", "distribute", "ack", "delete_document"},
"CRA": {"view", "create_version", "submit", "ack"},
"PV": {"view", "create_version", "submit", "ack"},
"IMP": {"view", "create_version", "submit", "ack"},
"CTA": {"view", "create_version", "submit", "ack"},
}
def is_allowed(action: str, user, membership) -> bool:
role_value = user.role.value if hasattr(user.role, "value") else str(user.role)
allowed = ROLE_ACTIONS.get(role_value, set())
if action in allowed:
return True
if getattr(user, "is_admin", False):
return action in ROLE_ACTIONS["ADMIN"]
member_role = getattr(membership, "role_in_study", None)
if member_role:
return action in ROLE_ACTIONS.get(member_role, set())
-2
View File
@@ -17,7 +17,6 @@ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login", scheme_name=
def create_access_token(
*,
user_id: str,
role: str,
expires_minutes: Optional[int] = None,
session_start: Optional[datetime] = None,
) -> str:
@@ -26,7 +25,6 @@ def create_access_token(
session_start_time = session_start or now
to_encode: Dict[str, Any] = {
"sub": user_id,
"role": role,
"exp": expire,
"iat": int(now.timestamp()),
"orig_iat": int(session_start_time.timestamp()),
+2
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
import uuid
from typing import Sequence
+1 -1
View File
@@ -73,7 +73,7 @@ async def create_ae(
is_sae=bool(ae_in.is_sae or ae_in.is_susar),
is_susar=bool(ae_in.is_susar),
report_due_date=due_date,
status="NEW",
status="FOLLOW_UP",
description=ae_in.description,
created_by=created_by,
)
+2 -5
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
import uuid
from typing import Sequence
@@ -65,8 +67,3 @@ async def list_logs(
async def get_log(db: AsyncSession, log_id: uuid.UUID) -> AuditLog | None:
result = await db.execute(select(AuditLog).where(AuditLog.id == log_id))
return result.scalar_one_or_none()
async def delete_log(db: AsyncSession, log: AuditLog) -> None:
await db.delete(log)
await db.commit()
+10 -6
View File
@@ -16,9 +16,13 @@ async def create_contract_fee(
contract_in: ContractFeeCreate,
) -> ContractFee:
contract = ContractFee(
project_id=contract_in.project_id,
project_id=contract_in.study_id,
center_id=contract_in.center_id,
contract_no=contract_in.contract_no,
signed_date=contract_in.signed_date,
contract_amount=contract_in.contract_amount,
currency=contract_in.currency,
remark=contract_in.remark,
contract_cases=contract_in.contract_cases,
actual_cases=contract_in.actual_cases,
settlement_amount=contract_in.settlement_amount,
@@ -35,18 +39,18 @@ async def get_contract_fee(db: AsyncSession, contract_id: uuid.UUID) -> Contract
return result.scalar_one_or_none()
async def get_contract_fee_by_project_center(
db: AsyncSession, project_id: uuid.UUID, center_id: uuid.UUID
async def get_contract_fee_by_study_center(
db: AsyncSession, study_id: uuid.UUID, center_id: uuid.UUID
) -> ContractFee | None:
result = await db.execute(
select(ContractFee).where(ContractFee.project_id == project_id, ContractFee.center_id == center_id)
select(ContractFee).where(ContractFee.project_id == study_id, ContractFee.center_id == center_id)
)
return result.scalar_one_or_none()
async def list_contract_fees(
db: AsyncSession,
project_id: uuid.UUID,
study_id: uuid.UUID,
center_id: uuid.UUID | None = None,
center_ids: set[uuid.UUID] | None = None,
q: str | None = None,
@@ -77,7 +81,7 @@ async def list_contract_fees(
)
.join(Site, Site.id == ContractFee.center_id)
.outerjoin(ContractFeePayment, ContractFeePayment.contract_fee_id == ContractFee.id)
.where(ContractFee.project_id == project_id)
.where(ContractFee.project_id == study_id)
.group_by(ContractFee.id, Site.name)
)
+2
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
import uuid
from typing import Sequence
+5
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
import uuid
from typing import Sequence
@@ -33,6 +35,7 @@ async def list_documents(
doc_type: str | None = None,
status: str | None = None,
scope_type: str | None = None,
etmf_node_id: uuid.UUID | None = None,
cra_site_ids: set[uuid.UUID] | None = None,
skip: int = 0,
limit: int = 100,
@@ -51,6 +54,8 @@ async def list_documents(
stmt = stmt.where(Document.status == status)
if site_id:
stmt = stmt.where(Document.site_id == site_id)
if etmf_node_id:
stmt = stmt.where(Document.etmf_node_id == etmf_node_id)
stmt = stmt.order_by(Document.updated_at.desc()).offset(skip).limit(limit)
result = await db.execute(stmt)
return result.scalars().all()

Some files were not shown because too many files have changed in this diff Show More