Merge branch 'dev' into release
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
# Conflicts: # nginx/nginx.conf
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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/
|
||||
|
||||
@@ -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
@@ -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,9 +20,17 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
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:
|
||||
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:
|
||||
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")
|
||||
+132
@@ -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)
|
||||
+139
@@ -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)
|
||||
+119
@@ -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")
|
||||
@@ -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
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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,14 +66,17 @@ 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"}:
|
||||
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
|
||||
@@ -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", "入组"),
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,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,
|
||||
@@ -25,7 +25,7 @@ async def get_project_overview(
|
||||
|
||||
返回项目各中心的进度情况,包括:
|
||||
- 机构立项状态
|
||||
- 伦理审批状态
|
||||
- 伦理记录状态
|
||||
- 合同签署状态
|
||||
- 启动会状态
|
||||
- 入组进度
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
}
|
||||
@@ -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))
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
+186
-97
@@ -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,9 +1134,7 @@ 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")
|
||||
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,
|
||||
@@ -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,9 +1493,7 @@ 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")
|
||||
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,
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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
@@ -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="至少保留一个管理员账号")
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Sequence
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Sequence
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user