4 Commits

Author SHA1 Message Date
Cheng Zhou d776e2ba7c release: merge main for users trailing slash fix 2026-03-31 10:09:16 +08:00
Cheng Zhou d2bdc49b7c release: merge main for frontend api config removal 2026-03-31 09:45:32 +08:00
Cheng Zhou f41b75722f release: merge main for nginx 8888 port change 2026-03-31 09:35:52 +08:00
Cheng Zhou 25dc484a22 release: merge main for nginx same-origin restore 2026-03-31 09:17:33 +08:00
765 changed files with 20184 additions and 131421 deletions
-22
View File
@@ -1,22 +0,0 @@
.git
.gitignore
.worktrees
.agents
.claude
.DS_Store
.env
.env.*
**/.env
**/.env.*
pg_data
tmp
.install-logs
.pytest_cache
**/.pytest_cache
**/__pycache__
**/*.pyc
frontend/node_modules
frontend/dist
backend/.coverage
backend/.pytest_cache
nginx/certs
-167
View File
@@ -1,167 +0,0 @@
name: Client Quality Gates
on:
pull_request:
paths:
- "AGENTS.md"
- "frontend/**"
- ".github/workflows/client-quality-gates.yml"
- ".github/workflows/desktop-release-candidate.yml"
- ".github/workflows/desktop-windows-internal.yml"
- "docs/branch-governance.md"
- "docs/desktop-project-plan.md"
- "docs/desktop-phase-1-design.md"
- "docs/desktop-phase-2-design.md"
- "docs/audits/desktop-release-stabilization-checklist.md"
- "docs/guides/client-release.md"
- "docs/guides/branch-maintenance-sop-zh.md"
push:
branches:
- dev
- main
- release
tags:
- "v*"
paths:
- "AGENTS.md"
- "frontend/**"
- ".github/workflows/client-quality-gates.yml"
- ".github/workflows/desktop-release-candidate.yml"
- ".github/workflows/desktop-windows-internal.yml"
- "docs/branch-governance.md"
- "docs/desktop-project-plan.md"
- "docs/desktop-phase-1-design.md"
- "docs/desktop-phase-2-design.md"
- "docs/audits/desktop-release-stabilization-checklist.md"
- "docs/guides/client-release.md"
- "docs/guides/branch-maintenance-sop-zh.md"
jobs:
web:
name: Shared client and Web
runs-on: ubuntu-latest
env:
VITE_DESKTOP_SERVER_URL: ${{ vars.VITE_DESKTOP_SERVER_URL }}
defaults:
run:
working-directory: frontend
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22.13"
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
run: npm ci
- name: Resolve build metadata
id: build-metadata
shell: bash
run: |
if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then
channel="release"
elif [[ "${GITHUB_REF_NAME}" == "dev" || "${GITHUB_REF_NAME}" == "main" || "${GITHUB_REF_NAME}" == "release" ]]; then
channel="${GITHUB_REF_NAME}"
else
channel="dev"
fi
echo "channel=${channel}" >> "${GITHUB_OUTPUT}"
echo "commit=${GITHUB_SHA}" >> "${GITHUB_OUTPUT}"
- name: Check release build metadata
run: npm run release:env:check
env:
VITE_BUILD_CHANNEL: ${{ steps.build-metadata.outputs.channel }}
VITE_BUILD_COMMIT: ${{ steps.build-metadata.outputs.commit }}
- name: Check synchronized client version
run: npm run version:check
- name: Check runtime boundary
run: npm run runtime:check
- name: Check desktop release and security gate
run: npm run desktop:release:check
- name: Check UI contract
run: npm run ui:contract
- name: Type check
run: npm run type-check
- name: Unit tests
run: npm run test:unit
- name: Build Web artifact
run: npm run build
env:
VITE_BUILD_CHANNEL: ${{ steps.build-metadata.outputs.channel }}
VITE_BUILD_COMMIT: ${{ steps.build-metadata.outputs.commit }}
desktop:
name: macOS Desktop
runs-on: macos-latest
env:
VITE_DESKTOP_SERVER_URL: ${{ vars.VITE_DESKTOP_SERVER_URL }}
defaults:
run:
working-directory: frontend
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22.13"
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
- name: Install dependencies
run: npm ci
- name: Resolve build metadata
id: build-metadata
shell: bash
run: |
if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then
channel="release"
elif [[ "${GITHUB_REF_NAME}" == "dev" || "${GITHUB_REF_NAME}" == "main" || "${GITHUB_REF_NAME}" == "release" ]]; then
channel="${GITHUB_REF_NAME}"
else
channel="dev"
fi
echo "channel=${channel}" >> "${GITHUB_OUTPUT}"
echo "commit=${GITHUB_SHA}" >> "${GITHUB_OUTPUT}"
- name: Check release build metadata
run: npm run release:env:check
env:
VITE_BUILD_CHANNEL: ${{ steps.build-metadata.outputs.channel }}
VITE_BUILD_COMMIT: ${{ steps.build-metadata.outputs.commit }}
- name: Check synchronized client version
run: npm run version:check
- name: Check runtime boundary
run: npm run runtime:check
- name: Check desktop release and security gate
run: npm run desktop:release:check
- name: Check UI contract
run: npm run ui:contract
- name: Build unsigned macOS application smoke artifact
run: npm run desktop:build:app
env:
VITE_BUILD_CHANNEL: ${{ steps.build-metadata.outputs.channel }}
VITE_BUILD_COMMIT: ${{ steps.build-metadata.outputs.commit }}
@@ -1,456 +0,0 @@
name: Desktop Release Candidate
on:
workflow_dispatch:
inputs:
artifact_base_url:
description: "Versioned HTTPS prefix for immutable desktop artifacts, for example https://ctms.example.com/desktop-updates/stable/v0.1.0/"
required: true
type: string
push:
tags:
- "v*"
permissions:
contents: read
concurrency:
group: desktop-release-candidate-${{ github.ref }}
cancel-in-progress: false
jobs:
release-policy:
name: Resolve desktop release signing policy
runs-on: ubuntu-latest
outputs:
platform_signing_mode: ${{ steps.policy.outputs.platform_signing_mode }}
macos_signing: ${{ steps.policy.outputs.macos_signing }}
windows_signing: ${{ steps.policy.outputs.windows_signing }}
artifact_label: ${{ steps.policy.outputs.artifact_label }}
warning_required: ${{ steps.policy.outputs.warning_required }}
defaults:
run:
working-directory: frontend
steps:
- name: Checkout release source
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22.13"
- name: Resolve version-scoped signing policy
id: policy
shell: bash
run: npm run desktop:release-policy:check -- --require-tag --github-output "${GITHUB_OUTPUT}"
macos-release-candidate:
name: macOS release candidate
needs: release-policy
runs-on: macos-latest
defaults:
run:
working-directory: frontend
env:
VITE_BUILD_CHANNEL: release
VITE_BUILD_COMMIT: ${{ github.sha }}
VITE_DESKTOP_SERVER_URL: ${{ vars.VITE_DESKTOP_SERVER_URL }}
RELEASE_BUILD: "true"
DESKTOP_RELEASE_PLATFORM: macos
DESKTOP_PLATFORM_SIGNING_MODE: ${{ needs.release-policy.outputs.platform_signing_mode }}
REQUIRE_UPDATER_SIGNING: "true"
REQUIRE_DESKTOP_SIGNING: ${{ needs.release-policy.outputs.platform_signing_mode == 'signed' && 'true' || 'false' }}
ALLOW_UNSIGNED_PLATFORM_RELEASE: ${{ needs.release-policy.outputs.platform_signing_mode == 'unsigned-exception' && 'true' || 'false' }}
DESKTOP_UPDATE_BASE_URL: ${{ github.event_name == 'workflow_dispatch' && inputs.artifact_base_url || vars.DESKTOP_UPDATE_BASE_URL }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
steps:
- name: Checkout release source
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22.13"
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Enforce release tag context
shell: bash
run: |
if [[ "${GITHUB_REF_TYPE}" != "tag" ]]; then
echo "Desktop release candidates must run from a vX.Y.Z tag."
exit 1
fi
expected_tag="v$(node -p "require('./package.json').version")"
if [[ "${GITHUB_REF_NAME}" != "${expected_tag}" ]]; then
echo "Release tag ${GITHUB_REF_NAME} does not match package version ${expected_tag}."
exit 1
fi
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin,x86_64-apple-darwin
- name: Install dependencies
run: npm ci
- name: Audit dependencies
run: npm audit
- name: Check release build metadata and signing environment
run: npm run release:env:check
- name: Check desktop release readiness
run: npm run desktop:release-readiness:check
- name: Check synchronized client version
run: npm run version:check
- name: Check runtime boundary
run: npm run runtime:check
- name: Check desktop release and security gate
run: npm run desktop:release:check
- name: Check UI contract
run: npm run ui:contract
- name: Type check
run: npm run type-check
- name: Unit tests
run: npm run test:unit
- name: Build Web artifact
run: npm run build
- name: Build Apple-signed and notarized Universal macOS artifacts
if: needs.release-policy.outputs.platform_signing_mode == 'signed'
run: npm run desktop:build:macos-release -- --ci
- name: Build ad-hoc Universal macOS artifacts
if: needs.release-policy.outputs.platform_signing_mode == 'unsigned-exception'
run: npm run desktop:build:macos-unsigned-release -- --ci
- name: Verify and stage macOS artifacts
shell: bash
run: |
app="$(find src-tauri/target -path '*/release/bundle/macos/*.app' -print -quit)"
artifact="$(find src-tauri/target -path '*/release/bundle/macos/*.app.tar.gz' -print -quit)"
dmg="$(find src-tauri/target -path '*/release/bundle/dmg/*.dmg' -print -quit)"
if [[ -z "${app}" || -z "${artifact}" || -z "${dmg}" || ! -s "${artifact}.sig" ]]; then
echo "macOS app, updater artifact/signature, and DMG are all required."
exit 1
fi
codesign --verify --deep --strict --verbose=2 "${app}"
if [[ "${DESKTOP_PLATFORM_SIGNING_MODE}" == "signed" ]]; then
spctl --assess --type execute --verbose=2 "${app}"
xcrun stapler validate "${app}"
xcrun stapler validate "${dmg}"
else
signature_info="$(codesign -dv --verbose=4 "${app}" 2>&1)"
if ! grep -q "Signature=adhoc" <<<"${signature_info}"; then
echo "The macOS unsigned-platform exception must produce an ad-hoc signed app."
exit 1
fi
fi
stage="src-tauri/target/desktop-release-macos"
mkdir -p "${stage}"
if [[ "${DESKTOP_PLATFORM_SIGNING_MODE}" == "unsigned-exception" ]]; then
artifact_name="$(basename "${artifact}" .app.tar.gz)_UNSIGNED.app.tar.gz"
dmg_name="$(basename "${dmg}" .dmg)_UNSIGNED.dmg"
cp "${artifact}" "${stage}/${artifact_name}"
cp "${artifact}.sig" "${stage}/${artifact_name}.sig"
cp "${dmg}" "${stage}/${dmg_name}"
cp desktop-release-unsigned-warning.txt "${stage}/UNSIGNED-PLATFORM-RELEASE.txt"
else
cp "${artifact}" "${artifact}.sig" "${dmg}" "${stage}/"
fi
- name: Upload macOS candidate artifacts
uses: actions/upload-artifact@v4
with:
name: ctms-desktop-macos-${{ github.ref_name }}-${{ needs.release-policy.outputs.artifact_label }}
path: frontend/src-tauri/target/desktop-release-macos/*
if-no-files-found: error
windows-release-candidate:
name: Windows release candidate
needs: release-policy
runs-on: windows-latest
defaults:
run:
working-directory: frontend
env:
VITE_BUILD_CHANNEL: release
VITE_BUILD_COMMIT: ${{ github.sha }}
VITE_DESKTOP_SERVER_URL: ${{ vars.VITE_DESKTOP_SERVER_URL }}
RELEASE_BUILD: "true"
DESKTOP_RELEASE_PLATFORM: windows
DESKTOP_PLATFORM_SIGNING_MODE: ${{ needs.release-policy.outputs.platform_signing_mode }}
REQUIRE_UPDATER_SIGNING: "true"
REQUIRE_WINDOWS_SIGNING: ${{ needs.release-policy.outputs.platform_signing_mode == 'signed' && 'true' || 'false' }}
ALLOW_UNSIGNED_PLATFORM_RELEASE: ${{ needs.release-policy.outputs.platform_signing_mode == 'unsigned-exception' && 'true' || 'false' }}
DESKTOP_UPDATE_BASE_URL: ${{ github.event_name == 'workflow_dispatch' && inputs.artifact_base_url || vars.DESKTOP_UPDATE_BASE_URL }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
WINDOWS_TIMESTAMP_URL: ${{ vars.WINDOWS_TIMESTAMP_URL }}
steps:
- name: Checkout release source
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22.13"
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Enforce release tag context
shell: pwsh
run: |
if ($env:GITHUB_REF_TYPE -ne "tag") {
throw "Desktop release candidates must run from a vX.Y.Z tag."
}
$expectedTag = "v$(node -p "require('./package.json').version")"
if ($env:GITHUB_REF_NAME -ne $expectedTag) {
throw "Release tag $env:GITHUB_REF_NAME does not match package version $expectedTag."
}
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
- name: Install dependencies
run: npm ci
- name: Audit dependencies
run: npm audit
- name: Check release build metadata and signing environment
run: npm run release:env:check
- name: Check desktop release readiness
run: npm run desktop:release-readiness:check
- name: Check synchronized client version
run: npm run version:check
- name: Check runtime boundary
run: npm run runtime:check
- name: Check desktop release and security gate
run: npm run desktop:release:check
- name: Check UI contract
run: npm run ui:contract
- name: Type check
run: npm run type-check
- name: Unit tests
run: npm run test:unit
- name: Build Web artifact
run: npm run build
- name: Import Windows code-signing certificate
if: needs.release-policy.outputs.platform_signing_mode == 'signed'
shell: pwsh
run: |
$pfxPath = Join-Path $env:RUNNER_TEMP "ctms-windows-signing.pfx"
$encodedCertificate = $env:WINDOWS_CERTIFICATE `
-replace "-----BEGIN [^-]+-----", "" `
-replace "-----END [^-]+-----", "" `
-replace "\s", ""
[IO.File]::WriteAllBytes($pfxPath, [Convert]::FromBase64String($encodedCertificate))
$password = ConvertTo-SecureString $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force
$importedCertificates = Import-PfxCertificate -FilePath $pfxPath -CertStoreLocation "Cert:\CurrentUser\My" -Password $password -Exportable:$false
$certificate = $importedCertificates | Where-Object { $_.HasPrivateKey } | Select-Object -First 1
if (-not $certificate -or -not $certificate.HasPrivateKey) {
throw "The imported Windows code-signing certificate is missing its private key."
}
$now = Get-Date
if ($certificate.NotBefore -gt $now -or $certificate.NotAfter -le $now) {
throw "The Windows code-signing certificate is not currently valid."
}
$codeSigningEku = $certificate.EnhancedKeyUsageList | Where-Object { $_.ObjectId.Value -eq "1.3.6.1.5.5.7.3.3" }
if (-not $codeSigningEku) {
throw "The Windows certificate does not contain the Code Signing enhanced key usage."
}
"WINDOWS_CERTIFICATE_THUMBPRINT=$($certificate.Thumbprint)" | Out-File -FilePath $env:GITHUB_ENV -Append
Remove-Item $pfxPath -Force
- name: Write signed Windows Tauri config
if: needs.release-policy.outputs.platform_signing_mode == 'signed'
shell: pwsh
run: |
$config = @{
bundle = @{
windows = @{
certificateThumbprint = $env:WINDOWS_CERTIFICATE_THUMBPRINT
digestAlgorithm = "sha256"
timestampUrl = $env:WINDOWS_TIMESTAMP_URL
tsp = $true
}
}
} | ConvertTo-Json -Depth 8
Set-Content -Path "tauri.windows.release.conf.json" -Value $config -Encoding utf8
Get-Content "tauri.windows.release.conf.json"
- name: Build signed Windows NSIS artifacts
if: needs.release-policy.outputs.platform_signing_mode == 'signed'
timeout-minutes: 30
run: npm run desktop:build:windows-release -- --config tauri.windows.release.conf.json --ci
- name: Build unsigned Windows NSIS artifacts
if: needs.release-policy.outputs.platform_signing_mode == 'unsigned-exception'
timeout-minutes: 30
run: npm run desktop:build:windows-release -- --ci
- name: Verify platform signing mode and stage Windows artifacts
shell: pwsh
run: |
$bundleDir = "src-tauri/target/release/bundle/nsis"
$installers = @(Get-ChildItem -Path $bundleDir -Filter "*.exe" -File)
$updaters = @(Get-ChildItem -Path $bundleDir -Filter "*.nsis.zip" -File)
$appExecutables = @(Get-ChildItem -Path "src-tauri/target/release" -Filter "*.exe" -File)
if ($installers.Count -ne 1 -or $updaters.Count -ne 1 -or $appExecutables.Count -eq 0) {
throw "Exactly one NSIS installer/updater and at least one application executable are required."
}
$signaturePath = "$($updaters[0].FullName).sig"
if (-not (Test-Path $signaturePath) -or (Get-Item $signaturePath).Length -eq 0) {
throw "The Windows updater signature is missing or empty."
}
foreach ($executable in @($appExecutables + $installers)) {
$signature = Get-AuthenticodeSignature -FilePath $executable.FullName
if ($env:DESKTOP_PLATFORM_SIGNING_MODE -eq "signed") {
if ($signature.Status -ne "Valid" -or -not $signature.SignerCertificate -or -not $signature.TimeStamperCertificate) {
throw "Authenticode signature or RFC 3161 timestamp is invalid for $($executable.FullName): $($signature.StatusMessage)"
}
} elseif ($signature.Status -ne "NotSigned") {
throw "The Windows unsigned-platform exception must produce an Authenticode-unsigned executable: $($executable.FullName) returned $($signature.Status)."
}
}
$stage = "src-tauri/target/desktop-release-windows"
New-Item -ItemType Directory -Path $stage -Force | Out-Null
if ($env:DESKTOP_PLATFORM_SIGNING_MODE -eq "unsigned-exception") {
$installerName = $installers[0].Name -replace '\.exe$', '_UNSIGNED.exe'
$updaterName = $updaters[0].Name -replace '\.nsis\.zip$', '_UNSIGNED.nsis.zip'
Copy-Item $installers[0].FullName -Destination (Join-Path $stage $installerName)
Copy-Item $updaters[0].FullName -Destination (Join-Path $stage $updaterName)
Copy-Item $signaturePath -Destination (Join-Path $stage "$updaterName.sig")
Copy-Item "desktop-release-unsigned-warning.txt" -Destination (Join-Path $stage "UNSIGNED-PLATFORM-RELEASE.txt")
} else {
Copy-Item $installers[0].FullName, $updaters[0].FullName, $signaturePath -Destination $stage
}
- name: Remove Windows signing certificate
if: always()
shell: pwsh
run: |
if ($env:WINDOWS_CERTIFICATE_THUMBPRINT) {
Remove-Item "Cert:\CurrentUser\My\$env:WINDOWS_CERTIFICATE_THUMBPRINT" -Force -ErrorAction SilentlyContinue
}
Remove-Item (Join-Path $env:RUNNER_TEMP "ctms-windows-signing.pfx") -Force -ErrorAction SilentlyContinue
Remove-Item "tauri.windows.release.conf.json" -Force -ErrorAction SilentlyContinue
- name: Upload Windows candidate artifacts
uses: actions/upload-artifact@v4
with:
name: ctms-desktop-windows-${{ github.ref_name }}-${{ needs.release-policy.outputs.artifact_label }}
path: frontend/src-tauri/target/desktop-release-windows/*
if-no-files-found: error
aggregate-release-candidate:
name: Verify combined desktop release directory
needs:
- release-policy
- macos-release-candidate
- windows-release-candidate
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
env:
DESKTOP_UPDATE_BASE_URL: ${{ github.event_name == 'workflow_dispatch' && inputs.artifact_base_url || vars.DESKTOP_UPDATE_BASE_URL }}
steps:
- name: Checkout release source
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22.13"
- name: Download macOS candidate artifacts
uses: actions/download-artifact@v4
with:
name: ctms-desktop-macos-${{ github.ref_name }}-${{ needs.release-policy.outputs.artifact_label }}
path: frontend/src-tauri/target/desktop-release-input/macos
- name: Download Windows candidate artifacts
uses: actions/download-artifact@v4
with:
name: ctms-desktop-windows-${{ github.ref_name }}-${{ needs.release-policy.outputs.artifact_label }}
path: frontend/src-tauri/target/desktop-release-input/windows
- name: Create desktop release provenance
run: npm run desktop:release-provenance:create -- --macos-signing "${{ needs.release-policy.outputs.macos_signing }}" --windows-signing "${{ needs.release-policy.outputs.windows_signing }}" --output src-tauri/target/desktop-release-input/DESKTOP-RELEASE-PROVENANCE.json
- name: Create combined desktop update feed
shell: bash
run: |
mapfile -t mac_artifacts < <(find src-tauri/target/desktop-release-input/macos -name '*.app.tar.gz' -type f)
mapfile -t dmgs < <(find src-tauri/target/desktop-release-input/macos -name '*.dmg' -type f)
mapfile -t windows_artifacts < <(find src-tauri/target/desktop-release-input/windows -name '*.nsis.zip' -type f)
mapfile -t installers < <(find src-tauri/target/desktop-release-input/windows -name '*.exe' -type f)
if [[ ${#mac_artifacts[@]} -ne 1 || ${#dmgs[@]} -ne 1 || ${#windows_artifacts[@]} -ne 1 || ${#installers[@]} -ne 1 ]]; then
echo "Exactly one macOS updater/DMG and one Windows updater/installer are required."
exit 1
fi
include_args=(--include "${dmgs[0]}" --include "${installers[0]}" --include src-tauri/target/desktop-release-input/DESKTOP-RELEASE-PROVENANCE.json)
notes_args=()
if [[ "${{ needs.release-policy.outputs.warning_required }}" == "true" ]]; then
mapfile -t warnings < <(find src-tauri/target/desktop-release-input/macos -name 'UNSIGNED-PLATFORM-RELEASE.txt' -type f)
if [[ ${#warnings[@]} -ne 1 ]]; then
echo "The unsigned-platform release warning is required."
exit 1
fi
include_args+=(--include "${warnings[0]}")
notes_args+=(--notes "UNSIGNED PLATFORM RELEASE: macOS is ad-hoc signed and not notarized; Windows is not Authenticode-signed. Gatekeeper and SmartScreen warnings are expected. Verify tag, commit, updater signatures, and SHA256SUMS.txt before installation.")
fi
npm run desktop:update-feed:create -- \
--artifact "${mac_artifacts[0]}" \
--platform-artifact "windows-x86_64=${windows_artifacts[0]}" \
"${include_args[@]}" \
"${notes_args[@]}" \
--output-dir src-tauri/target/desktop-release-feed \
--base-url "${DESKTOP_UPDATE_BASE_URL}"
- name: Verify combined desktop update feed
run: npm run desktop:update-feed:check -- --feed src-tauri/target/desktop-release-feed/latest.json --artifacts-dir src-tauri/target/desktop-release-feed --base-url "${DESKTOP_UPDATE_BASE_URL}" --require-platform windows-x86_64 --require-provenance
- name: Upload verified desktop release directory
uses: actions/upload-artifact@v4
with:
name: ctms-desktop-release-${{ github.ref_name }}-${{ needs.release-policy.outputs.artifact_label }}
path: frontend/src-tauri/target/desktop-release-feed/*
if-no-files-found: error
@@ -1,128 +0,0 @@
name: Desktop Windows Internal Build
on:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: desktop-windows-internal-${{ github.ref }}
cancel-in-progress: false
jobs:
windows-internal:
name: Windows NSIS internal validation
runs-on: windows-latest
env:
VITE_DESKTOP_SERVER_URL: ${{ vars.VITE_DESKTOP_SERVER_URL }}
defaults:
run:
working-directory: frontend
steps:
- name: Checkout source
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22.13"
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
- name: Resolve internal build metadata
id: build-metadata
shell: pwsh
run: |
if ($env:GITHUB_REF_TYPE -eq "tag") {
throw "Windows internal validation builds must run from a branch, not a release tag."
}
$channel = "dev"
if ($env:GITHUB_REF_NAME -in @("dev", "main", "release")) {
$channel = $env:GITHUB_REF_NAME
}
"channel=$channel" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
"commit=$env:GITHUB_SHA" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
- name: Install dependencies
run: npm ci
- name: Check release build metadata
run: npm run release:env:check
env:
VITE_BUILD_CHANNEL: ${{ steps.build-metadata.outputs.channel }}
VITE_BUILD_COMMIT: ${{ steps.build-metadata.outputs.commit }}
- name: Check synchronized client version
run: npm run version:check
- name: Check runtime boundary
run: npm run runtime:check
- name: Check desktop release and security gate
run: npm run desktop:release:check
- name: Check UI contract
run: npm run ui:contract
- name: Type check
run: npm run type-check
- name: Unit tests
run: npm run test:unit
- name: Build Web artifact
run: npm run build
env:
VITE_BUILD_CHANNEL: ${{ steps.build-metadata.outputs.channel }}
VITE_BUILD_COMMIT: ${{ steps.build-metadata.outputs.commit }}
- name: Write Windows internal Tauri config
shell: pwsh
run: |
$config = @{
bundle = @{
createUpdaterArtifacts = $false
}
} | ConvertTo-Json -Depth 8
Set-Content -Path "tauri.windows.internal.conf.json" -Value $config -Encoding utf8
Get-Content "tauri.windows.internal.conf.json"
- name: Build unsigned Windows NSIS internal installer
timeout-minutes: 30
run: npm run desktop:build -- --config tauri.windows.internal.conf.json --bundles nsis --ci
env:
VITE_BUILD_CHANNEL: ${{ steps.build-metadata.outputs.channel }}
VITE_BUILD_COMMIT: ${{ steps.build-metadata.outputs.commit }}
- name: Create Windows installer checksum manifest
shell: pwsh
run: |
$bundleDir = "src-tauri/target/release/bundle/nsis"
$installers = Get-ChildItem -Path $bundleDir -Filter "*.exe" -File
if ($installers.Count -eq 0) {
throw "No Windows NSIS EXE installer was produced."
}
$checksums = foreach ($installer in $installers) {
$hash = Get-FileHash -Path $installer.FullName -Algorithm SHA256
"$($hash.Hash.ToLowerInvariant()) $($installer.Name)"
}
$manifest = Join-Path $bundleDir "SHA256SUMS.txt"
$checksums | Set-Content -Path $manifest -Encoding utf8
Get-Content $manifest
- name: Upload Windows internal installer
uses: actions/upload-artifact@v4
with:
name: ctms-windows-internal-${{ steps.build-metadata.outputs.channel }}-${{ steps.build-metadata.outputs.commit }}
path: |
frontend/src-tauri/target/release/bundle/nsis/*.exe
frontend/src-tauri/target/release/bundle/nsis/SHA256SUMS.txt
if-no-files-found: error
-11
View File
@@ -5,7 +5,6 @@ Thumbs.db
# IDE/editor
.idea/
.vscode/
.claude/
*.swp
*.swo
@@ -53,8 +52,6 @@ pyrightconfig.json
frontend/node_modules/
frontend/dist/
frontend/.vite/
frontend/src-tauri/target/
frontend/src-tauri/gen/schemas/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
@@ -72,17 +69,9 @@ 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/
worktrees/
.install-logs/
-62
View File
@@ -1,62 +0,0 @@
# Agent Instructions
桌面端项目计划书已完成并移除;当前桌面端执行约束以本文件为准。桌面端任务包括但不限于 Tauri、macOS、Windows、桌面打包、桌面存储、文件集成、系统通知、自动更新和桌面端安全边界。历史设计只作追溯参考,见 `docs/desktop-phase-1-design.md``docs/desktop-phase-2-design.md``docs/audits/desktop-release-stabilization-checklist.md`
桌面端当前不是空白初始化项目。Tauri 基线、macOS 在线桌面壳和第二阶段原生能力主体已经形成;后续工作限于修复、稳定化、体验收口、发布准备、在线辅助缓存、Windows 兼容验证和已批准的 Windows 正式发布。不得重新按第一阶段空白项目初始化 Tauri,不得绕过现有 `frontend/src/runtime/` 适配层直接在业务模块中使用 Tauri API。
除非用户明确要求先调整本文件中的约束,否则不要实现离线功能、本地业务权威数据存储、内嵌后端服务、离线同步、本地优先工作流或新的阶段性桌面产品线。在线辅助本地缓存只允许作为已认证在线客户端的体验加速能力;处理桌面本地缓存、请求去重、条件请求、缓存诊断或缓存清理相关任务时,必须阅读并遵守 `docs/desktop-local-cache-plan.md`
处理前端或桌面端实现时,优先保持以下边界:
- 共享业务代码通过 `frontend/src/runtime/index.ts` 获取平台能力。
- Tauri API 仅允许出现在 `frontend/src/runtime/``frontend/src-tauri/` 或有明确记录的窄入口中。
- 本地缓存能力只能通过 `frontend/src/runtime/desktopDataCache.ts``frontend/src/runtime/index.ts``clientRuntime.dataCache` 和 API 客户端的受控入口暴露;业务模块不得直接使用 Tauri 存储 API、文件系统、SQLite、IndexedDB 或 Cache Storage 实现缓存。
- 未完成后端 token 校验和 `/me` 身份确认前,不得展示业务缓存;登出、切换服务器、切换用户、401/403、权限上下文变化或缓存 schema 变化时必须清理或失效相关缓存。
- 新增或调整 Tauri command、capability、CSP、updater、凭据、文件、通知、本地缓存持久化或底层存储能力时,必须同步评估 `frontend/scripts/verify-desktop-release.mjs``npm run runtime:check` 和桌面发布检查清单是否需要更新。
- token、附件下载凭据和敏感业务信息不得写入 URL、日志、系统通知正文或明文浏览器存储。
- 正式桌面客户端的默认 CTMS 服务端入口必须由构建环境变量 `VITE_DESKTOP_SERVER_URL` 注入,不得在运行时代码中写死生产域名;用户仍可在桌面服务器设置中手动覆盖,手动值优先并沿用既有的切换服务器登出与缓存失效边界。该变量只表示业务服务端 origin,不得与 updater 制品前缀 `DESKTOP_UPDATE_BASE_URL` 混用。
- Windows x64 NSIS 已获准作为正式桌面发布目标;正式制品必须由 `.github/workflows/desktop-release-candidate.yml` 从与 macOS/Web 相同的 `vX.Y.Z` tag 和 SHA 构建。平台签名默认要求组织 Windows 代码签名证书、RFC 3161 时间戳和 Authenticode 校验;只有 `frontend/desktop-release-policy.json` 中按精确版本记录、经发布负责人批准的例外可跳过平台签名。无论是否采用平台签名例外,都必须使用 updater 私钥、校验 updater feed,并明确标记平台未签名风险。`.github/workflows/desktop-windows-internal.yml` 仍只用于分支上的无签名兼容性验证,不得生成生产 `latest.json`、updater feed 或正式发布制品。
- v0.1.0 另获准在 GitHub Actions 额度不可用时,从最终 `release` 上不可移动的 `v0.1.0` tag/SHA 在受控 macOS 主机本地构建并先行上传 macOS ad-hoc 制品;Windows 只能在额度恢复后从同一 tag/SHA 后补。macOS 先行阶段必须发布 updater `.sig`、checksum、provenance、`UNSIGNED-PLATFORM` 与 Windows pending 说明,但不得发布或替换生产 `latest.json`;只有 Windows `NotSigned` 制品和联合 feed 均验证通过后才能激活自动更新源。该本地/分阶段例外不适用于后续版本。
## 分支与发布治理
处理代码提交、分支同步、版本晋级、正式发布或生产热修复前,必须先阅读:
- `docs/guides/branch-maintenance-sop-zh.md`
- `docs/branch-governance.md`
- 涉及网页端或桌面端客户端发布时,还需阅读 `docs/guides/client-release.md`
必须遵守以下规则:
- CTMS 网页端和桌面端属于同一个产品,共用 `dev``main``release` 分支,不创建 `web-dev``desktop-dev``web-release``desktop-release` 等长期平行分支。
- 默认晋级路径为短期任务分支进入 `dev`,再由 `dev` 晋级到 `main`,最后由 `main` 发布到 `release`
- Agent 创建分支时默认使用 `codex/<任务名称>`;分支必须从最新 `dev` 创建,并在合并到 `dev` 后删除。
- `codex/ctms-desktop` 是历史桌面端临时集成分支,不再作为当前工作线;不得继续向该分支提交、变基或推送新的桌面端工作,除非用户明确要求做收尾或删除分支。
- 生产热修复从 `release` 创建,合并到 `release` 后必须依次回合并到 `main``dev`
- 网页端和桌面端必须使用同一个语义化版本号、正式标签和源代码提交。修改客户端版本时使用 `frontend/package.json` 中的 `version:set``version:check` 命令。
- 平台差异必须收敛在 `frontend/src/runtime/` 之后,不能通过长期分支或复制业务代码维护桌面差异。
- 未经用户明确要求,不执行提交、推送、合并、变基、打标签、删除分支或强制更新远程分支。
- 执行用户明确要求的 Git 操作前,先检查工作区和目标分支,只暂存本次任务相关文件,不覆盖或撤销用户已有改动。
- 如果工作区处于 detached HEAD 或包含尚未归属到分支的提交,执行任何分支切换、提交、推送或变基前必须先说明目标基线,并等待用户明确指令。
- 分支治理规则发生变化时,必须同步更新上述治理文档,不能只修改 `AGENTS.md`
## 常用质量门禁
前端或桌面端代码变更应按影响范围执行相关检查。发布、桌面端适配层、Tauri 配置或安全边界相关变更至少考虑:
```bash
cd frontend
npm run version:check
npm run release:env:check
npm run runtime:check
npm run desktop:release:check
npm run ui:contract
npm run type-check
npm run test:unit
npm run build
npm run desktop:build:app
```
本地缓存相关变更至少执行 `runtime:check``desktop:release:check``ui:contract``type-check``test:unit``build`;若新增 Tauri command、capability、CSP 或底层持久化存储,还需执行 `desktop:build:app` 并在真实桌面 App 中验证缓存清理和诊断入口。
正式桌面发布构建必须使用组织批准的 updater 签名私钥,updater 签名不可因平台签名例外而关闭。平台签名默认要求 macOS 完成 Apple 签名/公证、Windows 完成组织代码签名、RFC 3161 时间戳和 Authenticode 校验。当前仅批准 v0.1.0 采用受控分发例外:macOS 使用 ad-hoc 签名且不公证,Windows 应用和安装器保持 Authenticode 未签名;制品名、发布说明、校验清单和 provenance 必须清楚标注 `UNSIGNED-PLATFORM`,并提示 Gatekeeper/SmartScreen 警告。该例外不自动适用于后续版本。
+7 -34
View File
@@ -1,26 +1,17 @@
# 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 --build backend-init`
- 初始化方式:`docker compose run --rm backend-init`
- 启动方式:`docker compose up -d --build`
- 运行拓扑:`nginx``backend``db``onlyoffice`
- 运行拓扑:`nginx``backend``db`
- 对外入口:`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 部署需改为共享缓存或启用粘性会话
- 默认无任何 demo 数据;生产初始化只确保固定管理员 `admin@huapont.cn / admin123` 存在
- 验证方式:
- `docker compose config`
- `curl -i http://127.0.0.1:8888/`
- `curl -i http://127.0.0.1:8888/health`
- `curl -i http://127.0.0.1:8888/readyz`
## 账号与注册
- 初始化管理员:`admin@huapont.cn / admin123`(通过生产初始化命令显式创建)
@@ -39,39 +30,21 @@
## 访问方式
- 前端:`http://localhost:8888`
- 后端 API:同域 `/api/v1/*`
- `nginx` 负责托管前端静态资源,并将 `/api``/health``/readyz` 转发到 `backend`
## macOS 桌面端开发
- 桌面端约束集中在 `AGENTS.md`:Tauri 在线客户端,不内嵌后端、不保存本地业务权威数据、不做离线同步;在线辅助缓存必须遵守 `docs/desktop-local-cache-plan.md`
- 开发启动:进入 `frontend/` 后执行 `npm run desktop:dev`
- 生产构建:进入 `frontend/` 后执行 `npm run desktop:build`DMG 构建执行 `npm run desktop:bundle:dmg`
- 首次启动桌面端会要求配置 CTMS 服务端地址,并在保存前检查 `${serverUrl}/health`
- 生产或非本地服务地址必须使用 HTTPS;本地开发允许 `http://localhost``http://127.0.0.1`
- Web 与桌面端共用产品版本;执行 `npm run version:set -- <version>` 统一升级,执行 `npm run version:check` 检查漂移。
- 桌面端与 Web 端从同一发布标签和 Git 提交构建,具体流程见 `docs/guides/client-release.md`
## ONLYOFFICE 标准组件
- `./install.sh``bash scripts/install.sh dev|main|release` 默认生成独立 JWT、持久化实例标识并启动 Document Server,不再需要额外启用 Compose profile。
- `bash scripts/onlyoffice-dev-up.sh` 仅保留为开发环境重建、健康验证和密钥轮换入口;普通安装无需再执行。
- 默认镜像、字体、安全配置和生产许可要求见 `docs/guides/onlyoffice-preview.md`
- `nginx` 负责托管前端静态资源,并将 `/api``/health` 转发到 `backend`
## 仓库治理文档
- 分支治理规范:`docs/branch-governance.md`
- 分支维护中文 SOP`docs/guides/branch-maintenance-sop-zh.md`
- 分支环境安装配置:`docs/guides/branch-environment-installation.md`
- 发布检查清单:`docs/guides/release-checklist.md`
- 系统监测简易运维:`docs/guides/system-monitoring-operations.md`
## 本地配置
- 本地编辑器配置(如 `.vscode/`)不纳入版本库。
- 前端环境变量请使用 `frontend/.env`,可从 `frontend/.env.example` 复制。
- 根目录 `.env` 不作为当前默认启动流程的提交配置文件。
- Postman 本地环境请基于 `docs/postman/local.postman_environment.example.json` 自行复制,不提交个人环境文件。
- 文档入口见 `docs/README.md`;当前约束集中在分支治理、发布清单和治理审计文档,操作手册集中在 `docs/guides/`历史交付记录集中在 `docs/reports/``docs/plans/`
- 文档入口见 `docs/README.md`;当前操作手册集中在 `docs/guides/`审计与治理文档集中在 `docs/audits/`
## 常用流程
1. 用管理员账号登录前端(默认 `admin@huapont.cn / admin123`)。
1. 用管理员账号登录前端(默认 `admin@example.com / admin123`)。
2. 进入左侧导航「账号治理」「项目治理」进行用户、项目、中心等全局管理;或在“项目列表”选择项目后进入项目内工作区。
3. Dashboard 查看进度、待办;左侧导航进入“里程碑”“任务”等页面。
4. 退出登录或切换项目可通过顶部右侧下拉。
@@ -84,6 +57,6 @@
> ADMIN 具备所有 PM 权限,项目内操作与 PM 同步放行。
## 注意
- 登录使用邮箱 + 密码,前端会先获取登录公钥并加密提交;未审核/已拒绝/已停用账号无法登录。
- 登录使用邮箱 + 密码,未审核/已拒绝/已停用账号无法登录。
- 令牌与当前项目上下文保存在浏览器 LocalStorage 中,清除后需重新登录/选择项目。
- 若后端重启,确认数据库容器仍健康,前端会弹出错误提示。
+1 -3
View File
@@ -14,6 +14,4 @@ COPY alembic /code/alembic
COPY app /code/app
COPY scripts /code/scripts
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"
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
@@ -19,18 +19,10 @@ 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("studies")}
if "is_locked" not in columns:
op.add_column("studies", sa.Column("is_locked", sa.Boolean(), nullable=False, server_default="false"))
# 添加 is_locked 字段到 studies 表
op.add_column('studies', sa.Column('is_locked', sa.Boolean(), nullable=False, server_default='false'))
def downgrade() -> None:
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")
# 删除 is_locked 字段
op.drop_column('studies', 'is_locked')
@@ -19,18 +19,10 @@ 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 "enrollment_target" not in columns:
op.add_column("sites", sa.Column("enrollment_target", sa.Integer(), nullable=True))
# 添加 enrollment_target 字段到 sites 表
op.add_column('sites', sa.Column('enrollment_target', sa.Integer(), nullable=True))
def downgrade() -> None:
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")
# 删除 enrollment_target 字段
op.drop_column('sites', 'enrollment_target')
@@ -1,4 +1,4 @@
"""disable overview demo data seed
"""seed overview demo data
Revision ID: 20260116_03
Revises: 20260116_02
@@ -6,7 +6,14 @@ Create Date: 2026-01-16 16:30:00.000000
"""
from typing import Sequence, Union
from datetime import date, timedelta
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
# revision identifiers, used by Alembic.
revision: str = '20260116_03'
down_revision: Union[str, None] = '20260116_02'
branch_labels: Union[str, Sequence[str], None] = None
@@ -14,11 +21,179 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# This historical revision is intentionally kept as a no-op so fresh
# databases no longer receive overview demo data during migration.
return
# 获取connection
conn = op.get_bind()
# 首先获取第一个项目ID
result = conn.execute(sa.text("SELECT id FROM studies LIMIT 1"))
study_row = result.first()
if not study_row:
print("没有项目,跳过示例数据注入")
return
study_id = study_row[0]
# 获取该项目的中心
result = conn.execute(sa.text("SELECT id, name FROM sites WHERE study_id = :study_id ORDER BY created_at LIMIT 5"), {"study_id": study_id})
sites = result.fetchall()
if not sites:
print("没有中心,跳过示例数据注入")
return
print(f"{len(sites)} 个中心注入示例数据...")
# 清理可能存在的演示数据(使用ON CONFLICT处理或先删除)
# 收集site_ids
site_ids = [site[0] for site in sites]
# 删除这些中心的existing数据 (如果是演示数据)
# 由于我们无法判断哪些是演示数据,使用ON CONFLICT DO NOTHING策略
# 更新入组目标
targets = [80, 60, 70, 50, 40]
for idx, site in enumerate(sites):
site_id = site[0]
target = targets[idx] if idx < len(targets) else 50
conn.execute(
sa.text("UPDATE sites SET enrollment_target = :target WHERE id = :site_id"),
{"target": target, "site_id": site_id}
)
# 基准日期
base_date = date(2024, 12, 1)
# 为每个中心添加启动流程数据
for idx, site in enumerate(sites):
site_id = site[0]
# 机构立项 - 使用 ON CONFLICT DO NOTHING (假设有唯一约束)
submit_date = base_date + timedelta(days=idx * 10)
accept_date = base_date + timedelta(days=idx * 10 + 5)
approved_date = base_date + timedelta(days=idx * 10 + 15) if idx < 4 else None
# 检查是否已存在
existing = conn.execute(sa.text(
"SELECT 1 FROM startup_feasibility WHERE study_id = :study_id AND site_id = :site_id LIMIT 1"
), {"study_id": study_id, "site_id": site_id}).first()
if not existing:
conn.execute(sa.text("""
INSERT INTO startup_feasibility (id, study_id, site_id, submit_date, accept_date, approved_date, project_no, created_at)
VALUES (gen_random_uuid(), :study_id, :site_id, :submit_date, :accept_date, :approved_date, :project_no, NOW())
"""), {
"study_id": study_id,
"site_id": site_id,
"submit_date": submit_date,
"accept_date": accept_date,
"approved_date": approved_date,
"project_no": f"PROJ-{idx+1:03d}"
})
# 伦理审批
ethics_submit = base_date + timedelta(days=idx * 10 + 20)
ethics_accept = base_date + timedelta(days=idx * 10 + 25)
meeting_date = base_date + timedelta(days=idx * 10 + 35) if idx < 4 else None
ethics_approved = base_date + timedelta(days=idx * 10 + 40) if idx < 4 else None
existing = conn.execute(sa.text(
"SELECT 1 FROM startup_ethics WHERE study_id = :study_id AND site_id = :site_id LIMIT 1"
), {"study_id": study_id, "site_id": site_id}).first()
if not existing:
conn.execute(sa.text("""
INSERT INTO startup_ethics (id, study_id, site_id, submit_date, accept_date, meeting_date, approved_date, approval_no, created_at)
VALUES (gen_random_uuid(), :study_id, :site_id, :submit_date, :accept_date, :meeting_date, :approved_date, :approval_no, NOW())
"""), {
"study_id": study_id,
"site_id": site_id,
"submit_date": ethics_submit,
"accept_date": ethics_accept,
"meeting_date": meeting_date,
"approved_date": ethics_approved,
"approval_no": f"EC-{idx+1:03d}" if idx < 4 else None
})
# 合同签署 (前3个中心)
if idx < 3:
site_name = site[1]
signed_date = base_date + timedelta(days=idx * 10 + 50)
# 检查合同是否已存在
existing = conn.execute(sa.text(
"SELECT 1 FROM finance_contracts WHERE study_id = :study_id AND site_name = :site_name LIMIT 1"
), {"study_id": study_id, "site_name": site_name}).first()
if not existing:
conn.execute(sa.text("""
INSERT INTO finance_contracts (id, study_id, site_name, contract_no, signed_date, amount, currency, created_at)
VALUES (gen_random_uuid(), :study_id, :site_name, :contract_no, :signed_date, 500000.0, 'CNY', NOW())
"""), {
"study_id": study_id,
"site_name": site_name,
"contract_no": f"CT-{idx+1:03d}",
"signed_date": signed_date
})
# 启动会 (前3个中心)
if idx < 3:
kickoff_date = base_date + timedelta(days=idx * 10 + 60)
# 检查启动会是否已存在
existing = conn.execute(sa.text(
"SELECT 1 FROM kickoff_meetings WHERE study_id = :study_id AND site_id = :site_id LIMIT 1"
), {"study_id": study_id, "site_id": site_id}).first()
if not existing:
conn.execute(sa.text("""
INSERT INTO kickoff_meetings (id, study_id, site_id, kickoff_date, attendees, created_at)
VALUES (gen_random_uuid(), :study_id, :site_id, :kickoff_date, '["研究者", "CRA", "PM"]'::jsonb, NOW())
"""), {
"study_id": study_id,
"site_id": site_id,
"kickoff_date": kickoff_date
})
# 为前3个中心添加受试者
enrollment_counts = [52, 18, 70]
start_enrollment = date(2025, 1, 1)
for idx in range(min(3, len(sites))):
site_id = sites[idx][0]
count = enrollment_counts[idx] if idx < len(enrollment_counts) else 0
# 检查该中心是否已有受试者数据
existing_count = conn.execute(sa.text(
"SELECT COUNT(*) FROM subjects WHERE study_id = :study_id AND site_id = :site_id"
), {"study_id": study_id, "site_id": site_id}).scalar()
# 如果已有数据,跳过该中心
if existing_count > 0:
print(f" 中心 {idx+1} 已有 {existing_count} 名受试者,跳过")
continue
for i in range(count):
enrollment_date = start_enrollment + timedelta(days=i * 4)
screening_date = enrollment_date - timedelta(days=7)
consent_date = enrollment_date - timedelta(days=3)
conn.execute(sa.text("""
INSERT INTO subjects (id, study_id, site_id, subject_no, status, screening_date, consent_date, enrollment_date, created_at)
VALUES (gen_random_uuid(), :study_id, :site_id, :subject_no, 'ENROLLED', :screening_date, :consent_date, :enrollment_date, NOW())
"""), {
"study_id": study_id,
"site_id": site_id,
"subject_no": f"S{idx+1:02d}-{i+1:03d}",
"screening_date": screening_date,
"consent_date": consent_date,
"enrollment_date": enrollment_date
})
print("✅ 示例数据注入完成")
def downgrade() -> None:
return
# 删除示例数据 (可选 - 也可以留空)
pass
@@ -20,18 +20,10 @@ 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.drop_table("workflow_actions")
op.drop_table("version_workflows")
op.drop_table("workflow_nodes")
op.drop_table("workflow_templates")
op.execute("DROP TYPE IF EXISTS workflow_action_type")
op.execute("DROP TYPE IF EXISTS workflow_status")
@@ -19,31 +19,18 @@ 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)
op.add_column(
"adverse_events",
sa.Column("is_sae", sa.Boolean(), nullable=False, server_default=sa.false()),
)
op.add_column(
"adverse_events",
sa.Column("is_susar", sa.Boolean(), nullable=False, server_default=sa.false()),
)
op.alter_column("adverse_events", "is_sae", server_default=None)
op.alter_column("adverse_events", "is_susar", server_default=None)
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")
op.drop_column("adverse_events", "is_susar")
op.drop_column("adverse_events", "is_sae")
@@ -1,397 +0,0 @@
"""cleanup overview demo data
Revision ID: 20260331_01
Revises: 20260302_02
Create Date: 2026-03-31 10:45:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260331_01"
down_revision: Union[str, None] = "20260302_02"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
DEMO_FEASIBILITY_CODES = ("PROJ-001", "PROJ-002", "PROJ-003", "PROJ-004", "PROJ-005")
DEMO_ETHICS_CODES = ("EC-001", "EC-002", "EC-003", "EC-004")
DEMO_FINANCE_CONTRACT_CODES = ("CT-001", "CT-002", "CT-003")
DEMO_STUDY_CODE = "DEMO-CTMS"
def upgrade() -> None:
conn = op.get_bind()
conn.execute(
sa.text(
"""
WITH candidate_studies AS (
SELECT DISTINCT study_id
FROM startup_feasibility
WHERE project_no = ANY(:feasibility_codes)
UNION
SELECT DISTINCT study_id
FROM startup_ethics
WHERE approval_no = ANY(:ethics_codes)
UNION
SELECT DISTINCT study_id
FROM finance_contracts
WHERE contract_no = ANY(:contract_codes)
AND amount = 500000.0
AND currency = 'CNY'
UNION
SELECT DISTINCT study_id
FROM kickoff_meetings
WHERE attendees::text = '["研究者", "CRA", "PM"]'
UNION
SELECT DISTINCT study_id
FROM subjects
WHERE subject_no ~ '^S0[1-3]-[0-9]{3}$'
),
candidate_sites AS (
SELECT DISTINCT site_id
FROM startup_feasibility
WHERE study_id IN (SELECT study_id FROM candidate_studies)
AND project_no = ANY(:feasibility_codes)
UNION
SELECT DISTINCT site_id
FROM startup_ethics
WHERE study_id IN (SELECT study_id FROM candidate_studies)
AND (
approval_no = ANY(:ethics_codes)
OR (
approval_no IS NULL
AND submit_date = DATE '2025-01-10'
AND accept_date = DATE '2025-01-15'
AND meeting_date IS NULL
AND approved_date IS NULL
)
OR (
approval_no IS NULL
AND submit_date = DATE '2025-01-20'
AND accept_date = DATE '2025-01-25'
AND meeting_date IS NULL
AND approved_date IS NULL
)
OR (
approval_no IS NULL
AND submit_date = DATE '2025-01-30'
AND accept_date = DATE '2025-02-04'
AND meeting_date IS NULL
AND approved_date IS NULL
)
)
UNION
SELECT DISTINCT site_id
FROM kickoff_meetings
WHERE study_id IN (SELECT study_id FROM candidate_studies)
AND attendees::text = '["研究者", "CRA", "PM"]'
UNION
SELECT DISTINCT site_id
FROM subjects
WHERE study_id IN (SELECT study_id FROM candidate_studies)
AND subject_no ~ '^S0[1-3]-[0-9]{3}$'
)
UPDATE sites
SET enrollment_target = 0
WHERE id IN (SELECT site_id FROM candidate_sites WHERE site_id IS NOT NULL)
AND enrollment_target IN (40, 50, 60, 70, 80)
"""
),
{
"feasibility_codes": list(DEMO_FEASIBILITY_CODES),
"ethics_codes": list(DEMO_ETHICS_CODES),
"contract_codes": list(DEMO_FINANCE_CONTRACT_CODES),
},
)
conn.execute(
sa.text(
"""
WITH candidate_studies AS (
SELECT DISTINCT study_id FROM startup_feasibility WHERE project_no = ANY(:feasibility_codes)
UNION
SELECT DISTINCT study_id FROM startup_ethics WHERE approval_no = ANY(:ethics_codes)
UNION
SELECT DISTINCT study_id FROM finance_contracts
WHERE contract_no = ANY(:contract_codes) AND amount = 500000.0 AND currency = 'CNY'
UNION
SELECT DISTINCT study_id FROM kickoff_meetings WHERE attendees::text = '["研究者", "CRA", "PM"]'
UNION
SELECT DISTINCT study_id FROM subjects WHERE subject_no ~ '^S0[1-3]-[0-9]{3}$'
)
DELETE FROM subjects
WHERE study_id IN (SELECT study_id FROM candidate_studies)
AND subject_no ~ '^S0[1-3]-[0-9]{3}$'
"""
),
{
"feasibility_codes": list(DEMO_FEASIBILITY_CODES),
"ethics_codes": list(DEMO_ETHICS_CODES),
"contract_codes": list(DEMO_FINANCE_CONTRACT_CODES),
},
)
conn.execute(
sa.text(
"""
WITH candidate_studies AS (
SELECT DISTINCT study_id FROM startup_feasibility WHERE project_no = ANY(:feasibility_codes)
UNION
SELECT DISTINCT study_id FROM startup_ethics WHERE approval_no = ANY(:ethics_codes)
UNION
SELECT DISTINCT study_id FROM finance_contracts
WHERE contract_no = ANY(:contract_codes) AND amount = 500000.0 AND currency = 'CNY'
UNION
SELECT DISTINCT study_id FROM kickoff_meetings WHERE attendees::text = '["研究者", "CRA", "PM"]'
UNION
SELECT DISTINCT study_id FROM subjects WHERE subject_no ~ '^S0[1-3]-[0-9]{3}$'
)
DELETE FROM kickoff_meetings
WHERE study_id IN (SELECT study_id FROM candidate_studies)
AND attendees::text = '["研究者", "CRA", "PM"]'
"""
),
{
"feasibility_codes": list(DEMO_FEASIBILITY_CODES),
"ethics_codes": list(DEMO_ETHICS_CODES),
"contract_codes": list(DEMO_FINANCE_CONTRACT_CODES),
},
)
conn.execute(
sa.text(
"""
WITH candidate_studies AS (
SELECT DISTINCT study_id FROM startup_feasibility WHERE project_no = ANY(:feasibility_codes)
UNION
SELECT DISTINCT study_id FROM startup_ethics WHERE approval_no = ANY(:ethics_codes)
UNION
SELECT DISTINCT study_id FROM finance_contracts
WHERE contract_no = ANY(:contract_codes) AND amount = 500000.0 AND currency = 'CNY'
UNION
SELECT DISTINCT study_id FROM kickoff_meetings WHERE attendees::text = '["研究者", "CRA", "PM"]'
UNION
SELECT DISTINCT study_id FROM subjects WHERE subject_no ~ '^S0[1-3]-[0-9]{3}$'
)
DELETE FROM finance_contracts
WHERE study_id IN (SELECT study_id FROM candidate_studies)
AND contract_no = ANY(:contract_codes)
AND amount = 500000.0
AND currency = 'CNY'
"""
),
{
"feasibility_codes": list(DEMO_FEASIBILITY_CODES),
"ethics_codes": list(DEMO_ETHICS_CODES),
"contract_codes": list(DEMO_FINANCE_CONTRACT_CODES),
},
)
conn.execute(
sa.text(
"""
WITH candidate_studies AS (
SELECT DISTINCT study_id FROM startup_feasibility WHERE project_no = ANY(:feasibility_codes)
UNION
SELECT DISTINCT study_id FROM startup_ethics WHERE approval_no = ANY(:ethics_codes)
UNION
SELECT DISTINCT study_id FROM finance_contracts
WHERE contract_no = ANY(:contract_codes) AND amount = 500000.0 AND currency = 'CNY'
UNION
SELECT DISTINCT study_id FROM kickoff_meetings WHERE attendees::text = '["研究者", "CRA", "PM"]'
UNION
SELECT DISTINCT study_id FROM subjects WHERE subject_no ~ '^S0[1-3]-[0-9]{3}$'
)
DELETE FROM startup_ethics
WHERE study_id IN (SELECT study_id FROM candidate_studies)
AND (
approval_no = ANY(:ethics_codes)
OR (
approval_no IS NULL
AND submit_date = DATE '2025-01-10'
AND accept_date = DATE '2025-01-15'
AND meeting_date IS NULL
AND approved_date IS NULL
)
OR (
approval_no IS NULL
AND submit_date = DATE '2025-01-20'
AND accept_date = DATE '2025-01-25'
AND meeting_date IS NULL
AND approved_date IS NULL
)
OR (
approval_no IS NULL
AND submit_date = DATE '2025-01-30'
AND accept_date = DATE '2025-02-04'
AND meeting_date IS NULL
AND approved_date IS NULL
)
)
"""
),
{
"feasibility_codes": list(DEMO_FEASIBILITY_CODES),
"ethics_codes": list(DEMO_ETHICS_CODES),
"contract_codes": list(DEMO_FINANCE_CONTRACT_CODES),
},
)
conn.execute(
sa.text(
"""
WITH candidate_studies AS (
SELECT DISTINCT study_id FROM startup_feasibility WHERE project_no = ANY(:feasibility_codes)
UNION
SELECT DISTINCT study_id FROM startup_ethics WHERE approval_no = ANY(:ethics_codes)
UNION
SELECT DISTINCT study_id FROM finance_contracts
WHERE contract_no = ANY(:contract_codes) AND amount = 500000.0 AND currency = 'CNY'
UNION
SELECT DISTINCT study_id FROM kickoff_meetings WHERE attendees::text = '["研究者", "CRA", "PM"]'
UNION
SELECT DISTINCT study_id FROM subjects WHERE subject_no ~ '^S0[1-3]-[0-9]{3}$'
)
DELETE FROM startup_feasibility
WHERE study_id IN (SELECT study_id FROM candidate_studies)
AND project_no = ANY(:feasibility_codes)
"""
),
{
"feasibility_codes": list(DEMO_FEASIBILITY_CODES),
"ethics_codes": list(DEMO_ETHICS_CODES),
"contract_codes": list(DEMO_FINANCE_CONTRACT_CODES),
},
)
conn.execute(
sa.text(
"""
WITH demo_study AS (
SELECT id
FROM studies
WHERE code = :demo_study_code
),
demo_contract_fees AS (
SELECT id
FROM contract_fees
WHERE project_id IN (SELECT id FROM demo_study)
),
demo_special_expenses AS (
SELECT id
FROM special_expenses
WHERE project_id IN (SELECT id FROM demo_study)
)
DELETE FROM fee_attachments
WHERE (
entity_type = 'contract_fee'
AND entity_id IN (SELECT id FROM demo_contract_fees)
) OR (
entity_type = 'special_expense'
AND entity_id IN (SELECT id FROM demo_special_expenses)
) OR (
url IN (
'https://example.com/contract-demo.pdf',
'https://example.com/voucher-demo.pdf',
'https://example.com/invoice-demo.pdf'
)
)
"""
),
{
"demo_study_code": DEMO_STUDY_CODE,
},
)
conn.execute(
sa.text(
"""
WITH demo_study AS (
SELECT id
FROM studies
WHERE code = :demo_study_code
),
demo_contract_fees AS (
SELECT id
FROM contract_fees
WHERE project_id IN (SELECT id FROM demo_study)
)
DELETE FROM contract_fee_payments
WHERE contract_fee_id IN (SELECT id FROM demo_contract_fees)
"""
),
{
"demo_study_code": DEMO_STUDY_CODE,
},
)
conn.execute(
sa.text(
"""
DELETE FROM contract_fees
WHERE project_id IN (
SELECT id
FROM studies
WHERE code = :demo_study_code
)
"""
),
{
"demo_study_code": DEMO_STUDY_CODE,
},
)
conn.execute(
sa.text(
"""
DELETE FROM special_expenses
WHERE project_id IN (
SELECT id
FROM studies
WHERE code = :demo_study_code
)
"""
),
{
"demo_study_code": DEMO_STUDY_CODE,
},
)
conn.execute(
sa.text(
"""
DELETE FROM sites
WHERE study_id IN (
SELECT id
FROM studies
WHERE code = :demo_study_code
)
AND name LIKE '示例中心 %'
"""
),
{
"demo_study_code": DEMO_STUDY_CODE,
},
)
conn.execute(
sa.text(
"""
DELETE FROM studies
WHERE code = :demo_study_code
"""
),
{
"demo_study_code": DEMO_STUDY_CODE,
},
)
def downgrade() -> None:
return
@@ -1,43 +0,0 @@
"""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")
@@ -1,47 +0,0 @@
"""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))
@@ -1,26 +0,0 @@
"""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")
@@ -1,59 +0,0 @@
"""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")
@@ -1,55 +0,0 @@
"""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")
@@ -1,34 +0,0 @@
"""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")
@@ -1,157 +0,0 @@
"""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
@@ -1,49 +0,0 @@
"""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)
@@ -1,36 +0,0 @@
"""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")
@@ -1,114 +0,0 @@
"""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"])
@@ -1,57 +0,0 @@
"""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")
@@ -1,25 +0,0 @@
"""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
@@ -1,81 +0,0 @@
"""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")
@@ -1,143 +0,0 @@
"""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")
@@ -1,28 +0,0 @@
"""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")
@@ -1,59 +0,0 @@
"""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")
@@ -1,42 +0,0 @@
"""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")
@@ -1,153 +0,0 @@
"""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
"""
)
@@ -1,58 +0,0 @@
"""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)
@@ -1,90 +0,0 @@
"""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
@@ -1,149 +0,0 @@
"""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")
@@ -1,51 +0,0 @@
"""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)
@@ -1,124 +0,0 @@
"""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"])
@@ -1,81 +0,0 @@
"""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
@@ -1,81 +0,0 @@
"""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
@@ -1,70 +0,0 @@
"""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
@@ -1,109 +0,0 @@
"""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))
@@ -1,213 +0,0 @@
"""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)
@@ -1,87 +0,0 @@
"""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'
"""
)
@@ -1,88 +0,0 @@
"""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")
@@ -1,25 +0,0 @@
"""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
@@ -1,44 +0,0 @@
"""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")
@@ -1,132 +0,0 @@
"""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)
@@ -1,139 +0,0 @@
"""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)
@@ -1,119 +0,0 @@
"""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")
@@ -1,36 +0,0 @@
"""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")
@@ -1,94 +0,0 @@
"""add email verification settings
Revision ID: 20260629_01
Revises: 20260608_01
Create Date: 2026-06-29 11:20:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "20260629_01"
down_revision: Union[str, None] = "20260608_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 _uuid_column() -> sa.Column:
return sa.Column(
"id",
postgresql.UUID(as_uuid=True),
primary_key=True,
nullable=False,
)
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
smtp_security = postgresql.ENUM("NONE", "SSL", "STARTTLS", name="smtp_security", create_type=False)
email_purpose = postgresql.ENUM("REGISTER", "PASSWORD_RESET", name="email_verification_purpose", create_type=False)
smtp_security.create(bind, checkfirst=True)
email_purpose.create(bind, checkfirst=True)
if not _table_exists(inspector, "system_email_settings"):
op.create_table(
"system_email_settings",
_uuid_column(),
sa.Column("smtp_host", sa.String(length=255), nullable=False),
sa.Column("smtp_port", sa.Integer(), nullable=False),
sa.Column("smtp_security", smtp_security, server_default="SSL", nullable=False),
sa.Column("smtp_username", sa.String(length=255), nullable=False),
sa.Column("smtp_password_encrypted", sa.Text(), nullable=True),
sa.Column("sender_email", sa.String(length=255), nullable=False),
sa.Column("sender_name", sa.String(length=255), nullable=True),
sa.Column("allowed_register_domain", sa.String(length=255), nullable=False),
sa.Column("verification_code_ttl_minutes", sa.Integer(), nullable=False),
sa.Column("send_cooldown_seconds", sa.Integer(), nullable=False),
sa.Column("max_verify_attempts", sa.Integer(), nullable=False),
sa.Column("updated_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(["updated_by"], ["users.id"]),
)
if not _table_exists(inspector, "email_verification_codes"):
op.create_table(
"email_verification_codes",
_uuid_column(),
sa.Column("email", sa.String(length=255), nullable=False),
sa.Column("purpose", email_purpose, server_default="REGISTER", nullable=False),
sa.Column("code_hash", sa.String(length=255), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("verified_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("attempt_count", sa.Integer(), server_default="0", nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index(
"ix_email_verification_codes_email",
"email_verification_codes",
["email"],
unique=False,
)
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if _table_exists(inspector, "email_verification_codes"):
op.drop_index("ix_email_verification_codes_email", table_name="email_verification_codes")
op.drop_table("email_verification_codes")
if _table_exists(inspector, "system_email_settings"):
op.drop_table("system_email_settings")
sa.Enum("REGISTER", "PASSWORD_RESET", name="email_verification_purpose").drop(bind, checkfirst=True)
sa.Enum("NONE", "SSL", "STARTTLS", name="smtp_security").drop(bind, checkfirst=True)
@@ -1,40 +0,0 @@
"""default multiple register email domains
Revision ID: 20260629_02
Revises: 20260629_01
Create Date: 2026-06-29 12:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260629_02"
down_revision: Union[str, None] = "20260629_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)
if "system_email_settings" in inspector.get_table_names():
op.execute(
"UPDATE system_email_settings "
"SET allowed_register_domain = 'huapont.cn,qq.com' "
"WHERE allowed_register_domain IN ('huapont.cn', '@huapont.cn')"
)
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if "system_email_settings" in inspector.get_table_names():
op.execute(
"UPDATE system_email_settings "
"SET allowed_register_domain = 'huapont.cn' "
"WHERE allowed_register_domain = 'huapont.cn,qq.com'"
)
@@ -1,123 +0,0 @@
"""email settings per register domain
Revision ID: 20260629_03
Revises: 20260629_02
Create Date: 2026-06-29 14:20:00.000000
"""
from typing import Sequence, Union
import uuid
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "20260629_03"
down_revision: Union[str, None] = "20260629_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 _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 _normalize_domains(value: str | None) -> list[str]:
domains: list[str] = []
for item in (value or "huapont.cn,qq.com").replace("", ",").split(","):
domain = item.strip().lower().lstrip("@")
if domain and domain not in domains:
domains.append(domain)
return domains or ["huapont.cn", "qq.com"]
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if not _table_exists(inspector, "system_email_settings"):
return
if not _column_exists(inspector, "system_email_settings", "register_domain"):
op.add_column("system_email_settings", sa.Column("register_domain", sa.String(length=255), nullable=True))
rows = bind.execute(sa.text("SELECT * FROM system_email_settings ORDER BY created_at ASC")).mappings().all()
for row in rows:
domains = _normalize_domains(row.get("allowed_register_domain"))
primary_domain = domains[0]
bind.execute(
sa.text(
"UPDATE system_email_settings "
"SET register_domain = :register_domain, allowed_register_domain = :register_domain "
"WHERE id = :id"
),
{"register_domain": primary_domain, "id": row["id"]},
)
for domain in domains[1:]:
exists = bind.execute(
sa.text("SELECT 1 FROM system_email_settings WHERE register_domain = :register_domain"),
{"register_domain": domain},
).first()
if exists:
continue
bind.execute(
sa.text(
"""
INSERT INTO system_email_settings (
id, register_domain, smtp_host, smtp_port, smtp_security, smtp_username,
smtp_password_encrypted, sender_email, sender_name, allowed_register_domain,
verification_code_ttl_minutes, send_cooldown_seconds, max_verify_attempts,
updated_by, created_at, updated_at
) VALUES (
:id, :register_domain, :smtp_host, :smtp_port, :smtp_security, :smtp_username,
:smtp_password_encrypted, :sender_email, :sender_name, :allowed_register_domain,
:verification_code_ttl_minutes, :send_cooldown_seconds, :max_verify_attempts,
:updated_by, now(), now()
)
"""
),
{
"id": uuid.uuid4(),
"register_domain": domain,
"smtp_host": row["smtp_host"],
"smtp_port": row["smtp_port"],
"smtp_security": row["smtp_security"],
"smtp_username": row["smtp_username"],
"smtp_password_encrypted": row["smtp_password_encrypted"],
"sender_email": row["sender_email"],
"sender_name": row["sender_name"],
"allowed_register_domain": domain,
"verification_code_ttl_minutes": row["verification_code_ttl_minutes"],
"send_cooldown_seconds": row["send_cooldown_seconds"],
"max_verify_attempts": row["max_verify_attempts"],
"updated_by": row["updated_by"],
},
)
op.alter_column("system_email_settings", "register_domain", existing_type=sa.String(length=255), nullable=False)
op.create_unique_constraint(
"uq_system_email_settings_register_domain",
"system_email_settings",
["register_domain"],
)
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if not _table_exists(inspector, "system_email_settings"):
return
uniques = {item["name"] for item in inspector.get_unique_constraints("system_email_settings")}
if "uq_system_email_settings_register_domain" in uniques:
op.drop_constraint(
"uq_system_email_settings_register_domain",
"system_email_settings",
type_="unique",
)
if _column_exists(inspector, "system_email_settings", "register_domain"):
op.drop_column("system_email_settings", "register_domain")
@@ -1,26 +0,0 @@
"""add password reset email purpose
Revision ID: 20260629_04
Revises: 20260629_03
Create Date: 2026-06-29 16:50:00.000000
"""
from typing import Sequence, Union
from alembic import op
revision: str = "20260629_04"
down_revision: Union[str, None] = "20260629_03"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute("ALTER TYPE email_verification_purpose ADD VALUE IF NOT EXISTS 'PASSWORD_RESET'")
def downgrade() -> None:
# PostgreSQL enum values cannot be removed safely without recreating the type.
pass
@@ -1,26 +0,0 @@
"""add password reset link email purpose
Revision ID: 20260630_01
Revises: 20260629_04
Create Date: 2026-06-30 10:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
revision: str = "20260630_01"
down_revision: Union[str, None] = "20260629_04"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute("ALTER TYPE email_verification_purpose ADD VALUE IF NOT EXISTS 'PASSWORD_RESET_LINK'")
def downgrade() -> None:
# PostgreSQL enum values cannot be removed safely without recreating the type.
pass
@@ -1,74 +0,0 @@
"""add desktop phase 2 notification and client metadata state
Revision ID: 20260630_02
Revises: 20260630_01
Create Date: 2026-06-30 21:30:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "20260630_02"
down_revision: Union[str, None] = "20260630_01"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("security_access_logs", sa.Column("client_type", sa.String(16), nullable=True))
op.add_column("security_access_logs", sa.Column("client_version", sa.String(32), nullable=True))
op.add_column("security_access_logs", sa.Column("client_platform", sa.String(16), nullable=True))
op.add_column("security_access_logs", sa.Column("build_channel", sa.String(16), nullable=True))
op.add_column("security_access_logs", sa.Column("build_commit", sa.String(64), nullable=True))
op.create_index(
"ix_security_log_client_created",
"security_access_logs",
["client_type", "client_version", "created_at"],
)
op.create_table(
"desktop_notification_subscriptions",
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("enabled", sa.Boolean(), server_default=sa.false(), nullable=False),
sa.Column("enabled_at", sa.DateTime(timezone=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(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("user_id"),
)
op.create_table(
"desktop_notification_deliveries",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("distribution_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("claim_token", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("claimed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("delivered_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("read_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["distribution_id"], ["distributions.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"user_id",
"distribution_id",
name="uq_desktop_notification_user_distribution",
),
)
op.create_index(
"ix_desktop_notification_claim",
"desktop_notification_deliveries",
["user_id", "delivered_at", "claimed_at"],
)
def downgrade() -> None:
op.drop_index("ix_desktop_notification_claim", table_name="desktop_notification_deliveries")
op.drop_table("desktop_notification_deliveries")
op.drop_table("desktop_notification_subscriptions")
op.drop_index("ix_security_log_client_created", table_name="security_access_logs")
for column in ("build_commit", "build_channel", "client_platform", "client_version", "client_type"):
op.drop_column("security_access_logs", column)
@@ -1,46 +0,0 @@
"""add access context to audit logs
Revision ID: 20260709_01
Revises: 20260630_02
Create Date: 2026-07-09 10:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "20260709_01"
down_revision: Union[str, None] = "20260630_02"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("audit_logs", sa.Column("client_ip", sa.String(45), nullable=True))
op.add_column("audit_logs", sa.Column("user_agent", sa.String(500), nullable=True))
op.add_column("audit_logs", sa.Column("client_type", sa.String(16), nullable=True))
op.add_column("audit_logs", sa.Column("client_version", sa.String(32), nullable=True))
op.add_column("audit_logs", sa.Column("client_platform", sa.String(16), nullable=True))
op.add_column("audit_logs", sa.Column("build_channel", sa.String(16), nullable=True))
op.add_column("audit_logs", sa.Column("build_commit", sa.String(64), nullable=True))
op.create_index("ix_audit_logs_operator_created", "audit_logs", ["operator_id", "created_at"])
op.create_index("ix_audit_logs_client_ip_created", "audit_logs", ["client_ip", "created_at"])
op.create_index("ix_audit_logs_client_source_created", "audit_logs", ["client_type", "created_at"])
def downgrade() -> None:
op.drop_index("ix_audit_logs_client_source_created", table_name="audit_logs")
op.drop_index("ix_audit_logs_client_ip_created", table_name="audit_logs")
op.drop_index("ix_audit_logs_operator_created", table_name="audit_logs")
for column in (
"build_commit",
"build_channel",
"client_platform",
"client_version",
"client_type",
"user_agent",
"client_ip",
):
op.drop_column("audit_logs", column)
@@ -1,42 +0,0 @@
"""add access context to permission access logs
Revision ID: 20260709_02
Revises: 20260709_01
Create Date: 2026-07-09 15:45:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "20260709_02"
down_revision: Union[str, None] = "20260709_01"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("permission_access_logs", sa.Column("user_agent", sa.String(500), nullable=True))
op.add_column("permission_access_logs", sa.Column("client_type", sa.String(16), nullable=True))
op.add_column("permission_access_logs", sa.Column("client_version", sa.String(32), nullable=True))
op.add_column("permission_access_logs", sa.Column("client_platform", sa.String(16), nullable=True))
op.add_column("permission_access_logs", sa.Column("build_channel", sa.String(16), nullable=True))
op.add_column("permission_access_logs", sa.Column("build_commit", sa.String(64), nullable=True))
op.create_index("ix_perm_log_ip_created", "permission_access_logs", ["ip_address", "created_at"])
op.create_index("ix_perm_log_client_source_created", "permission_access_logs", ["client_type", "created_at"])
def downgrade() -> None:
op.drop_index("ix_perm_log_client_source_created", table_name="permission_access_logs")
op.drop_index("ix_perm_log_ip_created", table_name="permission_access_logs")
for column in (
"build_commit",
"build_channel",
"client_platform",
"client_version",
"client_type",
"user_agent",
):
op.drop_column("permission_access_logs", column)
@@ -1,35 +0,0 @@
"""add request headers to permission access logs
Revision ID: 20260709_03
Revises: 20260709_02
Create Date: 2026-07-09 16:58:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "20260709_03"
down_revision: Union[str, None] = "20260709_02"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
REQUEST_HEADERS_TYPE = sa.JSON().with_variant(
postgresql.JSONB(astext_type=sa.Text()),
"postgresql",
)
def upgrade() -> None:
op.add_column(
"permission_access_logs",
sa.Column("request_headers", REQUEST_HEADERS_TYPE, nullable=True),
)
def downgrade() -> None:
op.drop_column("permission_access_logs", "request_headers")
@@ -1,35 +0,0 @@
"""add request headers to security access logs
Revision ID: 20260709_04
Revises: 20260709_03
Create Date: 2026-07-09 17:30:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "20260709_04"
down_revision: Union[str, None] = "20260709_03"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
REQUEST_HEADERS_TYPE = sa.JSON().with_variant(
postgresql.JSONB(astext_type=sa.Text()),
"postgresql",
)
def upgrade() -> None:
op.add_column(
"security_access_logs",
sa.Column("request_headers", REQUEST_HEADERS_TYPE, nullable=True),
)
def downgrade() -> None:
op.drop_column("security_access_logs", "request_headers")
@@ -1,40 +0,0 @@
"""add request snapshots to access logs
Revision ID: 20260709_05
Revises: 20260709_04
Create Date: 2026-07-09 18:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "20260709_05"
down_revision: Union[str, None] = "20260709_04"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
REQUEST_SNAPSHOT_TYPE = sa.JSON().with_variant(
postgresql.JSONB(astext_type=sa.Text()),
"postgresql",
)
def upgrade() -> None:
op.add_column(
"permission_access_logs",
sa.Column("request_snapshot", REQUEST_SNAPSHOT_TYPE, nullable=True),
)
op.add_column(
"security_access_logs",
sa.Column("request_snapshot", REQUEST_SNAPSHOT_TYPE, nullable=True),
)
def downgrade() -> None:
op.drop_column("security_access_logs", "request_snapshot")
op.drop_column("permission_access_logs", "request_snapshot")
@@ -1,67 +0,0 @@
"""add monitoring event identity and persisted security classification
Revision ID: 20260710_01
Revises: 20260709_05
Create Date: 2026-07-10 09:30:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "20260710_01"
down_revision: Union[str, None] = "20260709_05"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("permission_access_logs", sa.Column("request_id", sa.String(36), nullable=True))
op.add_column("security_access_logs", sa.Column("request_id", sa.String(36), nullable=True))
op.add_column("security_access_logs", sa.Column("category", sa.String(30), nullable=True))
op.add_column("security_access_logs", sa.Column("severity", sa.String(16), nullable=True))
op.create_index("ix_perm_log_request_id", "permission_access_logs", ["request_id"])
op.create_index("ix_security_log_request_id", "security_access_logs", ["request_id"])
op.create_index("ix_security_log_category_created", "security_access_logs", ["category", "created_at"])
op.create_index("ix_security_log_severity_created", "security_access_logs", ["severity", "created_at"])
op.execute(
"""
UPDATE security_access_logs
SET category = CASE
WHEN lower(path) LIKE '%/.env%' OR lower(path) LIKE '%.git%'
OR lower(path) LIKE '%backup%' OR lower(path) LIKE '%config.php%'
OR lower(path) LIKE '%wp-config%' OR lower(path) LIKE '%database.yml%'
THEN 'PROBE'
WHEN status_code >= 500 THEN 'SERVER_ERROR'
WHEN auth_status = 'INVALID_TOKEN' THEN 'INVALID_TOKEN'
WHEN auth_status = 'ANONYMOUS' AND status_code IN (401, 403) THEN 'ANONYMOUS_API'
WHEN status_code = 404 THEN 'NOT_FOUND_NOISE'
ELSE 'OTHER'
END,
severity = CASE
WHEN lower(path) LIKE '%/.env%' OR lower(path) LIKE '%.git%'
OR lower(path) LIKE '%backup%' OR lower(path) LIKE '%config.php%'
OR lower(path) LIKE '%wp-config%' OR lower(path) LIKE '%database.yml%'
THEN 'CRITICAL'
WHEN status_code >= 500 THEN 'HIGH'
WHEN auth_status = 'INVALID_TOKEN' THEN 'MEDIUM'
WHEN auth_status = 'ANONYMOUS' AND status_code IN (401, 403) THEN 'MEDIUM'
ELSE 'LOW'
END
"""
)
def downgrade() -> None:
op.drop_index("ix_security_log_severity_created", table_name="security_access_logs")
op.drop_index("ix_security_log_category_created", table_name="security_access_logs")
op.drop_index("ix_security_log_request_id", table_name="security_access_logs")
op.drop_index("ix_perm_log_request_id", table_name="permission_access_logs")
op.drop_column("security_access_logs", "severity")
op.drop_column("security_access_logs", "category")
op.drop_column("security_access_logs", "request_id")
op.drop_column("permission_access_logs", "request_id")
@@ -1,93 +0,0 @@
"""add source location hourly snapshots
Revision ID: 20260710_02
Revises: 20260710_01
Create Date: 2026-07-10 15:30:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "20260710_02"
down_revision: Union[str, None] = "20260710_01"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"source_location_snapshots",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("bucket_time", sa.DateTime(timezone=True), nullable=False),
sa.Column("ip_hash", sa.String(length=64), nullable=False),
sa.Column("user_hash", sa.String(length=64), nullable=False, server_default=""),
sa.Column("country", sa.String(length=100), nullable=False, server_default=""),
sa.Column("country_code", sa.String(length=16), nullable=False, server_default=""),
sa.Column("province", sa.String(length=100), nullable=False, server_default=""),
sa.Column("region_code", sa.String(length=24), nullable=False, server_default=""),
sa.Column("city", sa.String(length=100), nullable=False, server_default=""),
sa.Column("isp", sa.String(length=160), nullable=False, server_default=""),
sa.Column("location", sa.String(length=320), nullable=False, server_default=""),
sa.Column("longitude", sa.Float(), nullable=True),
sa.Column("latitude", sa.Float(), nullable=True),
sa.Column("accuracy_level", sa.String(length=16), nullable=False, server_default="unknown"),
sa.Column("allowed_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("denied_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("security_event_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("high_risk_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("auth_failure_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("first_seen_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("bucket_time", "ip_hash", "user_hash", name="uq_source_location_bucket_identity"),
)
op.create_index("ix_source_location_bucket", "source_location_snapshots", ["bucket_time"])
op.create_index(
"ix_source_location_country_bucket",
"source_location_snapshots",
["country_code", "bucket_time"],
)
op.create_index(
"ix_source_location_risk_bucket",
"source_location_snapshots",
["high_risk_count", "bucket_time"],
)
op.execute(
"""
UPDATE security_access_logs
SET category = CASE
WHEN lower(path) LIKE '%/.env%' OR lower(path) LIKE '%.git%'
OR lower(path) LIKE '%backup%' OR lower(path) LIKE '%config.php%'
OR lower(path) LIKE '%wp-config%' OR lower(path) LIKE '%database.yml%'
THEN 'PROBE'
WHEN status_code >= 500 THEN 'SERVER_ERROR'
WHEN auth_status = 'INVALID_TOKEN' THEN 'INVALID_TOKEN'
WHEN auth_status = 'ANONYMOUS' AND status_code IN (401, 403) THEN 'ANONYMOUS_API'
WHEN status_code = 404 THEN 'NOT_FOUND_NOISE'
ELSE 'OTHER'
END,
severity = CASE
WHEN lower(path) LIKE '%/.env%' OR lower(path) LIKE '%.git%'
OR lower(path) LIKE '%backup%' OR lower(path) LIKE '%config.php%'
OR lower(path) LIKE '%wp-config%' OR lower(path) LIKE '%database.yml%'
THEN 'CRITICAL'
WHEN status_code >= 500 THEN 'HIGH'
WHEN auth_status = 'INVALID_TOKEN' THEN 'MEDIUM'
WHEN auth_status = 'ANONYMOUS' AND status_code IN (401, 403) THEN 'MEDIUM'
ELSE 'LOW'
END
WHERE category = 'ABNORMAL_IP'
"""
)
def downgrade() -> None:
op.drop_index("ix_source_location_risk_bucket", table_name="source_location_snapshots")
op.drop_index("ix_source_location_country_bucket", table_name="source_location_snapshots")
op.drop_index("ix_source_location_bucket", table_name="source_location_snapshots")
op.drop_table("source_location_snapshots")
@@ -1,30 +0,0 @@
"""Remove deployment-specific coordinates from private source snapshots.
Revision ID: 20260710_03
Revises: 20260710_02
"""
from alembic import op
revision = "20260710_03"
down_revision = "20260710_02"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute(
"""
UPDATE source_location_snapshots
SET longitude = NULL,
latitude = NULL
WHERE accuracy_level = 'private'
"""
)
def downgrade() -> None:
# The previous coordinates were a hardcoded deployment assumption and
# cannot be restored without reintroducing incorrect data.
pass
@@ -1,40 +0,0 @@
"""Add user login session activity records.
Revision ID: 20260710_04
Revises: 20260710_03
"""
import sqlalchemy as sa
from alembic import op
revision = "20260710_04"
down_revision = "20260710_03"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"user_login_sessions",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("user_id", sa.Uuid(), nullable=False),
sa.Column("client_type", sa.String(length=16), nullable=False, server_default="web"),
sa.Column("client_platform", sa.String(length=32), nullable=True),
sa.Column("client_version", sa.String(length=64), nullable=True),
sa.Column("client_source", sa.String(length=32), nullable=True),
sa.Column("login_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
sa.Column("ended_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("end_reason", sa.String(length=32), nullable=True),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_user_login_sessions_user_login", "user_login_sessions", ["user_id", "login_at"])
op.create_index("ix_user_login_sessions_last_seen", "user_login_sessions", ["last_seen_at"])
def downgrade() -> None:
op.drop_index("ix_user_login_sessions_last_seen", table_name="user_login_sessions")
op.drop_index("ix_user_login_sessions_user_login", table_name="user_login_sessions")
op.drop_table("user_login_sessions")
@@ -1,25 +0,0 @@
"""Add the server-observed login IP to user login sessions.
Revision ID: 20260713_01
Revises: 20260710_04
"""
import sqlalchemy as sa
from alembic import op
revision = "20260713_01"
down_revision = "20260710_04"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"user_login_sessions",
sa.Column("login_ip", sa.String(length=45), nullable=True),
)
def downgrade() -> None:
op.drop_column("user_login_sessions", "login_ip")
@@ -1,25 +0,0 @@
"""Store original filenames for document versions.
Revision ID: 20260713_02
Revises: 20260713_01
"""
import sqlalchemy as sa
from alembic import op
revision = "20260713_02"
down_revision = "20260713_01"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"document_versions",
sa.Column("original_filename", sa.String(length=255), nullable=True),
)
def downgrade() -> None:
op.drop_column("document_versions", "original_filename")
@@ -1,136 +0,0 @@
"""Add the independent shared-library collaboration module.
Revision ID: 20260714_01
Revises: 20260713_02
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "20260714_01"
down_revision = "20260713_02"
branch_labels = None
depends_on = None
def upgrade() -> None:
uuid_type = postgresql.UUID(as_uuid=True)
op.create_table(
"collaboration_folders",
sa.Column("id", uuid_type, primary_key=True),
sa.Column("study_id", uuid_type, sa.ForeignKey("studies.id"), nullable=False),
sa.Column("parent_id", uuid_type, sa.ForeignKey("collaboration_folders.id", ondelete="SET NULL")),
sa.Column("name", sa.String(120), nullable=False),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
sa.Column("created_by", uuid_type, sa.ForeignKey("users.id"), nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=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()),
)
op.create_index("ix_collaboration_folders_study_parent", "collaboration_folders", ["study_id", "parent_id"])
op.create_table(
"collaboration_files",
sa.Column("id", uuid_type, primary_key=True),
sa.Column("study_id", uuid_type, sa.ForeignKey("studies.id"), nullable=False),
sa.Column("folder_id", uuid_type, sa.ForeignKey("collaboration_folders.id", ondelete="SET NULL")),
sa.Column("title", sa.String(255), nullable=False),
sa.Column("file_type", sa.String(16), nullable=False),
sa.Column("extension", sa.String(16), nullable=False),
sa.Column("status", sa.String(20), nullable=False, server_default="ACTIVE"),
sa.Column("owner_id", uuid_type, sa.ForeignKey("users.id"), nullable=False),
sa.Column("current_revision_id", uuid_type),
sa.Column("generation", sa.Integer(), nullable=False, server_default="1"),
sa.Column("deleted_at", sa.DateTime(timezone=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()),
)
op.create_index("ix_collaboration_files_study_folder", "collaboration_files", ["study_id", "folder_id"])
op.create_index("ix_collaboration_files_study_status", "collaboration_files", ["study_id", "status"])
op.create_table(
"collaboration_members",
sa.Column("id", uuid_type, primary_key=True),
sa.Column("file_id", uuid_type, sa.ForeignKey("collaboration_files.id", ondelete="CASCADE"), nullable=False),
sa.Column("user_id", uuid_type, sa.ForeignKey("users.id"), nullable=False),
sa.Column("role", sa.String(16), nullable=False),
sa.Column("invited_by", uuid_type, sa.ForeignKey("users.id"), nullable=False),
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.UniqueConstraint("file_id", "user_id", name="uq_collaboration_member_file_user"),
)
op.create_index("ix_collaboration_members_user", "collaboration_members", ["user_id"])
op.create_table(
"collaboration_revisions",
sa.Column("id", uuid_type, primary_key=True),
sa.Column("file_id", uuid_type, sa.ForeignKey("collaboration_files.id", ondelete="CASCADE"), nullable=False),
sa.Column("revision_no", sa.Integer(), nullable=False),
sa.Column("parent_revision_id", uuid_type, sa.ForeignKey("collaboration_revisions.id", ondelete="SET NULL")),
sa.Column("file_uri", sa.String(500), nullable=False),
sa.Column("original_filename", sa.String(255), nullable=False),
sa.Column("file_hash", sa.String(128), nullable=False),
sa.Column("file_size", sa.BigInteger(), nullable=False),
sa.Column("mime_type", sa.String(100), nullable=False),
sa.Column("source", sa.String(24), nullable=False),
sa.Column("change_summary", sa.Text()),
sa.Column("created_by", uuid_type, sa.ForeignKey("users.id")),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.UniqueConstraint("file_id", "revision_no", name="uq_collaboration_revision_file_no"),
)
op.create_index("ix_collaboration_revisions_file_created", "collaboration_revisions", ["file_id", "created_at"])
op.create_foreign_key(
"fk_collaboration_files_current_revision",
"collaboration_files",
"collaboration_revisions",
["current_revision_id"],
["id"],
ondelete="SET NULL",
)
op.create_table(
"collaboration_sessions",
sa.Column("id", uuid_type, primary_key=True),
sa.Column("file_id", uuid_type, sa.ForeignKey("collaboration_files.id", ondelete="CASCADE"), nullable=False),
sa.Column("base_revision_id", uuid_type, sa.ForeignKey("collaboration_revisions.id"), nullable=False),
sa.Column("document_key", sa.String(128), nullable=False),
sa.Column("generation", sa.Integer(), nullable=False),
sa.Column("status", sa.String(20), nullable=False, server_default="ACTIVE"),
sa.Column("started_by", uuid_type, sa.ForeignKey("users.id"), nullable=False),
sa.Column("active_users", sa.Text()),
sa.Column("last_callback_at", sa.DateTime(timezone=True)),
sa.Column("closed_at", sa.DateTime(timezone=True)),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.UniqueConstraint("document_key", name="uq_collaboration_session_document_key"),
)
op.create_index("ix_collaboration_sessions_file_status", "collaboration_sessions", ["file_id", "status"])
op.create_table(
"collaboration_callback_receipts",
sa.Column("id", uuid_type, primary_key=True),
sa.Column("session_id", uuid_type, sa.ForeignKey("collaboration_sessions.id", ondelete="CASCADE"), nullable=False),
sa.Column("fingerprint", sa.String(128), nullable=False),
sa.Column("callback_status", sa.Integer(), nullable=False),
sa.Column("result", sa.String(24), nullable=False),
sa.Column("revision_id", uuid_type, sa.ForeignKey("collaboration_revisions.id", ondelete="SET NULL")),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.UniqueConstraint("session_id", "fingerprint", name="uq_collaboration_callback_session_fingerprint"),
)
def downgrade() -> None:
op.drop_table("collaboration_callback_receipts")
op.drop_index("ix_collaboration_sessions_file_status", table_name="collaboration_sessions")
op.drop_table("collaboration_sessions")
op.drop_constraint("fk_collaboration_files_current_revision", "collaboration_files", type_="foreignkey")
op.drop_index("ix_collaboration_revisions_file_created", table_name="collaboration_revisions")
op.drop_table("collaboration_revisions")
op.drop_index("ix_collaboration_members_user", table_name="collaboration_members")
op.drop_table("collaboration_members")
op.drop_index("ix_collaboration_files_study_status", table_name="collaboration_files")
op.drop_index("ix_collaboration_files_study_folder", table_name="collaboration_files")
op.drop_table("collaboration_files")
op.drop_index("ix_collaboration_folders_study_parent", table_name="collaboration_folders")
op.drop_table("collaboration_folders")
@@ -1,53 +0,0 @@
"""Add protected public links for collaboration files.
Revision ID: 20260715_01
Revises: 20260714_01
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "20260715_01"
down_revision = "20260714_01"
branch_labels = None
depends_on = None
def upgrade() -> None:
uuid_type = postgresql.UUID(as_uuid=True)
op.create_table(
"collaboration_share_links",
sa.Column("id", uuid_type, primary_key=True),
sa.Column(
"file_id",
uuid_type,
sa.ForeignKey("collaboration_files.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("access_mode", sa.String(12), nullable=False, server_default="VIEW"),
sa.Column("expiry_policy", sa.String(16), nullable=False, server_default="SEVEN_DAYS"),
sa.Column("expires_at", sa.DateTime(timezone=True)),
sa.Column("password_hash", sa.String(255)),
sa.Column("token_version", sa.Integer(), nullable=False, server_default="1"),
sa.Column("failed_attempts", sa.Integer(), nullable=False, server_default="0"),
sa.Column("last_failed_at", sa.DateTime(timezone=True)),
sa.Column("locked_until", sa.DateTime(timezone=True)),
sa.Column("created_by", uuid_type, sa.ForeignKey("users.id"), nullable=False),
sa.Column("updated_by", uuid_type, sa.ForeignKey("users.id"), nullable=False),
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.UniqueConstraint("file_id", name="uq_collaboration_share_link_file"),
)
op.create_index(
"ix_collaboration_share_links_enabled_expiry",
"collaboration_share_links",
["enabled", "expires_at"],
)
def downgrade() -> None:
op.drop_index("ix_collaboration_share_links_enabled_expiry", table_name="collaboration_share_links")
op.drop_table("collaboration_share_links")
@@ -1,25 +0,0 @@
"""Add effective export controls to collaboration share links.
Revision ID: 20260715_02
Revises: 20260715_01
"""
import sqlalchemy as sa
from alembic import op
revision = "20260715_02"
down_revision = "20260715_01"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"collaboration_share_links",
sa.Column("allow_export", sa.Boolean(), nullable=False, server_default=sa.false()),
)
def downgrade() -> None:
op.drop_column("collaboration_share_links", "allow_export")
@@ -1,43 +0,0 @@
"""Add soft deletion metadata to collaboration revisions.
Revision ID: 20260715_03
Revises: 20260715_02
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "20260715_03"
down_revision = "20260715_02"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"collaboration_revisions",
sa.Column("deleted_by", postgresql.UUID(as_uuid=True), nullable=True),
)
op.add_column(
"collaboration_revisions",
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_foreign_key(
"fk_collaboration_revision_deleted_by",
"collaboration_revisions",
"users",
["deleted_by"],
["id"],
)
def downgrade() -> None:
op.drop_constraint(
"fk_collaboration_revision_deleted_by",
"collaboration_revisions",
type_="foreignkey",
)
op.drop_column("collaboration_revisions", "deleted_at")
op.drop_column("collaboration_revisions", "deleted_by")
@@ -1,35 +0,0 @@
"""Move collaboration export control to the file.
Revision ID: 20260715_04
Revises: 20260715_03
"""
import sqlalchemy as sa
from alembic import op
revision = "20260715_04"
down_revision = "20260715_03"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"collaboration_files",
sa.Column("allow_export", sa.Boolean(), nullable=False, server_default=sa.false()),
)
# Preserve links that already granted export: after consolidation the same
# setting applies to authenticated collaborators and anonymous visitors.
op.execute(
"""
UPDATE collaboration_files AS file
SET allow_export = true
FROM collaboration_share_links AS link
WHERE link.file_id = file.id AND link.allow_export = true
"""
)
def downgrade() -> None:
op.drop_column("collaboration_files", "allow_export")
@@ -1,33 +0,0 @@
"""Remove the redundant share-link export permission.
Revision ID: 20260715_05
Revises: 20260715_04
"""
import sqlalchemy as sa
from alembic import op
revision = "20260715_05"
down_revision = "20260715_04"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.drop_column("collaboration_share_links", "allow_export")
def downgrade() -> None:
op.add_column(
"collaboration_share_links",
sa.Column("allow_export", sa.Boolean(), nullable=False, server_default=sa.false()),
)
op.execute(
"""
UPDATE collaboration_share_links AS link
SET allow_export = file.allow_export
FROM collaboration_files AS file
WHERE file.id = link.file_id
"""
)
@@ -1,66 +0,0 @@
"""Add collaboration edit requests and ownership controls.
Revision ID: 20260715_06
Revises: 20260715_05
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "20260715_06"
down_revision = "20260715_05"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"collaboration_files",
sa.Column("allow_edit_request", sa.Boolean(), nullable=False, server_default=sa.false()),
)
op.add_column(
"collaboration_files",
sa.Column("allow_sheet_structure_edit", sa.Boolean(), nullable=False, server_default=sa.true()),
)
op.add_column(
"collaboration_files",
sa.Column("sheet_structure_protection_backup", sa.Text(), nullable=True),
)
op.create_table(
"collaboration_edit_requests",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("file_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("requester_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("status", sa.String(length=16), nullable=False, server_default="PENDING"),
sa.Column("resolved_by", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("resolved_at", sa.DateTime(timezone=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.ForeignKeyConstraint(["file_id"], ["collaboration_files.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["requester_id"], ["users.id"]),
sa.ForeignKeyConstraint(["resolved_by"], ["users.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_collaboration_edit_requests_file_status",
"collaboration_edit_requests",
["file_id", "status"],
)
op.create_index(
"uq_collaboration_edit_requests_pending_user",
"collaboration_edit_requests",
["file_id", "requester_id"],
unique=True,
postgresql_where=sa.text("status = 'PENDING'"),
)
def downgrade() -> None:
op.drop_index("uq_collaboration_edit_requests_pending_user", table_name="collaboration_edit_requests")
op.drop_index("ix_collaboration_edit_requests_file_status", table_name="collaboration_edit_requests")
op.drop_table("collaboration_edit_requests")
op.drop_column("collaboration_files", "sheet_structure_protection_backup")
op.drop_column("collaboration_files", "allow_sheet_structure_edit")
op.drop_column("collaboration_files", "allow_edit_request")
@@ -1,53 +0,0 @@
"""Add recipient-scoped generic notifications.
Revision ID: 20260716_01
Revises: 20260715_06
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "20260716_01"
down_revision = "20260715_06"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"notifications",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("study_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("recipient_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("category", sa.String(length=64), nullable=False),
sa.Column("priority", sa.String(length=16), nullable=False, server_default="NORMAL"),
sa.Column("title", sa.String(length=180), nullable=False),
sa.Column("message", sa.String(length=500), nullable=False),
sa.Column("action_path", sa.Text(), nullable=True),
sa.Column("source_type", sa.String(length=64), nullable=False),
sa.Column("source_id", sa.String(length=100), nullable=False),
sa.Column("source_version", sa.String(length=100), nullable=True),
sa.Column("dedupe_key", sa.String(length=255), nullable=False),
sa.Column("read_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("resolved_at", sa.DateTime(timezone=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.ForeignKeyConstraint(["recipient_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["study_id"], ["studies.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("recipient_id", "dedupe_key", name="uq_notifications_recipient_dedupe"),
)
op.create_index(
"ix_notifications_recipient_study_state",
"notifications",
["recipient_id", "study_id", "resolved_at", "read_at", "created_at"],
)
op.create_index("ix_notifications_source", "notifications", ["source_type", "source_id"])
def downgrade() -> None:
op.drop_index("ix_notifications_source", table_name="notifications")
op.drop_index("ix_notifications_recipient_study_state", table_name="notifications")
op.drop_table("notifications")
@@ -1,75 +0,0 @@
"""Backfill notifications for pending collaboration edit requests.
Revision ID: 20260716_02
Revises: 20260716_01
"""
from alembic import op
revision = "20260716_02"
down_revision = "20260716_01"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("""
INSERT INTO notifications (
id,
study_id,
recipient_id,
category,
priority,
title,
message,
action_path,
source_type,
source_id,
dedupe_key,
created_at,
updated_at
)
SELECT
gen_random_uuid(),
collaboration_files.study_id,
study_members.user_id,
'COLLABORATION_EDIT_REQUEST',
'NORMAL',
'新的编辑权限申请',
concat(
coalesce(nullif(users.full_name, ''), users.email, '项目成员'),
' 申请编辑“',
collaboration_files.title,
''
),
concat('/knowledge/collaboration?editRequestFile=', collaboration_files.id::text),
'COLLABORATION_EDIT_REQUEST',
collaboration_edit_requests.id::text,
concat('collaboration-edit-request:', collaboration_edit_requests.id::text),
collaboration_edit_requests.created_at,
collaboration_edit_requests.updated_at
FROM collaboration_edit_requests
JOIN collaboration_files
ON collaboration_files.id = collaboration_edit_requests.file_id
JOIN users
ON users.id = collaboration_edit_requests.requester_id
JOIN study_members
ON study_members.study_id = collaboration_files.study_id
AND study_members.is_active IS TRUE
LEFT JOIN collaboration_members
ON collaboration_members.file_id = collaboration_files.id
AND collaboration_members.user_id = study_members.user_id
WHERE collaboration_edit_requests.status = 'PENDING'
AND collaboration_files.deleted_at IS NULL
AND (
study_members.user_id = collaboration_files.owner_id
OR collaboration_members.role = 'MANAGER'
)
ON CONFLICT (recipient_id, dedupe_key) DO NOTHING
""")
def downgrade() -> None:
# 这是业务通知数据迁移;降级时保留已读/未读状态,避免删除升级后新产生的同源通知。
pass
@@ -1,65 +0,0 @@
"""Backfill pending edit request notifications for file owners.
Revision ID: 20260716_03
Revises: 20260716_02
"""
from alembic import op
revision = "20260716_03"
down_revision = "20260716_02"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("""
INSERT INTO notifications (
id,
study_id,
recipient_id,
category,
priority,
title,
message,
action_path,
source_type,
source_id,
dedupe_key,
created_at,
updated_at
)
SELECT
gen_random_uuid(),
collaboration_files.study_id,
collaboration_files.owner_id,
'COLLABORATION_EDIT_REQUEST',
'NORMAL',
'新的编辑权限申请',
concat(
coalesce(nullif(users.full_name, ''), users.email, '项目成员'),
' 申请编辑“',
collaboration_files.title,
''
),
concat('/knowledge/collaboration?editRequestFile=', collaboration_files.id::text),
'COLLABORATION_EDIT_REQUEST',
collaboration_edit_requests.id::text,
concat('collaboration-edit-request:', collaboration_edit_requests.id::text),
collaboration_edit_requests.created_at,
collaboration_edit_requests.updated_at
FROM collaboration_edit_requests
JOIN collaboration_files
ON collaboration_files.id = collaboration_edit_requests.file_id
JOIN users
ON users.id = collaboration_edit_requests.requester_id
WHERE collaboration_edit_requests.status = 'PENDING'
AND collaboration_files.deleted_at IS NULL
ON CONFLICT (recipient_id, dedupe_key) DO NOTHING
""")
def downgrade() -> None:
# 与上一数据迁移一致,降级时保留已产生的业务通知状态。
pass
@@ -1,35 +0,0 @@
"""Remove shared-library and passive Office preview audit noise.
Revision ID: 20260716_04
Revises: 20260716_03
"""
from alembic import op
revision = "20260716_04"
down_revision = "20260716_03"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("""
DELETE FROM audit_logs
WHERE action = 'OFFICE_PREVIEW_OPEN'
OR lower(entity_type) IN (
'faq_category',
'faq_item',
'faq_reply',
'faq_replies',
'precaution',
'knowledge_note',
'knowledge_notes',
'collaboration_file'
)
""")
def downgrade() -> None:
# 被清理的是明确排除出审计范围的共享库和被动预览记录,无法也不应伪造恢复。
pass
@@ -1,63 +0,0 @@
"""Expand generic notifications and route desktop delivery through them.
Revision ID: 20260716_05
Revises: 20260716_04
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "20260716_05"
down_revision = "20260716_04"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"notifications",
sa.Column("requires_action", sa.Boolean(), nullable=False, server_default=sa.true()),
)
op.add_column(
"notifications",
sa.Column("due_at", sa.DateTime(timezone=True), nullable=True),
)
op.alter_column("desktop_notification_deliveries", "distribution_id", nullable=True)
op.add_column(
"desktop_notification_deliveries",
sa.Column("notification_id", postgresql.UUID(as_uuid=True), nullable=True),
)
op.create_foreign_key(
"fk_desktop_notification_deliveries_notification",
"desktop_notification_deliveries",
"notifications",
["notification_id"],
["id"],
ondelete="CASCADE",
)
op.create_unique_constraint(
"uq_desktop_notification_user_notification",
"desktop_notification_deliveries",
["user_id", "notification_id"],
)
def downgrade() -> None:
op.drop_constraint(
"uq_desktop_notification_user_notification",
"desktop_notification_deliveries",
type_="unique",
)
op.drop_constraint(
"fk_desktop_notification_deliveries_notification",
"desktop_notification_deliveries",
type_="foreignkey",
)
op.drop_column("desktop_notification_deliveries", "notification_id")
# Legacy rows always have a distribution id; rows created by the generic delivery path do not.
op.execute("DELETE FROM desktop_notification_deliveries WHERE distribution_id IS NULL")
op.alter_column("desktop_notification_deliveries", "distribution_id", nullable=False)
op.drop_column("notifications", "due_at")
op.drop_column("notifications", "requires_action")
@@ -1,76 +0,0 @@
from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db_session, require_roles
from app.schemas.email_settings import (
EmailDomainCreateRequest,
EmailSettingsListResponse,
EmailSettingsRead,
EmailSettingsUpdate,
EmailTestRequest,
)
from app.services import email_service
router = APIRouter(prefix="/email-settings")
@router.get("/", response_model=EmailSettingsListResponse)
async def read_email_settings(
db: AsyncSession = Depends(get_db_session),
current_user=Depends(require_roles(["ADMIN"])),
) -> EmailSettingsListResponse:
items = [email_service.to_email_settings_read(row) for row in await email_service.list_email_settings(db)]
return EmailSettingsListResponse(items=items, total=len(items))
@router.post("/", response_model=EmailSettingsRead)
async def create_email_domain(
payload: EmailDomainCreateRequest,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(require_roles(["ADMIN"])),
) -> EmailSettingsRead:
settings_row = await email_service.create_email_domain(db, payload.register_domain, updated_by=current_user.id)
return email_service.to_email_settings_read(settings_row)
@router.get("/{register_domain}", response_model=EmailSettingsRead)
async def read_email_domain_settings(
register_domain: str,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(require_roles(["ADMIN"])),
) -> EmailSettingsRead:
return email_service.to_email_settings_read(await email_service.get_email_settings(db, register_domain))
@router.put("/{register_domain}", response_model=EmailSettingsRead)
async def update_email_settings(
register_domain: str,
payload: EmailSettingsUpdate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(require_roles(["ADMIN"])),
) -> EmailSettingsRead:
settings_row = await email_service.upsert_email_settings(db, register_domain, payload, updated_by=current_user.id)
return email_service.to_email_settings_read(settings_row)
@router.delete("/{register_domain}")
async def delete_email_domain(
register_domain: str,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(require_roles(["ADMIN"])),
):
await email_service.delete_email_domain(db, register_domain)
return {"message": "邮箱后缀已删除"}
@router.post("/{register_domain}/test")
async def test_email_settings(
register_domain: str,
payload: EmailTestRequest,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(require_roles(["ADMIN"])),
):
await email_service.send_test_email(db, register_domain, str(payload.recipient_email))
return {"message": "测试邮件已发送"}
+62
View File
@@ -0,0 +1,62 @@
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.crud import user as user_crud
from app.models.user import User, UserRole, UserStatus
from app.schemas.user import AdminUserListResponse, UserAdminReviewRequest, UserResponse
router = APIRouter(prefix="/users")
@router.get("/", response_model=AdminUserListResponse)
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])),
) -> AdminUserListResponse:
users = await user_crud.list_users_by_status(db, status=status_filter)
return AdminUserListResponse(items=list(users), total=len(users))
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:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="管理员账号不允许审核")
return user
@router.post("/{user_id}/approve", response_model=UserResponse)
async def approve_user(
user_id: uuid.UUID,
review: UserAdminReviewRequest,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(require_roles([UserRole.ADMIN.value])),
) -> UserResponse:
if review.action != "approve":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="非法操作")
user = await _get_review_user(db, user_id)
if user.status != UserStatus.PENDING:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅允许审核待审核用户")
user = await user_crud.approve_user(db, user, admin_id=current_user.id)
return user
@router.post("/{user_id}/reject", response_model=UserResponse)
async def reject_user(
user_id: uuid.UUID,
review: UserAdminReviewRequest,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(require_roles([UserRole.ADMIN.value])),
) -> UserResponse:
if review.action not in ("reject", "approve"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="非法操作")
user = await _get_review_user(db, user_id)
if user.status != UserStatus.PENDING:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅允许审核待审核用户")
user = await user_crud.reject_user(db, user, admin_id=current_user.id)
return user
+51 -92
View File
@@ -5,7 +5,7 @@ from datetime import date
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_operator_role_label, get_cra_site_scope, get_current_user, get_db_session, require_study_not_locked, require_api_permission
from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_member, require_study_not_locked
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,6 +16,9 @@ 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)
@@ -40,24 +43,20 @@ 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_api_permission("subject_aes:create")), Depends(require_study_not_locked())],
dependencies=[Depends(require_study_member()), Depends(require_study_not_locked())],
)
async def create_ae(
study_id: uuid.UUID,
@@ -76,6 +75,9 @@ 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:
@@ -86,24 +88,29 @@ async def create_ae(
entity_type="ae",
entity_id=ae.id,
action="CREATE_AE",
detail=_ae_audit_detail("创建", ae),
detail=f"AE {ae.id} 已创建",
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
operator_role=current_user.role,
)
data = AERead.model_validate(ae)
data.is_overdue = _is_overdue(data)
return data
async def _list_ae_records(
@router.get(
"/",
response_model=list[AERead],
dependencies=[Depends(require_study_member())],
)
async def list_ae(
study_id: uuid.UUID,
status_filter: str | None,
seriousness: str | None,
site_id: uuid.UUID | None,
subject_id: uuid.UUID | None,
overdue: bool | None,
db: AsyncSession,
current_user,
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]:
await _ensure_study_exists(db, study_id)
cra_scope = await get_cra_site_scope(db, study_id, current_user)
@@ -128,64 +135,10 @@ async def _list_ae_records(
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_api_permission("subject_aes:read"))],
dependencies=[Depends(require_study_member())],
)
async def get_ae(
study_id: uuid.UUID,
@@ -213,7 +166,7 @@ async def get_ae(
@router.patch(
"/{ae_id}",
response_model=AERead,
dependencies=[Depends(require_api_permission("subject_aes:update")), Depends(require_study_not_locked())],
dependencies=[Depends(require_study_member()), Depends(require_study_not_locked())],
)
async def update_ae(
study_id: uuid.UUID,
@@ -230,6 +183,19 @@ 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,
@@ -256,18 +222,9 @@ async def update_ae(
entity_type="ae",
entity_id=ae_id,
action=action,
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,
),
detail=json.dumps({"before": detail_before, "after": detail_after}, ensure_ascii=False, default=str),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
operator_role=current_user.role,
)
data = AERead.model_validate(updated)
data.is_overdue = _is_overdue(data)
@@ -277,7 +234,7 @@ async def update_ae(
@router.delete(
"/{ae_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_api_permission("subject_aes:delete")), Depends(require_study_not_locked())],
dependencies=[Depends(require_study_member()), Depends(require_study_not_locked())],
)
async def delete_ae(
study_id: uuid.UUID,
@@ -290,7 +247,9 @@ 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))
ae_detail = _ae_audit_detail("删除", 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="权限不足")
await ae_crud.delete_ae(db, ae)
await audit_crud.log_action(
db,
@@ -298,7 +257,7 @@ async def delete_ae(
entity_type="ae",
entity_id=ae_id,
action="DELETE_AE",
detail=ae_detail,
detail=f"AE {ae_id} 已删除",
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
operator_role=current_user.role,
)
-295
View File
@@ -1,295 +0,0 @@
"""接口级权限管理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
+49 -232
View File
@@ -8,17 +8,10 @@ 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, 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.core.deps import get_current_user, get_db_session, require_study_member, get_study_member, require_study_not_locked
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
@@ -29,62 +22,7 @@ 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",
}
SHARED_LIBRARY_ENTITY_TYPES = {"precaution", "faq_replies"}
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"
@@ -99,116 +37,11 @@ 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_not_locked())
],
dependencies=[Depends(require_study_member()), Depends(require_study_not_locked())],
)
async def upload_attachment(
study_id: uuid.UUID,
@@ -219,7 +52,6 @@ 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}"
@@ -240,17 +72,16 @@ async def upload_attachment(
content_type=file.content_type,
uploaded_by=current_user.id,
)
if entity_type not in SHARED_LIBRARY_ENTITY_TYPES:
await audit_crud.log_action(
db,
study_id=study_id,
entity_type=entity_type,
entity_id=entity_id,
action="UPLOAD_FILE",
detail=f"文件已上传:{file.filename}",
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type=entity_type,
entity_id=entity_id,
action="UPLOAD_FILE",
detail=f"文件已上传:{file.filename}",
operator_id=current_user.id,
operator_role=current_user.role,
)
return AttachmentRead(
id=attachment.id,
filename=attachment.filename,
@@ -265,16 +96,15 @@ 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)
@@ -297,6 +127,7 @@ async def list_attachments(
@router.get(
"/{attachment_id}/download",
dependencies=[Depends(require_study_member())],
)
async def download_attachment(
study_id: uuid.UUID,
@@ -304,10 +135,8 @@ 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="附件不存在")
@@ -323,6 +152,7 @@ async def download_attachment(
@router.get(
"/{attachment_id}/preview",
dependencies=[Depends(require_study_member())],
)
async def preview_attachment(
study_id: uuid.UUID,
@@ -330,10 +160,8 @@ 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="附件不存在")
@@ -348,16 +176,19 @@ async def preview_attachment(
async def _authorize_global(request: Request, db: AsyncSession, study_id: uuid.UUID):
token = None
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.lower().startswith("bearer "):
token = auth_header.split(" ", 1)[1]
else:
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="账号不存在或已停用")
if is_system_admin(user):
if user.role == "ADMIN":
return user, None
membership = await member_crud.get_member(db, study_id, user.id)
if not membership or not membership.is_active:
@@ -378,10 +209,7 @@ 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, 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
)
user, _ = await _authorize_global(request, db, attachment.study_id)
if not os.path.exists(attachment.file_path):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="服务器未找到文件")
return FileResponse(
@@ -405,10 +233,7 @@ 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)
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
)
await _authorize_global(request, db, attachment.study_id)
if not os.path.exists(attachment.file_path):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="服务器未找到文件")
return FileResponse(
@@ -433,36 +258,30 @@ 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 = (
is_system_admin(user)
user.role == "ADMIN"
or attachment.uploaded_by == user.id
or membership is not None
or (membership and getattr(membership, "role_in_study", None) == "PM")
)
if not can_delete:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限删除附件")
await attachment_crud.soft_delete_attachment(db, attachment)
if attachment.entity_type not in SHARED_LIBRARY_ENTITY_TYPES:
await audit_crud.log_action(
db,
study_id=attachment.study_id,
entity_type=attachment.entity_type,
entity_id=attachment.entity_id,
action="DELETE_ATTACHMENT",
detail=f"文件已删除:{attachment.filename}",
operator_id=user.id,
operator_role=await get_operator_role_label(db, attachment.study_id, user),
)
await audit_crud.log_action(
db,
study_id=attachment.study_id,
entity_type=attachment.entity_type,
entity_id=attachment.entity_id,
action="DELETE_ATTACHMENT",
detail=f"文件已删除:{attachment.filename}",
operator_id=user.id,
operator_role=user.role,
)
@router.delete(
"/{attachment_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[
Depends(require_study_not_locked())
],
dependencies=[Depends(require_study_member()), Depends(require_study_not_locked())],
)
async def delete_attachment(
study_id: uuid.UUID,
@@ -473,7 +292,6 @@ 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
@@ -485,26 +303,25 @@ async def delete_attachment(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在")
membership = None
if not is_system_admin(current_user):
if current_user.role != "ADMIN":
membership = await get_study_member(study_id, current_user=current_user, db=db)
can_delete = (
is_system_admin(current_user)
current_user.role == "ADMIN"
or attachment.uploaded_by == current_user.id
or membership is not None
or (membership and getattr(membership, "role_in_study", None) == "PM")
)
if not can_delete:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限删除附件")
await attachment_crud.soft_delete_attachment(db, attachment)
if entity_type not in SHARED_LIBRARY_ENTITY_TYPES:
await audit_crud.log_action(
db,
study_id=study_id,
entity_type=entity_type,
entity_id=entity_id,
action="DELETE_ATTACHMENT",
detail=f"文件已删除:{attachment.filename}",
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type=entity_type,
entity_id=entity_id,
action="DELETE_ATTACHMENT",
detail=f"文件已删除:{attachment.filename}",
operator_id=current_user.id,
operator_role=current_user.role,
)
+23 -76
View File
@@ -1,43 +1,19 @@
import json
import uuid
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_operator_role_label, get_current_user, get_db_session, require_api_permission
from app.core.deps import get_current_user, get_db_session, require_roles, require_study_member
from app.crud import audit as audit_crud
from app.crud import study as study_crud
from app.models.user import User
from app.schemas.audit import AuditEventCreate, AuditLogRead
from app.services.ip_location import resolve_ip_location
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_api_permission("audit_logs:read"))],
dependencies=[Depends(require_study_member())],
)
async def list_audit_logs(
study_id: uuid.UUID,
@@ -45,10 +21,6 @@ async def list_audit_logs(
entity_id: uuid.UUID | None = None,
action: str | None = None,
operator_id: uuid.UUID | None = None,
client_ip: str | None = None,
client_type: str | None = None,
start_time: datetime | None = None,
end_time: datetime | None = None,
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db_session),
@@ -60,61 +32,36 @@ async def list_audit_logs(
entity_id=entity_id,
action=action,
operator_id=operator_id,
client_ip=client_ip,
client_type=client_type,
start_time=start_time,
end_time=end_time,
skip=skip,
limit=limit,
)
operator_ids = {log.operator_id for log in logs}
operator_map: dict[uuid.UUID, User] = {}
if operator_ids:
result = await db.execute(select(User).where(User.id.in_(operator_ids)))
operator_map = {user.id: user for user in result.scalars().all()}
return list(logs)
items: list[AuditLogRead] = []
for log in logs:
ip_location = resolve_ip_location(log.client_ip)
operator = operator_map.get(log.operator_id)
items.append(
AuditLogRead(
id=log.id,
study_id=log.study_id,
entity_type=log.entity_type,
entity_id=log.entity_id,
action=log.action,
detail=log.detail,
operator_id=log.operator_id,
operator_name=operator.full_name if operator else None,
operator_email=operator.email if operator else None,
operator_role=log.operator_role,
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,
client_type=log.client_type,
client_version=log.client_version,
client_platform=log.client_platform,
build_channel=log.build_channel,
build_commit=log.build_commit,
created_at=log.created_at,
)
)
return items
@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),
):
@@ -124,11 +71,11 @@ async def create_audit_event(
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="audit_log",
entity_id=None,
entity_type=payload.entity_type or "audit_log",
entity_id=payload.entity_id,
action=payload.action,
detail=await _build_export_audit_detail(db, study_id, payload.action),
detail=payload.detail,
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
operator_role=current_user.role,
)
return {"ok": True}
+59 -312
View File
@@ -1,6 +1,5 @@
from datetime import datetime, timedelta, timezone
from dataclasses import dataclass
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import File, UploadFile
from pydantic import BaseModel, EmailStr, Field
from sqlalchemy.ext.asyncio import AsyncSession
@@ -8,159 +7,57 @@ 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.request_context import resolve_client_ip, resolve_ctms_client_type
from app.core.security import create_access_token, decode_token, decode_token_allow_expired, oauth2_scheme, verify_password
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 UserStatus
from app.schemas.email_settings import (
EmailCodeResponse,
EmailCodeVerifyResponse,
PasswordResetCodeVerifyRequest,
PasswordResetCodeVerifyResponse,
PasswordResetLinkSendRequest,
PasswordResetRequest,
PasswordResetTokenRequest,
RegisterEmailCodeSendRequest,
RegisterEmailCodeVerifyRequest,
)
from app.models.user import UserRole, UserStatus
from app.schemas.user import Token, UserRead, UserRegisterRequest, UserSelfUpdate, UserUpdate
from app.services import email_service
from app.services.user_login_sessions import (
create_login_session,
end_login_session,
session_id_from_payload,
touch_login_session,
)
from fastapi.responses import FileResponse
class LoginRequest(BaseModel):
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)
email: EmailStr
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 EmailAvailabilityResponse(BaseModel):
available: bool
class UnlockRequest(BaseModel):
email: EmailStr
password: str = Field(min_length=1)
class EmailDomainsResponse(BaseModel):
items: list[str]
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",
}
@dataclass(frozen=True)
class SessionPolicy:
access_minutes: int
absolute_max_seconds: int
@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 normalize_session_client_type(value: str | None) -> str:
return "desktop" if (value or "").strip().lower() == "desktop" else "web"
def get_session_policy_for_client_type(client_type: str) -> SessionPolicy:
if client_type == "desktop":
max_seconds = settings.DESKTOP_SESSION_MAX_DAYS * 24 * 3600
return SessionPolicy(
access_minutes=settings.DESKTOP_SESSION_MAX_DAYS * 24 * 60,
absolute_max_seconds=max_seconds,
)
return SessionPolicy(
access_minutes=settings.JWT_EXPIRE_MINUTES,
absolute_max_seconds=settings.ABSOLUTE_SESSION_MAX_HOURS * 3600,
)
def get_request_session_client_type(request: Request) -> str:
return normalize_session_client_type(resolve_ctms_client_type(request.headers))
def policy_expires_at(issued_at: datetime, session_start: datetime, policy: SessionPolicy) -> datetime:
access_expires_at = issued_at + timedelta(minutes=policy.access_minutes)
session_expires_at = session_start + timedelta(seconds=policy.absolute_max_seconds)
return min(access_expires_at, session_expires_at)
async def issue_user_token(db_user, request: Request, db: AsyncSession) -> Token:
session_start = datetime.now(timezone.utc)
session_id = uuid.uuid4()
client_type = get_request_session_client_type(request)
policy = get_session_policy_for_client_type(client_type)
access_token = create_access_token(
user_id=str(db_user.id),
expires_minutes=policy.access_minutes,
session_start=session_start,
max_age_seconds=policy.absolute_max_seconds,
issued_at=session_start,
client_type=client_type,
session_id=str(session_id),
)
await create_login_session(
db,
session_id=session_id,
user_id=db_user.id,
request=request,
login_at=session_start,
)
return Token(access_token=access_token, token_type="bearer")
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):
@router.post("/login", response_model=Token)
async def login_for_access_token(
payload: LoginRequest, db: AsyncSession = Depends(get_db_session)
) -> Token:
db_user = await user_crud.get_by_email(db, payload.email)
if not db_user:
raise HTTPException(
@@ -172,146 +69,20 @@ async def authenticate_plain_password(payload: DevLoginRequest, db: AsyncSession
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="账号未审核或不可用",
)
@router.get("/email-domains", response_model=EmailDomainsResponse)
async def read_email_domains(
response: Response,
db: AsyncSession = Depends(get_db_session),
) -> EmailDomainsResponse:
response.headers["Cache-Control"] = "no-store"
rows = await email_service.list_email_settings(db)
return EmailDomainsResponse(items=[row.register_domain for row in rows])
@router.get("/register/email-availability", response_model=EmailAvailabilityResponse)
async def check_register_email_availability(
email: EmailStr = Query(...),
db: AsyncSession = Depends(get_db_session),
) -> EmailAvailabilityResponse:
existing = await user_crud.get_by_email(db, str(email))
return EmailAvailabilityResponse(available=existing is None)
@router.post("/register/email-code/send", response_model=EmailCodeResponse)
async def send_register_email_code(
payload: RegisterEmailCodeSendRequest,
db: AsyncSession = Depends(get_db_session),
) -> EmailCodeResponse:
await email_service.send_register_code(db, str(payload.email))
return EmailCodeResponse(message="验证码已发送")
@router.post("/register/email-code/verify", response_model=EmailCodeVerifyResponse)
async def verify_register_email_code(
payload: RegisterEmailCodeVerifyRequest,
db: AsyncSession = Depends(get_db_session),
) -> EmailCodeVerifyResponse:
await email_service.verify_register_code(db, str(payload.email), payload.code)
return EmailCodeVerifyResponse(verified=True)
@router.post("/password-reset/email-code/send", response_model=EmailCodeResponse)
async def send_password_reset_email_code(
payload: PasswordResetLinkSendRequest,
db: AsyncSession = Depends(get_db_session),
) -> EmailCodeResponse:
await email_service.send_password_reset_code(db, str(payload.email))
return EmailCodeResponse(message="验证码发送成功,请查收邮箱")
@router.post("/password-reset/email-code/verify", response_model=PasswordResetCodeVerifyResponse)
async def verify_password_reset_email_code(
payload: PasswordResetCodeVerifyRequest,
db: AsyncSession = Depends(get_db_session),
) -> PasswordResetCodeVerifyResponse:
reset_token = await email_service.verify_password_reset_code(db, str(payload.email), payload.code)
return PasswordResetCodeVerifyResponse(verified=True, reset_token=reset_token)
@router.post("/password-reset-link/send", response_model=EmailCodeResponse)
async def send_password_reset_link(
payload: PasswordResetLinkSendRequest,
db: AsyncSession = Depends(get_db_session),
) -> EmailCodeResponse:
await email_service.send_password_reset_link(
db,
str(payload.email),
frontend_origin=settings.FRONTEND_PUBLIC_URL,
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,
)
return EmailCodeResponse(message="如果账号存在,重置链接已发送")
@router.post("/password-reset", response_model=EmailCodeResponse)
async def reset_password(
payload: PasswordResetRequest,
db: AsyncSession = Depends(get_db_session),
) -> EmailCodeResponse:
await email_service.reset_password_with_code(db, str(payload.email), payload.code, payload.password)
return EmailCodeResponse(message="密码已重置,请返回登录")
@router.post("/password-reset-link", response_model=EmailCodeResponse)
async def reset_password_with_link(
payload: PasswordResetTokenRequest,
db: AsyncSession = Depends(get_db_session),
) -> EmailCodeResponse:
await email_service.reset_password_with_token(db, payload.token, payload.password)
return EmailCodeResponse(message="密码已重置,请返回登录")
@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 email_service.ensure_register_email_verified(db, str(payload.email))
await user_crud.create_registered_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,
)
@router.post("/login", response_model=Token)
async def login_for_access_token(
payload: LoginRequest, request: Request, db: AsyncSession = Depends(get_db_session)
) -> Token:
db_user = await authenticate_encrypted_password(payload, db)
ensure_user_active(db_user)
return await issue_user_token(db_user, request, db)
@router.post("/dev-login", response_model=Token)
async def dev_login_for_access_token(
payload: DevLoginRequest, request: Request, 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 await issue_user_token(db_user, request, db)
return Token(access_token=access_token, token_type="bearer")
@router.get("/me", response_model=UserRead)
@@ -340,61 +111,44 @@ async def extend_access_token(
if db_user.status != UserStatus.ACTIVE:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已停用")
session_start_ts = payload.get("orig_iat") or payload.get("iat")
policy = get_session_policy_for_client_type(normalize_session_client_type(payload.get("client_type")))
if session_start_ts:
if now_ts - int(session_start_ts) > policy.absolute_max_seconds:
max_seconds = settings.ABSOLUTE_SESSION_MAX_HOURS * 3600
if now_ts - int(session_start_ts) > max_seconds:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="会话已到期,请重新登录")
session_start = datetime.fromtimestamp(int(session_start_ts), tz=timezone.utc)
else:
session_start = datetime.now(timezone.utc)
issued_at = datetime.now(timezone.utc)
new_token = create_access_token(
user_id=str(db_user.id),
expires_minutes=policy.access_minutes,
role=db_user.role.value if hasattr(db_user.role, "value") else db_user.role,
expires_minutes=None,
session_start=session_start,
max_age_seconds=policy.absolute_max_seconds,
issued_at=issued_at,
client_type=normalize_session_client_type(payload.get("client_type")),
session_id=str(session_id_from_payload(payload)),
)
expires_at = policy_expires_at(issued_at, session_start, policy)
expires_at = datetime.now(timezone.utc) + timedelta(minutes=settings.JWT_EXPIRE_MINUTES)
return ExtendResponse(accessToken=new_token, expiresAt=expires_at)
@router.post("/session/heartbeat")
async def heartbeat_login_session(
request: Request,
token: str = Depends(oauth2_scheme),
current_user=Depends(get_current_user),
@router.post("/unlock", response_model=UnlockResponse)
async def unlock_session(
payload: UnlockRequest,
db: AsyncSession = Depends(get_db_session),
) -> dict:
session = await touch_login_session(
db,
user_id=current_user.id,
payload=decode_token(token),
request=request,
) -> 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,
)
if session is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录会话已结束")
return {
"status": "online",
"last_seen_at": session.last_seen_at.isoformat(),
"client_ip": resolve_client_ip(request),
}
@router.post("/session/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout_login_session(
token: str = Depends(oauth2_scheme),
current_user=Depends(get_current_user),
db: AsyncSession = Depends(get_db_session),
) -> Response:
await end_login_session(
db,
user_id=current_user.id,
payload=decode_token(token),
)
return Response(status_code=status.HTTP_204_NO_CONTENT)
expires_at = session_start + timedelta(minutes=settings.JWT_EXPIRE_MINUTES)
return UnlockResponse(accessToken=access_token, expiresAt=expires_at)
@router.patch("/me", response_model=UserRead)
@@ -408,9 +162,7 @@ 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,
"clinical_department": (
payload.clinical_department if payload.clinical_department is not None else current_user.clinical_department
),
"department": payload.department if payload.department is not None else current_user.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,
}
@@ -424,15 +176,10 @@ 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()
-629
View File
@@ -1,629 +0,0 @@
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, File, Form, Header, Request, Response, 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_api_permission
from app.schemas.collaboration import (
CollaborationCallbackPayload,
CollaborationCandidateRead,
CollaborationEditorConfigRead,
CollaborationEditRequestRead,
CollaborationEditRequestResolve,
CollaborationExportRecord,
CollaborationFileCreate,
CollaborationFileRead,
CollaborationFileUpdate,
CollaborationFolderCreate,
CollaborationFolderRead,
CollaborationFolderUpdate,
CollaborationMemberRead,
CollaborationMemberUpsert,
CollaborationOwnershipTransferRequest,
CollaborationPublicEditorConfigRequest,
CollaborationPublicShareMetadata,
CollaborationRestoreRequest,
CollaborationRevisionCopyRequest,
CollaborationRevisionRead,
CollaborationRevisionUpdate,
CollaborationShareAccessGrant,
CollaborationShareLinkRead,
CollaborationShareLinkUpdate,
CollaborationSharePasswordRequest,
)
from app.schemas.onlyoffice import OnlyOfficePreviewConfigRead
from app.services import collaboration_service, collaboration_share_service, onlyoffice_collaboration_service, onlyoffice_service
router = APIRouter()
public_router = APIRouter()
internal_router = APIRouter(include_in_schema=False)
@router.get(
"/folders",
response_model=list[CollaborationFolderRead],
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def list_folders(study_id: uuid.UUID, db: AsyncSession = Depends(get_db_session)):
return await collaboration_service.list_folders(db, study_id)
@router.post(
"/folders",
response_model=CollaborationFolderRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_api_permission("collaboration:manage"))],
)
async def create_folder(
study_id: uuid.UUID,
payload: CollaborationFolderCreate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
return await collaboration_service.create_folder(db, study_id, payload, current_user)
@router.patch(
"/folders/{folder_id}",
response_model=CollaborationFolderRead,
dependencies=[Depends(require_api_permission("collaboration:manage"))],
)
async def update_folder(
study_id: uuid.UUID,
folder_id: uuid.UUID,
payload: CollaborationFolderUpdate,
db: AsyncSession = Depends(get_db_session),
):
return await collaboration_service.update_folder(db, study_id, folder_id, payload)
@router.delete(
"/folders/{folder_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_api_permission("collaboration:manage"))],
)
async def delete_folder(study_id: uuid.UUID, folder_id: uuid.UUID, db: AsyncSession = Depends(get_db_session)):
await collaboration_service.delete_folder(db, study_id, folder_id)
@router.get(
"/files",
response_model=list[CollaborationFileRead],
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def list_files(
study_id: uuid.UUID,
folder_id: uuid.UUID | None = None,
keyword: str | None = None,
deleted: bool = False,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
return await collaboration_service.list_files(
db, study_id, current_user, folder_id=folder_id, keyword=keyword, deleted=deleted
)
@router.post(
"/files",
response_model=CollaborationFileRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_api_permission("collaboration:create"))],
)
async def create_file(
study_id: uuid.UUID,
payload: CollaborationFileCreate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.create_blank_file(db, study_id, payload, current_user)
return await collaboration_service.file_read(db, item, current_user)
@router.post(
"/files/import",
response_model=CollaborationFileRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_api_permission("collaboration:create"))],
)
async def import_file(
study_id: uuid.UUID,
file: UploadFile = File(...),
folder_id: uuid.UUID | None = Form(None),
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.import_file(db, study_id, folder_id, file, current_user)
return await collaboration_service.file_read(db, item, current_user)
@router.post(
"/files/{file_id}/copy",
response_model=CollaborationFileRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_api_permission("collaboration:create"))],
)
async def copy_file(
study_id: uuid.UUID,
file_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
source = await collaboration_service.get_file_or_404(db, study_id, file_id)
item = await collaboration_service.copy_file(db, source, current_user)
return await collaboration_service.file_read(db, item, current_user)
@router.get(
"/files/{file_id}",
response_model=CollaborationFileRead,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def get_file(
study_id: uuid.UUID,
file_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
return await collaboration_service.file_read(db, item, current_user)
@router.get(
"/files/{file_id}/download",
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def download_file(
study_id: uuid.UUID,
file_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
revision = await collaboration_service.prepare_download(db, item, current_user)
return FileResponse(
path=revision.file_uri,
media_type=revision.mime_type,
filename=item.title,
content_disposition_type="attachment",
headers={"Cache-Control": "no-store"},
)
@router.patch(
"/files/{file_id}",
response_model=CollaborationFileRead,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def update_file(
study_id: uuid.UUID,
file_id: uuid.UUID,
payload: CollaborationFileUpdate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
item = await collaboration_service.update_file(db, item, payload, current_user)
return await collaboration_service.file_read(db, item, current_user)
@router.delete(
"/files/{file_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def trash_file(
study_id: uuid.UUID,
file_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
await collaboration_service.move_to_trash(db, item, current_user)
@router.post(
"/files/{file_id}/restore",
response_model=CollaborationFileRead,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def restore_file(
study_id: uuid.UUID,
file_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id, include_deleted=True)
item = await collaboration_service.restore_file(db, item, current_user)
return await collaboration_service.file_read(db, item, current_user)
@router.get(
"/files/{file_id}/members",
response_model=list[CollaborationMemberRead],
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def list_members(study_id: uuid.UUID, file_id: uuid.UUID, db: AsyncSession = Depends(get_db_session)):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
return await collaboration_service.list_members(db, item)
@router.put(
"/files/{file_id}/members",
response_model=CollaborationMemberRead,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def upsert_member(
study_id: uuid.UUID,
file_id: uuid.UUID,
payload: CollaborationMemberUpsert,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
return await collaboration_service.upsert_member(db, item, payload, current_user)
@router.delete(
"/files/{file_id}/members/{user_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def remove_member(
study_id: uuid.UUID,
file_id: uuid.UUID,
user_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
await collaboration_service.remove_member(db, item, user_id, current_user)
@router.post(
"/files/{file_id}/edit-requests",
response_model=CollaborationEditRequestRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def create_edit_request(
study_id: uuid.UUID,
file_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
return await collaboration_service.create_edit_request(db, item, current_user)
@router.get(
"/files/{file_id}/edit-requests",
response_model=list[CollaborationEditRequestRead],
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def list_edit_requests(
study_id: uuid.UUID,
file_id: uuid.UUID,
pending_only: bool = True,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
return await collaboration_service.list_edit_requests(db, item, current_user, pending_only=pending_only)
@router.post(
"/files/{file_id}/edit-requests/{request_id}/resolve",
response_model=CollaborationEditRequestRead,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def resolve_edit_request(
study_id: uuid.UUID,
file_id: uuid.UUID,
request_id: uuid.UUID,
payload: CollaborationEditRequestResolve,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
return await collaboration_service.resolve_edit_request(db, item, request_id, payload, current_user)
@router.post(
"/files/{file_id}/transfer-ownership",
response_model=CollaborationFileRead,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def transfer_ownership(
study_id: uuid.UUID,
file_id: uuid.UUID,
payload: CollaborationOwnershipTransferRequest,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
item = await collaboration_service.transfer_ownership(db, item, payload, current_user)
return await collaboration_service.file_read(db, item, current_user)
@router.get(
"/files/{file_id}/share-link",
response_model=CollaborationShareLinkRead,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def get_share_link(
study_id: uuid.UUID,
file_id: uuid.UUID,
response: Response,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
result = await collaboration_share_service.get_share_link(db, item, current_user)
response.headers["Cache-Control"] = "no-store"
return result
@router.put(
"/files/{file_id}/share-link",
response_model=CollaborationShareLinkRead,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def update_share_link(
study_id: uuid.UUID,
file_id: uuid.UUID,
payload: CollaborationShareLinkUpdate,
response: Response,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
result = await collaboration_share_service.update_share_link(db, item, payload, current_user)
response.headers["Cache-Control"] = "no-store"
return result
@router.get(
"/member-candidates",
response_model=list[CollaborationCandidateRead],
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def list_member_candidates(study_id: uuid.UUID, db: AsyncSession = Depends(get_db_session)):
return await collaboration_service.list_candidates(db, study_id)
@router.get(
"/files/{file_id}/revisions",
response_model=list[CollaborationRevisionRead],
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def list_revisions(study_id: uuid.UUID, file_id: uuid.UUID, db: AsyncSession = Depends(get_db_session)):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
return await collaboration_service.list_revisions(db, item)
@router.patch(
"/files/{file_id}/revisions/{revision_id}",
response_model=CollaborationRevisionRead,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def update_revision(
study_id: uuid.UUID,
file_id: uuid.UUID,
revision_id: uuid.UUID,
payload: CollaborationRevisionUpdate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
return await collaboration_service.update_revision(db, item, revision_id, payload, current_user)
@router.delete(
"/files/{file_id}/revisions/{revision_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def delete_revision(
study_id: uuid.UUID,
file_id: uuid.UUID,
revision_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
await collaboration_service.delete_revision(db, item, revision_id, current_user)
@router.post(
"/files/{file_id}/revisions/{revision_id}/copy",
response_model=CollaborationFileRead,
status_code=status.HTTP_201_CREATED,
dependencies=[
Depends(require_api_permission("collaboration:create")),
Depends(require_api_permission("collaboration:read")),
],
)
async def copy_revision(
study_id: uuid.UUID,
file_id: uuid.UUID,
revision_id: uuid.UUID,
payload: CollaborationRevisionCopyRequest,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
copied = await collaboration_service.copy_revision(db, item, revision_id, payload, current_user)
return await collaboration_service.file_read(db, copied, current_user)
@router.get(
"/files/{file_id}/revisions/{revision_id}/preview-config",
response_model=OnlyOfficePreviewConfigRead,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def get_revision_preview_config(
study_id: uuid.UUID,
file_id: uuid.UUID,
revision_id: uuid.UUID,
response: Response,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
revision = await collaboration_service.prepare_revision_preview(db, item, revision_id, current_user)
await onlyoffice_service.ensure_onlyoffice_available()
result = onlyoffice_service.build_preview_config(
resource_type="collaboration_revision",
resource_id=revision.id,
file_name=item.title,
file_hash=revision.file_hash,
user_id=current_user.id,
user_name=current_user.full_name,
)
response.headers["Cache-Control"] = "no-store"
return result
@router.post(
"/files/{file_id}/revisions/{revision_id}/restore",
response_model=CollaborationRevisionRead,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def restore_revision(
study_id: uuid.UUID,
file_id: uuid.UUID,
revision_id: uuid.UUID,
payload: CollaborationRestoreRequest,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
return await collaboration_service.restore_revision(
db, item, revision_id, current_user, payload.change_summary
)
@router.get(
"/files/{file_id}/editor-config",
response_model=CollaborationEditorConfigRead,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def get_editor_config(
study_id: uuid.UUID,
file_id: uuid.UUID,
response: Response,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
result = await onlyoffice_collaboration_service.build_editor_config(db, item, current_user)
response.headers["Cache-Control"] = "no-store"
return result
@router.post(
"/files/{file_id}/exports",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def record_export(
study_id: uuid.UUID,
file_id: uuid.UUID,
payload: CollaborationExportRecord,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
await collaboration_service.record_export(db, item, current_user, payload.file_type)
@router.post(
"/files/{file_id}/downloads",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_api_permission("collaboration:read"))],
)
async def record_download(
study_id: uuid.UUID,
file_id: uuid.UUID,
payload: CollaborationExportRecord,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
item = await collaboration_service.get_file_or_404(db, study_id, file_id)
await collaboration_service.record_download(db, item, current_user, payload.file_type)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@public_router.get("/metadata", response_model=CollaborationPublicShareMetadata)
async def get_public_share_metadata(
response: Response,
x_ctms_share_token: str | None = Header(default=None, alias="X-CTMS-Share-Token"),
db: AsyncSession = Depends(get_db_session),
):
result = await collaboration_share_service.public_metadata(db, x_ctms_share_token)
response.headers["Cache-Control"] = "no-store"
return result
@public_router.post("/access", response_model=CollaborationShareAccessGrant)
async def verify_public_share_password(
payload: CollaborationSharePasswordRequest,
response: Response,
x_ctms_share_token: str | None = Header(default=None, alias="X-CTMS-Share-Token"),
db: AsyncSession = Depends(get_db_session),
):
result = await collaboration_share_service.verify_share_password(
db, x_ctms_share_token, payload.password
)
response.headers["Cache-Control"] = "no-store"
return result
@public_router.post("/editor-config", response_model=CollaborationEditorConfigRead)
async def get_public_share_editor_config(
payload: CollaborationPublicEditorConfigRequest,
response: Response,
x_ctms_share_token: str | None = Header(default=None, alias="X-CTMS-Share-Token"),
db: AsyncSession = Depends(get_db_session),
):
link, item = await collaboration_share_service.resolve_active_share(db, x_ctms_share_token)
collaboration_share_service.validate_access_grant(link, payload.access_token)
result = await onlyoffice_collaboration_service.build_shared_editor_config(
db,
item,
link,
client_id=payload.client_id,
display_name=payload.display_name,
)
response.headers["Cache-Control"] = "no-store"
return result
@internal_router.get("/internal/onlyoffice/collaboration/sessions/{session_id}/content")
async def get_session_content(session_id: uuid.UUID, request: Request, db: AsyncSession = Depends(get_db_session)):
revision, item = await onlyoffice_collaboration_service.get_session_content(
db, session_id, request.headers.get("AuthorizationJwt")
)
return FileResponse(
path=revision.file_uri,
media_type=revision.mime_type,
filename=item.title,
content_disposition_type="inline",
)
@internal_router.post("/internal/onlyoffice/collaboration/sessions/{session_id}/callback")
async def collaboration_callback(
session_id: uuid.UUID,
payload: CollaborationCallbackPayload,
request: Request,
db: AsyncSession = Depends(get_db_session),
):
onlyoffice_collaboration_service.validate_callback_token(
request.headers.get("AuthorizationJwt"), payload
)
return await onlyoffice_collaboration_service.process_callback(db, session_id, payload)
+8 -15
View File
@@ -3,9 +3,7 @@ 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, 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.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_member
from app.models.milestone import Milestone
from app.schemas.progress import StudyProgressRead
from app.schemas.visit import VisitLostItem
@@ -16,10 +14,9 @@ from app.crud import overview as overview_crud
router = APIRouter()
@router.get("/progress", response_model=StudyProgressRead)
@router.get("/progress", response_model=StudyProgressRead, dependencies=[Depends(require_study_member())])
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)
@@ -39,11 +36,10 @@ async def get_progress(
)
@router.get("/lost-visits", response_model=list[VisitLostItem])
@router.get("/lost-visits", response_model=list[VisitLostItem], dependencies=[Depends(require_study_member())])
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]:
@@ -66,18 +62,15 @@ async def list_lost_visits(
return items
@router.get("/center-summary", response_model=list[CenterSummaryItem])
@router.get("/center-summary", response_model=list[CenterSummaryItem], dependencies=[Depends(require_study_member())])
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]:
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 []
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
if role_value not in {"ADMIN", "PM", "CRA"}:
return []
cra_scope = await get_cra_site_scope(db, study_id, current_user)
scope_ids = cra_scope[0] if cra_scope else None
scope_id_strs = {str(cid) for cid in scope_ids} if scope_ids is not None else None
@@ -86,7 +79,7 @@ async def get_center_summary(
stage_order = [
("institution_initiation_status", "机构立项"),
("ethics_status", "伦理记录"),
("ethics_status", "伦理审批"),
("contract_sign_status", "合同签署"),
("startup_status", "启动"),
("enrollment_status", "入组"),
@@ -1,86 +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
from app.schemas.notification import (
DesktopNotificationAckRequest,
DesktopNotificationClaimRequest,
DesktopNotificationClaimResponse,
DesktopNotificationSubscriptionRead,
DesktopNotificationSubscriptionUpdate,
)
from app.services import desktop_notification_service
router = APIRouter()
@router.get("/subscription", response_model=DesktopNotificationSubscriptionRead)
async def read_subscription(
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> DesktopNotificationSubscriptionRead:
subscription = await desktop_notification_service.get_subscription(db, current_user.id)
return DesktopNotificationSubscriptionRead(
enabled=bool(subscription and subscription.enabled),
enabled_at=subscription.enabled_at if subscription else None,
)
@router.put("/subscription", response_model=DesktopNotificationSubscriptionRead)
async def update_subscription(
payload: DesktopNotificationSubscriptionUpdate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> DesktopNotificationSubscriptionRead:
subscription = await desktop_notification_service.set_subscription(
db, current_user.id, payload.enabled
)
return DesktopNotificationSubscriptionRead(
enabled=subscription.enabled,
enabled_at=subscription.enabled_at,
)
@router.post("/claim", response_model=DesktopNotificationClaimResponse)
async def claim_notifications(
payload: DesktopNotificationClaimRequest,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> DesktopNotificationClaimResponse:
if payload.limit < 1 or payload.limit > 50:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="limit 必须在 1 到 50 之间")
claim_token, lease_expires_at, items = await desktop_notification_service.claim_notifications(
db, current_user.id, payload.limit
)
return DesktopNotificationClaimResponse(
claim_token=claim_token,
lease_expires_at=lease_expires_at,
items=items,
)
@router.post("/ack", status_code=status.HTTP_204_NO_CONTENT)
async def acknowledge_notifications(
payload: DesktopNotificationAckRequest,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> None:
await desktop_notification_service.acknowledge_notifications(
db,
current_user.id,
payload.claim_token,
payload.delivered_ids,
)
@router.post("/{notification_id}/read", status_code=status.HTTP_204_NO_CONTENT)
async def mark_notification_read(
notification_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> None:
await desktop_notification_service.mark_notification_read(
db, current_user.id, notification_id
)
+9 -13
View File
@@ -1,7 +1,7 @@
import uuid
from typing import Optional
from fastapi import APIRouter, Depends, File, Form, Query, UploadFile, status
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile, status
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
@@ -9,8 +9,9 @@ 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, DocumentUpdate
from app.schemas.document import DocumentCreate, DocumentDetail, DocumentSummary
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
@@ -66,23 +67,15 @@ 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)
@@ -130,6 +123,9 @@ 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)
+13 -49
View File
@@ -1,33 +1,18 @@
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_cra_site_scope, get_current_user, get_db_session, require_study_not_locked, require_api_permission
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 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,
validate_drug_shipment_required_fields,
)
from app.schemas.drug_shipment import DrugShipmentCreate, DrugShipmentRead, DrugShipmentUpdate
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:
@@ -48,7 +33,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_api_permission("drug_shipments:create")), Depends(require_study_not_locked())],
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
)
async def create_shipment(
study_id: uuid.UUID,
@@ -69,12 +54,9 @@ async def create_shipment(
entity_type="drug_shipment",
entity_id=shipment.id,
action="CREATE_DRUG_SHIPMENT",
detail=json.dumps(
{"targetName": _shipment_audit_name(shipment), "description": _shipment_audit_description("创建", shipment)},
ensure_ascii=False,
),
detail=f"药品运输 {shipment.id} 已创建",
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
operator_role=current_user.role,
)
return DrugShipmentRead.model_validate(shipment)
@@ -82,7 +64,7 @@ async def create_shipment(
@router.get(
"/shipments",
response_model=list[DrugShipmentRead],
dependencies=[Depends(require_api_permission("drug_shipments:read"))],
dependencies=[Depends(require_study_member())],
)
async def list_shipments(
study_id: uuid.UUID,
@@ -119,7 +101,7 @@ async def list_shipments(
@router.get(
"/shipments/{shipment_id}",
response_model=DrugShipmentRead,
dependencies=[Depends(require_api_permission("drug_shipments:read"))],
dependencies=[Depends(require_study_member())],
)
async def get_shipment(
study_id: uuid.UUID,
@@ -140,7 +122,7 @@ async def get_shipment(
@router.patch(
"/shipments/{shipment_id}",
response_model=DrugShipmentRead,
dependencies=[Depends(require_api_permission("drug_shipments:update")), Depends(require_study_not_locked())],
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
)
async def update_shipment(
study_id: uuid.UUID,
@@ -161,20 +143,6 @@ 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,
@@ -182,12 +150,9 @@ async def update_shipment(
entity_type="drug_shipment",
entity_id=shipment_id,
action="UPDATE_DRUG_SHIPMENT",
detail=json.dumps(
{"targetName": _shipment_audit_name(shipment), "description": _shipment_audit_description("更新", shipment)},
ensure_ascii=False,
),
detail=f"药品运输 {shipment_id} 已更新",
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
operator_role=current_user.role,
)
return DrugShipmentRead.model_validate(shipment)
@@ -195,7 +160,7 @@ async def update_shipment(
@router.delete(
"/shipments/{shipment_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_api_permission("drug_shipments:delete")), Depends(require_study_not_locked())],
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
)
async def delete_shipment(
study_id: uuid.UUID,
@@ -211,7 +176,6 @@ 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,
@@ -219,7 +183,7 @@ async def delete_shipment(
entity_type="drug_shipment",
entity_id=shipment_id,
action="DELETE_DRUG_SHIPMENT",
detail=json.dumps({"targetName": shipment_name, "description": f"删除药品流向“{shipment_name}"}, ensure_ascii=False),
detail=f"药品运输 {shipment_id} 已删除",
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
operator_role=current_user.role,
)
-63
View File
@@ -1,63 +0,0 @@
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)
+63 -31
View File
@@ -3,9 +3,11 @@ 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, is_system_admin, require_study_not_locked, require_api_permission
from app.core.deps import get_current_user, get_db_session, require_study_not_locked
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
from app.crud import member as member_crud
from app.schemas.common import PaginatedResponse
from app.schemas.faq import CategoryCreate, CategoryRead, CategoryUpdate
from app.utils.pagination import paginate
@@ -13,8 +15,9 @@ from app.utils.pagination import paginate
router = APIRouter()
def _is_system_admin(current_user) -> bool:
return is_system_admin(current_user)
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="权限不足")
@router.post(
@@ -22,11 +25,8 @@ def _is_system_admin(current_user) -> bool:
response_model=CategoryRead,
status_code=status.HTTP_201_CREATED,
summary="创建 FAQ 分类",
description="创建项目内 FAQ 分类,权限由项目级权限矩阵控制",
dependencies=[
Depends(require_api_permission("faq_category:create")),
Depends(require_study_not_locked())
],
description="创建全局或项目内 FAQ 分类,项目 PM/ADMIN 可用,全局仅 ADMIN",
dependencies=[Depends(require_study_not_locked())],
)
async def create_category(
payload: CategoryCreate,
@@ -38,11 +38,23 @@ 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="分类名称已存在")
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="该图标已被其他分类使用")
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)
category = await category_crud.create_category(db, payload)
await audit_crud.log_action(
db,
study_id=payload.study_id,
entity_type="faq_category",
entity_id=category.id,
action="CREATE_FAQ_CATEGORY",
detail=f"FAQ 分类 {category.name} 已创建",
operator_id=current_user.id,
operator_role=current_user.role,
)
return CategoryRead.model_validate(category)
@@ -51,16 +63,20 @@ 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")
categories = await category_crud.list_categories(db, study_id, include_global=False)
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)
return paginate([CategoryRead.model_validate(c) for c in categories], total=len(categories))
@@ -68,11 +84,8 @@ async def list_categories(
"/{category_id}",
response_model=CategoryRead,
summary="更新 FAQ 分类",
description="更新分类名称或启停状态,权限由项目级权限矩阵控制",
dependencies=[
Depends(require_api_permission("faq_category:update")),
Depends(require_study_not_locked())
],
description="更新分类名称或启停状态,项目 PM/ADMIN 或全局 ADMIN",
dependencies=[Depends(require_study_not_locked())],
)
async def update_category(
category_id: uuid.UUID,
@@ -83,21 +96,33 @@ 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 not _is_system_admin(current_user):
if "study_id" in update_data and update_data["study_id"] != category.study_id and current_user.role != "ADMIN":
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,
study_id=updated.study_id,
entity_type="faq_category",
entity_id=category_id,
action="UPDATE_FAQ_CATEGORY",
detail=f"FAQ 分类 {category_id} 已更新",
operator_id=current_user.id,
operator_role=current_user.role,
)
return CategoryRead.model_validate(updated)
@@ -105,11 +130,8 @@ async def update_category(
"/{category_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="删除 FAQ 分类",
description="删除分类,权限由项目级权限矩阵控制;分类下存在 FAQ 时不可删除。",
dependencies=[
Depends(require_api_permission("faq_category:delete")),
Depends(require_study_not_locked())
],
description="仅 ADMIN 可删除分类,分类下存在 FAQ 时不可删除。",
dependencies=[Depends(require_study_not_locked())],
)
async def delete_category(
category_id: uuid.UUID,
@@ -119,10 +141,20 @@ 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 not category.study_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="必须提供项目 ID")
if current_user.role != "ADMIN":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="仅管理员可删除 FAQ 分类")
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,无法删除")
await db.delete(category)
await db.commit()
await audit_crud.log_action(
db,
study_id=category.study_id,
entity_type="faq_category",
entity_id=category_id,
action="DELETE_FAQ_CATEGORY",
detail=f"FAQ 分类 {category_id} 已删除",
operator_id=current_user.id,
operator_role=current_user.role,
)
+147 -45
View File
@@ -3,7 +3,8 @@ 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, is_system_admin, require_study_not_locked, require_api_permission
from app.core.deps import get_current_user, get_db_session, require_study_not_locked
from app.crud import audit as audit_crud
from app.crud import faq_category as category_crud
from app.crud import faq_item as faq_crud
from app.crud import faq_reply as reply_crud
@@ -24,8 +25,14 @@ from app.utils.pagination import paginate
router = APIRouter()
def _is_system_admin(current_user) -> bool:
return is_system_admin(current_user)
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 _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="不是该项目成员")
@router.post(
@@ -33,11 +40,8 @@ def _is_system_admin(current_user) -> bool:
response_model=FaqRead,
status_code=status.HTTP_201_CREATED,
summary="创建 FAQ",
description="创建项目内 FAQ,权限由项目级权限矩阵控制",
dependencies=[
Depends(require_api_permission("faq:create")),
Depends(require_study_not_locked())
],
description="创建全局或项目内 FAQ项目 FAQ 需项目 PM/ADMIN 权限。",
dependencies=[Depends(require_study_not_locked())],
)
async def create_faq(
payload: FaqCreate,
@@ -51,6 +55,12 @@ 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:
@@ -64,6 +74,16 @@ async def create_faq(
reply_in=FaqReplyCreate(content=payload.answer),
)
await faq_crud.set_status(db, item.id, "PROCESSING")
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 已创建",
operator_id=current_user.id,
operator_role=current_user.role,
)
return FaqRead.model_validate(item)
@@ -72,12 +92,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]:
@@ -93,26 +113,35 @@ 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 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="不是项目成员")
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="权限不足")
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 not _is_system_admin(current_user):
if current_user.role != "ADMIN":
role = await _get_member_role(it.study_id)
if not role:
continue
if not it.is_active and current_user.role != "ADMIN":
role = await _get_member_role(it.study_id)
if role != "PM":
continue
visible.append(FaqRead.model_validate(it))
return paginate(visible, total=len(visible))
@@ -121,8 +150,7 @@ async def list_faqs(
"/{item_id}",
response_model=FaqRead,
summary="FAQ 详情",
description="获取单条 FAQ,停用 FAQ 需项目级写权限",
dependencies=[Depends(require_api_permission("faq:read"))],
description="获取单条 FAQ非 PM 不能查看停用 FAQ。",
)
async def get_faq(
item_id: uuid.UUID,
@@ -134,6 +162,15 @@ 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)
@@ -141,11 +178,8 @@ async def get_faq(
"/{item_id}",
response_model=FaqRead,
summary="更新 FAQ",
description="更新 FAQ 内容或启停状态,权限由项目级权限矩阵控制",
dependencies=[
Depends(require_api_permission("faq:update")),
Depends(require_study_not_locked())
],
description="更新 FAQ 内容或启停状态,项目内需 PM/ADMIN 权限。",
dependencies=[Depends(require_study_not_locked())],
)
async def update_faq(
item_id: uuid.UUID,
@@ -156,7 +190,29 @@ 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"
await audit_crud.log_action(
db,
study_id=item.study_id,
entity_type="faq_item",
entity_id=item_id,
action=action,
detail=detail,
operator_id=current_user.id,
operator_role=current_user.role,
)
return FaqRead.model_validate(updated)
@@ -164,11 +220,8 @@ async def update_faq(
"/{item_id}/status",
response_model=FaqRead,
summary="更新 FAQ 状态",
description="提问者或具备项目级写权限的成员可确认已解决。",
dependencies=[
Depends(require_api_permission("faq:update")),
Depends(require_study_not_locked())
],
description="提问者或项目 PM/ADMIN 可确认已解决。",
dependencies=[Depends(require_study_not_locked())],
)
async def update_faq_status(
item_id: uuid.UUID,
@@ -181,6 +234,13 @@ 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)
@@ -191,10 +251,7 @@ async def update_faq_status(
response_model=FaqRead,
summary="设置最佳回复",
description="项目成员可设置最佳回复,全局仅 ADMIN。",
dependencies=[
Depends(require_api_permission("faq:update")),
Depends(require_study_not_locked())
],
dependencies=[Depends(require_study_not_locked())],
)
async def set_best_reply(
item_id: uuid.UUID,
@@ -207,6 +264,10 @@ 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:
@@ -234,7 +295,6 @@ 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,
@@ -244,6 +304,10 @@ 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] = []
@@ -270,10 +334,7 @@ async def list_replies(
status_code=status.HTTP_201_CREATED,
summary="创建 FAQ 回复",
description="回复 FAQ,项目内成员可回复,全局仅 ADMIN。",
dependencies=[
Depends(require_api_permission("faq_reply:create")),
Depends(require_study_not_locked())
],
dependencies=[Depends(require_study_not_locked())],
)
async def create_reply(
item_id: uuid.UUID,
@@ -288,6 +349,10 @@ 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)
@@ -303,6 +368,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)
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 已回复",
operator_id=current_user.id,
operator_role=current_user.role,
)
data = FaqReplyRead.model_validate(reply)
if quote:
if quote.is_deleted:
@@ -321,11 +396,8 @@ async def create_reply(
"/{item_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="删除 FAQ",
description="删除 FAQ权限由项目级权限矩阵控制",
dependencies=[
Depends(require_api_permission("faq:delete")),
Depends(require_study_not_locked())
],
description="删除 FAQ项目内需 PM/ADMIN 权限,全局仅 ADMIN",
dependencies=[Depends(require_study_not_locked())],
)
async def delete_faq(
item_id: uuid.UUID,
@@ -335,20 +407,33 @@ 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)
await reply_crud.delete_replies_by_faq_id(db, item.id)
await db.delete(item)
await db.commit()
await audit_crud.log_action(
db,
study_id=item.study_id,
entity_type="faq_item",
entity_id=item_id,
action="DELETE_FAQ_ITEM",
detail="FAQ 已删除",
operator_id=current_user.id,
operator_role=current_user.role,
)
@router.delete(
"/{item_id}/replies/{reply_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="删除 FAQ 回复",
description="删除 FAQ 回复,回复者或具备项目级写权限的成员可删除。",
dependencies=[
Depends(require_api_permission("faq_reply:delete")),
Depends(require_study_not_locked())
],
description="删除 FAQ 回复,管理员、项目 PM 或回复者可删除。",
dependencies=[Depends(require_study_not_locked())],
)
async def delete_reply(
item_id: uuid.UUID,
@@ -362,6 +447,13 @@ async def delete_reply(
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)
@@ -381,3 +473,13 @@ async def delete_reply(
resolved_by_confirm=False,
)
await faq_crud.touch_item(db, item.id)
await audit_crud.log_action(
db,
study_id=item.study_id,
entity_type="faq_reply",
entity_id=reply_id,
action="DELETE_FAQ_REPLY",
detail="FAQ 回复已删除",
operator_id=current_user.id,
operator_role=current_user.role,
)
+255
View File
@@ -0,0 +1,255 @@
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,
)
+61 -121
View File
@@ -1,14 +1,12 @@
import uuid
import json
from decimal import Decimal
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, 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.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 contract_fee as contract_fee_crud
from app.crud import contract_fee_payment as payment_crud
@@ -23,87 +21,36 @@ from app.schemas.contract_fee_payment import (
ContractFeePaymentUpdate,
)
from app.schemas.fee_common import FeeApiResponse
from app.schemas.attachment import AttachmentRead
from app.schemas.fee_attachment import FeeAttachmentRead
from app.schemas.user import UserDisplay
from app.models.attachment import Attachment
router = APIRouter()
async def _ensure_study_access(db: AsyncSession, study_id: uuid.UUID, current_user, endpoint_key: str):
study = await study_crud.get(db, study_id)
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="项目不存在")
if is_system_admin(current_user):
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, study_id, current_user.id)
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="不是项目成员")
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="接口权限不足")
if write and membership.role_in_study != "PM":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="项目权限不足")
return membership
async def _ensure_center_active(db: AsyncSession, study_id: uuid.UUID, center_id: uuid.UUID | None):
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 != study_id or not site.is_active:
if not site or site.study_id != project_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
@@ -130,23 +77,21 @@ def _validate_payment_rules(data: ContractFeePaymentCreate | ContractFeePaymentU
dependencies=[Depends(get_current_user)],
)
async def list_contract_fees(
request: Request,
study_id: uuid.UUID | None = None,
center_id: uuid.UUID | None = None,
project_id: uuid.UUID = Query(..., alias="projectId"),
center_id: uuid.UUID | None = Query(None, alias="centerId"),
q: str | None = None,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> FeeApiResponse[list[ContractFeeListItem]]:
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)
await _ensure_project_access(db, project_id, current_user, write=False)
cra_scope = await get_cra_site_scope(db, project_id, current_user)
center_ids = cra_scope[0] if cra_scope else None
if resolved_center_id and center_ids is not None and resolved_center_id not in center_ids:
if center_id and center_ids is not None and center_id not in center_ids:
return FeeApiResponse(data=[], meta={"total": 0})
rows = await contract_fee_crud.list_contract_fees(
db,
resolved_study_id,
center_id=resolved_center_id,
project_id,
center_id=center_id,
center_ids=center_ids,
q=q,
)
@@ -160,13 +105,9 @@ async def list_contract_fees(
items.append(
ContractFeeListItem(
id=contract.id,
study_id=contract.study_id,
project_id=contract.project_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,
@@ -198,8 +139,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_study_access(db, contract.study_id, current_user, "fees_contracts:read")
cra_scope = await get_cra_site_scope(db, contract.study_id, current_user)
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)
if cra_scope and contract.center_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
@@ -216,7 +157,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[AttachmentRead]] = {
attachments_map: dict[str, list[FeeAttachmentRead]] = {
"contract": [],
"voucher": [],
"invoice": [],
@@ -226,11 +167,16 @@ async def get_contract_fee(
attachments_map.setdefault(key, [])
user = users_map.get(attachment.uploaded_by)
attachments_map[key].append(
AttachmentRead(
FeeAttachmentRead(
id=attachment.id,
entity_type="contract_fee",
entity_id=attachment.entity_id,
file_type=key,
filename=attachment.filename,
file_size=attachment.file_size,
content_type=attachment.content_type,
mime_type=attachment.content_type,
size=attachment.file_size,
storage_key=attachment.file_path,
url=None,
uploaded_by_id=attachment.uploaded_by,
uploaded_by=UserDisplay.model_validate(user) if user else None,
uploaded_at=attachment.uploaded_at,
@@ -241,13 +187,9 @@ async def get_contract_fee(
detail = ContractFeeDetail(
id=contract.id,
study_id=contract.study_id,
project_id=contract.project_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,
@@ -272,27 +214,27 @@ async def create_contract_fee(
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> FeeApiResponse[ContractFeeRead]:
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
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
)
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.study_id or not site.is_active:
if not site or site.study_id != contract_in.project_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.study_id,
study_id=contract.project_id,
entity_type="contract_fee",
entity_id=contract.id,
action="CREATE_CONTRACT_FEE",
detail=await _contract_audit_detail(db, "创建", contract),
detail="合同费用已创建",
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, contract.study_id, current_user),
operator_role=current_user.role,
)
return FeeApiResponse(data=ContractFeeRead.model_validate(contract))
@@ -311,18 +253,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_study_access(db, contract.study_id, current_user, "fees_contracts:update")
await _ensure_center_active(db, contract.study_id, contract.center_id)
await _ensure_project_access(db, contract.project_id, current_user, write=True)
await _ensure_center_active(db, contract.project_id, contract.center_id)
contract = await contract_fee_crud.update_contract_fee(db, contract, contract_in)
await audit_crud.log_action(
db,
study_id=contract.study_id,
study_id=contract.project_id,
entity_type="contract_fee",
entity_id=contract_id,
action="UPDATE_CONTRACT_FEE",
detail=await _contract_audit_detail(db, "更新", contract),
detail="合同费用已更新",
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, contract.study_id, current_user),
operator_role=current_user.role,
)
return FeeApiResponse(data=ContractFeeRead.model_validate(contract))
@@ -340,19 +282,18 @@ 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_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 _ensure_project_access(db, contract.project_id, current_user, write=True)
await _ensure_center_active(db, contract.project_id, contract.center_id)
await contract_fee_crud.delete_contract_fee(db, contract)
await audit_crud.log_action(
db,
study_id=contract.study_id,
study_id=contract.project_id,
entity_type="contract_fee",
entity_id=contract_id,
action="DELETE_CONTRACT_FEE",
detail=contract_detail,
detail="合同费用已删除",
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, contract.study_id, current_user),
operator_role=current_user.role,
)
@@ -371,18 +312,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_study_access(db, contract.study_id, current_user, "fees_contracts:update")
await _ensure_project_access(db, contract.project_id, current_user, write=True)
_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.study_id,
study_id=contract.project_id,
entity_type="contract_fee_payment",
entity_id=payment.id,
action="CREATE_CONTRACT_FEE_PAYMENT",
detail=await _payment_audit_detail(db, "创建", contract, payment),
detail="合同费用分期已创建",
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, contract.study_id, current_user),
operator_role=current_user.role,
)
return FeeApiResponse(data=ContractFeePaymentRead.model_validate(payment))
@@ -404,7 +345,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_study_access(db, contract.study_id, current_user, "fees_contracts:update")
await _ensure_project_access(db, contract.project_id, current_user, write=True)
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,
@@ -417,13 +358,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.study_id,
study_id=contract.project_id,
entity_type="contract_fee_payment",
entity_id=payment_id,
action="UPDATE_CONTRACT_FEE_PAYMENT",
detail=await _payment_audit_detail(db, "更新", contract, payment),
detail="合同费用分期已更新",
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, contract.study_id, current_user),
operator_role=current_user.role,
)
return FeeApiResponse(data=ContractFeePaymentRead.model_validate(payment))
@@ -444,17 +385,16 @@ 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_study_access(db, contract.study_id, current_user, "fees_contracts:update")
payment_detail = await _payment_audit_detail(db, "删除", contract, payment)
await _ensure_project_access(db, contract.project_id, current_user, write=True)
await payment_crud.delete_payment(db, payment)
await payment_crud.resequence_payments(db, contract.id)
await audit_crud.log_action(
db,
study_id=contract.study_id,
study_id=contract.project_id,
entity_type="contract_fee_payment",
entity_id=payment_id,
action="DELETE_CONTRACT_FEE_PAYMENT",
detail=payment_detail,
detail="合同费用分期已删除",
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, contract.study_id, current_user),
operator_role=current_user.role,
)
+245
View File
@@ -0,0 +1,245 @@
import uuid
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_not_locked
from app.crud import audit as audit_crud
from app.crud import member as member_crud
from app.crud import special_expense as special_crud
from app.crud import study as study_crud
from app.crud import site as site_crud
from app.schemas.fee_common import FeeApiResponse
from app.schemas.special_expense import (
SpecialExpenseCreate,
SpecialExpenseListItem,
SpecialExpenseRead,
SpecialExpenseUpdate,
)
router = APIRouter()
ALLOWED_CATEGORIES = {"travel", "meal", "meeting", "supplies", "other"}
async def _ensure_project_access(db: AsyncSession, project_id: uuid.UUID, current_user, write: bool = False):
study = await study_crud.get(db, project_id)
if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
if role_value == "ADMIN":
return None
membership = await member_crud.get_member(db, project_id, current_user.id)
if not membership or not membership.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员")
if write and membership.role_in_study != "PM":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="项目权限不足")
return membership
def _validate_special_rules(payload: SpecialExpenseCreate | SpecialExpenseUpdate):
if payload.is_verified is True and payload.is_paid is False:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="核销需先打款")
if payload.is_paid and not payload.paid_date:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="已打款需填写打款日期")
if payload.is_verified and not payload.verified_date:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="已核销需填写核销日期")
def _validate_category(value: str | None):
if value and value not in ALLOWED_CATEGORIES:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="费用类别无效")
async def _ensure_center_active(db: AsyncSession, project_id: uuid.UUID, center_id: uuid.UUID | None):
if not center_id:
return
site = await site_crud.get_site(db, center_id)
if not site or site.study_id != project_id or not site.is_active:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="中心已停用")
@router.get(
"/special",
response_model=FeeApiResponse[list[SpecialExpenseListItem]],
dependencies=[Depends(get_current_user)],
)
async def list_special_expenses(
project_id: uuid.UUID = Query(..., alias="projectId"),
center_id: uuid.UUID | None = Query(None, alias="centerId"),
category: str | None = None,
date_from: date | None = Query(None, alias="dateFrom"),
date_to: date | None = Query(None, alias="dateTo"),
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> FeeApiResponse[list[SpecialExpenseListItem]]:
await _ensure_project_access(db, project_id, current_user, write=False)
_validate_category(category)
cra_scope = await get_cra_site_scope(db, project_id, current_user)
center_ids = cra_scope[0] if cra_scope else None
if center_id and center_ids is not None and center_id not in center_ids:
return FeeApiResponse(data=[], meta={"total": 0})
rows = await special_crud.list_special_expenses(
db,
project_id,
center_id=center_id,
center_ids=center_ids,
category=category,
date_from=date_from,
date_to=date_to,
)
items: list[SpecialExpenseListItem] = []
for expense, center_name, attachments_count in rows:
items.append(
SpecialExpenseListItem(
id=expense.id,
project_id=expense.project_id,
center_id=expense.center_id,
category=expense.category,
amount=expense.amount,
happen_date=expense.happen_date,
description=expense.description,
is_paid=expense.is_paid,
paid_date=expense.paid_date,
is_verified=expense.is_verified,
verified_date=expense.verified_date,
created_by=expense.created_by,
created_at=expense.created_at,
updated_at=expense.updated_at,
center_name=center_name,
attachments_count=attachments_count or 0,
)
)
return FeeApiResponse(data=items, meta={"total": len(items)})
@router.get(
"/special/{expense_id}",
response_model=FeeApiResponse[SpecialExpenseRead],
dependencies=[Depends(get_current_user)],
)
async def get_special_expense(
expense_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> FeeApiResponse[SpecialExpenseRead]:
expense = await special_crud.get_special_expense(db, expense_id)
if not expense:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="特殊费用不存在")
await _ensure_project_access(db, expense.project_id, current_user, write=False)
cra_scope = await get_cra_site_scope(db, expense.project_id, current_user)
if cra_scope and expense.center_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
return FeeApiResponse(data=SpecialExpenseRead.model_validate(expense))
@router.post(
"/special",
response_model=FeeApiResponse[SpecialExpenseRead],
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(get_current_user), Depends(require_study_not_locked())],
)
async def create_special_expense(
expense_in: SpecialExpenseCreate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> FeeApiResponse[SpecialExpenseRead]:
await _ensure_project_access(db, expense_in.project_id, current_user, write=True)
_validate_category(expense_in.category)
_validate_special_rules(expense_in)
if expense_in.center_id:
site = await site_crud.get_site(db, expense_in.center_id)
if not site or site.study_id != expense_in.project_id or not site.is_active:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="中心不存在或已停用")
await _ensure_center_active(db, expense_in.project_id, expense_in.center_id)
expense = await special_crud.create_special_expense(db, expense_in, created_by=current_user.id)
await audit_crud.log_action(
db,
study_id=expense.project_id,
entity_type="special_expense",
entity_id=expense.id,
action="CREATE_SPECIAL_EXPENSE",
detail="特殊费用已创建",
operator_id=current_user.id,
operator_role=current_user.role,
)
return FeeApiResponse(data=SpecialExpenseRead.model_validate(expense))
@router.patch(
"/special/{expense_id}",
response_model=FeeApiResponse[SpecialExpenseRead],
dependencies=[Depends(get_current_user), Depends(require_study_not_locked())],
)
async def update_special_expense(
expense_id: uuid.UUID,
expense_in: SpecialExpenseUpdate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> FeeApiResponse[SpecialExpenseRead]:
expense = await special_crud.get_special_expense(db, expense_id)
if not expense:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="特殊费用不存在")
await _ensure_project_access(db, expense.project_id, current_user, write=True)
_validate_category(expense_in.category)
merged = SpecialExpenseCreate(
project_id=expense.project_id,
center_id=expense_in.center_id if expense_in.center_id is not None else expense.center_id,
category=expense_in.category if expense_in.category is not None else expense.category,
amount=expense_in.amount if expense_in.amount is not None else expense.amount,
happen_date=expense_in.happen_date if expense_in.happen_date is not None else expense.happen_date,
description=expense_in.description if expense_in.description is not None else expense.description,
is_paid=expense_in.is_paid if expense_in.is_paid is not None else expense.is_paid,
paid_date=expense_in.paid_date if expense_in.paid_date is not None else expense.paid_date,
is_verified=expense_in.is_verified if expense_in.is_verified is not None else expense.is_verified,
verified_date=expense_in.verified_date if expense_in.verified_date is not None else expense.verified_date,
)
_validate_special_rules(merged)
if expense_in.center_id:
site = await site_crud.get_site(db, expense_in.center_id)
if not site or site.study_id != expense.project_id or not site.is_active:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="中心不存在或已停用")
await _ensure_center_active(db, expense.project_id, expense_in.center_id)
else:
await _ensure_center_active(db, expense.project_id, expense.center_id)
expense = await special_crud.update_special_expense(db, expense, expense_in)
await audit_crud.log_action(
db,
study_id=expense.project_id,
entity_type="special_expense",
entity_id=expense_id,
action="UPDATE_SPECIAL_EXPENSE",
detail="特殊费用已更新",
operator_id=current_user.id,
operator_role=current_user.role,
)
return FeeApiResponse(data=SpecialExpenseRead.model_validate(expense))
@router.delete(
"/special/{expense_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(get_current_user), Depends(require_study_not_locked())],
)
async def delete_special_expense(
expense_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> None:
expense = await special_crud.get_special_expense(db, expense_id)
if not expense:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="特殊费用不存在")
await _ensure_project_access(db, expense.project_id, current_user, write=True)
await _ensure_center_active(db, expense.project_id, expense.center_id)
await special_crud.delete_special_expense(db, expense)
await audit_crud.log_action(
db,
study_id=expense.project_id,
entity_type="special_expense",
entity_id=expense_id,
action="DELETE_SPECIAL_EXPENSE",
detail="特殊费用已删除",
operator_id=current_user.id,
operator_role=current_user.role,
)
+177
View File
@@ -0,0 +1,177 @@
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,
)
+2 -3
View File
@@ -4,7 +4,7 @@ from datetime import date
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db_session, require_api_permission
from app.core.deps import get_db_session, require_study_member
from app.crud import finance as finance_crud
from app.crud import study as study_crud
from app.schemas.finance import FinanceSummaryRead
@@ -12,12 +12,11 @@ from app.schemas.finance import FinanceSummaryRead
router = APIRouter()
@router.get("/summary", response_model=FinanceSummaryRead)
@router.get("/summary", response_model=FinanceSummaryRead, dependencies=[Depends(require_study_member())])
async def finance_summary(
study_id: uuid.UUID,
date_from: date | None = None,
date_to: date | None = None,
_=Depends(require_api_permission("fees_contracts:read")),
db: AsyncSession = Depends(get_db_session),
) -> FinanceSummaryRead:
# ensure study exists
+177
View File
@@ -0,0 +1,177 @@
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,
)

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