Compare commits
197 Commits
d23dead775
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c533b8c37 | |||
| 795e0a75ca | |||
| 72b907947b | |||
| e3489f3b06 | |||
| 403776cc1c | |||
| c23a5d6a22 | |||
| fa26c84bd2 | |||
| c5c9bb5527 | |||
| feb1f3db65 | |||
| de3dc87920 | |||
| a0aa9384d7 | |||
| a938d2ed5f | |||
| 4863ade45b | |||
| d5279b124f | |||
| 1837ceff58 | |||
| 3e77127687 | |||
| 88bd0c5942 | |||
| 6b55c18610 | |||
| 1d26646a96 | |||
| c68dddfc01 | |||
| 44db5db838 | |||
| ab59476d10 | |||
| b26ebdda02 | |||
| f2fa10c178 | |||
| f11a5c84d9 | |||
| 400c9be3a7 | |||
| 5ea9b24912 | |||
| aed628a49b | |||
| 0bbc125806 | |||
| 878d1dc701 | |||
| 0c187a35fa | |||
| 1dc10f569d | |||
| fd4208d06f | |||
| 0aceb1fd74 | |||
| 141fea674f | |||
| 6b980f884a | |||
| 0841eb0f79 | |||
| f4844aa8f1 | |||
| b03e30d0dd | |||
| 0aff10fe0d | |||
| 76f2d9f22a | |||
| b73f23c1eb | |||
| e7b18758b2 | |||
| 84d5daebab | |||
| 3a866accd9 | |||
| a17fa618cd | |||
| 0d03e1656a | |||
| 1c1527a224 | |||
| 360988de5e | |||
| b8c5c4123a | |||
| 8c8327df92 | |||
| 32167fba02 | |||
| 593c60c782 | |||
| 18a3166463 | |||
| 45ed1d7f36 | |||
| 2d7d13b2f5 | |||
| 5ee84c97e8 | |||
| b491b6a146 | |||
| 9cac75e85c | |||
| c923f887a0 | |||
| 628ff8828b | |||
| 7c721d4e5c | |||
| d1a6c957f7 | |||
| 4654a812a0 | |||
| b283cf1e5c | |||
| 18daea7f2c | |||
| 1078b67de6 | |||
| 668e719b6d | |||
| b25055775e | |||
| 6c2bcc59b2 | |||
| e242e067f0 | |||
| c7ca97a6d6 | |||
| f94acebd1a | |||
| f9a9f7eafe | |||
| b1c55abb10 | |||
| 24d5b7445f | |||
| 2df8d60172 | |||
| b83c900109 | |||
| 20b414e741 | |||
| a81647a4f8 | |||
| b72b5314a0 | |||
| b126a759f0 | |||
| 05cd74fd43 | |||
| e531ce75f9 | |||
| 4bbc234b53 | |||
| efc325568d | |||
| c351ca59c4 | |||
| 79d2ee344c | |||
| 6056c8364a | |||
| 360a2ba2b1 | |||
| e507ab98cb | |||
| 9b2b8e90f6 | |||
| 53249daf8d | |||
| 061792c73f | |||
| 1886765db8 | |||
| b7484c8e01 | |||
| 84e55af8fe | |||
| ea3f19e241 | |||
| 1fef12cd86 | |||
| d472b8410e | |||
| 30e2398400 | |||
| 8cd8c38900 | |||
| 834b4f1d48 | |||
| a3f6a04f35 | |||
| e5ed18c3cd | |||
| f6e5a4c744 | |||
| e78bb04c4b | |||
| 0b3a4d4d31 | |||
| 4ea0e88c98 | |||
| d6452e3f9d | |||
| 02f2c6660b | |||
| 41968384b5 | |||
| 90ca061db8 | |||
| 6e8494abd5 | |||
| 6a2e43f829 | |||
| 720c98765f | |||
| ab4f0d93ed | |||
| 44d69c2d7b | |||
| 63457aab11 | |||
| c9b677e96a | |||
| 8611bd66f8 | |||
| fa961f1391 | |||
| 31fcb7e6f2 | |||
| d2b41ae454 | |||
| 8da7fc715c | |||
| 2c85742040 | |||
| 6d1c98bcae | |||
| c909fc9387 | |||
| 41bd423be0 | |||
| 3c168565ce | |||
| 6c2f61eebd | |||
| 934d114a29 | |||
| 747dd55225 | |||
| dd2973c429 | |||
| a2add43fa5 | |||
| f40e979c53 | |||
| ffcf217142 | |||
| fce671d7e0 | |||
| 78247a8149 | |||
| da6f2f2d41 | |||
| 9305ced664 | |||
| 6d682103f3 | |||
| 5b97ea32ab | |||
| 6cefa620e4 | |||
| e95eeed90e | |||
| f8a959b801 | |||
| 20ce6bccef | |||
| 3b1bdc2070 | |||
| 3630b9000f | |||
| 5327e00cf1 | |||
| 6eea6b35fd | |||
| ebbb6aed25 | |||
| cb36606607 | |||
| e0c165e194 | |||
| 50106452ac | |||
| fdf640692d | |||
| 7b397640b9 | |||
| 77b16ffa82 | |||
| 4a70c1df08 | |||
| b9c87046fa | |||
| 35acf96d6b | |||
| c73c00d932 | |||
| 387d035b08 | |||
| 1247b64e91 | |||
| 1fec510e2e | |||
| 3c66ac9b37 | |||
| 0cc87210af | |||
| a17f4cc522 | |||
| ab1975d3c6 | |||
| 89cecce6b5 | |||
| 1049599147 | |||
| 939fc70532 | |||
| 77e842637d | |||
| 6e90370a5f | |||
| ad575d024c | |||
| cd7e12ce1f | |||
| 5513ec764a | |||
| 20d45cfdfe | |||
| 8d061a1520 | |||
| f6a7a3d6ee | |||
| 4592ff375d | |||
| 917ab7ccf1 | |||
| b0f45030b0 | |||
| 74feca4467 | |||
| a7bbcaa5dc | |||
| ceda34fd1d | |||
| 4bebc64662 | |||
| fc74d1e9a9 | |||
| ba880dad9a | |||
| 84f865a7b0 | |||
| 9535fece6c | |||
| ada3beca8e | |||
| fa96d4b6db | |||
| 97c1fc3c32 | |||
| 27ba5eabff | |||
| 7ccb8c20cc | |||
| 3a1415d1e1 |
@@ -0,0 +1,167 @@
|
||||
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 }}
|
||||
@@ -0,0 +1,456 @@
|
||||
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
|
||||
@@ -0,0 +1,128 @@
|
||||
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
@@ -5,6 +5,7 @@ Thumbs.db
|
||||
# IDE/editor
|
||||
.idea/
|
||||
.vscode/
|
||||
.claude/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
@@ -52,6 +53,8 @@ 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*
|
||||
@@ -69,9 +72,17 @@ 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/
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# 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 后补。面向安装用户的 GitHub Release 只保留 DMG 与只校验该安装包的 `SHA256SUMS.txt`,平台未签名风险和 Windows pending 状态写入 Release Notes。macOS updater 包及 `.sig`、完整 checksum、provenance、`UNSIGNED-PLATFORM` 与 Windows pending 证据必须保存在被 Git 忽略的私有发布目录,待 Windows `NotSigned` 制品和联合 feed 均验证通过后再发布到可匿名读取的独立 HTTPS updater 源;此前不得发布或替换生产 `latest.json`。该本地/分阶段例外不适用于后续版本。
|
||||
|
||||
## 分支与发布治理
|
||||
|
||||
处理代码提交、分支同步、版本晋级、正式发布或生产热修复前,必须先阅读:
|
||||
|
||||
- `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 未签名;制品名、Release Notes、私有发布证据、完整 updater 校验清单和 provenance 必须清楚标注 `UNSIGNED-PLATFORM`,并提示 Gatekeeper/SmartScreen 警告。面向安装用户的 GitHub Release 可按精确版本策略精简为安装包与对应 checksum,但不得因此删除私有 updater 签名制品或验证证据。该例外不自动适用于后续版本。
|
||||
@@ -1,17 +1,26 @@
|
||||
# CTMS 项目快速上手
|
||||
|
||||
## 交互安装入口
|
||||
- 推荐执行 `./install.sh`,通过键盘 `↑/↓` 选择安装、更新、卸载、资源状态等操作;该入口不接受命令行参数。
|
||||
- 菜单顶部会常驻显示当前 CTMS 容器部署状态,包括已检测到的环境、Compose 项目和容器运行数量。
|
||||
- 底层脚本位于 `scripts/`:`install.sh`、`update.sh`、`uninstall.sh`、`status.sh`;状态脚本使用 `docker compose stats --no-stream` 采集资源快照并美化展示。
|
||||
|
||||
## 生产部署
|
||||
- 生产入口:`docker-compose.yaml`
|
||||
- 初始化方式:`docker compose run --rm backend-init`
|
||||
- 初始化方式:`docker compose run --rm --build backend-init`
|
||||
- 启动方式:`docker compose up -d --build`
|
||||
- 运行拓扑:`nginx`、`backend`、`db`
|
||||
- 运行拓扑:`nginx`、`backend`、`db`、`onlyoffice`
|
||||
- 对外入口:`nginx` 提供前端静态资源,并同域反代后端 API
|
||||
- 数据库 schema 来源:Alembic migration,不再依赖 `database/init.sql`
|
||||
- 默认无任何业务预置数据;生产初始化只确保固定管理员 `admin@huapont.cn / admin123` 存在
|
||||
- 生产环境必须配置 `LOGIN_RSA_PRIVATE_KEY`;登录密码传输使用 RSA-OAEP 包裹 AES-GCM 密钥,数据库仍只保存 bcrypt 哈希
|
||||
- 浏览器端加密依赖 WebCrypto 安全上下文;生产访问必须使用 HTTPS,本地 `localhost` 例外
|
||||
- 当前 challenge 缓存在后端进程内,默认最多保留 1000 个;多实例或多 worker 部署需改为共享缓存或启用粘性会话
|
||||
- 验证方式:
|
||||
- `docker compose config`
|
||||
- `curl -i http://127.0.0.1:8888/`
|
||||
- `curl -i http://127.0.0.1:8888/health`
|
||||
- `curl -i http://127.0.0.1:8888/readyz`
|
||||
|
||||
## 账号与注册
|
||||
- 初始化管理员:`admin@huapont.cn / admin123`(通过生产初始化命令显式创建)
|
||||
@@ -30,21 +39,39 @@
|
||||
## 访问方式
|
||||
- 前端:`http://localhost:8888`
|
||||
- 后端 API:同域 `/api/v1/*`
|
||||
- `nginx` 负责托管前端静态资源,并将 `/api` 与 `/health` 转发到 `backend`
|
||||
- `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`。
|
||||
|
||||
## 仓库治理文档
|
||||
- 分支治理规范:`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/audits/`。
|
||||
- 文档入口见 `docs/README.md`;当前约束集中在分支治理、发布清单和治理审计文档,操作手册集中在 `docs/guides/`,历史交付记录集中在 `docs/reports/` 与 `docs/plans/`。
|
||||
|
||||
## 常用流程
|
||||
1. 用管理员账号登录前端(默认 `admin@example.com / admin123`)。
|
||||
1. 用管理员账号登录前端(默认 `admin@huapont.cn / admin123`)。
|
||||
2. 进入左侧导航「账号治理」「项目治理」进行用户、项目、中心等全局管理;或在“项目列表”选择项目后进入项目内工作区。
|
||||
3. Dashboard 查看进度、待办;左侧导航进入“里程碑”“任务”等页面。
|
||||
4. 退出登录或切换项目可通过顶部右侧下拉。
|
||||
@@ -57,6 +84,6 @@
|
||||
> ADMIN 具备所有 PM 权限,项目内操作与 PM 同步放行。
|
||||
|
||||
## 注意
|
||||
- 登录使用邮箱 + 密码,未审核/已拒绝/已停用账号无法登录。
|
||||
- 登录使用邮箱 + 密码,前端会先获取登录公钥并加密提交;未审核/已拒绝/已停用账号无法登录。
|
||||
- 令牌与当前项目上下文保存在浏览器 LocalStorage 中,清除后需重新登录/选择项目。
|
||||
- 若后端重启,确认数据库容器仍健康,前端会弹出错误提示。
|
||||
|
||||
+3
-1
@@ -14,4 +14,6 @@ COPY alembic /code/alembic
|
||||
COPY app /code/app
|
||||
COPY scripts /code/scripts
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
RUN chmod +x /code/scripts/*.py
|
||||
|
||||
CMD sh -c "cd /code && PYTHONPATH=/code alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000"
|
||||
|
||||
@@ -19,10 +19,18 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 添加 is_locked 字段到 studies 表
|
||||
op.add_column('studies', sa.Column('is_locked', sa.Boolean(), nullable=False, server_default='false'))
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
columns = {col["name"] for col in inspector.get_columns("studies")}
|
||||
|
||||
if "is_locked" not in columns:
|
||||
op.add_column("studies", sa.Column("is_locked", sa.Boolean(), nullable=False, server_default="false"))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 删除 is_locked 字段
|
||||
op.drop_column('studies', 'is_locked')
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
columns = {col["name"] for col in inspector.get_columns("studies")}
|
||||
|
||||
if "is_locked" in columns:
|
||||
op.drop_column("studies", "is_locked")
|
||||
|
||||
@@ -19,10 +19,18 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 添加 enrollment_target 字段到 sites 表
|
||||
op.add_column('sites', sa.Column('enrollment_target', sa.Integer(), nullable=True))
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
columns = {col["name"] for col in inspector.get_columns("sites")}
|
||||
|
||||
if "enrollment_target" not in columns:
|
||||
op.add_column("sites", sa.Column("enrollment_target", sa.Integer(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 删除 enrollment_target 字段
|
||||
op.drop_column('sites', 'enrollment_target')
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
columns = {col["name"] for col in inspector.get_columns("sites")}
|
||||
|
||||
if "enrollment_target" in columns:
|
||||
op.drop_column("sites", "enrollment_target")
|
||||
|
||||
@@ -20,10 +20,18 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_table("workflow_actions")
|
||||
op.drop_table("version_workflows")
|
||||
op.drop_table("workflow_nodes")
|
||||
op.drop_table("workflow_templates")
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
tables = set(inspector.get_table_names())
|
||||
|
||||
if "workflow_actions" in tables:
|
||||
op.drop_table("workflow_actions")
|
||||
if "version_workflows" in tables:
|
||||
op.drop_table("version_workflows")
|
||||
if "workflow_nodes" in tables:
|
||||
op.drop_table("workflow_nodes")
|
||||
if "workflow_templates" in tables:
|
||||
op.drop_table("workflow_templates")
|
||||
op.execute("DROP TYPE IF EXISTS workflow_action_type")
|
||||
op.execute("DROP TYPE IF EXISTS workflow_status")
|
||||
|
||||
|
||||
@@ -19,18 +19,31 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"adverse_events",
|
||||
sa.Column("is_sae", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
)
|
||||
op.add_column(
|
||||
"adverse_events",
|
||||
sa.Column("is_susar", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
)
|
||||
op.alter_column("adverse_events", "is_sae", server_default=None)
|
||||
op.alter_column("adverse_events", "is_susar", server_default=None)
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
columns = {col["name"] for col in inspector.get_columns("adverse_events")}
|
||||
|
||||
if "is_sae" not in columns:
|
||||
op.add_column(
|
||||
"adverse_events",
|
||||
sa.Column("is_sae", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
)
|
||||
if "is_susar" not in columns:
|
||||
op.add_column(
|
||||
"adverse_events",
|
||||
sa.Column("is_susar", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
)
|
||||
if "is_sae" in columns or "is_susar" in columns:
|
||||
op.alter_column("adverse_events", "is_sae", server_default=None)
|
||||
op.alter_column("adverse_events", "is_susar", server_default=None)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("adverse_events", "is_susar")
|
||||
op.drop_column("adverse_events", "is_sae")
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
columns = {col["name"] for col in inspector.get_columns("adverse_events")}
|
||||
|
||||
if "is_susar" in columns:
|
||||
op.drop_column("adverse_events", "is_susar")
|
||||
if "is_sae" in columns:
|
||||
op.drop_column("adverse_events", "is_sae")
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""replace global visit window with per-visit schedule
|
||||
|
||||
Revision ID: 20260508_01
|
||||
Revises: 20260331_01
|
||||
Create Date: 2026-05-08 11:30:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
revision: str = "20260508_01"
|
||||
down_revision: Union[str, None] = "20260331_01"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"studies",
|
||||
sa.Column(
|
||||
"visit_schedule",
|
||||
postgresql.JSONB(astext_type=sa.Text()),
|
||||
nullable=False,
|
||||
server_default=sa.text("'[]'::jsonb"),
|
||||
),
|
||||
)
|
||||
op.drop_column("studies", "visit_window_end_offset")
|
||||
op.drop_column("studies", "visit_window_start_offset")
|
||||
op.drop_column("studies", "visit_total")
|
||||
op.drop_column("studies", "visit_interval_days")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.add_column("studies", sa.Column("visit_interval_days", sa.Integer(), nullable=True))
|
||||
op.add_column("studies", sa.Column("visit_total", sa.Integer(), nullable=True))
|
||||
op.add_column("studies", sa.Column("visit_window_start_offset", sa.Integer(), nullable=True))
|
||||
op.add_column("studies", sa.Column("visit_window_end_offset", sa.Integer(), nullable=True))
|
||||
op.drop_column("studies", "visit_schedule")
|
||||
@@ -0,0 +1,47 @@
|
||||
"""remove summary and objective note fields
|
||||
|
||||
Revision ID: 20260508_02
|
||||
Revises: 20260508_01
|
||||
Create Date: 2026-05-08 15:55:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "20260508_02"
|
||||
down_revision: Union[str, None] = "20260508_01"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE study_setup_configs
|
||||
SET published_project_snapshot = published_project_snapshot - 'summary_note' - 'objective_note'
|
||||
WHERE published_project_snapshot IS NOT NULL
|
||||
"""
|
||||
)
|
||||
)
|
||||
bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE study_setup_config_versions
|
||||
SET published_project_snapshot = published_project_snapshot - 'summary_note' - 'objective_note'
|
||||
WHERE published_project_snapshot IS NOT NULL
|
||||
"""
|
||||
)
|
||||
)
|
||||
op.drop_column("studies", "objective_note")
|
||||
op.drop_column("studies", "summary_note")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.add_column("studies", sa.Column("summary_note", sa.Text(), nullable=True))
|
||||
op.add_column("studies", sa.Column("objective_note", sa.Text(), nullable=True))
|
||||
@@ -0,0 +1,26 @@
|
||||
"""add subject baseline date
|
||||
|
||||
Revision ID: 20260508_03
|
||||
Revises: 20260508_02
|
||||
Create Date: 2026-05-08 16:35:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "20260508_03"
|
||||
down_revision: Union[str, None] = "20260508_02"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("subjects", sa.Column("baseline_date", sa.Date(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("subjects", "baseline_date")
|
||||
@@ -0,0 +1,59 @@
|
||||
"""add template fields to monitoring_visit_issues
|
||||
|
||||
Revision ID: 20260509_01
|
||||
Revises: 20260508_03
|
||||
Create Date: 2026-05-09 13:45:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "20260509_01"
|
||||
down_revision: Union[str, None] = "20260508_03"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
columns = {col["name"] for col in inspector.get_columns("monitoring_visit_issues")}
|
||||
|
||||
if "severity" not in columns:
|
||||
op.add_column("monitoring_visit_issues", sa.Column("severity", sa.String(length=64), nullable=True))
|
||||
if "mark" not in columns:
|
||||
op.add_column("monitoring_visit_issues", sa.Column("mark", sa.String(length=100), nullable=True))
|
||||
if "visit_cycle" not in columns:
|
||||
op.add_column("monitoring_visit_issues", sa.Column("visit_cycle", sa.String(length=100), nullable=True))
|
||||
if "center_query" not in columns:
|
||||
op.add_column("monitoring_visit_issues", sa.Column("center_query", sa.Text(), nullable=True))
|
||||
if "center_latest_reply" not in columns:
|
||||
op.add_column("monitoring_visit_issues", sa.Column("center_latest_reply", sa.Text(), nullable=True))
|
||||
if "rectification_completed" not in columns:
|
||||
op.add_column(
|
||||
"monitoring_visit_issues",
|
||||
sa.Column("rectification_completed", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
columns = {col["name"] for col in inspector.get_columns("monitoring_visit_issues")}
|
||||
|
||||
if "rectification_completed" in columns:
|
||||
op.drop_column("monitoring_visit_issues", "rectification_completed")
|
||||
if "center_latest_reply" in columns:
|
||||
op.drop_column("monitoring_visit_issues", "center_latest_reply")
|
||||
if "center_query" in columns:
|
||||
op.drop_column("monitoring_visit_issues", "center_query")
|
||||
if "visit_cycle" in columns:
|
||||
op.drop_column("monitoring_visit_issues", "visit_cycle")
|
||||
if "mark" in columns:
|
||||
op.drop_column("monitoring_visit_issues", "mark")
|
||||
if "severity" in columns:
|
||||
op.drop_column("monitoring_visit_issues", "severity")
|
||||
@@ -0,0 +1,55 @@
|
||||
"""add site_id to monitoring_visit_issues
|
||||
|
||||
Revision ID: 20260509_02
|
||||
Revises: 20260509_01
|
||||
Create Date: 2026-05-09 14:30:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "20260509_02"
|
||||
down_revision: Union[str, None] = "20260509_01"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
columns = {col["name"] for col in inspector.get_columns("monitoring_visit_issues")}
|
||||
fks = {fk["name"] for fk in inspector.get_foreign_keys("monitoring_visit_issues")}
|
||||
indexes = {idx["name"] for idx in inspector.get_indexes("monitoring_visit_issues")}
|
||||
|
||||
if "site_id" not in columns:
|
||||
op.add_column("monitoring_visit_issues", sa.Column("site_id", postgresql.UUID(as_uuid=True), nullable=True))
|
||||
if "fk_monitoring_visit_issues_site_id" not in fks:
|
||||
op.create_foreign_key(
|
||||
"fk_monitoring_visit_issues_site_id",
|
||||
"monitoring_visit_issues",
|
||||
"sites",
|
||||
["site_id"],
|
||||
["id"],
|
||||
)
|
||||
if "ix_monitoring_visit_issues_site_id" not in indexes:
|
||||
op.create_index("ix_monitoring_visit_issues_site_id", "monitoring_visit_issues", ["site_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
columns = {col["name"] for col in inspector.get_columns("monitoring_visit_issues")}
|
||||
fks = {fk["name"] for fk in inspector.get_foreign_keys("monitoring_visit_issues")}
|
||||
indexes = {idx["name"] for idx in inspector.get_indexes("monitoring_visit_issues")}
|
||||
|
||||
if "ix_monitoring_visit_issues_site_id" in indexes:
|
||||
op.drop_index("ix_monitoring_visit_issues_site_id", table_name="monitoring_visit_issues")
|
||||
if "fk_monitoring_visit_issues_site_id" in fks:
|
||||
op.drop_constraint("fk_monitoring_visit_issues_site_id", "monitoring_visit_issues", type_="foreignkey")
|
||||
if "site_id" in columns:
|
||||
op.drop_column("monitoring_visit_issues", "site_id")
|
||||
@@ -0,0 +1,34 @@
|
||||
"""add subject actual medication count
|
||||
|
||||
Revision ID: 20260509_03
|
||||
Revises: 20260509_02
|
||||
Create Date: 2026-05-09 14:58:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "20260509_03"
|
||||
down_revision: Union[str, None] = "20260509_02"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
columns = {col["name"] for col in inspector.get_columns("subjects")}
|
||||
if "actual_medication_count" not in columns:
|
||||
op.add_column("subjects", sa.Column("actual_medication_count", sa.Integer(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
columns = {col["name"] for col in inspector.get_columns("subjects")}
|
||||
if "actual_medication_count" in columns:
|
||||
op.drop_column("subjects", "actual_medication_count")
|
||||
@@ -0,0 +1,157 @@
|
||||
"""backfill project info in setup drafts
|
||||
|
||||
Revision ID: 20260511_01
|
||||
Revises: 20260509_03
|
||||
Create Date: 2026-05-11 10:30:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "20260511_01"
|
||||
down_revision: Union[str, None] = "20260509_03"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
EMPTY_PROJECT_INFO_JSON = """{
|
||||
"code": "",
|
||||
"name": "",
|
||||
"project_full_name": "",
|
||||
"sponsor": "",
|
||||
"protocol_no": "",
|
||||
"lead_unit": "",
|
||||
"principal_investigator": "",
|
||||
"main_pm": "",
|
||||
"research_analysis": "",
|
||||
"research_product": "",
|
||||
"control_product": "",
|
||||
"indication": "",
|
||||
"research_population": "",
|
||||
"research_design": "",
|
||||
"plan_start_date": "",
|
||||
"plan_end_date": "",
|
||||
"planned_site_count": null,
|
||||
"planned_enrollment_count": null,
|
||||
"status": "",
|
||||
"visit_schedule": []
|
||||
}"""
|
||||
|
||||
|
||||
def _has_table(inspector: sa.Inspector, table_name: str) -> bool:
|
||||
return table_name in set(inspector.get_table_names())
|
||||
|
||||
|
||||
def _has_columns(inspector: sa.Inspector, table_name: str, column_names: set[str]) -> bool:
|
||||
existing = {col["name"] for col in inspector.get_columns(table_name)}
|
||||
return column_names.issubset(existing)
|
||||
|
||||
|
||||
def _backfill_config_column(table_name: str, config_column: str, *, require_existing_setup_content: bool) -> None:
|
||||
content_filter = ""
|
||||
if require_existing_setup_content:
|
||||
content_filter = f"""
|
||||
AND (
|
||||
jsonb_array_length(COALESCE(setup.{config_column}->'projectMilestones', '[]'::jsonb)) > 0
|
||||
OR COALESCE(setup.{config_column}->'enrollmentPlan', '{{}}'::jsonb) <> '{{"totalTarget": 0, "startDate": "", "endDate": "", "monthlyGoalNote": "", "stageBreakdown": ""}}'::jsonb
|
||||
OR jsonb_array_length(COALESCE(setup.{config_column}->'siteMilestones', '[]'::jsonb)) > 0
|
||||
OR jsonb_array_length(COALESCE(setup.{config_column}->'siteEnrollmentPlans', '[]'::jsonb)) > 0
|
||||
OR jsonb_array_length(COALESCE(setup.{config_column}->'monitoringStrategies', '[]'::jsonb)) > 0
|
||||
OR jsonb_array_length(COALESCE(setup.{config_column}->'centerConfirm', '[]'::jsonb)) > 0
|
||||
)
|
||||
"""
|
||||
op.execute(
|
||||
sa.text(
|
||||
f"""
|
||||
UPDATE {table_name} AS setup
|
||||
SET {config_column} = jsonb_set(
|
||||
setup.{config_column},
|
||||
'{{projectInfo}}',
|
||||
jsonb_build_object(
|
||||
'code', COALESCE(studies.code, ''),
|
||||
'name', COALESCE(studies.name, ''),
|
||||
'project_full_name', COALESCE(studies.project_full_name, ''),
|
||||
'sponsor', COALESCE(studies.sponsor, ''),
|
||||
'protocol_no', COALESCE(studies.protocol_no, ''),
|
||||
'lead_unit', COALESCE(studies.lead_unit, ''),
|
||||
'principal_investigator', COALESCE(studies.principal_investigator, ''),
|
||||
'main_pm', COALESCE(studies.main_pm, ''),
|
||||
'research_analysis', COALESCE(studies.research_analysis, ''),
|
||||
'research_product', COALESCE(studies.research_product, ''),
|
||||
'control_product', COALESCE(studies.control_product, ''),
|
||||
'indication', COALESCE(studies.indication, ''),
|
||||
'research_population', COALESCE(studies.research_population, ''),
|
||||
'research_design', COALESCE(studies.research_design, ''),
|
||||
'plan_start_date', COALESCE(to_char(studies.plan_start_date, 'YYYY-MM-DD'), ''),
|
||||
'plan_end_date', COALESCE(to_char(studies.plan_end_date, 'YYYY-MM-DD'), ''),
|
||||
'planned_site_count', to_jsonb(studies.planned_site_count),
|
||||
'planned_enrollment_count', to_jsonb(studies.planned_enrollment_count),
|
||||
'status', COALESCE(studies.status, ''),
|
||||
'visit_schedule', COALESCE(studies.visit_schedule::jsonb, '[]'::jsonb)
|
||||
),
|
||||
true
|
||||
)
|
||||
FROM studies
|
||||
WHERE setup.study_id = studies.id
|
||||
AND setup.{config_column} IS NOT NULL
|
||||
AND (
|
||||
NOT setup.{config_column} ? 'projectInfo'
|
||||
OR setup.{config_column}->'projectInfo' = CAST(:empty_project_info AS jsonb)
|
||||
)
|
||||
{content_filter}
|
||||
"""
|
||||
).bindparams(empty_project_info=EMPTY_PROJECT_INFO_JSON)
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
required_study_columns = {
|
||||
"id",
|
||||
"code",
|
||||
"name",
|
||||
"project_full_name",
|
||||
"sponsor",
|
||||
"protocol_no",
|
||||
"lead_unit",
|
||||
"principal_investigator",
|
||||
"main_pm",
|
||||
"research_analysis",
|
||||
"research_product",
|
||||
"control_product",
|
||||
"indication",
|
||||
"research_population",
|
||||
"research_design",
|
||||
"plan_start_date",
|
||||
"plan_end_date",
|
||||
"planned_site_count",
|
||||
"planned_enrollment_count",
|
||||
"status",
|
||||
"visit_schedule",
|
||||
}
|
||||
if not _has_table(inspector, "studies") or not _has_columns(inspector, "studies", required_study_columns):
|
||||
return
|
||||
|
||||
if _has_table(inspector, "study_setup_configs") and _has_columns(
|
||||
inspector, "study_setup_configs", {"study_id", "config"}
|
||||
):
|
||||
_backfill_config_column("study_setup_configs", "config", require_existing_setup_content=True)
|
||||
|
||||
if _has_table(inspector, "study_setup_configs") and _has_columns(
|
||||
inspector, "study_setup_configs", {"study_id", "published_config"}
|
||||
):
|
||||
_backfill_config_column("study_setup_configs", "published_config", require_existing_setup_content=False)
|
||||
|
||||
if _has_table(inspector, "study_setup_config_versions") and _has_columns(
|
||||
inspector, "study_setup_config_versions", {"study_id", "config"}
|
||||
):
|
||||
_backfill_config_column("study_setup_config_versions", "config", require_existing_setup_content=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,49 @@
|
||||
"""rename user department to clinical_department
|
||||
|
||||
Revision ID: 20260511_02
|
||||
Revises: 20260511_01
|
||||
Create Date: 2026-05-11 17:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "20260511_02"
|
||||
down_revision: Union[str, None] = "20260511_01"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
TABLE_NAME = "users"
|
||||
OLD_COLUMN = "department"
|
||||
NEW_COLUMN = "clinical_department"
|
||||
|
||||
|
||||
def _has_table(inspector: sa.Inspector, table_name: str) -> bool:
|
||||
return table_name in set(inspector.get_table_names())
|
||||
|
||||
|
||||
def _has_column(inspector: sa.Inspector, table_name: str, column_name: str) -> bool:
|
||||
return any(column["name"] == column_name for column in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
if not _has_table(inspector, TABLE_NAME):
|
||||
return
|
||||
if _has_column(inspector, TABLE_NAME, OLD_COLUMN) and not _has_column(inspector, TABLE_NAME, NEW_COLUMN):
|
||||
op.alter_column(TABLE_NAME, OLD_COLUMN, new_column_name=NEW_COLUMN)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
if not _has_table(inspector, TABLE_NAME):
|
||||
return
|
||||
if _has_column(inspector, TABLE_NAME, NEW_COLUMN) and not _has_column(inspector, TABLE_NAME, OLD_COLUMN):
|
||||
op.alter_column(TABLE_NAME, NEW_COLUMN, new_column_name=OLD_COLUMN)
|
||||
@@ -0,0 +1,36 @@
|
||||
"""add phone to sites
|
||||
|
||||
Revision ID: 20260512_01
|
||||
Revises: 20260511_02
|
||||
Create Date: 2026-05-12 09:30:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "20260512_01"
|
||||
down_revision: Union[str, None] = "20260511_02"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
columns = {col["name"] for col in inspector.get_columns("sites")}
|
||||
|
||||
if "phone" not in columns:
|
||||
op.add_column("sites", sa.Column("phone", sa.String(length=100), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
columns = {col["name"] for col in inspector.get_columns("sites")}
|
||||
|
||||
if "phone" in columns:
|
||||
op.drop_column("sites", "phone")
|
||||
@@ -0,0 +1,114 @@
|
||||
"""remove special fee modules
|
||||
|
||||
Revision ID: 20260512_02
|
||||
Revises: 20260512_01
|
||||
Create Date: 2026-05-12 10:15:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
revision: str = "20260512_02"
|
||||
down_revision: Union[str, None] = "20260512_01"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def _table_exists(inspector: sa.Inspector, table_name: str) -> bool:
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if _table_exists(inspector, "attachments"):
|
||||
op.execute(
|
||||
"""
|
||||
DELETE FROM attachments
|
||||
WHERE entity_type IN (
|
||||
'finance_special',
|
||||
'special_expense',
|
||||
'special_expense_voucher',
|
||||
'special_expense_invoice',
|
||||
'special_expense_other'
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
if _table_exists(inspector, "fee_attachments"):
|
||||
op.execute(
|
||||
"""
|
||||
DELETE FROM fee_attachments
|
||||
WHERE entity_type = 'special_expense'
|
||||
"""
|
||||
)
|
||||
|
||||
if _table_exists(inspector, "audit_logs"):
|
||||
op.execute(
|
||||
"""
|
||||
DELETE FROM audit_logs
|
||||
WHERE entity_type IN ('finance_special', 'special_expense')
|
||||
"""
|
||||
)
|
||||
|
||||
if _table_exists(inspector, "finance_specials"):
|
||||
op.drop_table("finance_specials")
|
||||
|
||||
if _table_exists(inspector, "special_expenses"):
|
||||
op.drop_table("special_expenses")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if not _table_exists(inspector, "special_expenses"):
|
||||
op.create_table(
|
||||
"special_expenses",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("project_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("center_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column("category", sa.String(length=50), nullable=False),
|
||||
sa.Column("amount", sa.Numeric(12, 2), nullable=False),
|
||||
sa.Column("happen_date", sa.Date(), nullable=True),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("is_paid", sa.Boolean(), server_default="false", nullable=False),
|
||||
sa.Column("paid_date", sa.Date(), nullable=True),
|
||||
sa.Column("is_verified", sa.Boolean(), server_default="false", nullable=False),
|
||||
sa.Column("verified_date", sa.Date(), nullable=True),
|
||||
sa.Column("created_by", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["center_id"], ["sites.id"]),
|
||||
sa.ForeignKeyConstraint(["created_by"], ["users.id"]),
|
||||
sa.ForeignKeyConstraint(["project_id"], ["studies.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_special_expenses_project_id", "special_expenses", ["project_id"])
|
||||
op.create_index("ix_special_expenses_center_id", "special_expenses", ["center_id"])
|
||||
|
||||
if not _table_exists(inspector, "finance_specials"):
|
||||
op.create_table(
|
||||
"finance_specials",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("study_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("site_name", sa.String(length=255), nullable=False),
|
||||
sa.Column("fee_type", sa.String(length=50), nullable=False),
|
||||
sa.Column("amount", sa.Numeric(12, 2), nullable=False),
|
||||
sa.Column("occur_date", sa.Date(), nullable=True),
|
||||
sa.Column("staff_name", sa.String(length=100), nullable=True),
|
||||
sa.Column("remark", sa.Text(), nullable=True),
|
||||
sa.Column("created_by", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["created_by"], ["users.id"]),
|
||||
sa.ForeignKeyConstraint(["study_id"], ["studies.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_finance_specials_study_id", "finance_specials", ["study_id"])
|
||||
@@ -0,0 +1,57 @@
|
||||
"""add study role permissions
|
||||
|
||||
Revision ID: 20260512_03
|
||||
Revises: 20260512_02
|
||||
Create Date: 2026-05-12 10:45:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
revision: str = "20260512_03"
|
||||
down_revision: Union[str, None] = "20260512_02"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def _table_exists(inspector: sa.Inspector, table_name: str) -> bool:
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
if _table_exists(inspector, "study_role_permissions"):
|
||||
return
|
||||
|
||||
op.create_table(
|
||||
"study_role_permissions",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("study_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("role", sa.String(length=20), nullable=False),
|
||||
sa.Column("module", sa.String(length=80), nullable=False),
|
||||
sa.Column("can_read", sa.Boolean(), server_default="false", nullable=False),
|
||||
sa.Column("can_write", sa.Boolean(), server_default="false", nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["study_id"], ["studies.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("study_id", "role", "module", name="uq_study_role_permissions_study_role_module"),
|
||||
)
|
||||
op.create_index("ix_study_role_permissions_study_id", "study_role_permissions", ["study_id"])
|
||||
op.create_index("ix_study_role_permissions_role", "study_role_permissions", ["role"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
if not _table_exists(inspector, "study_role_permissions"):
|
||||
return
|
||||
|
||||
op.drop_index("ix_study_role_permissions_role", table_name="study_role_permissions")
|
||||
op.drop_index("ix_study_role_permissions_study_id", table_name="study_role_permissions")
|
||||
op.drop_table("study_role_permissions")
|
||||
@@ -0,0 +1,25 @@
|
||||
"""add medical review role
|
||||
|
||||
Revision ID: 20260513_01
|
||||
Revises: 20260512_03
|
||||
Create Date: 2026-05-13 09:15:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = "20260513_01"
|
||||
down_revision: Union[str, None] = "20260512_03"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("ALTER TYPE user_role ADD VALUE IF NOT EXISTS 'MEDICAL_REVIEW'")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,81 @@
|
||||
"""add api endpoint permissions and registry
|
||||
|
||||
Revision ID: 20260513_02
|
||||
Revises: 20260513_01
|
||||
Create Date: 2026-05-13 10:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
revision: str = "20260513_02"
|
||||
down_revision: Union[str, None] = "20260513_01"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def _table_exists(inspector: sa.Inspector, table_name: str) -> bool:
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
# Create api_endpoint_registries table
|
||||
if not _table_exists(inspector, "api_endpoint_registries"):
|
||||
op.create_table(
|
||||
"api_endpoint_registries",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("endpoint_key", sa.String(length=100), nullable=False),
|
||||
sa.Column("method", sa.String(length=10), nullable=False),
|
||||
sa.Column("path", sa.String(length=200), nullable=False),
|
||||
sa.Column("module", sa.String(length=80), nullable=False),
|
||||
sa.Column("action", sa.String(length=20), nullable=False),
|
||||
sa.Column("description", sa.String(length=500), nullable=True),
|
||||
sa.Column("default_roles", sa.String(length=200), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("endpoint_key", name="uq_api_endpoint_registry_key"),
|
||||
)
|
||||
op.create_index("ix_api_endpoint_registries_endpoint_key", "api_endpoint_registries", ["endpoint_key"])
|
||||
|
||||
# Create api_endpoint_permissions table
|
||||
if not _table_exists(inspector, "api_endpoint_permissions"):
|
||||
op.create_table(
|
||||
"api_endpoint_permissions",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("study_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("role", sa.String(length=20), nullable=False),
|
||||
sa.Column("endpoint_key", sa.String(length=100), nullable=False),
|
||||
sa.Column("allowed", sa.Boolean(), server_default="false", nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["study_id"], ["studies.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("study_id", "role", "endpoint_key", name="uq_api_endpoint_perm"),
|
||||
)
|
||||
op.create_index("ix_api_endpoint_permissions_study_id", "api_endpoint_permissions", ["study_id"])
|
||||
op.create_index("ix_api_endpoint_permissions_role", "api_endpoint_permissions", ["role"])
|
||||
op.create_index("ix_api_endpoint_permissions_endpoint_key", "api_endpoint_permissions", ["endpoint_key"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
# Drop api_endpoint_permissions table
|
||||
if _table_exists(inspector, "api_endpoint_permissions"):
|
||||
op.drop_index("ix_api_endpoint_permissions_endpoint_key", table_name="api_endpoint_permissions")
|
||||
op.drop_index("ix_api_endpoint_permissions_role", table_name="api_endpoint_permissions")
|
||||
op.drop_index("ix_api_endpoint_permissions_study_id", table_name="api_endpoint_permissions")
|
||||
op.drop_table("api_endpoint_permissions")
|
||||
|
||||
# Drop api_endpoint_registries table
|
||||
if _table_exists(inspector, "api_endpoint_registries"):
|
||||
op.drop_index("ix_api_endpoint_registries_endpoint_key", table_name="api_endpoint_registries")
|
||||
op.drop_table("api_endpoint_registries")
|
||||
@@ -0,0 +1,143 @@
|
||||
"""add permission templates
|
||||
|
||||
Revision ID: 20260514_01
|
||||
Revises: 20260513_02
|
||||
Create Date: 2026-05-14 10:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = "20260514_01"
|
||||
down_revision: Union[str, None] = "20260513_02"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 创建 permission_templates 表
|
||||
op.create_table(
|
||||
"permission_templates",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("name", sa.String(100), nullable=False),
|
||||
sa.Column("description", sa.String(500), nullable=True),
|
||||
sa.Column(
|
||||
"template_type",
|
||||
sa.Enum("ROLE", "SCENARIO", "CUSTOM", name="template_type"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("is_system", sa.Boolean(), nullable=False, server_default="false"),
|
||||
sa.Column("created_by", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.Column("permissions", postgresql.JSON(), nullable=False),
|
||||
sa.Column("tags", sa.String(200), nullable=True),
|
||||
sa.Column("category", sa.String(50), nullable=True),
|
||||
sa.Column("recommended_roles", sa.String(200), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
|
||||
# 创建 permission_template_versions 表
|
||||
op.create_table(
|
||||
"permission_template_versions",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("template_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("version", sa.Integer(), nullable=False),
|
||||
sa.Column("permissions", postgresql.JSON(), nullable=False),
|
||||
sa.Column("change_log", sa.String(500), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["template_id"],
|
||||
["permission_templates.id"],
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
|
||||
# 创建唯一约束
|
||||
op.create_unique_constraint(
|
||||
"uq_template_versions",
|
||||
"permission_template_versions",
|
||||
["template_id", "version"],
|
||||
)
|
||||
|
||||
# 插入系统预设模板
|
||||
_insert_system_templates()
|
||||
|
||||
|
||||
def _insert_system_templates() -> None:
|
||||
"""插入6个系统预设角色模板"""
|
||||
templates = [
|
||||
(
|
||||
"3078efe6-60d6-4623-b7b3-862aac0084cd",
|
||||
"PM",
|
||||
"项目负责人,统筹项目全局,协调进度、资源与关键决策。",
|
||||
"PM",
|
||||
"PM",
|
||||
'{"PM": {"subjects:create": true, "subjects:list": true, "subjects:read": true, "subjects:update": true, "subjects:delete": true, "visits:create": true, "visits:list": true, "visits:read": true, "visits:update": true, "visits:delete": true, "risk_issues:create": true, "risk_issues:list": true, "risk_issues:read": true, "risk_issues:update": true, "risk_issues:delete": true, "finance_contracts:create": true, "finance_contracts:list": true, "finance_contracts:read": true, "finance_contracts:update": true, "finance_contracts:delete": true, "fees_contracts:create": true, "fees_contracts:list": true, "fees_contracts:read": true, "fees_contracts:update": true, "fees_contracts:delete": true, "fees_payments:create": true, "fees_payments:update": true, "fees_payments:delete": true, "project_members:create": true, "project_members:list": true, "project_members:candidates": true, "project_members:update": true, "project_members:delete": true, "sites:create": true, "sites:list": true, "sites:read": true, "sites:update": true, "sites:delete": true, "ethics:create": true, "ethics:list": true, "ethics:read": true, "ethics:update": true, "ethics:delete": true, "feasibility:create": true, "feasibility:list": true, "feasibility:read": true, "feasibility:update": true, "feasibility:delete": true, "permissions:read": true, "permissions:update": true, "overview:read": true, "monitoring_issues:create": true, "monitoring_issues:list": true, "monitoring_issues:read": true, "monitoring_issues:update": true, "monitoring_issues:delete": true, "drug_shipments:create": true, "drug_shipments:list": true, "drug_shipments:read": true, "drug_shipments:update": true, "drug_shipments:delete": true, "materials:create": true, "materials:list": true, "materials:read": true, "materials:update": true, "materials:delete": true, "subject_pds:create": true, "subject_pds:list": true, "subject_pds:read": true, "subject_pds:update": true, "audit_logs:list": true, "audit_logs:read": true, "audit_logs:export": true, "knowledge_notes:create": true, "knowledge_notes:list": true, "knowledge_notes:read": true, "knowledge_notes:update": true, "knowledge_notes:delete": true, "subject_history:list": true, "subject_history:read": true, "subject_history:timeline": true, "subject_history:export": true, "subject_history:search": true, "milestones:list": true, "milestones:update": true, "attachments:create": true, "attachments:read": true, "attachments:update": true, "attachments:delete": true, "fees_attachments:create": true, "fees_attachments:read": true, "fees_attachments:delete": true, "faq:create": true, "faq:read": true, "faq:update": true, "faq:delete": true, "faq_category:create": true, "faq_category:read": true, "faq_category:update": true, "faq_category:delete": true, "faq_reply:create": true, "faq_reply:delete": true, "subject_histories:create": true, "subject_histories:list": true, "subject_histories:read": true, "subject_histories:update": true, "subject_histories:delete": true, "material_equipments:create": true, "material_equipments:list": true, "material_equipments:read": true, "material_equipments:update": true, "material_equipments:delete": true, "documents:create": true, "documents:read": true, "documents:update": true, "documents:delete": true}}',
|
||||
),
|
||||
(
|
||||
"a17e6fd5-f0c6-4723-b166-6c25a595e28d",
|
||||
"CRA",
|
||||
"负责各中心临床监查执行,跟进现场质量、数据和问题闭环。",
|
||||
"CRA",
|
||||
"CRA",
|
||||
'{"CRA": {"subjects:create": true, "subjects:list": true, "subjects:read": true, "subjects:update": true, "subjects:delete": false, "visits:create": true, "visits:list": true, "visits:read": true, "visits:update": true, "visits:delete": false, "risk_issues:create": true, "risk_issues:list": true, "risk_issues:read": true, "risk_issues:update": true, "risk_issues:delete": false, "finance_contracts:create": false, "finance_contracts:list": true, "finance_contracts:read": true, "finance_contracts:update": false, "finance_contracts:delete": false, "fees_contracts:create": false, "fees_contracts:list": true, "fees_contracts:read": true, "fees_contracts:update": false, "fees_contracts:delete": false, "fees_payments:create": false, "fees_payments:update": false, "fees_payments:delete": false, "project_members:create": false, "project_members:list": false, "project_members:candidates": false, "project_members:update": false, "project_members:delete": false, "sites:create": false, "sites:list": true, "sites:read": true, "sites:update": false, "sites:delete": false, "ethics:create": false, "ethics:list": true, "ethics:read": true, "ethics:update": false, "ethics:delete": false, "feasibility:create": false, "feasibility:list": true, "feasibility:read": true, "feasibility:update": false, "feasibility:delete": false, "permissions:read": false, "permissions:update": false, "overview:read": true, "monitoring_issues:create": true, "monitoring_issues:list": true, "monitoring_issues:read": true, "monitoring_issues:update": true, "monitoring_issues:delete": false, "drug_shipments:create": false, "drug_shipments:list": true, "drug_shipments:read": true, "drug_shipments:update": false, "drug_shipments:delete": false, "materials:create": false, "materials:list": true, "materials:read": true, "materials:update": false, "materials:delete": false, "subject_pds:create": true, "subject_pds:list": true, "subject_pds:read": true, "subject_pds:update": true, "audit_logs:list": false, "audit_logs:read": false, "audit_logs:export": false, "knowledge_notes:create": true, "knowledge_notes:list": true, "knowledge_notes:read": true, "knowledge_notes:update": true, "knowledge_notes:delete": false, "subject_history:list": true, "subject_history:read": true, "subject_history:timeline": true, "subject_history:export": true, "subject_history:search": true, "milestones:list": true, "milestones:update": false, "attachments:create": true, "attachments:read": true, "attachments:update": true, "attachments:delete": true, "fees_attachments:create": true, "fees_attachments:read": true, "fees_attachments:delete": true, "faq:create": false, "faq:read": true, "faq:update": false, "faq:delete": false, "faq_category:create": false, "faq_category:read": true, "faq_category:update": false, "faq_category:delete": false, "faq_reply:create": true, "faq_reply:delete": false, "subject_histories:create": true, "subject_histories:list": true, "subject_histories:read": true, "subject_histories:update": true, "subject_histories:delete": false, "material_equipments:create": true, "material_equipments:list": true, "material_equipments:read": true, "material_equipments:update": true, "material_equipments:delete": false, "documents:create": true, "documents:read": true, "documents:update": true, "documents:delete": false}}',
|
||||
),
|
||||
(
|
||||
"f45fbfda-faed-4b83-b4a8-24ca4011894b",
|
||||
"PV",
|
||||
"负责药物警戒相关工作,跟踪安全性事件并支持风险评估。",
|
||||
"PV",
|
||||
"PV",
|
||||
'{"PV": {"subjects:create": false, "subjects:list": true, "subjects:read": true, "subjects:update": false, "subjects:delete": false, "visits:create": false, "visits:list": true, "visits:read": true, "visits:update": false, "visits:delete": false, "risk_issues:create": true, "risk_issues:list": true, "risk_issues:read": true, "risk_issues:update": true, "risk_issues:delete": false, "finance_contracts:create": false, "finance_contracts:list": false, "finance_contracts:read": false, "finance_contracts:update": false, "finance_contracts:delete": false, "fees_contracts:create": false, "fees_contracts:list": false, "fees_contracts:read": false, "fees_contracts:update": false, "fees_contracts:delete": false, "fees_payments:create": false, "fees_payments:update": false, "fees_payments:delete": false, "project_members:create": false, "project_members:list": false, "project_members:candidates": false, "project_members:update": false, "project_members:delete": false, "sites:create": false, "sites:list": true, "sites:read": true, "sites:update": false, "sites:delete": false, "ethics:create": false, "ethics:list": true, "ethics:read": true, "ethics:update": false, "ethics:delete": false, "feasibility:create": false, "feasibility:list": true, "feasibility:read": true, "feasibility:update": false, "feasibility:delete": false, "permissions:read": false, "permissions:update": false, "overview:read": true, "monitoring_issues:create": false, "monitoring_issues:list": true, "monitoring_issues:read": true, "monitoring_issues:update": false, "monitoring_issues:delete": false, "drug_shipments:create": false, "drug_shipments:list": false, "drug_shipments:read": false, "drug_shipments:update": false, "drug_shipments:delete": false, "materials:create": false, "materials:list": false, "materials:read": false, "materials:update": false, "materials:delete": false, "subject_pds:create": false, "subject_pds:list": true, "subject_pds:read": true, "subject_pds:update": false, "audit_logs:list": false, "audit_logs:read": false, "audit_logs:export": false, "knowledge_notes:create": true, "knowledge_notes:list": true, "knowledge_notes:read": true, "knowledge_notes:update": true, "knowledge_notes:delete": false, "subject_history:list": true, "subject_history:read": true, "subject_history:timeline": true, "subject_history:export": false, "subject_history:search": true, "milestones:list": true, "milestones:update": false, "attachments:create": true, "attachments:read": true, "attachments:update": false, "attachments:delete": false, "fees_attachments:create": false, "fees_attachments:read": true, "fees_attachments:delete": false, "faq:create": false, "faq:read": true, "faq:update": false, "faq:delete": false, "faq_category:create": false, "faq_category:read": true, "faq_category:update": false, "faq_category:delete": false, "faq_reply:create": true, "faq_reply:delete": false, "subject_histories:create": false, "subject_histories:list": true, "subject_histories:read": true, "subject_histories:update": false, "subject_histories:delete": false, "material_equipments:create": false, "material_equipments:list": true, "material_equipments:read": true, "material_equipments:update": false, "material_equipments:delete": false, "documents:create": false, "documents:read": true, "documents:update": false, "documents:delete": false}}',
|
||||
),
|
||||
(
|
||||
"b43d06e4-2ee2-4e80-b733-85964d738f40",
|
||||
"QA",
|
||||
"负责医学审核与稽查,关注质量风险、合规性和医学一致性。",
|
||||
"MEDICAL_REVIEW",
|
||||
"MEDICAL_REVIEW",
|
||||
'{"MEDICAL_REVIEW": {"subjects:create": false, "subjects:list": true, "subjects:read": true, "subjects:update": false, "subjects:delete": false, "visits:create": false, "visits:list": true, "visits:read": true, "visits:update": false, "visits:delete": false, "risk_issues:create": true, "risk_issues:list": true, "risk_issues:read": true, "risk_issues:update": true, "risk_issues:delete": false, "finance_contracts:create": false, "finance_contracts:list": false, "finance_contracts:read": false, "finance_contracts:update": false, "finance_contracts:delete": false, "fees_contracts:create": false, "fees_contracts:list": false, "fees_contracts:read": false, "fees_contracts:update": false, "fees_contracts:delete": false, "fees_payments:create": false, "fees_payments:update": false, "fees_payments:delete": false, "project_members:create": false, "project_members:list": false, "project_members:candidates": false, "project_members:update": false, "project_members:delete": false, "sites:create": false, "sites:list": true, "sites:read": true, "sites:update": false, "sites:delete": false, "ethics:create": false, "ethics:list": true, "ethics:read": true, "ethics:update": false, "ethics:delete": false, "feasibility:create": false, "feasibility:list": true, "feasibility:read": true, "feasibility:update": false, "feasibility:delete": false, "permissions:read": false, "permissions:update": false, "overview:read": true, "monitoring_issues:create": false, "monitoring_issues:list": true, "monitoring_issues:read": true, "monitoring_issues:update": false, "monitoring_issues:delete": false, "drug_shipments:create": false, "drug_shipments:list": false, "drug_shipments:read": false, "drug_shipments:update": false, "drug_shipments:delete": false, "materials:create": false, "materials:list": false, "materials:read": false, "materials:update": false, "materials:delete": false, "subject_pds:create": false, "subject_pds:list": true, "subject_pds:read": true, "subject_pds:update": false, "audit_logs:list": false, "audit_logs:read": false, "audit_logs:export": false, "knowledge_notes:create": true, "knowledge_notes:list": true, "knowledge_notes:read": true, "knowledge_notes:update": true, "knowledge_notes:delete": false, "subject_history:list": true, "subject_history:read": true, "subject_history:timeline": true, "subject_history:export": false, "subject_history:search": true, "milestones:list": true, "milestones:update": false, "attachments:create": false, "attachments:read": true, "attachments:update": false, "attachments:delete": false, "fees_attachments:create": false, "fees_attachments:read": false, "fees_attachments:delete": false, "faq:create": false, "faq:read": true, "faq:update": false, "faq:delete": false, "faq_category:create": false, "faq_category:read": true, "faq_category:update": false, "faq_category:delete": false, "faq_reply:create": true, "faq_reply:delete": false, "subject_histories:create": false, "subject_histories:list": true, "subject_histories:read": true, "subject_histories:update": false, "subject_histories:delete": false, "material_equipments:create": false, "material_equipments:list": true, "material_equipments:read": true, "material_equipments:update": false, "material_equipments:delete": false, "documents:create": false, "documents:read": true, "documents:update": false, "documents:delete": false}}',
|
||||
),
|
||||
(
|
||||
"1e38cffb-a7f2-4243-a31c-1a4f8ce66a05",
|
||||
"CTA",
|
||||
"负责合同、药品及相关项目事务管理,保障执行支持与物资协同。",
|
||||
"IMP",
|
||||
"IMP",
|
||||
'{"IMP": {"subjects:create": false, "subjects:list": true, "subjects:read": true, "subjects:update": false, "subjects:delete": false, "visits:create": false, "visits:list": true, "visits:read": true, "visits:update": false, "visits:delete": false, "risk_issues:create": false, "risk_issues:list": true, "risk_issues:read": true, "risk_issues:update": false, "risk_issues:delete": false, "finance_contracts:create": false, "finance_contracts:list": true, "finance_contracts:read": true, "finance_contracts:update": false, "finance_contracts:delete": false, "fees_contracts:create": false, "fees_contracts:list": true, "fees_contracts:read": true, "fees_contracts:update": false, "fees_contracts:delete": false, "fees_payments:create": false, "fees_payments:update": false, "fees_payments:delete": false, "project_members:create": false, "project_members:list": false, "project_members:candidates": false, "project_members:update": false, "project_members:delete": false, "sites:create": false, "sites:list": true, "sites:read": true, "sites:update": false, "sites:delete": false, "ethics:create": false, "ethics:list": false, "ethics:read": false, "ethics:update": false, "ethics:delete": false, "feasibility:create": false, "feasibility:list": false, "feasibility:read": false, "feasibility:update": false, "feasibility:delete": false, "permissions:read": false, "permissions:update": false, "overview:read": true, "monitoring_issues:create": false, "monitoring_issues:list": false, "monitoring_issues:read": false, "monitoring_issues:update": false, "monitoring_issues:delete": false, "drug_shipments:create": true, "drug_shipments:list": true, "drug_shipments:read": true, "drug_shipments:update": true, "drug_shipments:delete": false, "materials:create": true, "materials:list": true, "materials:read": true, "materials:update": true, "materials:delete": false, "subject_pds:create": false, "subject_pds:list": false, "subject_pds:read": false, "subject_pds:update": false, "audit_logs:list": false, "audit_logs:read": false, "audit_logs:export": false, "knowledge_notes:create": false, "knowledge_notes:list": true, "knowledge_notes:read": true, "knowledge_notes:update": false, "knowledge_notes:delete": false, "subject_history:list": false, "subject_history:read": false, "subject_history:timeline": false, "subject_history:export": false, "subject_history:search": false, "milestones:list": true, "milestones:update": false, "attachments:create": false, "attachments:read": true, "attachments:update": false, "attachments:delete": false, "fees_attachments:create": false, "fees_attachments:read": false, "fees_attachments:delete": false, "faq:create": false, "faq:read": true, "faq:update": false, "faq:delete": false, "faq_category:create": false, "faq_category:read": true, "faq_category:update": false, "faq_category:delete": false, "faq_reply:create": false, "faq_reply:delete": false, "subject_histories:create": false, "subject_histories:list": true, "subject_histories:read": true, "subject_histories:update": false, "subject_histories:delete": false, "material_equipments:create": false, "material_equipments:list": true, "material_equipments:read": true, "material_equipments:update": false, "material_equipments:delete": false, "documents:create": false, "documents:read": true, "documents:update": false, "documents:delete": false}}',
|
||||
),
|
||||
]
|
||||
|
||||
for tid, name, desc, category, recommended_roles, perms_json in templates:
|
||||
op.execute(
|
||||
f"""INSERT INTO permission_templates
|
||||
(id, name, description, template_type, is_system, category, recommended_roles, permissions, created_at, updated_at)
|
||||
VALUES ('{tid}', '{name}', '{desc}', 'ROLE', true, '{category}', '{recommended_roles}',
|
||||
'{perms_json}'::json, NOW(), NOW())"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("permission_template_versions")
|
||||
op.drop_table("permission_templates")
|
||||
op.execute("DROP TYPE IF EXISTS template_type")
|
||||
@@ -0,0 +1,28 @@
|
||||
"""add active_roles to studies
|
||||
|
||||
Revision ID: 20260518_01
|
||||
Revises: 20260514_01
|
||||
Create Date: 2026-05-18 16:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20260518_01"
|
||||
down_revision: Union[str, None] = "20260514_01"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"studies",
|
||||
sa.Column("active_roles", sa.JSON(), nullable=False, server_default="[]"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("studies", "active_roles")
|
||||
@@ -0,0 +1,59 @@
|
||||
"""add permission monitoring tables
|
||||
|
||||
Revision ID: 20260519_01
|
||||
Revises: 20260518_01
|
||||
Create Date: 2026-05-19 10:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20260519_01"
|
||||
down_revision: Union[str, None] = "20260518_01"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"permission_access_logs",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("study_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("studies.id"), nullable=False),
|
||||
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("endpoint_key", sa.String(100), nullable=False),
|
||||
sa.Column("role", sa.String(30), nullable=False),
|
||||
sa.Column("allowed", sa.Boolean(), nullable=False),
|
||||
sa.Column("elapsed_ms", sa.Float(), nullable=False),
|
||||
sa.Column("ip_address", sa.String(45), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_perm_log_study_created", "permission_access_logs", ["study_id", "created_at"])
|
||||
op.create_index("ix_perm_log_user_created", "permission_access_logs", ["user_id", "created_at"])
|
||||
op.create_index("ix_perm_log_endpoint_created", "permission_access_logs", ["endpoint_key", "created_at"])
|
||||
op.create_index("ix_perm_log_created_at", "permission_access_logs", ["created_at"])
|
||||
op.create_index("ix_perm_log_allowed", "permission_access_logs", ["allowed", "created_at"])
|
||||
|
||||
op.create_table(
|
||||
"permission_metric_snapshots",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("bucket_time", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("total_checks", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("allowed_checks", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("denied_checks", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("avg_elapsed_ms", sa.Float(), nullable=False, server_default="0"),
|
||||
sa.Column("max_elapsed_ms", sa.Float(), nullable=False, server_default="0"),
|
||||
sa.Column("cache_hits", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("cache_misses", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("error_count", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_perm_snapshot_bucket", "permission_metric_snapshots", ["bucket_time"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("permission_metric_snapshots")
|
||||
op.drop_table("permission_access_logs")
|
||||
@@ -0,0 +1,42 @@
|
||||
"""add security access logs
|
||||
|
||||
Revision ID: 20260520_01
|
||||
Revises: 20260519_01
|
||||
Create Date: 2026-05-20 10:40:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20260520_01"
|
||||
down_revision: Union[str, None] = "20260519_01"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"security_access_logs",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("method", sa.String(12), nullable=False),
|
||||
sa.Column("path", sa.String(500), nullable=False),
|
||||
sa.Column("status_code", sa.Integer(), nullable=False),
|
||||
sa.Column("elapsed_ms", sa.Float(), nullable=False),
|
||||
sa.Column("client_ip", sa.String(45), nullable=True),
|
||||
sa.Column("user_agent", sa.String(500), nullable=True),
|
||||
sa.Column("auth_status", sa.String(30), nullable=False),
|
||||
sa.Column("user_identifier", sa.String(80), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_security_log_created_at", "security_access_logs", ["created_at"])
|
||||
op.create_index("ix_security_log_ip_created", "security_access_logs", ["client_ip", "created_at"])
|
||||
op.create_index("ix_security_log_status_created", "security_access_logs", ["status_code", "created_at"])
|
||||
op.create_index("ix_security_log_auth_created", "security_access_logs", ["auth_status", "created_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("security_access_logs")
|
||||
@@ -0,0 +1,153 @@
|
||||
"""remove legacy qa role
|
||||
|
||||
Revision ID: 20260521_01
|
||||
Revises: 20260520_01
|
||||
Create Date: 2026-05-21 09:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = "20260521_01"
|
||||
down_revision: Union[str, None] = "20260520_01"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
USER_ROLE_VALUES = ("ADMIN", "PM", "CRA", "PV", "IMP", "MEDICAL_REVIEW")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("CREATE TEMP TABLE qa_user_ids AS SELECT id FROM users WHERE role = 'QA'")
|
||||
op.execute("DELETE FROM permission_access_logs WHERE role = 'QA'")
|
||||
op.execute("DELETE FROM api_endpoint_permissions WHERE role = 'QA'")
|
||||
op.execute("DELETE FROM study_members WHERE role_in_study = 'QA'")
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE studies
|
||||
SET active_roles = (
|
||||
SELECT COALESCE(json_agg(role), '[]'::json)
|
||||
FROM json_array_elements_text(active_roles) AS role
|
||||
WHERE role <> 'QA'
|
||||
)
|
||||
WHERE active_roles::text LIKE '%QA%'
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
DELETE FROM permission_template_versions
|
||||
WHERE template_id IN (
|
||||
SELECT id FROM permission_templates
|
||||
WHERE category = 'QA' OR recommended_roles = 'QA' OR (permissions::jsonb ? 'QA')
|
||||
)
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
DELETE FROM permission_templates
|
||||
WHERE category = 'QA' OR recommended_roles = 'QA' OR (permissions::jsonb ? 'QA')
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE permission_templates
|
||||
SET created_by = NULL
|
||||
WHERE created_by IN (SELECT id FROM qa_user_ids)
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
DELETE FROM audit_logs
|
||||
WHERE operator_role = 'QA'
|
||||
OR operator_id IN (SELECT id FROM users WHERE role = 'QA')
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
DO $$
|
||||
DECLARE
|
||||
rel record;
|
||||
BEGIN
|
||||
FOR rel IN
|
||||
SELECT
|
||||
quote_ident(ns.nspname) AS schema_name,
|
||||
quote_ident(cls.relname) AS table_name,
|
||||
quote_ident(att.attname) AS column_name,
|
||||
att.attnotnull AS not_null
|
||||
FROM pg_constraint con
|
||||
JOIN pg_class cls ON cls.oid = con.conrelid
|
||||
JOIN pg_namespace ns ON ns.oid = cls.relnamespace
|
||||
JOIN pg_attribute att ON att.attrelid = con.conrelid AND att.attnum = ANY(con.conkey)
|
||||
WHERE con.contype = 'f'
|
||||
AND con.confrelid = 'users'::regclass
|
||||
AND array_length(con.conkey, 1) = 1
|
||||
LOOP
|
||||
IF rel.table_name = 'users' AND rel.column_name = 'id' THEN
|
||||
CONTINUE;
|
||||
END IF;
|
||||
|
||||
IF rel.not_null THEN
|
||||
EXECUTE format(
|
||||
'DELETE FROM %s.%s WHERE %s IN (SELECT id FROM qa_user_ids)',
|
||||
rel.schema_name,
|
||||
rel.table_name,
|
||||
rel.column_name
|
||||
);
|
||||
ELSE
|
||||
EXECUTE format(
|
||||
'UPDATE %s.%s SET %s = NULL WHERE %s IN (SELECT id FROM qa_user_ids)',
|
||||
rel.schema_name,
|
||||
rel.table_name,
|
||||
rel.column_name,
|
||||
rel.column_name
|
||||
);
|
||||
END IF;
|
||||
END LOOP;
|
||||
END $$;
|
||||
"""
|
||||
)
|
||||
|
||||
# Delete legacy QA users after dependent project-role and audit rows have been removed.
|
||||
op.execute("DELETE FROM users WHERE id IN (SELECT id FROM qa_user_ids)")
|
||||
op.execute("DROP TABLE qa_user_ids")
|
||||
|
||||
allowed = ", ".join(f"'{role}'" for role in USER_ROLE_VALUES)
|
||||
op.execute(
|
||||
f"""
|
||||
ALTER TABLE users
|
||||
ALTER COLUMN role TYPE text
|
||||
USING role::text
|
||||
"""
|
||||
)
|
||||
op.execute("DROP TYPE user_role")
|
||||
op.execute(f"CREATE TYPE user_role AS ENUM ({allowed})")
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE users
|
||||
ALTER COLUMN role TYPE user_role
|
||||
USING role::user_role
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
allowed = ", ".join(f"'{role}'" for role in USER_ROLE_VALUES)
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE users
|
||||
ALTER COLUMN role TYPE text
|
||||
USING role::text
|
||||
"""
|
||||
)
|
||||
op.execute("DROP TYPE user_role")
|
||||
op.execute(f"CREATE TYPE user_role AS ENUM ({allowed})")
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE users
|
||||
ALTER COLUMN role TYPE user_role
|
||||
USING role::user_role
|
||||
"""
|
||||
)
|
||||
@@ -0,0 +1,58 @@
|
||||
"""update role template copy
|
||||
|
||||
Revision ID: 20260522_01
|
||||
Revises: 20260521_01
|
||||
Create Date: 2026-05-22 09:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = "20260522_01"
|
||||
down_revision: Union[str, None] = "20260521_01"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
ROLE_TEMPLATE_COPY = {
|
||||
"PM": ("PM", "项目负责人,统筹项目全局,协调进度、资源与关键决策。"),
|
||||
"CRA": ("CRA", "负责各中心临床监查执行,跟进现场质量、数据和问题闭环。"),
|
||||
"IMP": ("CTA", "负责合同、药品及相关项目事务管理,保障执行支持与物资协同。"),
|
||||
"MEDICAL_REVIEW": ("QA", "负责医学审核与稽查,关注质量风险、合规性和医学一致性。"),
|
||||
"PV": ("PV", "负责药物警戒相关工作,跟踪安全性事件并支持风险评估。"),
|
||||
}
|
||||
|
||||
PREVIOUS_ROLE_TEMPLATE_COPY = {
|
||||
"PM": ("项目经理", "项目管理员,拥有所有权限"),
|
||||
"CRA": ("临床研究协调员", "数据输入和日常管理人员"),
|
||||
"IMP": ("物资管理员", "物资和设备管理人员"),
|
||||
"MEDICAL_REVIEW": ("医学审核", "医学审核人员"),
|
||||
"PV": ("访视员", "访视和参与者管理人员"),
|
||||
}
|
||||
|
||||
|
||||
def _update_role_copy(category: str, name: str, description: str) -> None:
|
||||
op.execute(
|
||||
f"""
|
||||
UPDATE permission_templates
|
||||
SET name = '{name}',
|
||||
description = '{description}',
|
||||
updated_at = NOW()
|
||||
WHERE category = '{category}'
|
||||
AND template_type = 'ROLE'
|
||||
AND is_system = true
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for category, (name, description) in ROLE_TEMPLATE_COPY.items():
|
||||
_update_role_copy(category, name, description)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for category, (name, description) in PREVIOUS_ROLE_TEMPLATE_COPY.items():
|
||||
_update_role_copy(category, name, description)
|
||||
@@ -0,0 +1,90 @@
|
||||
"""remove stale unused permissions
|
||||
|
||||
Revision ID: 20260526_01
|
||||
Revises: 20260522_01
|
||||
Create Date: 2026-05-26 15:40:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = "20260526_01"
|
||||
down_revision: Union[str, None] = "20260522_01"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
LEGACY_MONITORING_AUDIT_PREFIX = "monitoring" + "_audit"
|
||||
MONITORING_ISSUE_PREFIX = "monitoring" + "_issues"
|
||||
|
||||
STALE_PERMISSION_KEYS = (
|
||||
"subject_history:list",
|
||||
"subject_history:read",
|
||||
"subject_history:timeline",
|
||||
"subject_history:export",
|
||||
"subject_history:search",
|
||||
"subject_pds:read",
|
||||
"visits:read",
|
||||
"risk_issues:create",
|
||||
"risk_issues:list",
|
||||
"risk_issues:read",
|
||||
"risk_issues:update",
|
||||
"risk_issues:delete",
|
||||
"risk_issue_aes:list",
|
||||
"risk_issue_pds:list",
|
||||
f"{MONITORING_ISSUE_PREFIX}:close",
|
||||
f"{MONITORING_ISSUE_PREFIX}:history",
|
||||
f"{LEGACY_MONITORING_AUDIT_PREFIX}:create",
|
||||
f"{LEGACY_MONITORING_AUDIT_PREFIX}:read",
|
||||
f"{LEGACY_MONITORING_AUDIT_PREFIX}:update",
|
||||
f"{LEGACY_MONITORING_AUDIT_PREFIX}:delete",
|
||||
)
|
||||
|
||||
|
||||
def _quoted_keys() -> str:
|
||||
return ", ".join(f"'{key}'" for key in STALE_PERMISSION_KEYS)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
f"""
|
||||
DELETE FROM api_endpoint_permissions
|
||||
WHERE endpoint_key IN ({_quoted_keys()})
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
f"""
|
||||
UPDATE permission_templates
|
||||
SET permissions = (
|
||||
SELECT jsonb_object_agg(role_key, role_permissions - ARRAY[{_quoted_keys()}])
|
||||
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
|
||||
),
|
||||
updated_at = NOW()
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
|
||||
WHERE role_permissions ?| ARRAY[{_quoted_keys()}]
|
||||
)
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
f"""
|
||||
UPDATE permission_template_versions
|
||||
SET permissions = (
|
||||
SELECT jsonb_object_agg(role_key, role_permissions - ARRAY[{_quoted_keys()}])
|
||||
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
|
||||
)
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
|
||||
WHERE role_permissions ?| ARRAY[{_quoted_keys()}]
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,149 @@
|
||||
"""rename project roles to qa and cta
|
||||
|
||||
Revision ID: 20260527_01
|
||||
Revises: 20260526_01
|
||||
Create Date: 2026-05-27 08:45:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = "20260527_01"
|
||||
down_revision: Union[str, None] = "20260526_01"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def _replace_role_values(table: str, column: str) -> None:
|
||||
op.execute(f"UPDATE {table} SET {column} = 'QA' WHERE {column} = 'MEDICAL_REVIEW'")
|
||||
op.execute(f"UPDATE {table} SET {column} = 'CTA' WHERE {column} = 'IMP'")
|
||||
|
||||
|
||||
def _restore_role_values(table: str, column: str) -> None:
|
||||
op.execute(f"UPDATE {table} SET {column} = 'MEDICAL_REVIEW' WHERE {column} = 'QA'")
|
||||
op.execute(f"UPDATE {table} SET {column} = 'IMP' WHERE {column} = 'CTA'")
|
||||
|
||||
|
||||
def _normalize_role_array(table: str, column: str) -> None:
|
||||
op.execute(
|
||||
f"""
|
||||
UPDATE {table}
|
||||
SET {column} = (
|
||||
SELECT COALESCE(json_agg(DISTINCT mapped_role), '[]'::json)
|
||||
FROM (
|
||||
SELECT CASE role
|
||||
WHEN 'MEDICAL_REVIEW' THEN 'QA'
|
||||
WHEN 'IMP' THEN 'CTA'
|
||||
ELSE role
|
||||
END AS mapped_role
|
||||
FROM json_array_elements_text({column}) AS role
|
||||
) mapped
|
||||
)
|
||||
WHERE {column}::text LIKE '%MEDICAL_REVIEW%'
|
||||
OR {column}::text LIKE '%IMP%'
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _normalize_permission_template_roles(table: str) -> None:
|
||||
op.execute(
|
||||
f"""
|
||||
UPDATE {table}
|
||||
SET permissions = (
|
||||
SELECT jsonb_object_agg(
|
||||
CASE role_key
|
||||
WHEN 'MEDICAL_REVIEW' THEN 'QA'
|
||||
WHEN 'IMP' THEN 'CTA'
|
||||
ELSE role_key
|
||||
END,
|
||||
role_permissions
|
||||
)
|
||||
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
|
||||
)
|
||||
WHERE permissions::jsonb ? 'MEDICAL_REVIEW'
|
||||
OR permissions::jsonb ? 'IMP'
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _normalize_recommended_roles() -> None:
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE permission_templates
|
||||
SET recommended_roles = regexp_replace(
|
||||
regexp_replace(recommended_roles, '(^|,)MEDICAL_REVIEW(,|$)', '\\1QA\\2', 'g'),
|
||||
'(^|,)IMP(,|$)', '\\1CTA\\2', 'g'
|
||||
)
|
||||
WHERE recommended_roles ~ '(^|,)(MEDICAL_REVIEW|IMP)(,|$)'
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _restore_permission_template_roles(table: str) -> None:
|
||||
op.execute(
|
||||
f"""
|
||||
UPDATE {table}
|
||||
SET permissions = (
|
||||
SELECT jsonb_object_agg(
|
||||
CASE role_key
|
||||
WHEN 'QA' THEN 'MEDICAL_REVIEW'
|
||||
WHEN 'CTA' THEN 'IMP'
|
||||
ELSE role_key
|
||||
END,
|
||||
role_permissions
|
||||
)
|
||||
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
|
||||
)
|
||||
WHERE permissions::jsonb ? 'QA'
|
||||
OR permissions::jsonb ? 'CTA'
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _replace_role_enum_values() -> None:
|
||||
op.execute("ALTER TYPE user_role ADD VALUE IF NOT EXISTS 'QA'")
|
||||
op.execute("ALTER TYPE user_role ADD VALUE IF NOT EXISTS 'CTA'")
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE users
|
||||
ALTER COLUMN role TYPE text
|
||||
USING role::text
|
||||
"""
|
||||
)
|
||||
_replace_role_values("users", "role")
|
||||
op.execute("DROP TYPE user_role")
|
||||
op.execute("CREATE TYPE user_role AS ENUM ('ADMIN', 'PM', 'CRA', 'PV', 'QA', 'CTA')")
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE users
|
||||
ALTER COLUMN role TYPE user_role
|
||||
USING role::user_role
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
_replace_role_enum_values()
|
||||
_replace_role_values("study_members", "role_in_study")
|
||||
_replace_role_values("api_endpoint_permissions", "role")
|
||||
_replace_role_values("permission_access_logs", "role")
|
||||
_replace_role_values("audit_logs", "operator_role")
|
||||
_replace_role_values("permission_templates", "category")
|
||||
_normalize_recommended_roles()
|
||||
_normalize_role_array("studies", "active_roles")
|
||||
_normalize_permission_template_roles("permission_templates")
|
||||
_normalize_permission_template_roles("permission_template_versions")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
_restore_role_values("study_members", "role_in_study")
|
||||
_restore_role_values("api_endpoint_permissions", "role")
|
||||
_restore_role_values("permission_access_logs", "role")
|
||||
_restore_role_values("audit_logs", "operator_role")
|
||||
_restore_role_values("permission_templates", "category")
|
||||
_restore_role_values("permission_templates", "recommended_roles")
|
||||
_restore_permission_template_roles("permission_templates")
|
||||
_restore_permission_template_roles("permission_template_versions")
|
||||
@@ -0,0 +1,51 @@
|
||||
"""add contract fee basic fields
|
||||
|
||||
Revision ID: 20260527_02
|
||||
Revises: 20260527_01
|
||||
Create Date: 2026-05-27 10:15:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "20260527_02"
|
||||
down_revision: Union[str, None] = "20260527_01"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def _column_exists(inspector: sa.Inspector, table_name: str, column_name: str) -> bool:
|
||||
return column_name in {column["name"] for column in inspector.get_columns(table_name)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if "contract_fees" not in inspector.get_table_names():
|
||||
return
|
||||
|
||||
if not _column_exists(inspector, "contract_fees", "contract_no"):
|
||||
op.add_column("contract_fees", sa.Column("contract_no", sa.String(length=100), nullable=True))
|
||||
if not _column_exists(inspector, "contract_fees", "signed_date"):
|
||||
op.add_column("contract_fees", sa.Column("signed_date", sa.Date(), nullable=True))
|
||||
if not _column_exists(inspector, "contract_fees", "currency"):
|
||||
op.add_column("contract_fees", sa.Column("currency", sa.String(length=10), server_default="CNY", nullable=False))
|
||||
if not _column_exists(inspector, "contract_fees", "remark"):
|
||||
op.add_column("contract_fees", sa.Column("remark", sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if "contract_fees" not in inspector.get_table_names():
|
||||
return
|
||||
|
||||
for column_name in ("remark", "currency", "signed_date", "contract_no"):
|
||||
if _column_exists(inspector, "contract_fees", column_name):
|
||||
op.drop_column("contract_fees", column_name)
|
||||
@@ -0,0 +1,124 @@
|
||||
"""remove legacy finance contracts
|
||||
|
||||
Revision ID: 20260527_03
|
||||
Revises: 20260527_02
|
||||
Create Date: 2026-05-27 10:25:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
revision: str = "20260527_03"
|
||||
down_revision: Union[str, None] = "20260527_02"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
LEGACY_KEYS = (
|
||||
"finance_contracts:create",
|
||||
"finance_contracts:list",
|
||||
"finance_contracts:read",
|
||||
"finance_contracts:update",
|
||||
"finance_contracts:delete",
|
||||
"fees_payments:create",
|
||||
"fees_payments:update",
|
||||
"fees_payments:delete",
|
||||
"fees_attachments:create",
|
||||
"fees_attachments:read",
|
||||
"fees_attachments:delete",
|
||||
)
|
||||
|
||||
|
||||
def _table_exists(inspector: sa.Inspector, table_name: str) -> bool:
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if _table_exists(inspector, "finance_contracts") and _table_exists(inspector, "contract_fees"):
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE contract_fees cf
|
||||
SET
|
||||
contract_no = COALESCE(cf.contract_no, legacy.contract_no),
|
||||
signed_date = COALESCE(cf.signed_date, legacy.signed_date),
|
||||
currency = COALESCE(NULLIF(cf.currency, ''), legacy.currency, 'CNY'),
|
||||
remark = COALESCE(cf.remark, legacy.remark)
|
||||
FROM (
|
||||
SELECT DISTINCT ON (fc.study_id, s.id)
|
||||
fc.study_id,
|
||||
s.id AS center_id,
|
||||
fc.contract_no,
|
||||
fc.signed_date,
|
||||
fc.currency,
|
||||
fc.remark
|
||||
FROM finance_contracts fc
|
||||
JOIN sites s
|
||||
ON s.study_id = fc.study_id
|
||||
AND s.name = fc.site_name
|
||||
ORDER BY fc.study_id, s.id, fc.updated_at DESC, fc.created_at DESC
|
||||
) legacy
|
||||
WHERE cf.project_id = legacy.study_id
|
||||
AND cf.center_id = legacy.center_id
|
||||
"""
|
||||
)
|
||||
|
||||
if _table_exists(inspector, "api_endpoint_permissions"):
|
||||
quoted_keys = ", ".join(f"'{key}'" for key in LEGACY_KEYS)
|
||||
op.execute(f"DELETE FROM api_endpoint_permissions WHERE endpoint_key IN ({quoted_keys})")
|
||||
|
||||
if _table_exists(inspector, "permission_templates"):
|
||||
for key in LEGACY_KEYS:
|
||||
op.execute(f"UPDATE permission_templates SET permissions = permissions::jsonb #- '{{PM,{key}}}'")
|
||||
op.execute(f"UPDATE permission_templates SET permissions = permissions::jsonb #- '{{CRA,{key}}}'")
|
||||
op.execute(f"UPDATE permission_templates SET permissions = permissions::jsonb #- '{{PV,{key}}}'")
|
||||
op.execute(f"UPDATE permission_templates SET permissions = permissions::jsonb #- '{{QA,{key}}}'")
|
||||
op.execute(f"UPDATE permission_templates SET permissions = permissions::jsonb #- '{{CTA,{key}}}'")
|
||||
|
||||
if _table_exists(inspector, "permission_template_versions"):
|
||||
for key in LEGACY_KEYS:
|
||||
op.execute(f"UPDATE permission_template_versions SET permissions = permissions::jsonb #- '{{PM,{key}}}'")
|
||||
op.execute(f"UPDATE permission_template_versions SET permissions = permissions::jsonb #- '{{CRA,{key}}}'")
|
||||
op.execute(f"UPDATE permission_template_versions SET permissions = permissions::jsonb #- '{{PV,{key}}}'")
|
||||
op.execute(f"UPDATE permission_template_versions SET permissions = permissions::jsonb #- '{{QA,{key}}}'")
|
||||
op.execute(f"UPDATE permission_template_versions SET permissions = permissions::jsonb #- '{{CTA,{key}}}'")
|
||||
|
||||
if _table_exists(inspector, "attachments"):
|
||||
op.execute("DELETE FROM attachments WHERE entity_type = 'finance_contract'")
|
||||
|
||||
if _table_exists(inspector, "audit_logs"):
|
||||
op.execute("DELETE FROM audit_logs WHERE entity_type = 'finance_contract'")
|
||||
|
||||
if _table_exists(inspector, "finance_contracts"):
|
||||
op.drop_table("finance_contracts")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if not _table_exists(inspector, "finance_contracts"):
|
||||
op.create_table(
|
||||
"finance_contracts",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("study_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("site_name", sa.String(length=255), nullable=False),
|
||||
sa.Column("contract_no", sa.String(length=100), nullable=False),
|
||||
sa.Column("signed_date", sa.Date(), nullable=True),
|
||||
sa.Column("amount", sa.Numeric(12, 2), nullable=False),
|
||||
sa.Column("currency", sa.String(length=10), nullable=False),
|
||||
sa.Column("remark", sa.Text(), nullable=True),
|
||||
sa.Column("created_by", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["created_by"], ["users.id"]),
|
||||
sa.ForeignKeyConstraint(["study_id"], ["studies.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_finance_contracts_study_id", "finance_contracts", ["study_id"])
|
||||
@@ -0,0 +1,81 @@
|
||||
"""remove stale startup permissions
|
||||
|
||||
Revision ID: 20260527_04
|
||||
Revises: 20260527_03
|
||||
Create Date: 2026-05-27 11:05:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "20260527_04"
|
||||
down_revision: Union[str, None] = "20260527_03"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
STALE_STARTUP_PERMISSION_KEYS = (
|
||||
"budget:create",
|
||||
"budget:list",
|
||||
"budget:read",
|
||||
"budget:update",
|
||||
"budget:delete",
|
||||
"timeline:create",
|
||||
"timeline:list",
|
||||
"timeline:read",
|
||||
"timeline:update",
|
||||
"timeline:delete",
|
||||
)
|
||||
|
||||
|
||||
def _quoted_keys() -> str:
|
||||
return ", ".join(f"'{key}'" for key in STALE_STARTUP_PERMISSION_KEYS)
|
||||
|
||||
|
||||
def _table_exists(inspector: sa.Inspector, table_name: str) -> bool:
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def _remove_template_permission_keys(table_name: str, *, touch_updated_at: bool = False) -> None:
|
||||
updated_at_assignment = ", updated_at = NOW()" if touch_updated_at else ""
|
||||
op.execute(
|
||||
f"""
|
||||
UPDATE {table_name}
|
||||
SET permissions = (
|
||||
SELECT jsonb_object_agg(role_key, role_permissions - ARRAY[{_quoted_keys()}])
|
||||
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
|
||||
){updated_at_assignment}
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
|
||||
WHERE role_permissions ?| ARRAY[{_quoted_keys()}]
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if _table_exists(inspector, "api_endpoint_permissions"):
|
||||
op.execute(
|
||||
f"""
|
||||
DELETE FROM api_endpoint_permissions
|
||||
WHERE endpoint_key IN ({_quoted_keys()})
|
||||
"""
|
||||
)
|
||||
|
||||
if _table_exists(inspector, "permission_templates"):
|
||||
_remove_template_permission_keys("permission_templates", touch_updated_at=True)
|
||||
|
||||
if _table_exists(inspector, "permission_template_versions"):
|
||||
_remove_template_permission_keys("permission_template_versions")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,81 @@
|
||||
"""remove legacy startup ethics permission keys
|
||||
|
||||
Revision ID: 20260527_05
|
||||
Revises: 20260527_04
|
||||
Create Date: 2026-05-27 11:20:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "20260527_05"
|
||||
down_revision: Union[str, None] = "20260527_04"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
LEGACY_STARTUP_ETHICS_PERMISSION_KEYS = (
|
||||
"feasibility:create",
|
||||
"feasibility:list",
|
||||
"feasibility:read",
|
||||
"feasibility:update",
|
||||
"feasibility:delete",
|
||||
"ethics:create",
|
||||
"ethics:list",
|
||||
"ethics:read",
|
||||
"ethics:update",
|
||||
"ethics:delete",
|
||||
)
|
||||
|
||||
|
||||
def _quoted_keys() -> str:
|
||||
return ", ".join(f"'{key}'" for key in LEGACY_STARTUP_ETHICS_PERMISSION_KEYS)
|
||||
|
||||
|
||||
def _table_exists(inspector: sa.Inspector, table_name: str) -> bool:
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def _remove_template_permission_keys(table_name: str, *, touch_updated_at: bool = False) -> None:
|
||||
updated_at_assignment = ", updated_at = NOW()" if touch_updated_at else ""
|
||||
op.execute(
|
||||
f"""
|
||||
UPDATE {table_name}
|
||||
SET permissions = (
|
||||
SELECT jsonb_object_agg(role_key, role_permissions - ARRAY[{_quoted_keys()}])
|
||||
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
|
||||
){updated_at_assignment}
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
|
||||
WHERE role_permissions ?| ARRAY[{_quoted_keys()}]
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if _table_exists(inspector, "api_endpoint_permissions"):
|
||||
op.execute(
|
||||
f"""
|
||||
DELETE FROM api_endpoint_permissions
|
||||
WHERE endpoint_key IN ({_quoted_keys()})
|
||||
"""
|
||||
)
|
||||
|
||||
if _table_exists(inspector, "permission_templates"):
|
||||
_remove_template_permission_keys("permission_templates", touch_updated_at=True)
|
||||
|
||||
if _table_exists(inspector, "permission_template_versions"):
|
||||
_remove_template_permission_keys("permission_template_versions")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,70 @@
|
||||
"""remove dashboard permission
|
||||
|
||||
Revision ID: 20260527_06
|
||||
Revises: 20260527_05
|
||||
Create Date: 2026-05-27 16:05:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "20260527_06"
|
||||
down_revision: Union[str, None] = "20260527_05"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
LEGACY_DASHBOARD_PERMISSION_KEYS = ("dashboard:read",)
|
||||
|
||||
|
||||
def _quoted_keys() -> str:
|
||||
return ", ".join(f"'{key}'" for key in LEGACY_DASHBOARD_PERMISSION_KEYS)
|
||||
|
||||
|
||||
def _table_exists(inspector: sa.Inspector, table_name: str) -> bool:
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def _remove_template_permission_keys(table_name: str, *, touch_updated_at: bool = False) -> None:
|
||||
updated_at_assignment = ", updated_at = NOW()" if touch_updated_at else ""
|
||||
op.execute(
|
||||
f"""
|
||||
UPDATE {table_name}
|
||||
SET permissions = (
|
||||
SELECT jsonb_object_agg(role_key, role_permissions - ARRAY[{_quoted_keys()}])
|
||||
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
|
||||
){updated_at_assignment}
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
|
||||
WHERE role_permissions ?| ARRAY[{_quoted_keys()}]
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if _table_exists(inspector, "api_endpoint_permissions"):
|
||||
op.execute(
|
||||
f"""
|
||||
DELETE FROM api_endpoint_permissions
|
||||
WHERE endpoint_key IN ({_quoted_keys()})
|
||||
"""
|
||||
)
|
||||
|
||||
if _table_exists(inspector, "permission_templates"):
|
||||
_remove_template_permission_keys("permission_templates", touch_updated_at=True)
|
||||
|
||||
if _table_exists(inspector, "permission_template_versions"):
|
||||
_remove_template_permission_keys("permission_template_versions")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,109 @@
|
||||
"""rename knowledge note permissions to precautions
|
||||
|
||||
Revision ID: 20260527_07_permissions
|
||||
Revises: 20260527_06
|
||||
Create Date: 2026-05-27 16:35:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "20260527_07_permissions"
|
||||
down_revision: Union[str, None] = "20260527_06"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
PERMISSION_KEY_RENAMES = (
|
||||
("knowledge_notes:create", "precautions:create"),
|
||||
("knowledge_notes:list", "precautions:list"),
|
||||
("knowledge_notes:read", "precautions:read"),
|
||||
("knowledge_notes:update", "precautions:update"),
|
||||
("knowledge_notes:delete", "precautions:delete"),
|
||||
("knowledge_notes_attachments:create", "precautions_attachments:create"),
|
||||
("knowledge_notes_attachments:read", "precautions_attachments:read"),
|
||||
("knowledge_notes_attachments:delete", "precautions_attachments:delete"),
|
||||
)
|
||||
|
||||
|
||||
def _table_exists(inspector: sa.Inspector, table_name: str) -> bool:
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def _rename_api_endpoint_permissions(old_key: str, new_key: str) -> None:
|
||||
op.execute(
|
||||
f"""
|
||||
DELETE FROM api_endpoint_permissions AS old_permissions
|
||||
WHERE old_permissions.endpoint_key = '{old_key}'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM api_endpoint_permissions AS new_permissions
|
||||
WHERE new_permissions.study_id = old_permissions.study_id
|
||||
AND new_permissions.role = old_permissions.role
|
||||
AND new_permissions.endpoint_key = '{new_key}'
|
||||
)
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
f"""
|
||||
UPDATE api_endpoint_permissions
|
||||
SET endpoint_key = '{new_key}'
|
||||
WHERE endpoint_key = '{old_key}'
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _rename_template_permission_key(table_name: str, old_key: str, new_key: str, *, touch_updated_at: bool = False) -> None:
|
||||
updated_at_assignment = ", updated_at = NOW()" if touch_updated_at else ""
|
||||
op.execute(
|
||||
f"""
|
||||
UPDATE {table_name}
|
||||
SET permissions = (
|
||||
SELECT jsonb_object_agg(
|
||||
role_key,
|
||||
CASE
|
||||
WHEN role_permissions ? '{old_key}' AND NOT role_permissions ? '{new_key}'
|
||||
THEN (role_permissions - '{old_key}') || jsonb_build_object('{new_key}', role_permissions -> '{old_key}')
|
||||
WHEN role_permissions ? '{old_key}'
|
||||
THEN role_permissions - '{old_key}'
|
||||
ELSE role_permissions
|
||||
END
|
||||
)
|
||||
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
|
||||
){updated_at_assignment}
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
|
||||
WHERE role_permissions ? '{old_key}'
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _rename_permission_keys(mapping: tuple[tuple[str, str], ...]) -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if _table_exists(inspector, "api_endpoint_permissions"):
|
||||
for old_key, new_key in mapping:
|
||||
_rename_api_endpoint_permissions(old_key, new_key)
|
||||
|
||||
if _table_exists(inspector, "permission_templates"):
|
||||
for old_key, new_key in mapping:
|
||||
_rename_template_permission_key("permission_templates", old_key, new_key, touch_updated_at=True)
|
||||
|
||||
if _table_exists(inspector, "permission_template_versions"):
|
||||
for old_key, new_key in mapping:
|
||||
_rename_template_permission_key("permission_template_versions", old_key, new_key)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
_rename_permission_keys(PERMISSION_KEY_RENAMES)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
_rename_permission_keys(tuple((new_key, old_key) for old_key, new_key in PERMISSION_KEY_RENAMES))
|
||||
@@ -0,0 +1,213 @@
|
||||
"""remove generic attachment permissions
|
||||
|
||||
Revision ID: 20260527_08
|
||||
Revises: 20260527_07_permissions
|
||||
Create Date: 2026-05-27 17:10:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "20260527_08"
|
||||
down_revision: Union[str, None] = "20260527_07_permissions"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
GENERIC_TO_MODULE_KEYS = {
|
||||
"attachments:create": (
|
||||
"fees_contracts_attachments:create",
|
||||
"startup_initiation_attachments:create",
|
||||
"startup_ethics_attachments:create",
|
||||
"startup_auth_attachments:create",
|
||||
"drug_shipments_attachments:create",
|
||||
"precautions_attachments:create",
|
||||
"faq_attachments:create",
|
||||
),
|
||||
"attachments:read": (
|
||||
"fees_contracts_attachments:read",
|
||||
"startup_initiation_attachments:read",
|
||||
"startup_ethics_attachments:read",
|
||||
"startup_auth_attachments:read",
|
||||
"drug_shipments_attachments:read",
|
||||
"precautions_attachments:read",
|
||||
"faq_attachments:read",
|
||||
),
|
||||
"attachments:delete": (
|
||||
"fees_contracts_attachments:delete",
|
||||
"startup_initiation_attachments:delete",
|
||||
"startup_ethics_attachments:delete",
|
||||
"startup_auth_attachments:delete",
|
||||
"drug_shipments_attachments:delete",
|
||||
"precautions_attachments:delete",
|
||||
"faq_attachments:delete",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _table_exists(inspector: sa.Inspector, table_name: str) -> bool:
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def _expand_api_endpoint_permission(old_key: str, new_key: str) -> None:
|
||||
op.execute(
|
||||
f"""
|
||||
INSERT INTO api_endpoint_permissions (id, study_id, role, endpoint_key, allowed, updated_at)
|
||||
SELECT (md5(random()::text || clock_timestamp()::text))::uuid, source.study_id, source.role, '{new_key}', source.allowed, NOW()
|
||||
FROM api_endpoint_permissions AS source
|
||||
WHERE source.endpoint_key = '{old_key}'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM api_endpoint_permissions AS target
|
||||
WHERE target.study_id = source.study_id
|
||||
AND target.role = source.role
|
||||
AND target.endpoint_key = '{new_key}'
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _remove_api_endpoint_permissions(keys: tuple[str, ...]) -> None:
|
||||
quoted_keys = ", ".join(f"'{key}'" for key in keys)
|
||||
op.execute(f"DELETE FROM api_endpoint_permissions WHERE endpoint_key IN ({quoted_keys})")
|
||||
|
||||
|
||||
def _expand_template_permission_key(table_name: str, old_key: str, new_key: str, *, touch_updated_at: bool = False) -> None:
|
||||
updated_at_assignment = ", updated_at = NOW()" if touch_updated_at else ""
|
||||
op.execute(
|
||||
f"""
|
||||
UPDATE {table_name}
|
||||
SET permissions = (
|
||||
SELECT jsonb_object_agg(
|
||||
role_key,
|
||||
CASE
|
||||
WHEN role_permissions ? '{old_key}' AND NOT role_permissions ? '{new_key}'
|
||||
THEN role_permissions || jsonb_build_object('{new_key}', role_permissions -> '{old_key}')
|
||||
ELSE role_permissions
|
||||
END
|
||||
)
|
||||
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
|
||||
){updated_at_assignment}
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
|
||||
WHERE role_permissions ? '{old_key}'
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _remove_template_permission_key(table_name: str, key: str, *, touch_updated_at: bool = False) -> None:
|
||||
updated_at_assignment = ", updated_at = NOW()" if touch_updated_at else ""
|
||||
op.execute(
|
||||
f"""
|
||||
UPDATE {table_name}
|
||||
SET permissions = (
|
||||
SELECT jsonb_object_agg(role_key, role_permissions - '{key}')
|
||||
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
|
||||
){updated_at_assignment}
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
|
||||
WHERE role_permissions ? '{key}'
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _restore_api_endpoint_permission(old_key: str, new_keys: tuple[str, ...]) -> None:
|
||||
quoted_new_keys = ", ".join(f"'{key}'" for key in new_keys)
|
||||
op.execute(
|
||||
f"""
|
||||
INSERT INTO api_endpoint_permissions (id, study_id, role, endpoint_key, allowed, updated_at)
|
||||
SELECT (md5(random()::text || clock_timestamp()::text))::uuid, source.study_id, source.role, '{old_key}', bool_or(source.allowed), NOW()
|
||||
FROM api_endpoint_permissions AS source
|
||||
WHERE source.endpoint_key IN ({quoted_new_keys})
|
||||
GROUP BY source.study_id, source.role
|
||||
HAVING NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM api_endpoint_permissions AS target
|
||||
WHERE target.study_id = source.study_id
|
||||
AND target.role = source.role
|
||||
AND target.endpoint_key = '{old_key}'
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _restore_template_permission_key(table_name: str, old_key: str, new_keys: tuple[str, ...], *, touch_updated_at: bool = False) -> None:
|
||||
updated_at_assignment = ", updated_at = NOW()" if touch_updated_at else ""
|
||||
new_key_checks = " OR ".join(f"role_permissions ? '{key}'" for key in new_keys)
|
||||
new_key_values = ", ".join(f"role_permissions -> '{key}'" for key in new_keys)
|
||||
op.execute(
|
||||
f"""
|
||||
UPDATE {table_name}
|
||||
SET permissions = (
|
||||
SELECT jsonb_object_agg(
|
||||
role_key,
|
||||
CASE
|
||||
WHEN NOT role_permissions ? '{old_key}' AND ({new_key_checks})
|
||||
THEN role_permissions || jsonb_build_object('{old_key}', COALESCE({new_key_values}))
|
||||
ELSE role_permissions
|
||||
END
|
||||
)
|
||||
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
|
||||
){updated_at_assignment}
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
|
||||
WHERE {new_key_checks}
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
old_keys = tuple(GENERIC_TO_MODULE_KEYS)
|
||||
|
||||
if _table_exists(inspector, "api_endpoint_permissions"):
|
||||
for old_key, new_keys in GENERIC_TO_MODULE_KEYS.items():
|
||||
for new_key in new_keys:
|
||||
_expand_api_endpoint_permission(old_key, new_key)
|
||||
_remove_api_endpoint_permissions(old_keys)
|
||||
|
||||
if _table_exists(inspector, "permission_templates"):
|
||||
for old_key, new_keys in GENERIC_TO_MODULE_KEYS.items():
|
||||
for new_key in new_keys:
|
||||
_expand_template_permission_key("permission_templates", old_key, new_key, touch_updated_at=True)
|
||||
_remove_template_permission_key("permission_templates", old_key, touch_updated_at=True)
|
||||
|
||||
if _table_exists(inspector, "permission_template_versions"):
|
||||
for old_key, new_keys in GENERIC_TO_MODULE_KEYS.items():
|
||||
for new_key in new_keys:
|
||||
_expand_template_permission_key("permission_template_versions", old_key, new_key)
|
||||
_remove_template_permission_key("permission_template_versions", old_key)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
module_keys = tuple(key for new_keys in GENERIC_TO_MODULE_KEYS.values() for key in new_keys)
|
||||
|
||||
if _table_exists(inspector, "api_endpoint_permissions"):
|
||||
for old_key, new_keys in GENERIC_TO_MODULE_KEYS.items():
|
||||
_restore_api_endpoint_permission(old_key, new_keys)
|
||||
_remove_api_endpoint_permissions(module_keys)
|
||||
|
||||
if _table_exists(inspector, "permission_templates"):
|
||||
for old_key, new_keys in GENERIC_TO_MODULE_KEYS.items():
|
||||
_restore_template_permission_key("permission_templates", old_key, new_keys, touch_updated_at=True)
|
||||
for new_key in new_keys:
|
||||
_remove_template_permission_key("permission_templates", new_key, touch_updated_at=True)
|
||||
|
||||
if _table_exists(inspector, "permission_template_versions"):
|
||||
for old_key, new_keys in GENERIC_TO_MODULE_KEYS.items():
|
||||
_restore_template_permission_key("permission_template_versions", old_key, new_keys)
|
||||
for new_key in new_keys:
|
||||
_remove_template_permission_key("permission_template_versions", new_key)
|
||||
@@ -0,0 +1,87 @@
|
||||
"""rename knowledge notes table to precautions
|
||||
|
||||
Revision ID: 20260527_08_precautions
|
||||
Revises: 20260527_08
|
||||
Create Date: 2026-05-27 17:35:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "20260527_08_precautions"
|
||||
down_revision: Union[str, None] = "20260527_08"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def _table_exists(inspector: sa.Inspector, table_name: str) -> bool:
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def _column_exists(inspector: sa.Inspector, table_name: str, column_name: str) -> bool:
|
||||
if not _table_exists(inspector, table_name):
|
||||
return False
|
||||
return column_name in {column["name"] for column in inspector.get_columns(table_name)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if _table_exists(inspector, "knowledge_notes") and not _table_exists(inspector, "precautions"):
|
||||
op.rename_table("knowledge_notes", "precautions")
|
||||
|
||||
if _column_exists(inspector, "attachments", "entity_type"):
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE attachments
|
||||
SET entity_type = 'precaution'
|
||||
WHERE entity_type = 'knowledge_note'
|
||||
"""
|
||||
)
|
||||
|
||||
if _column_exists(inspector, "audit_logs", "entity_type"):
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE audit_logs
|
||||
SET entity_type = 'precaution'
|
||||
WHERE entity_type = 'knowledge_note'
|
||||
"""
|
||||
)
|
||||
|
||||
# Keep permission data aligned if this migration is applied without the
|
||||
# earlier permission-key migration in a partial database.
|
||||
if _table_exists(inspector, "api_endpoint_permissions"):
|
||||
pass
|
||||
if _table_exists(inspector, "permission_templates"):
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if _table_exists(inspector, "precautions") and not _table_exists(inspector, "knowledge_notes"):
|
||||
op.rename_table("precautions", "knowledge_notes")
|
||||
|
||||
if _column_exists(inspector, "attachments", "entity_type"):
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE attachments
|
||||
SET entity_type = 'knowledge_note'
|
||||
WHERE entity_type = 'precaution'
|
||||
"""
|
||||
)
|
||||
|
||||
if _column_exists(inspector, "audit_logs", "entity_type"):
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE audit_logs
|
||||
SET entity_type = 'knowledge_note'
|
||||
WHERE entity_type = 'precaution'
|
||||
"""
|
||||
)
|
||||
@@ -0,0 +1,88 @@
|
||||
"""add etmf nodes
|
||||
|
||||
Revision ID: 20260527_09
|
||||
Revises: 20260527_08_precautions
|
||||
Create Date: 2026-05-27 20:30:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
revision: str = "20260527_09"
|
||||
down_revision: Union[str, None] = "20260527_08_precautions"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def _table_exists(inspector: sa.Inspector, table_name: str) -> bool:
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def _column_exists(inspector: sa.Inspector, table_name: str, column_name: str) -> bool:
|
||||
return column_name in {column["name"] for column in inspector.get_columns(table_name)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
uuid_type = postgresql.UUID(as_uuid=True)
|
||||
scope_type = postgresql.ENUM(
|
||||
"GLOBAL",
|
||||
"SITE",
|
||||
"DERIVED",
|
||||
name="document_scope_type",
|
||||
create_type=False,
|
||||
)
|
||||
|
||||
if not _table_exists(inspector, "etmf_nodes"):
|
||||
op.create_table(
|
||||
"etmf_nodes",
|
||||
sa.Column("id", uuid_type, nullable=False),
|
||||
sa.Column("study_id", uuid_type, nullable=False),
|
||||
sa.Column("parent_id", uuid_type, nullable=True),
|
||||
sa.Column("code", sa.String(length=50), nullable=False),
|
||||
sa.Column("name", sa.String(length=200), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("scope_type", scope_type, server_default="GLOBAL", nullable=False),
|
||||
sa.Column("required", sa.Boolean(), server_default=sa.text("false"), nullable=False),
|
||||
sa.Column("expected_doc_type", sa.String(length=50), nullable=True),
|
||||
sa.Column("sort_order", sa.Integer(), server_default="0", nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), server_default=sa.text("true"), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["parent_id"], ["etmf_nodes.id"]),
|
||||
sa.ForeignKeyConstraint(["study_id"], ["studies.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("study_id", "parent_id", "code", name="uq_etmf_nodes_study_parent_code"),
|
||||
)
|
||||
op.create_index("ix_etmf_nodes_study_id", "etmf_nodes", ["study_id"])
|
||||
op.create_index("ix_etmf_nodes_parent_id", "etmf_nodes", ["parent_id"])
|
||||
op.create_index("ix_etmf_nodes_scope_type", "etmf_nodes", ["scope_type"])
|
||||
op.create_index("ix_etmf_nodes_is_active", "etmf_nodes", ["is_active"])
|
||||
|
||||
if _table_exists(inspector, "documents") and not _column_exists(inspector, "documents", "etmf_node_id"):
|
||||
op.add_column("documents", sa.Column("etmf_node_id", uuid_type, nullable=True))
|
||||
op.create_index("ix_documents_etmf_node_id", "documents", ["etmf_node_id"])
|
||||
op.create_foreign_key("fk_documents_etmf_node_id", "documents", "etmf_nodes", ["etmf_node_id"], ["id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if _table_exists(inspector, "documents") and _column_exists(inspector, "documents", "etmf_node_id"):
|
||||
op.drop_constraint("fk_documents_etmf_node_id", "documents", type_="foreignkey")
|
||||
op.drop_index("ix_documents_etmf_node_id", table_name="documents")
|
||||
op.drop_column("documents", "etmf_node_id")
|
||||
|
||||
if _table_exists(inspector, "etmf_nodes"):
|
||||
op.drop_index("ix_etmf_nodes_is_active", table_name="etmf_nodes")
|
||||
op.drop_index("ix_etmf_nodes_scope_type", table_name="etmf_nodes")
|
||||
op.drop_index("ix_etmf_nodes_parent_id", table_name="etmf_nodes")
|
||||
op.drop_index("ix_etmf_nodes_study_id", table_name="etmf_nodes")
|
||||
op.drop_table("etmf_nodes")
|
||||
@@ -0,0 +1,25 @@
|
||||
"""drop legacy fee attachments
|
||||
|
||||
Revision ID: 20260528_01
|
||||
Revises: 20260527_09
|
||||
Create Date: 2026-05-28 17:20:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = "20260528_01"
|
||||
down_revision: Union[str, None] = "20260527_09"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("DROP TABLE IF EXISTS fee_attachments")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,44 @@
|
||||
"""remove user global role
|
||||
|
||||
Revision ID: 20260529_01
|
||||
Revises: 20260528_01
|
||||
Create Date: 2026-05-29 10:15:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "20260529_01"
|
||||
down_revision: Union[str, None] = "20260528_01"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
columns = {column["name"] for column in inspector.get_columns("users")}
|
||||
|
||||
if "is_admin" not in columns:
|
||||
op.add_column("users", sa.Column("is_admin", sa.Boolean(), nullable=False, server_default=sa.false()))
|
||||
|
||||
if "role" in columns:
|
||||
op.execute("UPDATE users SET is_admin = true WHERE role::text = 'ADMIN'")
|
||||
op.drop_column("users", "role")
|
||||
|
||||
op.execute("DROP TYPE IF EXISTS user_role")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("CREATE TYPE user_role AS ENUM ('ADMIN', 'PM', 'CRA', 'PV', 'QA', 'CTA')")
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column("role", sa.Enum("ADMIN", "PM", "CRA", "PV", "QA", "CTA", name="user_role"), nullable=True),
|
||||
)
|
||||
op.execute("UPDATE users SET role = CASE WHEN is_admin THEN 'ADMIN'::user_role ELSE 'PV'::user_role END")
|
||||
op.alter_column("users", "role", nullable=False)
|
||||
op.drop_column("users", "is_admin")
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
"""remove contract fee attachment create and read permissions
|
||||
|
||||
Revision ID: 20260529_02
|
||||
Revises: 20260529_01
|
||||
Create Date: 2026-05-29 15:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "20260529_02"
|
||||
down_revision: Union[str, None] = "20260529_01"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
REMOVED_KEYS = (
|
||||
"fees_contracts_attachments:create",
|
||||
"fees_contracts_attachments:read",
|
||||
)
|
||||
|
||||
RESTORE_FROM_PARENT = {
|
||||
"fees_contracts_attachments:create": "fees_contracts:create",
|
||||
"fees_contracts_attachments:read": "fees_contracts:read",
|
||||
}
|
||||
|
||||
|
||||
def _table_exists(inspector: sa.Inspector, table_name: str) -> bool:
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def _remove_permission_rows() -> None:
|
||||
quoted_keys = ", ".join(f"'{key}'" for key in REMOVED_KEYS)
|
||||
op.execute(f"DELETE FROM api_endpoint_permissions WHERE endpoint_key IN ({quoted_keys})")
|
||||
|
||||
|
||||
def _remove_template_key(table_name: str, key: str, *, touch_updated_at: bool = False) -> None:
|
||||
updated_at_assignment = ", updated_at = NOW()" if touch_updated_at else ""
|
||||
op.execute(
|
||||
f"""
|
||||
UPDATE {table_name}
|
||||
SET permissions = (
|
||||
SELECT jsonb_object_agg(role_key, role_permissions - '{key}')
|
||||
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
|
||||
){updated_at_assignment}
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
|
||||
WHERE role_permissions ? '{key}'
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _restore_permission_rows(new_key: str, source_key: str) -> None:
|
||||
op.execute(
|
||||
f"""
|
||||
INSERT INTO api_endpoint_permissions (id, study_id, role, endpoint_key, allowed, updated_at)
|
||||
SELECT (md5(random()::text || clock_timestamp()::text))::uuid, source.study_id, source.role, '{new_key}', source.allowed, NOW()
|
||||
FROM api_endpoint_permissions AS source
|
||||
WHERE source.endpoint_key = '{source_key}'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM api_endpoint_permissions AS target
|
||||
WHERE target.study_id = source.study_id
|
||||
AND target.role = source.role
|
||||
AND target.endpoint_key = '{new_key}'
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _restore_template_key(table_name: str, new_key: str, source_key: str, *, touch_updated_at: bool = False) -> None:
|
||||
updated_at_assignment = ", updated_at = NOW()" if touch_updated_at else ""
|
||||
op.execute(
|
||||
f"""
|
||||
UPDATE {table_name}
|
||||
SET permissions = (
|
||||
SELECT jsonb_object_agg(
|
||||
role_key,
|
||||
CASE
|
||||
WHEN role_permissions ? '{source_key}' AND NOT role_permissions ? '{new_key}'
|
||||
THEN role_permissions || jsonb_build_object('{new_key}', role_permissions -> '{source_key}')
|
||||
ELSE role_permissions
|
||||
END
|
||||
)
|
||||
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
|
||||
){updated_at_assignment}
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
|
||||
WHERE role_permissions ? '{source_key}'
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if _table_exists(inspector, "api_endpoint_permissions"):
|
||||
_remove_permission_rows()
|
||||
|
||||
if _table_exists(inspector, "permission_templates"):
|
||||
for key in REMOVED_KEYS:
|
||||
_remove_template_key("permission_templates", key, touch_updated_at=True)
|
||||
|
||||
if _table_exists(inspector, "permission_template_versions"):
|
||||
for key in REMOVED_KEYS:
|
||||
_remove_template_key("permission_template_versions", key)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if _table_exists(inspector, "api_endpoint_permissions"):
|
||||
for new_key, source_key in RESTORE_FROM_PARENT.items():
|
||||
_restore_permission_rows(new_key, source_key)
|
||||
|
||||
if _table_exists(inspector, "permission_templates"):
|
||||
for new_key, source_key in RESTORE_FROM_PARENT.items():
|
||||
_restore_template_key("permission_templates", new_key, source_key, touch_updated_at=True)
|
||||
|
||||
if _table_exists(inspector, "permission_template_versions"):
|
||||
for new_key, source_key in RESTORE_FROM_PARENT.items():
|
||||
_restore_template_key("permission_template_versions", new_key, source_key)
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
"""remove business attachment create and read permissions
|
||||
|
||||
Revision ID: 20260529_03
|
||||
Revises: 20260529_02
|
||||
Create Date: 2026-05-29 15:12:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "20260529_03"
|
||||
down_revision: Union[str, None] = "20260529_02"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
RESTORE_FROM_PARENT = {
|
||||
"startup_initiation_attachments:create": "startup_initiation:create",
|
||||
"startup_initiation_attachments:read": "startup_initiation:read",
|
||||
"startup_ethics_attachments:create": "startup_ethics:create",
|
||||
"startup_ethics_attachments:read": "startup_ethics:read",
|
||||
"startup_auth_attachments:create": "startup_auth:create",
|
||||
"startup_auth_attachments:read": "startup_auth:read",
|
||||
"drug_shipments_attachments:create": "drug_shipments:create",
|
||||
"drug_shipments_attachments:read": "drug_shipments:read",
|
||||
"precautions_attachments:create": "precautions:create",
|
||||
"precautions_attachments:read": "precautions:read",
|
||||
"faq_attachments:create": "faq_reply:create",
|
||||
"faq_attachments:read": "faq:read",
|
||||
}
|
||||
|
||||
REMOVED_KEYS = tuple(RESTORE_FROM_PARENT)
|
||||
|
||||
|
||||
def _table_exists(inspector: sa.Inspector, table_name: str) -> bool:
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def _remove_permission_rows() -> None:
|
||||
quoted_keys = ", ".join(f"'{key}'" for key in REMOVED_KEYS)
|
||||
op.execute(f"DELETE FROM api_endpoint_permissions WHERE endpoint_key IN ({quoted_keys})")
|
||||
|
||||
|
||||
def _remove_template_key(table_name: str, key: str, *, touch_updated_at: bool = False) -> None:
|
||||
updated_at_assignment = ", updated_at = NOW()" if touch_updated_at else ""
|
||||
op.execute(
|
||||
f"""
|
||||
UPDATE {table_name}
|
||||
SET permissions = (
|
||||
SELECT jsonb_object_agg(role_key, role_permissions - '{key}')
|
||||
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
|
||||
){updated_at_assignment}
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
|
||||
WHERE role_permissions ? '{key}'
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _restore_permission_rows(new_key: str, source_key: str) -> None:
|
||||
op.execute(
|
||||
f"""
|
||||
INSERT INTO api_endpoint_permissions (id, study_id, role, endpoint_key, allowed, updated_at)
|
||||
SELECT (md5(random()::text || clock_timestamp()::text))::uuid, source.study_id, source.role, '{new_key}', source.allowed, NOW()
|
||||
FROM api_endpoint_permissions AS source
|
||||
WHERE source.endpoint_key = '{source_key}'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM api_endpoint_permissions AS target
|
||||
WHERE target.study_id = source.study_id
|
||||
AND target.role = source.role
|
||||
AND target.endpoint_key = '{new_key}'
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _restore_template_key(table_name: str, new_key: str, source_key: str, *, touch_updated_at: bool = False) -> None:
|
||||
updated_at_assignment = ", updated_at = NOW()" if touch_updated_at else ""
|
||||
op.execute(
|
||||
f"""
|
||||
UPDATE {table_name}
|
||||
SET permissions = (
|
||||
SELECT jsonb_object_agg(
|
||||
role_key,
|
||||
CASE
|
||||
WHEN role_permissions ? '{source_key}' AND NOT role_permissions ? '{new_key}'
|
||||
THEN role_permissions || jsonb_build_object('{new_key}', role_permissions -> '{source_key}')
|
||||
ELSE role_permissions
|
||||
END
|
||||
)
|
||||
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
|
||||
){updated_at_assignment}
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
|
||||
WHERE role_permissions ? '{source_key}'
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if _table_exists(inspector, "api_endpoint_permissions"):
|
||||
_remove_permission_rows()
|
||||
|
||||
if _table_exists(inspector, "permission_templates"):
|
||||
for key in REMOVED_KEYS:
|
||||
_remove_template_key("permission_templates", key, touch_updated_at=True)
|
||||
|
||||
if _table_exists(inspector, "permission_template_versions"):
|
||||
for key in REMOVED_KEYS:
|
||||
_remove_template_key("permission_template_versions", key)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if _table_exists(inspector, "api_endpoint_permissions"):
|
||||
for new_key, source_key in RESTORE_FROM_PARENT.items():
|
||||
_restore_permission_rows(new_key, source_key)
|
||||
|
||||
if _table_exists(inspector, "permission_templates"):
|
||||
for new_key, source_key in RESTORE_FROM_PARENT.items():
|
||||
_restore_template_key("permission_templates", new_key, source_key, touch_updated_at=True)
|
||||
|
||||
if _table_exists(inspector, "permission_template_versions"):
|
||||
for new_key, source_key in RESTORE_FROM_PARENT.items():
|
||||
_restore_template_key("permission_template_versions", new_key, source_key)
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
"""add material equipment attachment delete permission
|
||||
|
||||
Revision ID: 20260529_04
|
||||
Revises: 20260529_03
|
||||
Create Date: 2026-05-29 15:30:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "20260529_04"
|
||||
down_revision: Union[str, None] = "20260529_03"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
SOURCE_KEY = "material_equipments:update"
|
||||
TARGET_KEY = "material_equipments_attachments:delete"
|
||||
|
||||
|
||||
def _table_exists(inspector: sa.Inspector, table_name: str) -> bool:
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def _copy_permission_rows() -> None:
|
||||
op.execute(
|
||||
f"""
|
||||
INSERT INTO api_endpoint_permissions (id, study_id, role, endpoint_key, allowed, updated_at)
|
||||
SELECT (md5(random()::text || clock_timestamp()::text))::uuid, source.study_id, source.role, '{TARGET_KEY}', source.allowed, NOW()
|
||||
FROM api_endpoint_permissions AS source
|
||||
WHERE source.endpoint_key = '{SOURCE_KEY}'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM api_endpoint_permissions AS target
|
||||
WHERE target.study_id = source.study_id
|
||||
AND target.role = source.role
|
||||
AND target.endpoint_key = '{TARGET_KEY}'
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _remove_permission_rows() -> None:
|
||||
op.execute(f"DELETE FROM api_endpoint_permissions WHERE endpoint_key = '{TARGET_KEY}'")
|
||||
|
||||
|
||||
def _copy_template_key(table_name: str, *, touch_updated_at: bool = False) -> None:
|
||||
updated_at_assignment = ", updated_at = NOW()" if touch_updated_at else ""
|
||||
op.execute(
|
||||
f"""
|
||||
UPDATE {table_name}
|
||||
SET permissions = (
|
||||
SELECT jsonb_object_agg(
|
||||
role_key,
|
||||
CASE
|
||||
WHEN role_permissions ? '{SOURCE_KEY}' AND NOT role_permissions ? '{TARGET_KEY}'
|
||||
THEN role_permissions || jsonb_build_object('{TARGET_KEY}', role_permissions -> '{SOURCE_KEY}')
|
||||
ELSE role_permissions
|
||||
END
|
||||
)
|
||||
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
|
||||
){updated_at_assignment}
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
|
||||
WHERE role_permissions ? '{SOURCE_KEY}'
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _remove_template_key(table_name: str, *, touch_updated_at: bool = False) -> None:
|
||||
updated_at_assignment = ", updated_at = NOW()" if touch_updated_at else ""
|
||||
op.execute(
|
||||
f"""
|
||||
UPDATE {table_name}
|
||||
SET permissions = (
|
||||
SELECT jsonb_object_agg(role_key, role_permissions - '{TARGET_KEY}')
|
||||
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
|
||||
){updated_at_assignment}
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions)
|
||||
WHERE role_permissions ? '{TARGET_KEY}'
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if _table_exists(inspector, "api_endpoint_permissions"):
|
||||
_copy_permission_rows()
|
||||
|
||||
if _table_exists(inspector, "permission_templates"):
|
||||
_copy_template_key("permission_templates", touch_updated_at=True)
|
||||
|
||||
if _table_exists(inspector, "permission_template_versions"):
|
||||
_copy_template_key("permission_template_versions")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if _table_exists(inspector, "api_endpoint_permissions"):
|
||||
_remove_permission_rows()
|
||||
|
||||
if _table_exists(inspector, "permission_templates"):
|
||||
_remove_template_key("permission_templates", touch_updated_at=True)
|
||||
|
||||
if _table_exists(inspector, "permission_template_versions"):
|
||||
_remove_template_key("permission_template_versions")
|
||||
@@ -0,0 +1,36 @@
|
||||
"""add faq category icon column
|
||||
|
||||
Revision ID: 20260608_01
|
||||
Revises: 20260529_04
|
||||
Create Date: 2026-06-08 14:30:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "20260608_01"
|
||||
down_revision: Union[str, None] = "20260529_04"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def _column_exists(inspector: sa.Inspector, table_name: str, column_name: str) -> bool:
|
||||
return any(column["name"] == column_name for column in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
if "faq_categories" in inspector.get_table_names() and not _column_exists(inspector, "faq_categories", "icon"):
|
||||
op.add_column("faq_categories", sa.Column("icon", sa.String(length=30), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
if "faq_categories" in inspector.get_table_names() and _column_exists(inspector, "faq_categories", "icon"):
|
||||
op.drop_column("faq_categories", "icon")
|
||||
@@ -0,0 +1,94 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,40 @@
|
||||
"""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'"
|
||||
)
|
||||
@@ -0,0 +1,123 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,26 @@
|
||||
"""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
|
||||
@@ -0,0 +1,26 @@
|
||||
"""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
|
||||
@@ -0,0 +1,74 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,46 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,42 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,35 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,35 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,40 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,67 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,93 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,30 @@
|
||||
"""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
|
||||
@@ -0,0 +1,40 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,25 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,25 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,136 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,53 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,25 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,43 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,35 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,33 @@
|
||||
"""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
|
||||
"""
|
||||
)
|
||||
@@ -0,0 +1,66 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,53 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,75 @@
|
||||
"""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
|
||||
@@ -0,0 +1,65 @@
|
||||
"""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
|
||||
@@ -0,0 +1,35 @@
|
||||
"""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
|
||||
@@ -0,0 +1,63 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,76 @@
|
||||
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": "测试邮件已发送"}
|
||||
@@ -1,62 +0,0 @@
|
||||
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
|
||||
+92
-51
@@ -5,7 +5,7 @@ from datetime import date
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_member, require_study_not_locked
|
||||
from app.core.deps import get_operator_role_label, get_cra_site_scope, get_current_user, get_db_session, require_study_not_locked, require_api_permission
|
||||
from app.crud import ae as ae_crud
|
||||
from app.crud import audit as audit_crud
|
||||
from app.crud import member as member_crud
|
||||
@@ -16,9 +16,6 @@ from app.schemas.ae import AECreate, AERead, AEUpdate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
ALLOWED_CREATE_ROLES = {"PM", "CRA", "PV"}
|
||||
ALLOWED_UPDATE_ROLES = {"PM", "PV", "CRA"}
|
||||
|
||||
|
||||
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
|
||||
study = await study_crud.get(db, study_id)
|
||||
@@ -43,20 +40,24 @@ async def _ensure_ae_visible(db: AsyncSession, study_id: uuid.UUID, ae: AERead |
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="AE 不存在")
|
||||
|
||||
|
||||
async def _get_member_role(db: AsyncSession, study_id: uuid.UUID, user_id: uuid.UUID) -> str | None:
|
||||
member = await member_crud.get_member(db, study_id, user_id)
|
||||
return member.role_in_study if member else None
|
||||
|
||||
|
||||
def _is_overdue(ae: AERead) -> bool:
|
||||
return bool(ae.report_due_date and date.today() > ae.report_due_date and ae.status != "CLOSED")
|
||||
|
||||
|
||||
def _ae_audit_name(ae) -> str:
|
||||
return str(getattr(ae, "term", "") or "").strip() or "AE 记录"
|
||||
|
||||
|
||||
def _ae_audit_detail(action: str, ae) -> str:
|
||||
name = _ae_audit_name(ae)
|
||||
return json.dumps({"targetName": name, "description": f"{action}AE“{name}”"}, ensure_ascii=False)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/",
|
||||
response_model=AERead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(require_study_member()), Depends(require_study_not_locked())],
|
||||
dependencies=[Depends(require_api_permission("subject_aes:create")), Depends(require_study_not_locked())],
|
||||
)
|
||||
async def create_ae(
|
||||
study_id: uuid.UUID,
|
||||
@@ -75,9 +76,6 @@ async def create_ae(
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
|
||||
if ae_in.onset_date and ae_in.onset_date > date.today():
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="发生日期不能晚于今天")
|
||||
member_role = await _get_member_role(db, study_id, current_user.id)
|
||||
if current_user.role != "ADMIN" and member_role not in ALLOWED_CREATE_ROLES:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
|
||||
try:
|
||||
ae = await ae_crud.create_ae(db, study_id, ae_in, created_by=current_user.id)
|
||||
except ValueError as exc:
|
||||
@@ -88,29 +86,24 @@ async def create_ae(
|
||||
entity_type="ae",
|
||||
entity_id=ae.id,
|
||||
action="CREATE_AE",
|
||||
detail=f"AE {ae.id} 已创建",
|
||||
detail=_ae_audit_detail("创建", ae),
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
)
|
||||
data = AERead.model_validate(ae)
|
||||
data.is_overdue = _is_overdue(data)
|
||||
return data
|
||||
|
||||
|
||||
@router.get(
|
||||
"/",
|
||||
response_model=list[AERead],
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def list_ae(
|
||||
async def _list_ae_records(
|
||||
study_id: uuid.UUID,
|
||||
status_filter: str | None = None,
|
||||
seriousness: str | None = None,
|
||||
site_id: uuid.UUID | None = None,
|
||||
subject_id: uuid.UUID | None = None,
|
||||
overdue: bool | None = None,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
status_filter: str | None,
|
||||
seriousness: str | None,
|
||||
site_id: uuid.UUID | None,
|
||||
subject_id: uuid.UUID | None,
|
||||
overdue: bool | None,
|
||||
db: AsyncSession,
|
||||
current_user,
|
||||
) -> list[AERead]:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
cra_scope = await get_cra_site_scope(db, study_id, current_user)
|
||||
@@ -135,10 +128,64 @@ async def list_ae(
|
||||
return result
|
||||
|
||||
|
||||
@router.get(
|
||||
"/summary",
|
||||
response_model=list[AERead],
|
||||
dependencies=[Depends(require_api_permission("subject_aes:read"))],
|
||||
)
|
||||
async def list_risk_issue_ae(
|
||||
study_id: uuid.UUID,
|
||||
status_filter: str | None = None,
|
||||
seriousness: str | None = None,
|
||||
site_id: uuid.UUID | None = None,
|
||||
subject_id: uuid.UUID | None = None,
|
||||
overdue: bool | None = None,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> list[AERead]:
|
||||
return await _list_ae_records(
|
||||
study_id,
|
||||
status_filter,
|
||||
seriousness,
|
||||
site_id,
|
||||
subject_id,
|
||||
overdue,
|
||||
db,
|
||||
current_user,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/",
|
||||
response_model=list[AERead],
|
||||
dependencies=[Depends(require_api_permission("subject_aes:read"))],
|
||||
)
|
||||
async def list_ae(
|
||||
study_id: uuid.UUID,
|
||||
status_filter: str | None = None,
|
||||
seriousness: str | None = None,
|
||||
site_id: uuid.UUID | None = None,
|
||||
subject_id: uuid.UUID | None = None,
|
||||
overdue: bool | None = None,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> list[AERead]:
|
||||
return await _list_ae_records(
|
||||
study_id,
|
||||
status_filter,
|
||||
seriousness,
|
||||
site_id,
|
||||
subject_id,
|
||||
overdue,
|
||||
db,
|
||||
current_user,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{ae_id}",
|
||||
response_model=AERead,
|
||||
dependencies=[Depends(require_study_member())],
|
||||
dependencies=[Depends(require_api_permission("subject_aes:read"))],
|
||||
)
|
||||
async def get_ae(
|
||||
study_id: uuid.UUID,
|
||||
@@ -166,7 +213,7 @@ async def get_ae(
|
||||
@router.patch(
|
||||
"/{ae_id}",
|
||||
response_model=AERead,
|
||||
dependencies=[Depends(require_study_member()), Depends(require_study_not_locked())],
|
||||
dependencies=[Depends(require_api_permission("subject_aes:update")), Depends(require_study_not_locked())],
|
||||
)
|
||||
async def update_ae(
|
||||
study_id: uuid.UUID,
|
||||
@@ -183,19 +230,6 @@ async def update_ae(
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="AE 不存在")
|
||||
await _ensure_ae_visible(db, study_id, AERead.model_validate(ae))
|
||||
|
||||
member_role = await _get_member_role(db, study_id, current_user.id)
|
||||
if current_user.role == "ADMIN":
|
||||
pass
|
||||
elif member_role in ALLOWED_UPDATE_ROLES:
|
||||
pass
|
||||
elif member_role == "CRA":
|
||||
if ae.created_by != current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="仅创建人可更新 AE")
|
||||
if ae_in.status == "CLOSED":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="CRA 无法关闭 AE")
|
||||
else:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
|
||||
|
||||
old_status = ae.status
|
||||
detail_before = {
|
||||
"event": ae.term,
|
||||
@@ -222,9 +256,18 @@ async def update_ae(
|
||||
entity_type="ae",
|
||||
entity_id=ae_id,
|
||||
action=action,
|
||||
detail=json.dumps({"before": detail_before, "after": detail_after}, ensure_ascii=False, default=str),
|
||||
detail=json.dumps(
|
||||
{
|
||||
"targetName": _ae_audit_name(updated),
|
||||
"description": f"{'变更' if action == 'AE_STATUS_CHANGE' else '更新'}AE“{_ae_audit_name(updated)}”",
|
||||
"before": detail_before,
|
||||
"after": detail_after,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
),
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
)
|
||||
data = AERead.model_validate(updated)
|
||||
data.is_overdue = _is_overdue(data)
|
||||
@@ -234,7 +277,7 @@ async def update_ae(
|
||||
@router.delete(
|
||||
"/{ae_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(require_study_member()), Depends(require_study_not_locked())],
|
||||
dependencies=[Depends(require_api_permission("subject_aes:delete")), Depends(require_study_not_locked())],
|
||||
)
|
||||
async def delete_ae(
|
||||
study_id: uuid.UUID,
|
||||
@@ -247,9 +290,7 @@ async def delete_ae(
|
||||
if not ae or ae.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="AE 不存在")
|
||||
await _ensure_ae_visible(db, study_id, AERead.model_validate(ae))
|
||||
member_role = await _get_member_role(db, study_id, current_user.id)
|
||||
if current_user.role != "ADMIN" and member_role not in {"PM", "PV"}:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
|
||||
ae_detail = _ae_audit_detail("删除", ae)
|
||||
await ae_crud.delete_ae(db, ae)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
@@ -257,7 +298,7 @@ async def delete_ae(
|
||||
entity_type="ae",
|
||||
entity_id=ae_id,
|
||||
action="DELETE_AE",
|
||||
detail=f"AE {ae_id} 已删除",
|
||||
detail=ae_detail,
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
"""接口级权限管理API"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_user, get_db_session, is_system_admin, require_study_member, require_system_permission
|
||||
from app.core.project_permissions import (
|
||||
get_api_endpoint_permissions,
|
||||
get_effective_api_endpoint_permissions,
|
||||
PrerequisitePermissionConfirmationRequired,
|
||||
replace_api_endpoint_permissions,
|
||||
get_missing_prerequisites,
|
||||
)
|
||||
from app.crud import member as member_crud
|
||||
from app.models.api_endpoint_registry import ApiEndpointRegistry
|
||||
from app.models.study import Study
|
||||
from app.core.api_permissions import API_ENDPOINT_PERMISSIONS, PROJECT_PERMISSION_ROLES, OPERATION_PREREQUISITES
|
||||
|
||||
router = APIRouter(prefix="/api-permissions", tags=["api-permissions"])
|
||||
|
||||
# 项目级路由(需要 study_id,挂载到 /studies/{study_id}/api-permissions)
|
||||
study_router = APIRouter(prefix="/api-permissions", tags=["api-permissions"])
|
||||
|
||||
|
||||
async def _get_configurable_roles(db: AsyncSession, study_id: uuid.UUID) -> list[str]:
|
||||
result = await db.execute(select(Study).where(Study.id == study_id))
|
||||
study = result.scalar_one_or_none()
|
||||
active_roles = [role for role in (study.active_roles if study else []) if isinstance(role, str) and role.strip()]
|
||||
return list(dict.fromkeys([*PROJECT_PERMISSION_ROLES, *active_roles]))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/operations",
|
||||
summary="获取系统中所有权限操作",
|
||||
description="返回系统中所有权限操作及其描述和前置权限",
|
||||
)
|
||||
async def list_api_operations() -> dict[str, list[dict]]:
|
||||
"""获取所有权限操作"""
|
||||
operations_list = [
|
||||
{
|
||||
"operation_key": key,
|
||||
"module": config["module"],
|
||||
"action": config["action"],
|
||||
"description": config["description"],
|
||||
"default_roles": config["default_roles"],
|
||||
"prerequisite_permissions": config.get("prerequisite_permissions", []),
|
||||
}
|
||||
for key, config in API_ENDPOINT_PERMISSIONS.items()
|
||||
]
|
||||
|
||||
return {"operations": operations_list}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/endpoints",
|
||||
summary="获取系统中所有已注册的API端点",
|
||||
description="返回系统中所有已注册的API端点及其元数据",
|
||||
)
|
||||
async def list_api_endpoints(
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||
) -> dict[str, list[dict]]:
|
||||
"""获取所有已注册的API端点"""
|
||||
result = await db.execute(select(ApiEndpointRegistry))
|
||||
endpoints = result.scalars().all()
|
||||
|
||||
endpoints_list = [
|
||||
{
|
||||
"endpoint_key": ep.endpoint_key,
|
||||
"method": ep.method,
|
||||
"path": ep.path,
|
||||
"module": ep.module,
|
||||
"action": ep.action,
|
||||
"description": ep.description,
|
||||
"default_roles": ep.default_roles.split(",") if ep.default_roles else [],
|
||||
}
|
||||
for ep in endpoints
|
||||
]
|
||||
|
||||
return {"endpoints": endpoints_list}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/operations/prerequisites",
|
||||
summary="获取所有权限操作的前置权限依赖",
|
||||
description="返回系统中所有权限操作及其前置权限依赖关系",
|
||||
)
|
||||
async def list_operation_prerequisites() -> dict[str, dict]:
|
||||
"""获取所有权限操作的前置权限依赖"""
|
||||
prerequisites_map = {}
|
||||
for operation_key, prerequisites in OPERATION_PREREQUISITES.items():
|
||||
prerequisites_map[operation_key] = {
|
||||
"prerequisites": prerequisites,
|
||||
"description": API_ENDPOINT_PERMISSIONS.get(operation_key, {}).get("description", ""),
|
||||
}
|
||||
|
||||
return {"prerequisites": prerequisites_map}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{endpoint_key}/prerequisites",
|
||||
summary="获取特定操作的缺失前置权限",
|
||||
description="检查指定角色对特定操作的前置权限是否满足",
|
||||
)
|
||||
async def check_operation_prerequisites(
|
||||
study_id: uuid.UUID,
|
||||
endpoint_key: str,
|
||||
role: str,
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||
) -> dict:
|
||||
"""检查特定操作的前置权限
|
||||
|
||||
返回格式:
|
||||
{
|
||||
"endpoint_key": "subjects:create",
|
||||
"role": "CRA",
|
||||
"has_main_permission": true,
|
||||
"prerequisites": ["sites:read"],
|
||||
"missing_prerequisites": [],
|
||||
"can_perform": true
|
||||
}
|
||||
"""
|
||||
from app.core.project_permissions import role_has_api_permission
|
||||
|
||||
if role == "ADMIN":
|
||||
return {
|
||||
"endpoint_key": endpoint_key,
|
||||
"role": role,
|
||||
"has_main_permission": True,
|
||||
"prerequisites": OPERATION_PREREQUISITES.get(endpoint_key, []),
|
||||
"missing_prerequisites": [],
|
||||
"can_perform": True,
|
||||
}
|
||||
|
||||
has_main = await role_has_api_permission(
|
||||
db, study_id, role, endpoint_key, check_prerequisites=False
|
||||
)
|
||||
missing = await get_missing_prerequisites(db, study_id, role, endpoint_key)
|
||||
|
||||
return {
|
||||
"endpoint_key": endpoint_key,
|
||||
"role": role,
|
||||
"has_main_permission": has_main,
|
||||
"prerequisites": OPERATION_PREREQUISITES.get(endpoint_key, []),
|
||||
"missing_prerequisites": missing,
|
||||
"can_perform": has_main and len(missing) == 0,
|
||||
}
|
||||
|
||||
|
||||
@study_router.get(
|
||||
"/me",
|
||||
summary="获取当前用户在项目内的有效接口权限",
|
||||
description="返回当前用户项目角色对应的有效权限,用于前端菜单和路由判断",
|
||||
response_model=None,
|
||||
)
|
||||
async def get_my_study_api_permissions(
|
||||
study_id: uuid.UUID,
|
||||
_=Depends(require_study_member()),
|
||||
current_user=Depends(get_current_user),
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)] = None,
|
||||
):
|
||||
"""获取当前用户当前项目角色的有效权限。"""
|
||||
if is_system_admin(current_user):
|
||||
return {
|
||||
"ADMIN": {
|
||||
endpoint_key: {"allowed": True}
|
||||
for endpoint_key in API_ENDPOINT_PERMISSIONS.keys()
|
||||
}
|
||||
}
|
||||
|
||||
membership = await member_crud.get_member(db, study_id, current_user.id)
|
||||
role = membership.role_in_study if membership and membership.is_active else ""
|
||||
permissions = await get_effective_api_endpoint_permissions(db, study_id)
|
||||
role_permissions = permissions.get(role, {})
|
||||
return {
|
||||
role: {
|
||||
endpoint_key: role_permissions.get(endpoint_key, {"allowed": False})
|
||||
for endpoint_key in API_ENDPOINT_PERMISSIONS.keys()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@study_router.get(
|
||||
"",
|
||||
summary="获取项目的接口级权限矩阵",
|
||||
description="返回项目中各角色对API端点的权限配置",
|
||||
response_model=None,
|
||||
)
|
||||
async def get_study_api_permissions(
|
||||
study_id: uuid.UUID,
|
||||
_=Depends(require_system_permission("system:permissions:project_config")),
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)] = None,
|
||||
):
|
||||
"""获取项目的接口级权限矩阵
|
||||
|
||||
返回格式:
|
||||
{
|
||||
"role": {
|
||||
"endpoint_key": {
|
||||
"allowed": true/false
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
permissions = await get_api_endpoint_permissions(db, study_id)
|
||||
|
||||
# 构建返回格式(get_api_endpoint_permissions 已返回 {role: {key: {"allowed": bool}}})
|
||||
result: dict[str, dict[str, dict[str, bool]]] = {}
|
||||
for role in await _get_configurable_roles(db, study_id):
|
||||
if role == "ADMIN":
|
||||
continue
|
||||
result[role] = {}
|
||||
for endpoint_key in API_ENDPOINT_PERMISSIONS.keys():
|
||||
perm = permissions.get(role, {}).get(endpoint_key, {"allowed": False})
|
||||
# perm 已经是 {"allowed": bool},直接使用
|
||||
allowed = perm["allowed"] if isinstance(perm, dict) else bool(perm)
|
||||
result[role][endpoint_key] = {"allowed": allowed}
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@study_router.put(
|
||||
"",
|
||||
summary="更新项目的接口级权限矩阵",
|
||||
description="批量更新项目中各角色对API端点的权限配置",
|
||||
status_code=status.HTTP_200_OK,
|
||||
response_model=None,
|
||||
)
|
||||
async def update_study_api_permissions(
|
||||
study_id: uuid.UUID,
|
||||
payload: dict[str, dict[str, bool]],
|
||||
confirm_prerequisite_adjustments: bool = False,
|
||||
_=Depends(require_system_permission("system:permissions:project_config")),
|
||||
current_user=Depends(get_current_user),
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)] = None,
|
||||
):
|
||||
"""更新项目的接口级权限矩阵
|
||||
|
||||
请求体格式:
|
||||
{
|
||||
"role": {
|
||||
"endpoint_key": true/false
|
||||
}
|
||||
}
|
||||
"""
|
||||
# 验证输入
|
||||
configurable_roles = set(await _get_configurable_roles(db, study_id))
|
||||
for role in payload.keys():
|
||||
if role == "ADMIN":
|
||||
continue
|
||||
if role not in configurable_roles:
|
||||
raise ValueError(f"无效的角色: {role}")
|
||||
if role == "PM" and not is_system_admin(current_user):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="仅系统管理员可修改项目负责人权限",
|
||||
)
|
||||
|
||||
# 替换权限配置
|
||||
try:
|
||||
await replace_api_endpoint_permissions(
|
||||
db,
|
||||
study_id,
|
||||
payload,
|
||||
confirm_prerequisite_adjustments=confirm_prerequisite_adjustments,
|
||||
)
|
||||
except PrerequisitePermissionConfirmationRequired as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail={
|
||||
"code": "PREREQUISITE_PERMISSION_CONFIRMATION_REQUIRED",
|
||||
"message": "需要确认前置权限调整",
|
||||
"adjustments": exc.adjustments,
|
||||
},
|
||||
) from exc
|
||||
|
||||
# 返回更新后的权限矩阵
|
||||
permissions = await get_api_endpoint_permissions(db, study_id)
|
||||
|
||||
result: dict[str, dict[str, dict[str, bool]]] = {}
|
||||
for role in await _get_configurable_roles(db, study_id):
|
||||
if role == "ADMIN":
|
||||
continue
|
||||
result[role] = {}
|
||||
for endpoint_key in API_ENDPOINT_PERMISSIONS.keys():
|
||||
perm = permissions.get(role, {}).get(endpoint_key, {"allowed": False})
|
||||
allowed = perm["allowed"] if isinstance(perm, dict) else bool(perm)
|
||||
result[role][endpoint_key] = {"allowed": allowed}
|
||||
|
||||
return result
|
||||
@@ -8,10 +8,17 @@ from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status,
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_user, get_db_session, require_study_member, get_study_member, require_study_not_locked
|
||||
from app.core.deps import get_current_user, get_db_session, get_operator_role_label, get_study_member, is_system_admin, require_study_not_locked
|
||||
from app.core.project_permissions import role_has_api_permission
|
||||
from app.crud import attachment as attachment_crud
|
||||
from app.crud import audit as audit_crud
|
||||
from app.crud import contract_fee as contract_fee_crud
|
||||
from app.crud import drug_shipment as drug_shipment_crud
|
||||
from app.crud import faq_reply as faq_reply_crud
|
||||
from app.crud import material_equipment as material_equipment_crud
|
||||
from app.crud import precaution as precaution_crud
|
||||
from app.crud import study as study_crud
|
||||
from app.crud import startup as startup_crud
|
||||
from app.crud import user as user_crud
|
||||
from app.crud import member as member_crud
|
||||
from app.core.security import decode_token
|
||||
@@ -22,7 +29,62 @@ 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"
|
||||
@@ -37,11 +99,116 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
|
||||
return study
|
||||
|
||||
|
||||
def _attachment_operation(action: str) -> str:
|
||||
if action in {"create", "read", "delete"}:
|
||||
return action
|
||||
return "read" if action == "read" else "create"
|
||||
|
||||
|
||||
async def _resolve_attachment_parent_permission(
|
||||
db: AsyncSession,
|
||||
study_id: uuid.UUID,
|
||||
entity_type: str,
|
||||
entity_id: uuid.UUID,
|
||||
action: str,
|
||||
) -> str | None:
|
||||
operation = _attachment_operation(action)
|
||||
|
||||
if entity_type in FEE_ATTACHMENT_ENTITY_TYPES:
|
||||
contract = await contract_fee_crud.get_contract_fee(db, entity_id)
|
||||
if not contract or contract.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
|
||||
return FEE_ATTACHMENT_PERMISSION_BY_ACTION[operation]
|
||||
|
||||
if entity_type == "startup_feasibility":
|
||||
record = await startup_crud.get_feasibility(db, entity_id)
|
||||
if not record or record.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="立项记录不存在")
|
||||
return STARTUP_INITIATION_ATTACHMENT_PERMISSION_BY_ACTION[operation]
|
||||
|
||||
if entity_type == "startup_ethics":
|
||||
record = await startup_crud.get_ethics(db, entity_id)
|
||||
if not record or record.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="伦理记录不存在")
|
||||
return STARTUP_ETHICS_ATTACHMENT_PERMISSION_BY_ACTION[operation]
|
||||
|
||||
if entity_type in STARTUP_AUTH_ATTACHMENT_ENTITY_TYPES:
|
||||
if entity_type == "training_authorization":
|
||||
record = await startup_crud.get_training_authorization(db, entity_id)
|
||||
missing_detail = "培训授权不存在"
|
||||
else:
|
||||
record = await startup_crud.get_kickoff(db, entity_id)
|
||||
missing_detail = "启动会不存在"
|
||||
if not record or record.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=missing_detail)
|
||||
return STARTUP_AUTH_ATTACHMENT_PERMISSION_BY_ACTION[operation]
|
||||
|
||||
if entity_type == "drug_shipment":
|
||||
shipment = await drug_shipment_crud.get_shipment(db, entity_id)
|
||||
if not shipment or shipment.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="药物发货不存在")
|
||||
return DRUG_SHIPMENT_ATTACHMENT_PERMISSION_BY_ACTION[operation]
|
||||
|
||||
if entity_type in MATERIAL_EQUIPMENT_ATTACHMENT_ENTITY_TYPES:
|
||||
equipment = await material_equipment_crud.get_equipment(db, entity_id)
|
||||
if not equipment or equipment.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="设备记录不存在")
|
||||
return MATERIAL_EQUIPMENT_ATTACHMENT_PERMISSION_BY_ACTION[operation]
|
||||
|
||||
if entity_type == "precaution":
|
||||
precaution = await precaution_crud.get_precaution(db, entity_id)
|
||||
if not precaution or precaution.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="注意事项不存在")
|
||||
return PRECAUTION_ATTACHMENT_PERMISSION_BY_ACTION[operation]
|
||||
|
||||
if entity_type == "faq_replies":
|
||||
reply = await faq_reply_crud.get_reply(db, entity_id)
|
||||
if not reply or reply.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ回复不存在")
|
||||
return FAQ_REPLY_ATTACHMENT_PERMISSION_BY_ACTION[operation]
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def _ensure_attachment_permission(
|
||||
db: AsyncSession,
|
||||
study_id: uuid.UUID,
|
||||
entity_type: str,
|
||||
entity_id: uuid.UUID,
|
||||
action: str,
|
||||
current_user,
|
||||
membership=None,
|
||||
) -> None:
|
||||
parent_permission = await _resolve_attachment_parent_permission(db, study_id, entity_type, entity_id, action)
|
||||
if is_system_admin(current_user):
|
||||
return
|
||||
|
||||
if membership is None:
|
||||
membership = await member_crud.get_member(db, study_id, current_user.id)
|
||||
if not membership or not membership.is_active:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员")
|
||||
|
||||
if not parent_permission:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="附件类型未配置权限")
|
||||
|
||||
allowed = await role_has_api_permission(
|
||||
db,
|
||||
study_id,
|
||||
membership.role_in_study,
|
||||
parent_permission,
|
||||
check_prerequisites=False,
|
||||
)
|
||||
if not allowed:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="项目权限不足")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/",
|
||||
response_model=AttachmentRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(require_study_member()), Depends(require_study_not_locked())],
|
||||
dependencies=[
|
||||
Depends(require_study_not_locked())
|
||||
],
|
||||
)
|
||||
async def upload_attachment(
|
||||
study_id: uuid.UUID,
|
||||
@@ -52,6 +219,7 @@ async def upload_attachment(
|
||||
current_user=Depends(get_current_user),
|
||||
) -> AttachmentRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
await _ensure_attachment_permission(db, study_id, entity_type, entity_id, "create", current_user)
|
||||
dest_dir = UPLOAD_ROOT / f"study_{study_id}" / f"{entity_type}_{entity_id}"
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
unique_name = f"{uuid.uuid4()}{Path(file.filename).suffix}"
|
||||
@@ -72,16 +240,17 @@ async def upload_attachment(
|
||||
content_type=file.content_type,
|
||||
uploaded_by=current_user.id,
|
||||
)
|
||||
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,
|
||||
)
|
||||
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),
|
||||
)
|
||||
return AttachmentRead(
|
||||
id=attachment.id,
|
||||
filename=attachment.filename,
|
||||
@@ -96,15 +265,16 @@ async def upload_attachment(
|
||||
@router.get(
|
||||
"/",
|
||||
response_model=list[AttachmentRead],
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def list_attachments(
|
||||
study_id: uuid.UUID,
|
||||
entity_type: str,
|
||||
entity_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> list[AttachmentRead]:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
await _ensure_attachment_permission(db, study_id, entity_type, entity_id, "read", current_user)
|
||||
attachments = await attachment_crud.list_attachments(db, study_id, entity_type, entity_id)
|
||||
user_ids = {a.uploaded_by for a in attachments if a.uploaded_by}
|
||||
users_map = await user_crud.get_users_by_ids(db, user_ids)
|
||||
@@ -127,7 +297,6 @@ async def list_attachments(
|
||||
|
||||
@router.get(
|
||||
"/{attachment_id}/download",
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def download_attachment(
|
||||
study_id: uuid.UUID,
|
||||
@@ -135,8 +304,10 @@ async def download_attachment(
|
||||
entity_id: uuid.UUID,
|
||||
attachment_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> FileResponse:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
await _ensure_attachment_permission(db, study_id, entity_type, entity_id, "read", current_user)
|
||||
attachment = await attachment_crud.get_attachment(db, attachment_id)
|
||||
if not attachment or attachment.study_id != study_id or attachment.entity_id != entity_id or attachment.entity_type != entity_type:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在")
|
||||
@@ -152,7 +323,6 @@ async def download_attachment(
|
||||
|
||||
@router.get(
|
||||
"/{attachment_id}/preview",
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def preview_attachment(
|
||||
study_id: uuid.UUID,
|
||||
@@ -160,8 +330,10 @@ async def preview_attachment(
|
||||
entity_id: uuid.UUID,
|
||||
attachment_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> FileResponse:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
await _ensure_attachment_permission(db, study_id, entity_type, entity_id, "read", current_user)
|
||||
attachment = await attachment_crud.get_attachment(db, attachment_id)
|
||||
if not attachment or attachment.study_id != study_id or attachment.entity_id != entity_id or attachment.entity_type != entity_type:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在")
|
||||
@@ -176,19 +348,16 @@ 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]
|
||||
if not token:
|
||||
token = request.query_params.get("token")
|
||||
if not token:
|
||||
else:
|
||||
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 user.role == "ADMIN":
|
||||
if is_system_admin(user):
|
||||
return user, None
|
||||
membership = await member_crud.get_member(db, study_id, user.id)
|
||||
if not membership or not membership.is_active:
|
||||
@@ -209,7 +378,10 @@ async def global_download_attachment(
|
||||
if not attachment:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在")
|
||||
await _ensure_study_exists(db, attachment.study_id)
|
||||
user, _ = await _authorize_global(request, db, attachment.study_id)
|
||||
user, membership = await _authorize_global(request, db, attachment.study_id)
|
||||
await _ensure_attachment_permission(
|
||||
db, attachment.study_id, attachment.entity_type, attachment.entity_id, "read", user, membership
|
||||
)
|
||||
if not os.path.exists(attachment.file_path):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="服务器未找到文件")
|
||||
return FileResponse(
|
||||
@@ -233,7 +405,10 @@ async def global_preview_attachment(
|
||||
if not attachment:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在")
|
||||
await _ensure_study_exists(db, attachment.study_id)
|
||||
await _authorize_global(request, db, attachment.study_id)
|
||||
user, membership = await _authorize_global(request, db, attachment.study_id)
|
||||
await _ensure_attachment_permission(
|
||||
db, attachment.study_id, attachment.entity_type, attachment.entity_id, "read", user, membership
|
||||
)
|
||||
if not os.path.exists(attachment.file_path):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="服务器未找到文件")
|
||||
return FileResponse(
|
||||
@@ -258,30 +433,36 @@ async def global_delete_attachment(
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在")
|
||||
await _ensure_study_exists(db, attachment.study_id)
|
||||
user, membership = await _authorize_global(request, db, attachment.study_id)
|
||||
await _ensure_attachment_permission(
|
||||
db, attachment.study_id, attachment.entity_type, attachment.entity_id, "delete", user, membership
|
||||
)
|
||||
can_delete = (
|
||||
user.role == "ADMIN"
|
||||
is_system_admin(user)
|
||||
or attachment.uploaded_by == user.id
|
||||
or (membership and getattr(membership, "role_in_study", None) == "PM")
|
||||
or membership is not None
|
||||
)
|
||||
if not can_delete:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限删除附件")
|
||||
await attachment_crud.soft_delete_attachment(db, attachment)
|
||||
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,
|
||||
)
|
||||
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),
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{attachment_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(require_study_member()), Depends(require_study_not_locked())],
|
||||
dependencies=[
|
||||
Depends(require_study_not_locked())
|
||||
],
|
||||
)
|
||||
async def delete_attachment(
|
||||
study_id: uuid.UUID,
|
||||
@@ -292,6 +473,7 @@ async def delete_attachment(
|
||||
current_user=Depends(get_current_user),
|
||||
):
|
||||
await _ensure_study_exists(db, study_id)
|
||||
await _ensure_attachment_permission(db, study_id, entity_type, entity_id, "delete", current_user)
|
||||
attachment = await attachment_crud.get_attachment(db, attachment_id)
|
||||
if (
|
||||
not attachment
|
||||
@@ -303,25 +485,26 @@ async def delete_attachment(
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在")
|
||||
|
||||
membership = None
|
||||
if current_user.role != "ADMIN":
|
||||
if not is_system_admin(current_user):
|
||||
membership = await get_study_member(study_id, current_user=current_user, db=db)
|
||||
|
||||
can_delete = (
|
||||
current_user.role == "ADMIN"
|
||||
is_system_admin(current_user)
|
||||
or attachment.uploaded_by == current_user.id
|
||||
or (membership and getattr(membership, "role_in_study", None) == "PM")
|
||||
or membership is not None
|
||||
)
|
||||
if not can_delete:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限删除附件")
|
||||
|
||||
await attachment_crud.soft_delete_attachment(db, attachment)
|
||||
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,
|
||||
)
|
||||
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),
|
||||
)
|
||||
|
||||
@@ -1,19 +1,43 @@
|
||||
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_current_user, get_db_session, require_roles, require_study_member
|
||||
from app.core.deps import get_operator_role_label, get_current_user, get_db_session, require_api_permission
|
||||
from app.crud import audit as audit_crud
|
||||
from app.crud import study as study_crud
|
||||
from app.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_study_member())],
|
||||
dependencies=[Depends(require_api_permission("audit_logs:read"))],
|
||||
)
|
||||
async def list_audit_logs(
|
||||
study_id: uuid.UUID,
|
||||
@@ -21,6 +45,10 @@ 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),
|
||||
@@ -32,36 +60,61 @@ 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,
|
||||
)
|
||||
return list(logs)
|
||||
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()}
|
||||
|
||||
|
||||
@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)
|
||||
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.post(
|
||||
"/events",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def create_audit_event(
|
||||
study_id: uuid.UUID,
|
||||
payload: AuditEventCreate,
|
||||
_export_permission=Depends(require_api_permission("audit_logs:export")),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
):
|
||||
@@ -71,11 +124,11 @@ async def create_audit_event(
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type=payload.entity_type or "audit_log",
|
||||
entity_id=payload.entity_id,
|
||||
entity_type="audit_log",
|
||||
entity_id=None,
|
||||
action=payload.action,
|
||||
detail=payload.detail,
|
||||
detail=await _build_export_audit_detail(db, study_id, payload.action),
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
+312
-59
@@ -1,5 +1,6 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from dataclasses import dataclass
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
|
||||
from fastapi import File, UploadFile
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -7,57 +8,159 @@ from pathlib import Path
|
||||
import uuid
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.security import create_access_token, decode_token_allow_expired, oauth2_scheme, verify_password
|
||||
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.deps import get_current_user, get_db_session
|
||||
from app.crud import user as user_crud
|
||||
from app.models.user import UserRole, UserStatus
|
||||
from app.models.user import UserStatus
|
||||
from app.schemas.email_settings import (
|
||||
EmailCodeResponse,
|
||||
EmailCodeVerifyResponse,
|
||||
PasswordResetCodeVerifyRequest,
|
||||
PasswordResetCodeVerifyResponse,
|
||||
PasswordResetLinkSendRequest,
|
||||
PasswordResetRequest,
|
||||
PasswordResetTokenRequest,
|
||||
RegisterEmailCodeSendRequest,
|
||||
RegisterEmailCodeVerifyRequest,
|
||||
)
|
||||
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):
|
||||
email: EmailStr
|
||||
key_id: str = Field(min_length=1)
|
||||
challenge: str = Field(min_length=16)
|
||||
ciphertext: str = Field(min_length=1)
|
||||
|
||||
|
||||
class DevLoginRequest(BaseModel):
|
||||
email: str = Field(min_length=1)
|
||||
password: str = Field(min_length=1)
|
||||
|
||||
|
||||
class LoginKeyResponse(BaseModel):
|
||||
key_id: str
|
||||
public_key: str
|
||||
challenge: str
|
||||
expires_at: datetime
|
||||
|
||||
|
||||
class ExtendResponse(BaseModel):
|
||||
accessToken: str
|
||||
expiresAt: datetime
|
||||
|
||||
|
||||
class UnlockRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str = Field(min_length=1)
|
||||
class EmailAvailabilityResponse(BaseModel):
|
||||
available: bool
|
||||
|
||||
|
||||
class UnlockResponse(BaseModel):
|
||||
accessToken: str
|
||||
expiresAt: datetime
|
||||
class EmailDomainsResponse(BaseModel):
|
||||
items: list[str]
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
AVATAR_ROOT = Path(__file__).resolve().parent.parent.parent / "uploads" / "avatars"
|
||||
AVATAR_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
AVATAR_ALLOWED_CONTENT_TYPES = {
|
||||
"image/png": ".png",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/gif": ".gif",
|
||||
"image/webp": ".webp",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/register", status_code=status.HTTP_201_CREATED)
|
||||
async def register(
|
||||
payload: UserRegisterRequest,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
if payload.role == UserRole.ADMIN.value:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不允许注册管理员账号")
|
||||
existing = await user_crud.get_by_email(db, payload.email)
|
||||
if existing:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="邮箱已注册")
|
||||
await user_crud.create_pending_user(db, payload)
|
||||
return {"message": "注册成功,等待管理员审核"}
|
||||
@dataclass(frozen=True)
|
||||
class SessionPolicy:
|
||||
access_minutes: int
|
||||
absolute_max_seconds: int
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
async def login_for_access_token(
|
||||
payload: LoginRequest, db: AsyncSession = Depends(get_db_session)
|
||||
) -> Token:
|
||||
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):
|
||||
db_user = await user_crud.get_by_email(db, payload.email)
|
||||
if not db_user:
|
||||
raise HTTPException(
|
||||
@@ -69,20 +172,146 @@ async def login_for_access_token(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="密码错误",
|
||||
)
|
||||
return db_user
|
||||
|
||||
|
||||
def ensure_user_active(db_user) -> None:
|
||||
if db_user.status != UserStatus.ACTIVE:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="账号未审核或不可用",
|
||||
)
|
||||
|
||||
session_start = datetime.now(timezone.utc)
|
||||
access_token = create_access_token(
|
||||
user_id=str(db_user.id),
|
||||
role=db_user.role.value if hasattr(db_user.role, "value") else db_user.role,
|
||||
expires_minutes=None,
|
||||
session_start=session_start,
|
||||
|
||||
@router.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,
|
||||
)
|
||||
return Token(access_token=access_token, token_type="bearer")
|
||||
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)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserRead)
|
||||
@@ -111,44 +340,61 @@ 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:
|
||||
max_seconds = settings.ABSOLUTE_SESSION_MAX_HOURS * 3600
|
||||
if now_ts - int(session_start_ts) > max_seconds:
|
||||
if now_ts - int(session_start_ts) > policy.absolute_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),
|
||||
role=db_user.role.value if hasattr(db_user.role, "value") else db_user.role,
|
||||
expires_minutes=None,
|
||||
expires_minutes=policy.access_minutes,
|
||||
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 = datetime.now(timezone.utc) + timedelta(minutes=settings.JWT_EXPIRE_MINUTES)
|
||||
expires_at = policy_expires_at(issued_at, session_start, policy)
|
||||
return ExtendResponse(accessToken=new_token, expiresAt=expires_at)
|
||||
|
||||
|
||||
@router.post("/unlock", response_model=UnlockResponse)
|
||||
async def unlock_session(
|
||||
payload: UnlockRequest,
|
||||
@router.post("/session/heartbeat")
|
||||
async def heartbeat_login_session(
|
||||
request: Request,
|
||||
token: str = Depends(oauth2_scheme),
|
||||
current_user=Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> UnlockResponse:
|
||||
db_user = await user_crud.get_by_email(db, payload.email)
|
||||
if not db_user:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="账号不存在")
|
||||
if not verify_password(payload.password, db_user.password_hash):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="密码错误")
|
||||
if db_user.status != UserStatus.ACTIVE:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已停用")
|
||||
session_start = datetime.now(timezone.utc)
|
||||
access_token = create_access_token(
|
||||
user_id=str(db_user.id),
|
||||
role=db_user.role.value if hasattr(db_user.role, "value") else db_user.role,
|
||||
expires_minutes=None,
|
||||
session_start=session_start,
|
||||
) -> dict:
|
||||
session = await touch_login_session(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
payload=decode_token(token),
|
||||
request=request,
|
||||
)
|
||||
expires_at = session_start + timedelta(minutes=settings.JWT_EXPIRE_MINUTES)
|
||||
return UnlockResponse(accessToken=access_token, expiresAt=expires_at)
|
||||
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)
|
||||
|
||||
|
||||
@router.patch("/me", response_model=UserRead)
|
||||
@@ -162,7 +408,9 @@ async def update_me(
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="当前密码不正确")
|
||||
update_data = {
|
||||
"full_name": payload.full_name if payload.full_name is not None else current_user.full_name,
|
||||
"department": payload.department if payload.department is not None else current_user.department,
|
||||
"clinical_department": (
|
||||
payload.clinical_department if payload.clinical_department is not None else current_user.clinical_department
|
||||
),
|
||||
"password": payload.password if payload.password else None,
|
||||
"avatar_url": payload.avatar_url if payload.avatar_url is not None else current_user.avatar_url,
|
||||
}
|
||||
@@ -176,10 +424,15 @@ async def upload_avatar(
|
||||
current_user=Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> UserRead:
|
||||
ext = AVATAR_ALLOWED_CONTENT_TYPES.get(file.content_type or "")
|
||||
if not ext:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="头像仅支持图片格式",
|
||||
)
|
||||
AVATAR_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
user_dir = AVATAR_ROOT / str(current_user.id)
|
||||
user_dir.mkdir(parents=True, exist_ok=True)
|
||||
ext = Path(file.filename).suffix or ".png"
|
||||
filename = f"{uuid.uuid4()}{ext}"
|
||||
dest = user_dir / filename
|
||||
content = await file.read()
|
||||
|
||||
@@ -0,0 +1,629 @@
|
||||
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)
|
||||
@@ -3,7 +3,9 @@ from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_member
|
||||
from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, is_system_admin, require_api_permission
|
||||
from app.core.project_permissions import role_has_api_permission
|
||||
from app.crud import member as member_crud
|
||||
from app.models.milestone import Milestone
|
||||
from app.schemas.progress import StudyProgressRead
|
||||
from app.schemas.visit import VisitLostItem
|
||||
@@ -14,9 +16,10 @@ from app.crud import overview as overview_crud
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/progress", response_model=StudyProgressRead, dependencies=[Depends(require_study_member())])
|
||||
@router.get("/progress", response_model=StudyProgressRead)
|
||||
async def get_progress(
|
||||
study_id: uuid.UUID,
|
||||
_=Depends(require_api_permission("project_overview:read")),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> StudyProgressRead:
|
||||
milestone_total_stmt = select(func.count()).select_from(Milestone).where(Milestone.study_id == study_id)
|
||||
@@ -36,10 +39,11 @@ async def get_progress(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/lost-visits", response_model=list[VisitLostItem], dependencies=[Depends(require_study_member())])
|
||||
@router.get("/lost-visits", response_model=list[VisitLostItem])
|
||||
async def list_lost_visits(
|
||||
study_id: uuid.UUID,
|
||||
limit: int = 20,
|
||||
_=Depends(require_api_permission("project_overview:read")),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> list[VisitLostItem]:
|
||||
@@ -62,15 +66,18 @@ async def list_lost_visits(
|
||||
return items
|
||||
|
||||
|
||||
@router.get("/center-summary", response_model=list[CenterSummaryItem], dependencies=[Depends(require_study_member())])
|
||||
@router.get("/center-summary", response_model=list[CenterSummaryItem])
|
||||
async def get_center_summary(
|
||||
study_id: uuid.UUID,
|
||||
_=Depends(require_api_permission("project_overview:read")),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> list[CenterSummaryItem]:
|
||||
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
|
||||
if role_value not in {"ADMIN", "PM", "CRA"}:
|
||||
return []
|
||||
membership = None
|
||||
if not is_system_admin(current_user):
|
||||
membership = await member_crud.get_member(db, study_id, current_user.id)
|
||||
if not membership or not membership.is_active:
|
||||
return []
|
||||
cra_scope = await get_cra_site_scope(db, study_id, current_user)
|
||||
scope_ids = cra_scope[0] if cra_scope else None
|
||||
scope_id_strs = {str(cid) for cid in scope_ids} if scope_ids is not None else None
|
||||
@@ -79,7 +86,7 @@ async def get_center_summary(
|
||||
|
||||
stage_order = [
|
||||
("institution_initiation_status", "机构立项"),
|
||||
("ethics_status", "伦理审批"),
|
||||
("ethics_status", "伦理记录"),
|
||||
("contract_sign_status", "合同签署"),
|
||||
("startup_status", "启动"),
|
||||
("enrollment_status", "入组"),
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
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
|
||||
)
|
||||
@@ -1,7 +1,7 @@
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile, status
|
||||
from fastapi import APIRouter, Depends, File, Form, Query, UploadFile, status
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -9,9 +9,8 @@ from app.core.deps import get_current_user, get_db_session
|
||||
from app.schemas.acknowledgement import AcknowledgementCreate, AcknowledgementRead
|
||||
from app.schemas.common import PaginatedResponse
|
||||
from app.schemas.distribution import DistributionCreate, DistributionRead
|
||||
from app.schemas.document import DocumentCreate, DocumentDetail, DocumentSummary
|
||||
from app.schemas.document import DocumentCreate, DocumentDetail, DocumentSummary, DocumentUpdate
|
||||
from app.schemas.document_version import DocumentVersionRead
|
||||
from app.models.user import UserRole
|
||||
from app.services import document_service
|
||||
from app.utils.pagination import paginate
|
||||
|
||||
@@ -67,15 +66,23 @@ async def get_document_detail(
|
||||
return await document_service.get_document_detail(db, document_id, current_user)
|
||||
|
||||
|
||||
@router.patch("/documents/{document_id}", response_model=DocumentSummary)
|
||||
async def update_document(
|
||||
document_id: uuid.UUID,
|
||||
payload: DocumentUpdate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> DocumentSummary:
|
||||
doc = await document_service.update_document(db, document_id, payload, current_user)
|
||||
return DocumentSummary.model_validate(doc)
|
||||
|
||||
|
||||
@router.delete("/documents/{document_id}", response_model=DocumentSummary)
|
||||
async def delete_document(
|
||||
document_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> DocumentSummary:
|
||||
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
|
||||
if role_value != UserRole.ADMIN.value:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="仅管理员可删除文档")
|
||||
doc = await document_service.delete_document(db, document_id, current_user)
|
||||
return DocumentSummary.model_validate(doc)
|
||||
|
||||
@@ -123,9 +130,6 @@ async def delete_version(
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> None:
|
||||
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
|
||||
if role_value != UserRole.ADMIN.value:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="仅管理员可删除版本")
|
||||
await document_service.delete_version(db, version_id, current_user)
|
||||
|
||||
|
||||
|
||||
@@ -1,18 +1,33 @@
|
||||
import uuid
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_member, require_study_roles, require_study_not_locked
|
||||
from app.core.deps import get_operator_role_label, get_cra_site_scope, get_current_user, get_db_session, require_study_not_locked, require_api_permission
|
||||
from app.crud import audit as audit_crud
|
||||
from app.crud import drug_shipment as shipment_crud
|
||||
from app.crud import site as site_crud
|
||||
from app.crud import study as study_crud
|
||||
from app.schemas.drug_shipment import DrugShipmentCreate, DrugShipmentRead, DrugShipmentUpdate
|
||||
from app.schemas.drug_shipment import (
|
||||
DrugShipmentCreate,
|
||||
DrugShipmentRead,
|
||||
DrugShipmentUpdate,
|
||||
validate_drug_shipment_required_fields,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _shipment_audit_name(shipment) -> str:
|
||||
return shipment.tracking_no or shipment.batch_no or shipment.site_name or "药品运输记录"
|
||||
|
||||
|
||||
def _shipment_audit_description(action: str, shipment) -> str:
|
||||
name = _shipment_audit_name(shipment)
|
||||
return f"{action}药品流向“{name}”"
|
||||
|
||||
|
||||
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
|
||||
study = await study_crud.get(db, study_id)
|
||||
if not study:
|
||||
@@ -33,7 +48,7 @@ async def _ensure_center_active(db: AsyncSession, study_id: uuid.UUID, center_id
|
||||
"/shipments",
|
||||
response_model=DrugShipmentRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
|
||||
dependencies=[Depends(require_api_permission("drug_shipments:create")), Depends(require_study_not_locked())],
|
||||
)
|
||||
async def create_shipment(
|
||||
study_id: uuid.UUID,
|
||||
@@ -54,9 +69,12 @@ async def create_shipment(
|
||||
entity_type="drug_shipment",
|
||||
entity_id=shipment.id,
|
||||
action="CREATE_DRUG_SHIPMENT",
|
||||
detail=f"药品运输 {shipment.id} 已创建",
|
||||
detail=json.dumps(
|
||||
{"targetName": _shipment_audit_name(shipment), "description": _shipment_audit_description("创建", shipment)},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
)
|
||||
return DrugShipmentRead.model_validate(shipment)
|
||||
|
||||
@@ -64,7 +82,7 @@ async def create_shipment(
|
||||
@router.get(
|
||||
"/shipments",
|
||||
response_model=list[DrugShipmentRead],
|
||||
dependencies=[Depends(require_study_member())],
|
||||
dependencies=[Depends(require_api_permission("drug_shipments:read"))],
|
||||
)
|
||||
async def list_shipments(
|
||||
study_id: uuid.UUID,
|
||||
@@ -101,7 +119,7 @@ async def list_shipments(
|
||||
@router.get(
|
||||
"/shipments/{shipment_id}",
|
||||
response_model=DrugShipmentRead,
|
||||
dependencies=[Depends(require_study_member())],
|
||||
dependencies=[Depends(require_api_permission("drug_shipments:read"))],
|
||||
)
|
||||
async def get_shipment(
|
||||
study_id: uuid.UUID,
|
||||
@@ -122,7 +140,7 @@ async def get_shipment(
|
||||
@router.patch(
|
||||
"/shipments/{shipment_id}",
|
||||
response_model=DrugShipmentRead,
|
||||
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
|
||||
dependencies=[Depends(require_api_permission("drug_shipments:update")), Depends(require_study_not_locked())],
|
||||
)
|
||||
async def update_shipment(
|
||||
study_id: uuid.UUID,
|
||||
@@ -143,6 +161,20 @@ async def update_shipment(
|
||||
shipment_in = shipment_in.model_copy(update={"site_name": site.name})
|
||||
else:
|
||||
await _ensure_center_active(db, study_id, shipment.center_id)
|
||||
update_data = shipment_in.model_dump(exclude_unset=True)
|
||||
try:
|
||||
validate_drug_shipment_required_fields(
|
||||
update_data.get("status", shipment.status),
|
||||
update_data.get("ship_date", shipment.ship_date),
|
||||
update_data.get("receive_date", shipment.receive_date),
|
||||
update_data.get("quantity", shipment.quantity),
|
||||
update_data.get("batch_no", shipment.batch_no),
|
||||
update_data.get("carrier", shipment.carrier),
|
||||
update_data.get("tracking_no", shipment.tracking_no),
|
||||
update_data.get("remark", shipment.remark),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
shipment = await shipment_crud.update_shipment(db, shipment, shipment_in)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
@@ -150,9 +182,12 @@ async def update_shipment(
|
||||
entity_type="drug_shipment",
|
||||
entity_id=shipment_id,
|
||||
action="UPDATE_DRUG_SHIPMENT",
|
||||
detail=f"药品运输 {shipment_id} 已更新",
|
||||
detail=json.dumps(
|
||||
{"targetName": _shipment_audit_name(shipment), "description": _shipment_audit_description("更新", shipment)},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
)
|
||||
return DrugShipmentRead.model_validate(shipment)
|
||||
|
||||
@@ -160,7 +195,7 @@ async def update_shipment(
|
||||
@router.delete(
|
||||
"/shipments/{shipment_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
|
||||
dependencies=[Depends(require_api_permission("drug_shipments:delete")), Depends(require_study_not_locked())],
|
||||
)
|
||||
async def delete_shipment(
|
||||
study_id: uuid.UUID,
|
||||
@@ -176,6 +211,7 @@ async def delete_shipment(
|
||||
if cra_scope and shipment.center_id not in cra_scope[0]:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
|
||||
await _ensure_center_active(db, study_id, shipment.center_id)
|
||||
shipment_name = _shipment_audit_name(shipment)
|
||||
await shipment_crud.delete_shipment(db, shipment)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
@@ -183,7 +219,7 @@ async def delete_shipment(
|
||||
entity_type="drug_shipment",
|
||||
entity_id=shipment_id,
|
||||
action="DELETE_DRUG_SHIPMENT",
|
||||
detail=f"药品运输 {shipment_id} 已删除",
|
||||
detail=json.dumps({"targetName": shipment_name, "description": f"删除药品流向“{shipment_name}”"}, ensure_ascii=False),
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_user, get_db_session
|
||||
from app.schemas.document import DocumentCreate, DocumentSummary
|
||||
from app.schemas.etmf import EtmfNodeCreate, EtmfNodeRead, EtmfNodeUpdate, EtmfTreeNode
|
||||
from app.services import etmf_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/tree", response_model=list[EtmfTreeNode])
|
||||
async def get_etmf_tree(
|
||||
study_id: uuid.UUID = Query(...),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> list[EtmfTreeNode]:
|
||||
return await etmf_service.list_etmf_tree(db, study_id=study_id, current_user=current_user)
|
||||
|
||||
|
||||
@router.post("/nodes", response_model=EtmfNodeRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_etmf_node(
|
||||
payload: EtmfNodeCreate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> EtmfNodeRead:
|
||||
node = await etmf_service.create_node(db, payload, current_user)
|
||||
return EtmfNodeRead.model_validate(node)
|
||||
|
||||
|
||||
@router.patch("/nodes/{node_id}", response_model=EtmfNodeRead)
|
||||
async def update_etmf_node(
|
||||
node_id: uuid.UUID,
|
||||
payload: EtmfNodeUpdate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> EtmfNodeRead:
|
||||
node = await etmf_service.update_node(db, node_id, payload, current_user)
|
||||
return EtmfNodeRead.model_validate(node)
|
||||
|
||||
|
||||
@router.get("/nodes/{node_id}/documents", response_model=list[DocumentSummary])
|
||||
async def list_etmf_node_documents(
|
||||
node_id: uuid.UUID,
|
||||
site_id: uuid.UUID | None = Query(None),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> list[DocumentSummary]:
|
||||
return await etmf_service.list_node_documents(db, node_id=node_id, site_id=site_id, current_user=current_user)
|
||||
|
||||
|
||||
@router.post("/nodes/{node_id}/documents", response_model=DocumentSummary, status_code=status.HTTP_201_CREATED)
|
||||
async def create_etmf_node_document(
|
||||
node_id: uuid.UUID,
|
||||
payload: DocumentCreate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> DocumentSummary:
|
||||
return await etmf_service.create_node_document(db, node_id=node_id, payload=payload, current_user=current_user)
|
||||
@@ -3,11 +3,9 @@ import uuid
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_user, get_db_session, require_study_not_locked
|
||||
from app.crud import audit as audit_crud
|
||||
from app.core.deps import get_current_user, get_db_session, is_system_admin, require_study_not_locked, require_api_permission
|
||||
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
|
||||
@@ -15,9 +13,8 @@ from app.utils.pagination import paginate
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _check_permission_for_scope(study_id: uuid.UUID, current_user, member_role: str | None):
|
||||
if current_user.role != "ADMIN" and member_role != "PM":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
|
||||
def _is_system_admin(current_user) -> bool:
|
||||
return is_system_admin(current_user)
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -25,8 +22,11 @@ def _check_permission_for_scope(study_id: uuid.UUID, current_user, member_role:
|
||||
response_model=CategoryRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="创建 FAQ 分类",
|
||||
description="创建全局或项目内 FAQ 分类,项目 PM/ADMIN 可用,全局仅 ADMIN。",
|
||||
dependencies=[Depends(require_study_not_locked())],
|
||||
description="创建项目内 FAQ 分类,权限由项目级权限矩阵控制。",
|
||||
dependencies=[
|
||||
Depends(require_api_permission("faq_category:create")),
|
||||
Depends(require_study_not_locked())
|
||||
],
|
||||
)
|
||||
async def create_category(
|
||||
payload: CategoryCreate,
|
||||
@@ -38,23 +38,11 @@ async def create_category(
|
||||
existing = await category_crud.get_category_by_name(db, payload.study_id, payload.name)
|
||||
if existing:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类名称已存在")
|
||||
member_role = None
|
||||
member = await member_crud.get_member(db, payload.study_id, current_user.id)
|
||||
if not member and current_user.role != "ADMIN":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是该项目成员")
|
||||
member_role = member.role_in_study if member else None
|
||||
_check_permission_for_scope(payload.study_id, current_user, member_role)
|
||||
if payload.icon:
|
||||
dup = await category_crud.get_category_by_icon(db, payload.study_id, payload.icon)
|
||||
if dup:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="该图标已被其他分类使用")
|
||||
category = await category_crud.create_category(db, payload)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
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)
|
||||
|
||||
|
||||
@@ -63,20 +51,16 @@ async def create_category(
|
||||
response_model=PaginatedResponse[CategoryRead],
|
||||
summary="FAQ 分类列表",
|
||||
description="返回全局及项目内 FAQ 分类列表,可按 study_id 过滤。",
|
||||
dependencies=[Depends(require_api_permission("faq_category:read"))],
|
||||
)
|
||||
async def list_categories(
|
||||
study_id: uuid.UUID | None = None,
|
||||
is_active: bool | None = True,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> list[CategoryRead]:
|
||||
if not study_id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="必须提供项目 ID")
|
||||
if study_id:
|
||||
member = await member_crud.get_member(db, study_id, current_user.id)
|
||||
if not member and current_user.role != "ADMIN":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是该项目成员")
|
||||
categories = await category_crud.list_categories(db, study_id, include_global=False, is_active=is_active)
|
||||
categories = await category_crud.list_categories(db, study_id, include_global=False)
|
||||
return paginate([CategoryRead.model_validate(c) for c in categories], total=len(categories))
|
||||
|
||||
|
||||
@@ -84,8 +68,11 @@ async def list_categories(
|
||||
"/{category_id}",
|
||||
response_model=CategoryRead,
|
||||
summary="更新 FAQ 分类",
|
||||
description="更新分类名称或启停状态,项目 PM/ADMIN 或全局 ADMIN。",
|
||||
dependencies=[Depends(require_study_not_locked())],
|
||||
description="更新分类名称或启停状态,权限由项目级权限矩阵控制。",
|
||||
dependencies=[
|
||||
Depends(require_api_permission("faq_category:update")),
|
||||
Depends(require_study_not_locked())
|
||||
],
|
||||
)
|
||||
async def update_category(
|
||||
category_id: uuid.UUID,
|
||||
@@ -96,33 +83,21 @@ async def update_category(
|
||||
category = await category_crud.get_category(db, category_id)
|
||||
if not category:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分类不存在")
|
||||
member_role = None
|
||||
update_data = payload.model_dump(exclude_unset=True)
|
||||
target_study_id = update_data.get("study_id", category.study_id)
|
||||
target_name = update_data.get("name", category.name)
|
||||
if "study_id" in update_data and update_data["study_id"] != category.study_id and current_user.role != "ADMIN":
|
||||
if "study_id" in update_data and update_data["study_id"] != category.study_id and not _is_system_admin(current_user):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="仅管理员可修改分类范围")
|
||||
existing = await category_crud.get_category_by_name(db, target_study_id, target_name)
|
||||
if existing and existing.id != category.id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类名称已存在")
|
||||
if payload.icon:
|
||||
dup = await category_crud.get_category_by_icon(db, target_study_id, payload.icon)
|
||||
if dup and dup.id != category.id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="该图标已被其他分类使用")
|
||||
if not target_study_id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="必须提供项目 ID")
|
||||
member = await member_crud.get_member(db, target_study_id, current_user.id)
|
||||
member_role = member.role_in_study if member else None
|
||||
if not member and current_user.role != "ADMIN":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是该项目成员")
|
||||
_check_permission_for_scope(target_study_id, current_user, member_role)
|
||||
updated = await category_crud.update_category(db, category, payload)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
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)
|
||||
|
||||
|
||||
@@ -130,8 +105,11 @@ async def update_category(
|
||||
"/{category_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
summary="删除 FAQ 分类",
|
||||
description="仅 ADMIN 可删除分类,分类下存在 FAQ 时不可删除。",
|
||||
dependencies=[Depends(require_study_not_locked())],
|
||||
description="删除分类,权限由项目级权限矩阵控制;分类下存在 FAQ 时不可删除。",
|
||||
dependencies=[
|
||||
Depends(require_api_permission("faq_category:delete")),
|
||||
Depends(require_study_not_locked())
|
||||
],
|
||||
)
|
||||
async def delete_category(
|
||||
category_id: uuid.UUID,
|
||||
@@ -141,20 +119,10 @@ async def delete_category(
|
||||
category = await category_crud.get_category(db, category_id)
|
||||
if not category:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分类不存在")
|
||||
if current_user.role != "ADMIN":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="仅管理员可删除 FAQ 分类")
|
||||
if not category.study_id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="必须提供项目 ID")
|
||||
item_count = await item_crud.count_items_by_category(db, category_id)
|
||||
if item_count > 0:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类下存在 FAQ,无法删除")
|
||||
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,
|
||||
)
|
||||
|
||||
+45
-147
@@ -3,8 +3,7 @@ import uuid
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_user, get_db_session, require_study_not_locked
|
||||
from app.crud import audit as audit_crud
|
||||
from app.core.deps import get_current_user, get_db_session, is_system_admin, require_study_not_locked, require_api_permission
|
||||
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
|
||||
@@ -25,14 +24,8 @@ from app.utils.pagination import paginate
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _check_write_permission(study_id: uuid.UUID, current_user, member_role: str | None):
|
||||
if current_user.role != "ADMIN" and member_role != "PM":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
|
||||
|
||||
|
||||
def _check_create_permission(current_user, is_member: bool):
|
||||
if current_user.role != "ADMIN" and not is_member:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是该项目成员")
|
||||
def _is_system_admin(current_user) -> bool:
|
||||
return is_system_admin(current_user)
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -40,8 +33,11 @@ def _check_create_permission(current_user, is_member: bool):
|
||||
response_model=FaqRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="创建 FAQ",
|
||||
description="创建全局或项目内 FAQ,项目 FAQ 需项目 PM/ADMIN 权限。",
|
||||
dependencies=[Depends(require_study_not_locked())],
|
||||
description="创建项目内 FAQ,权限由项目级权限矩阵控制。",
|
||||
dependencies=[
|
||||
Depends(require_api_permission("faq:create")),
|
||||
Depends(require_study_not_locked())
|
||||
],
|
||||
)
|
||||
async def create_faq(
|
||||
payload: FaqCreate,
|
||||
@@ -55,12 +51,6 @@ async def create_faq(
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分类不存在")
|
||||
if cat.study_id != payload.study_id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类范围不匹配")
|
||||
member_role = None
|
||||
is_member = False
|
||||
member = await member_crud.get_member(db, payload.study_id, current_user.id)
|
||||
member_role = member.role_in_study if member else None
|
||||
is_member = member is not None
|
||||
_check_create_permission(current_user, is_member)
|
||||
try:
|
||||
item = await faq_crud.create_item(db, payload, created_by=current_user.id)
|
||||
except ValueError as exc:
|
||||
@@ -74,16 +64,6 @@ 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)
|
||||
|
||||
|
||||
@@ -92,12 +72,12 @@ async def create_faq(
|
||||
response_model=PaginatedResponse[FaqRead],
|
||||
summary="FAQ 列表",
|
||||
description="返回全局与项目 FAQ,可按关键词、分类过滤。",
|
||||
dependencies=[Depends(require_api_permission("faq:read"))],
|
||||
)
|
||||
async def list_faqs(
|
||||
study_id: uuid.UUID | None = None,
|
||||
category_id: uuid.UUID | None = None,
|
||||
keyword: str | None = None,
|
||||
is_active: bool | None = None,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> list[FaqRead]:
|
||||
@@ -113,35 +93,26 @@ async def list_faqs(
|
||||
|
||||
if not study_id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="必须提供项目 ID")
|
||||
if current_user.role != "ADMIN":
|
||||
role = await _get_member_role(study_id)
|
||||
if not role:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是该项目成员")
|
||||
|
||||
if is_active is False and current_user.role != "ADMIN":
|
||||
role = await _get_member_role(study_id)
|
||||
if role != "PM":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
|
||||
if not is_system_admin(current_user):
|
||||
member = await member_crud.get_member(db, study_id, current_user.id)
|
||||
if not member or not member.is_active:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员")
|
||||
|
||||
items = await faq_crud.list_items(
|
||||
db,
|
||||
study_id=study_id,
|
||||
category_id=category_id,
|
||||
keyword=keyword,
|
||||
is_active=is_active,
|
||||
study_scope="project",
|
||||
)
|
||||
|
||||
visible: list[FaqRead] = []
|
||||
for it in items:
|
||||
if current_user.role != "ADMIN":
|
||||
if not _is_system_admin(current_user):
|
||||
role = await _get_member_role(it.study_id)
|
||||
if not role:
|
||||
continue
|
||||
if not it.is_active and current_user.role != "ADMIN":
|
||||
role = await _get_member_role(it.study_id)
|
||||
if role != "PM":
|
||||
continue
|
||||
visible.append(FaqRead.model_validate(it))
|
||||
return paginate(visible, total=len(visible))
|
||||
|
||||
@@ -150,7 +121,8 @@ async def list_faqs(
|
||||
"/{item_id}",
|
||||
response_model=FaqRead,
|
||||
summary="FAQ 详情",
|
||||
description="获取单条 FAQ,非 PM 不能查看停用 FAQ。",
|
||||
description="获取单条 FAQ,停用 FAQ 需项目级写权限。",
|
||||
dependencies=[Depends(require_api_permission("faq:read"))],
|
||||
)
|
||||
async def get_faq(
|
||||
item_id: uuid.UUID,
|
||||
@@ -162,15 +134,6 @@ async def get_faq(
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
|
||||
if not item.study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
|
||||
if current_user.role != "ADMIN":
|
||||
member = await member_crud.get_member(db, item.study_id, current_user.id)
|
||||
if not member:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是该项目成员")
|
||||
if not item.is_active and current_user.role not in {"ADMIN"}:
|
||||
member = await member_crud.get_member(db, item.study_id, current_user.id)
|
||||
member_role = member.role_in_study if member else None
|
||||
if member_role != "PM":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="FAQ 已停用")
|
||||
return FaqRead.model_validate(item)
|
||||
|
||||
|
||||
@@ -178,8 +141,11 @@ async def get_faq(
|
||||
"/{item_id}",
|
||||
response_model=FaqRead,
|
||||
summary="更新 FAQ",
|
||||
description="更新 FAQ 内容或启停状态,项目内需 PM/ADMIN 权限。",
|
||||
dependencies=[Depends(require_study_not_locked())],
|
||||
description="更新 FAQ 内容或启停状态,权限由项目级权限矩阵控制。",
|
||||
dependencies=[
|
||||
Depends(require_api_permission("faq:update")),
|
||||
Depends(require_study_not_locked())
|
||||
],
|
||||
)
|
||||
async def update_faq(
|
||||
item_id: uuid.UUID,
|
||||
@@ -190,29 +156,7 @@ 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)
|
||||
|
||||
|
||||
@@ -220,8 +164,11 @@ async def update_faq(
|
||||
"/{item_id}/status",
|
||||
response_model=FaqRead,
|
||||
summary="更新 FAQ 状态",
|
||||
description="提问者或项目 PM/ADMIN 可确认已解决。",
|
||||
dependencies=[Depends(require_study_not_locked())],
|
||||
description="提问者或具备项目级写权限的成员可确认已解决。",
|
||||
dependencies=[
|
||||
Depends(require_api_permission("faq:update")),
|
||||
Depends(require_study_not_locked())
|
||||
],
|
||||
)
|
||||
async def update_faq_status(
|
||||
item_id: uuid.UUID,
|
||||
@@ -234,13 +181,6 @@ async def update_faq_status(
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
|
||||
if payload.status != "RESOLVED":
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅允许设置为已解决")
|
||||
if item.created_by != current_user.id and current_user.role != "ADMIN":
|
||||
member_role = None
|
||||
if item.study_id:
|
||||
member = await member_crud.get_member(db, item.study_id, current_user.id)
|
||||
member_role = member.role_in_study if member else None
|
||||
if member_role != "PM":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
|
||||
await faq_crud.set_status(db, item.id, "RESOLVED", resolved_by_confirm=True)
|
||||
updated = await faq_crud.get_item(db, item_id)
|
||||
return FaqRead.model_validate(updated)
|
||||
@@ -251,7 +191,10 @@ async def update_faq_status(
|
||||
response_model=FaqRead,
|
||||
summary="设置最佳回复",
|
||||
description="项目成员可设置最佳回复,全局仅 ADMIN。",
|
||||
dependencies=[Depends(require_study_not_locked())],
|
||||
dependencies=[
|
||||
Depends(require_api_permission("faq:update")),
|
||||
Depends(require_study_not_locked())
|
||||
],
|
||||
)
|
||||
async def set_best_reply(
|
||||
item_id: uuid.UUID,
|
||||
@@ -264,10 +207,6 @@ async def set_best_reply(
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
|
||||
if not item.study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
|
||||
is_member = False
|
||||
member = await member_crud.get_member(db, item.study_id, current_user.id)
|
||||
is_member = member is not None
|
||||
_check_create_permission(current_user, is_member)
|
||||
if payload.best_reply_id:
|
||||
reply = await reply_crud.get_reply(db, payload.best_reply_id)
|
||||
if not reply or reply.faq_id != item.id:
|
||||
@@ -295,6 +234,7 @@ async def set_best_reply(
|
||||
response_model=PaginatedResponse[FaqReplyRead],
|
||||
summary="FAQ 回复列表",
|
||||
description="获取 FAQ 的回复列表。",
|
||||
dependencies=[Depends(require_api_permission("faq:read"))],
|
||||
)
|
||||
async def list_replies(
|
||||
item_id: uuid.UUID,
|
||||
@@ -304,10 +244,6 @@ async def list_replies(
|
||||
item = await faq_crud.get_item(db, item_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
|
||||
if item.study_id and current_user.role != "ADMIN":
|
||||
member = await member_crud.get_member(db, item.study_id, current_user.id)
|
||||
if not member:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是该项目成员")
|
||||
replies = await reply_crud.list_replies(db, item_id)
|
||||
reply_map = {r.id: r for r in replies}
|
||||
result: list[FaqReplyRead] = []
|
||||
@@ -334,7 +270,10 @@ async def list_replies(
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="创建 FAQ 回复",
|
||||
description="回复 FAQ,项目内成员可回复,全局仅 ADMIN。",
|
||||
dependencies=[Depends(require_study_not_locked())],
|
||||
dependencies=[
|
||||
Depends(require_api_permission("faq_reply:create")),
|
||||
Depends(require_study_not_locked())
|
||||
],
|
||||
)
|
||||
async def create_reply(
|
||||
item_id: uuid.UUID,
|
||||
@@ -349,10 +288,6 @@ async def create_reply(
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
|
||||
if not payload.content.strip():
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="回复内容不能为空")
|
||||
is_member = False
|
||||
member = await member_crud.get_member(db, item.study_id, current_user.id)
|
||||
is_member = member is not None
|
||||
_check_create_permission(current_user, is_member)
|
||||
quote = None
|
||||
if payload.quote_reply_id:
|
||||
quote = await reply_crud.get_reply(db, payload.quote_reply_id)
|
||||
@@ -368,16 +303,6 @@ 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:
|
||||
@@ -396,8 +321,11 @@ async def create_reply(
|
||||
"/{item_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
summary="删除 FAQ",
|
||||
description="删除 FAQ,项目内需 PM/ADMIN 权限,全局仅 ADMIN。",
|
||||
dependencies=[Depends(require_study_not_locked())],
|
||||
description="删除 FAQ,权限由项目级权限矩阵控制。",
|
||||
dependencies=[
|
||||
Depends(require_api_permission("faq:delete")),
|
||||
Depends(require_study_not_locked())
|
||||
],
|
||||
)
|
||||
async def delete_faq(
|
||||
item_id: uuid.UUID,
|
||||
@@ -407,33 +335,20 @@ 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 回复,管理员、项目 PM 或回复者可删除。",
|
||||
dependencies=[Depends(require_study_not_locked())],
|
||||
description="删除 FAQ 回复,回复者或具备项目级写权限的成员可删除。",
|
||||
dependencies=[
|
||||
Depends(require_api_permission("faq_reply:delete")),
|
||||
Depends(require_study_not_locked())
|
||||
],
|
||||
)
|
||||
async def delete_reply(
|
||||
item_id: uuid.UUID,
|
||||
@@ -447,13 +362,6 @@ 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)
|
||||
@@ -473,13 +381,3 @@ 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,
|
||||
)
|
||||
|
||||
@@ -1,255 +0,0 @@
|
||||
import os
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import aiofiles
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status, Request, Form
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_user, get_db_session, require_study_not_locked
|
||||
from app.core.security import decode_token
|
||||
from app.crud import audit as audit_crud
|
||||
from app.crud import contract_fee as contract_fee_crud
|
||||
from app.crud import contract_fee_payment as payment_crud
|
||||
from app.crud import fee_attachment as fee_attachment_crud
|
||||
from app.crud import member as member_crud
|
||||
from app.crud import special_expense as special_crud
|
||||
from app.crud import user as user_crud
|
||||
from app.schemas.fee_attachment import FeeAttachmentRead
|
||||
from app.schemas.fee_common import FeeApiResponse
|
||||
from app.schemas.user import UserDisplay
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
UPLOAD_ROOT = Path(__file__).resolve().parent.parent.parent / "uploads" / "fees"
|
||||
|
||||
ALLOWED_ENTITY_TYPES = {"contract_fee", "contract_payment", "special_expense"}
|
||||
ALLOWED_FILE_TYPES = {
|
||||
"contract_fee": {"contract", "voucher", "invoice"},
|
||||
"contract_payment": {"voucher", "invoice"},
|
||||
"special_expense": {"voucher", "invoice", "other"},
|
||||
}
|
||||
|
||||
|
||||
async def _resolve_project_id(db: AsyncSession, entity_type: str, entity_id: uuid.UUID) -> uuid.UUID:
|
||||
if entity_type == "contract_fee":
|
||||
contract = await contract_fee_crud.get_contract_fee(db, entity_id)
|
||||
if not contract:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
|
||||
return contract.project_id
|
||||
if entity_type == "contract_payment":
|
||||
payment = await payment_crud.get_payment(db, entity_id)
|
||||
if not payment:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分期记录不存在")
|
||||
contract = await contract_fee_crud.get_contract_fee(db, payment.contract_fee_id)
|
||||
if not contract:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
|
||||
return contract.project_id
|
||||
if entity_type == "special_expense":
|
||||
expense = await special_crud.get_special_expense(db, entity_id)
|
||||
if not expense:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="特殊费用不存在")
|
||||
return expense.project_id
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不支持的附件类型")
|
||||
|
||||
|
||||
async def _ensure_project_access(db: AsyncSession, project_id: uuid.UUID, current_user, write: bool = False):
|
||||
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
|
||||
if role_value == "ADMIN":
|
||||
return None
|
||||
membership = await member_crud.get_member(db, project_id, current_user.id)
|
||||
if not membership or not membership.is_active:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员")
|
||||
if write and membership.role_in_study != "PM":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="项目权限不足")
|
||||
return membership
|
||||
|
||||
|
||||
async def _authorize_download_user(request: Request, db: AsyncSession):
|
||||
token = None
|
||||
auth_header = request.headers.get("Authorization")
|
||||
if auth_header and auth_header.lower().startswith("bearer "):
|
||||
token = auth_header.split(" ", 1)[1]
|
||||
if not token:
|
||||
token = request.query_params.get("token")
|
||||
if not token:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="未登录")
|
||||
payload = decode_token(token)
|
||||
user = await user_crud.get_by_id(db, uuid.UUID(str(payload.get("sub"))))
|
||||
if not user or not user.is_active:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="账号不存在或已停用")
|
||||
return user
|
||||
|
||||
|
||||
@router.post(
|
||||
"/attachments",
|
||||
response_model=FeeApiResponse[FeeAttachmentRead],
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(get_current_user), Depends(require_study_not_locked())],
|
||||
)
|
||||
async def upload_fee_attachment(
|
||||
entity_type: str = Form(...),
|
||||
entity_id: uuid.UUID = Form(...),
|
||||
file_type: str = Form(...),
|
||||
file: UploadFile = File(...),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> FeeApiResponse[FeeAttachmentRead]:
|
||||
if entity_type not in ALLOWED_ENTITY_TYPES:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="附件类型无效")
|
||||
if file_type not in ALLOWED_FILE_TYPES.get(entity_type, set()):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="附件文件类型无效")
|
||||
project_id = await _resolve_project_id(db, entity_type, entity_id)
|
||||
await _ensure_project_access(db, project_id, current_user, write=True)
|
||||
|
||||
dest_dir = UPLOAD_ROOT / f"{entity_type}_{entity_id}"
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
unique_name = f"{uuid.uuid4()}{Path(file.filename).suffix}"
|
||||
dest_path = dest_dir / unique_name
|
||||
|
||||
content = await file.read()
|
||||
async with aiofiles.open(dest_path, "wb") as out_file:
|
||||
await out_file.write(content)
|
||||
|
||||
attachment = await fee_attachment_crud.create_attachment(
|
||||
db,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
file_type=file_type,
|
||||
filename=file.filename,
|
||||
mime_type=file.content_type,
|
||||
size=len(content),
|
||||
storage_key=str(dest_path),
|
||||
url=None,
|
||||
uploaded_by=current_user.id,
|
||||
)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=project_id,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
action="UPLOAD_FEE_ATTACHMENT",
|
||||
detail=f"附件已上传:{file.filename}",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
return FeeApiResponse(
|
||||
data=FeeAttachmentRead(
|
||||
id=attachment.id,
|
||||
entity_type=attachment.entity_type,
|
||||
entity_id=attachment.entity_id,
|
||||
file_type=attachment.file_type,
|
||||
filename=attachment.filename,
|
||||
mime_type=attachment.mime_type,
|
||||
size=attachment.size,
|
||||
storage_key=attachment.storage_key,
|
||||
url=attachment.url,
|
||||
uploaded_by=UserDisplay.model_validate(current_user),
|
||||
uploaded_by_id=attachment.uploaded_by,
|
||||
uploaded_at=attachment.uploaded_at,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/attachments",
|
||||
response_model=FeeApiResponse[list[FeeAttachmentRead]],
|
||||
dependencies=[Depends(get_current_user)],
|
||||
)
|
||||
async def list_fee_attachments(
|
||||
entity_type: str,
|
||||
entity_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> FeeApiResponse[list[FeeAttachmentRead]]:
|
||||
if entity_type not in ALLOWED_ENTITY_TYPES:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="附件类型无效")
|
||||
project_id = await _resolve_project_id(db, entity_type, entity_id)
|
||||
await _ensure_project_access(db, project_id, current_user, write=False)
|
||||
attachments = await fee_attachment_crud.list_attachments(db, entity_type=entity_type, entity_id=entity_id)
|
||||
user_ids = {a.uploaded_by for a in attachments if a.uploaded_by}
|
||||
users_map = await user_crud.get_users_by_ids(db, user_ids)
|
||||
items: list[FeeAttachmentRead] = []
|
||||
for attachment in attachments:
|
||||
user = users_map.get(attachment.uploaded_by)
|
||||
items.append(
|
||||
FeeAttachmentRead(
|
||||
id=attachment.id,
|
||||
entity_type=attachment.entity_type,
|
||||
entity_id=attachment.entity_id,
|
||||
file_type=attachment.file_type,
|
||||
filename=attachment.filename,
|
||||
mime_type=attachment.mime_type,
|
||||
size=attachment.size,
|
||||
storage_key=attachment.storage_key,
|
||||
url=attachment.url,
|
||||
uploaded_by=UserDisplay.model_validate(user) if user else None,
|
||||
uploaded_by_id=attachment.uploaded_by,
|
||||
uploaded_at=attachment.uploaded_at,
|
||||
)
|
||||
)
|
||||
return FeeApiResponse(data=items, meta={"total": len(items)})
|
||||
|
||||
|
||||
@router.get(
|
||||
"/attachments/{attachment_id}/download",
|
||||
response_class=FileResponse,
|
||||
)
|
||||
async def download_fee_attachment(
|
||||
attachment_id: uuid.UUID,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
attachment = await fee_attachment_crud.get_attachment(db, attachment_id)
|
||||
if not attachment:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在")
|
||||
current_user = await _authorize_download_user(request, db)
|
||||
project_id = await _resolve_project_id(db, attachment.entity_type, attachment.entity_id)
|
||||
await _ensure_project_access(db, project_id, current_user, write=False)
|
||||
if not attachment.storage_key or not os.path.exists(attachment.storage_key):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="服务器未找到文件")
|
||||
return FileResponse(
|
||||
path=attachment.storage_key,
|
||||
filename=attachment.filename,
|
||||
media_type=attachment.mime_type or "application/octet-stream",
|
||||
headers={"Content-Disposition": f'inline; filename="{attachment.filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/attachments/{attachment_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(get_current_user), Depends(require_study_not_locked())],
|
||||
)
|
||||
async def delete_fee_attachment(
|
||||
attachment_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> None:
|
||||
attachment = await fee_attachment_crud.get_attachment(db, attachment_id)
|
||||
if not attachment:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在")
|
||||
project_id = await _resolve_project_id(db, attachment.entity_type, attachment.entity_id)
|
||||
membership = await _ensure_project_access(db, project_id, current_user, write=True)
|
||||
|
||||
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
|
||||
can_delete = (
|
||||
role_value == "ADMIN"
|
||||
or attachment.uploaded_by == current_user.id
|
||||
or (membership and getattr(membership, "role_in_study", None) == "PM")
|
||||
)
|
||||
if not can_delete:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限删除附件")
|
||||
|
||||
await fee_attachment_crud.soft_delete_attachment(db, attachment)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=project_id,
|
||||
entity_type=attachment.entity_type,
|
||||
entity_id=attachment.entity_id,
|
||||
action="DELETE_FEE_ATTACHMENT",
|
||||
detail=f"附件已删除:{attachment.filename}",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
@@ -1,12 +1,14 @@
|
||||
import uuid
|
||||
import json
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_not_locked
|
||||
from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, get_operator_role_label, is_system_admin, require_study_not_locked, require_api_permission
|
||||
from app.core.project_permissions import role_has_api_permission
|
||||
from app.crud import audit as audit_crud
|
||||
from app.crud import contract_fee as contract_fee_crud
|
||||
from app.crud import contract_fee_payment as payment_crud
|
||||
@@ -21,36 +23,87 @@ from app.schemas.contract_fee_payment import (
|
||||
ContractFeePaymentUpdate,
|
||||
)
|
||||
from app.schemas.fee_common import FeeApiResponse
|
||||
from app.schemas.fee_attachment import FeeAttachmentRead
|
||||
from app.schemas.attachment import AttachmentRead
|
||||
from app.schemas.user import UserDisplay
|
||||
from app.models.attachment import Attachment
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def _ensure_project_access(db: AsyncSession, project_id: uuid.UUID, current_user, write: bool = False):
|
||||
study = await study_crud.get(db, project_id)
|
||||
async def _ensure_study_access(db: AsyncSession, study_id: uuid.UUID, current_user, endpoint_key: str):
|
||||
study = await study_crud.get(db, study_id)
|
||||
if not study:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
|
||||
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
|
||||
if role_value == "ADMIN":
|
||||
if is_system_admin(current_user):
|
||||
return None
|
||||
membership = await member_crud.get_member(db, project_id, current_user.id)
|
||||
membership = await member_crud.get_member(db, study_id, current_user.id)
|
||||
if not membership or not membership.is_active:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员")
|
||||
if write and membership.role_in_study != "PM":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="项目权限不足")
|
||||
allowed = await role_has_api_permission(db, study_id, membership.role_in_study, endpoint_key)
|
||||
if not allowed:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="接口权限不足")
|
||||
return membership
|
||||
|
||||
|
||||
async def _ensure_center_active(db: AsyncSession, project_id: uuid.UUID, center_id: uuid.UUID | None):
|
||||
async def _ensure_center_active(db: AsyncSession, study_id: uuid.UUID, center_id: uuid.UUID | None):
|
||||
if not center_id:
|
||||
return
|
||||
site = await site_crud.get_site(db, center_id)
|
||||
if not site or site.study_id != project_id or not site.is_active:
|
||||
if not site or site.study_id != study_id or not site.is_active:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="中心已停用")
|
||||
|
||||
|
||||
async def _contract_audit_name(db: AsyncSession, contract) -> str:
|
||||
site = await site_crud.get_site(db, contract.center_id)
|
||||
center_name = site.name if site else "中心"
|
||||
contract_no = str(contract.contract_no or "").strip()
|
||||
return f"{center_name} / {contract_no}" if contract_no else center_name
|
||||
|
||||
|
||||
async def _contract_audit_detail(db: AsyncSession, action: str, contract) -> str:
|
||||
name = await _contract_audit_name(db, contract)
|
||||
return json.dumps({"targetName": name, "description": f"{action}合同费用“{name}”"}, ensure_ascii=False)
|
||||
|
||||
|
||||
async def _payment_audit_name(db: AsyncSession, contract, payment) -> str:
|
||||
contract_name = await _contract_audit_name(db, contract)
|
||||
seq = getattr(payment, "seq", None)
|
||||
amount = getattr(payment, "amount", None)
|
||||
if seq:
|
||||
return f"{contract_name} / 第{seq}期"
|
||||
if amount is not None:
|
||||
return f"{contract_name} / {amount}"
|
||||
return contract_name
|
||||
|
||||
|
||||
async def _payment_audit_detail(db: AsyncSession, action: str, contract, payment) -> str:
|
||||
name = await _payment_audit_name(db, contract, payment)
|
||||
return json.dumps({"targetName": name, "description": f"{action}合同费用分期“{name}”"}, ensure_ascii=False)
|
||||
|
||||
|
||||
def _parse_optional_uuid(value: str | None, detail: str) -> uuid.UUID | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return uuid.UUID(str(value))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=detail) from exc
|
||||
|
||||
|
||||
def _resolve_contract_fee_list_query(
|
||||
request: Request,
|
||||
study_id: uuid.UUID | None,
|
||||
center_id: uuid.UUID | None,
|
||||
) -> tuple[uuid.UUID, uuid.UUID | None]:
|
||||
query = request.query_params
|
||||
resolved_study_id = study_id or _parse_optional_uuid(query.get("projectId"), "项目 ID 格式错误")
|
||||
if not resolved_study_id:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="缺少项目 ID")
|
||||
|
||||
resolved_center_id = center_id or _parse_optional_uuid(query.get("centerId"), "中心 ID 格式错误")
|
||||
return resolved_study_id, resolved_center_id
|
||||
|
||||
|
||||
def _to_decimal(value: Any) -> Decimal:
|
||||
if isinstance(value, Decimal):
|
||||
return value
|
||||
@@ -77,21 +130,23 @@ def _validate_payment_rules(data: ContractFeePaymentCreate | ContractFeePaymentU
|
||||
dependencies=[Depends(get_current_user)],
|
||||
)
|
||||
async def list_contract_fees(
|
||||
project_id: uuid.UUID = Query(..., alias="projectId"),
|
||||
center_id: uuid.UUID | None = Query(None, alias="centerId"),
|
||||
request: Request,
|
||||
study_id: uuid.UUID | None = None,
|
||||
center_id: uuid.UUID | None = None,
|
||||
q: str | None = None,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> FeeApiResponse[list[ContractFeeListItem]]:
|
||||
await _ensure_project_access(db, project_id, current_user, write=False)
|
||||
cra_scope = await get_cra_site_scope(db, project_id, current_user)
|
||||
resolved_study_id, resolved_center_id = _resolve_contract_fee_list_query(request, study_id, center_id)
|
||||
await _ensure_study_access(db, resolved_study_id, current_user, "fees_contracts:read")
|
||||
cra_scope = await get_cra_site_scope(db, resolved_study_id, current_user)
|
||||
center_ids = cra_scope[0] if cra_scope else None
|
||||
if center_id and center_ids is not None and center_id not in center_ids:
|
||||
if resolved_center_id and center_ids is not None and resolved_center_id not in center_ids:
|
||||
return FeeApiResponse(data=[], meta={"total": 0})
|
||||
rows = await contract_fee_crud.list_contract_fees(
|
||||
db,
|
||||
project_id,
|
||||
center_id=center_id,
|
||||
resolved_study_id,
|
||||
center_id=resolved_center_id,
|
||||
center_ids=center_ids,
|
||||
q=q,
|
||||
)
|
||||
@@ -105,9 +160,13 @@ async def list_contract_fees(
|
||||
items.append(
|
||||
ContractFeeListItem(
|
||||
id=contract.id,
|
||||
project_id=contract.project_id,
|
||||
study_id=contract.study_id,
|
||||
center_id=contract.center_id,
|
||||
contract_no=contract.contract_no,
|
||||
signed_date=contract.signed_date,
|
||||
contract_amount=contract_amount_decimal,
|
||||
currency=contract.currency,
|
||||
remark=contract.remark,
|
||||
contract_cases=contract.contract_cases,
|
||||
actual_cases=contract.actual_cases,
|
||||
settlement_amount=_to_decimal(contract.settlement_amount) if contract.settlement_amount else None,
|
||||
@@ -139,8 +198,8 @@ async def get_contract_fee(
|
||||
contract = await contract_fee_crud.get_contract_fee(db, contract_id)
|
||||
if not contract:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
|
||||
await _ensure_project_access(db, contract.project_id, current_user, write=False)
|
||||
cra_scope = await get_cra_site_scope(db, contract.project_id, current_user)
|
||||
await _ensure_study_access(db, contract.study_id, current_user, "fees_contracts:read")
|
||||
cra_scope = await get_cra_site_scope(db, contract.study_id, current_user)
|
||||
if cra_scope and contract.center_id not in cra_scope[0]:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
|
||||
|
||||
@@ -157,7 +216,7 @@ async def get_contract_fee(
|
||||
user_ids = {a.uploaded_by for a in attachments if a.uploaded_by}
|
||||
users_map = await user_crud.get_users_by_ids(db, user_ids)
|
||||
|
||||
attachments_map: dict[str, list[FeeAttachmentRead]] = {
|
||||
attachments_map: dict[str, list[AttachmentRead]] = {
|
||||
"contract": [],
|
||||
"voucher": [],
|
||||
"invoice": [],
|
||||
@@ -167,16 +226,11 @@ async def get_contract_fee(
|
||||
attachments_map.setdefault(key, [])
|
||||
user = users_map.get(attachment.uploaded_by)
|
||||
attachments_map[key].append(
|
||||
FeeAttachmentRead(
|
||||
AttachmentRead(
|
||||
id=attachment.id,
|
||||
entity_type="contract_fee",
|
||||
entity_id=attachment.entity_id,
|
||||
file_type=key,
|
||||
filename=attachment.filename,
|
||||
mime_type=attachment.content_type,
|
||||
size=attachment.file_size,
|
||||
storage_key=attachment.file_path,
|
||||
url=None,
|
||||
file_size=attachment.file_size,
|
||||
content_type=attachment.content_type,
|
||||
uploaded_by_id=attachment.uploaded_by,
|
||||
uploaded_by=UserDisplay.model_validate(user) if user else None,
|
||||
uploaded_at=attachment.uploaded_at,
|
||||
@@ -187,9 +241,13 @@ async def get_contract_fee(
|
||||
|
||||
detail = ContractFeeDetail(
|
||||
id=contract.id,
|
||||
project_id=contract.project_id,
|
||||
study_id=contract.study_id,
|
||||
center_id=contract.center_id,
|
||||
contract_no=contract.contract_no,
|
||||
signed_date=contract.signed_date,
|
||||
contract_amount=_to_decimal(contract.contract_amount),
|
||||
currency=contract.currency,
|
||||
remark=contract.remark,
|
||||
contract_cases=contract.contract_cases,
|
||||
actual_cases=contract.actual_cases,
|
||||
settlement_amount=_to_decimal(contract.settlement_amount) if contract.settlement_amount else None,
|
||||
@@ -214,27 +272,27 @@ async def create_contract_fee(
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> FeeApiResponse[ContractFeeRead]:
|
||||
await _ensure_project_access(db, contract_in.project_id, current_user, write=True)
|
||||
existing = await contract_fee_crud.get_contract_fee_by_project_center(
|
||||
db, contract_in.project_id, contract_in.center_id
|
||||
await _ensure_study_access(db, contract_in.study_id, current_user, "fees_contracts:create")
|
||||
existing = await contract_fee_crud.get_contract_fee_by_study_center(
|
||||
db, contract_in.study_id, contract_in.center_id
|
||||
)
|
||||
if existing:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="该中心已存在合同费用")
|
||||
|
||||
site = await site_crud.get_site(db, contract_in.center_id)
|
||||
if not site or site.study_id != contract_in.project_id or not site.is_active:
|
||||
if not site or site.study_id != contract_in.study_id or not site.is_active:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="中心不存在或已停用")
|
||||
|
||||
contract = await contract_fee_crud.create_contract_fee(db, contract_in)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=contract.project_id,
|
||||
study_id=contract.study_id,
|
||||
entity_type="contract_fee",
|
||||
entity_id=contract.id,
|
||||
action="CREATE_CONTRACT_FEE",
|
||||
detail="合同费用已创建",
|
||||
detail=await _contract_audit_detail(db, "创建", contract),
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
operator_role=await get_operator_role_label(db, contract.study_id, current_user),
|
||||
)
|
||||
return FeeApiResponse(data=ContractFeeRead.model_validate(contract))
|
||||
|
||||
@@ -253,18 +311,18 @@ async def update_contract_fee(
|
||||
contract = await contract_fee_crud.get_contract_fee(db, contract_id)
|
||||
if not contract:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
|
||||
await _ensure_project_access(db, contract.project_id, current_user, write=True)
|
||||
await _ensure_center_active(db, contract.project_id, contract.center_id)
|
||||
await _ensure_study_access(db, contract.study_id, current_user, "fees_contracts:update")
|
||||
await _ensure_center_active(db, contract.study_id, contract.center_id)
|
||||
contract = await contract_fee_crud.update_contract_fee(db, contract, contract_in)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=contract.project_id,
|
||||
study_id=contract.study_id,
|
||||
entity_type="contract_fee",
|
||||
entity_id=contract_id,
|
||||
action="UPDATE_CONTRACT_FEE",
|
||||
detail="合同费用已更新",
|
||||
detail=await _contract_audit_detail(db, "更新", contract),
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
operator_role=await get_operator_role_label(db, contract.study_id, current_user),
|
||||
)
|
||||
return FeeApiResponse(data=ContractFeeRead.model_validate(contract))
|
||||
|
||||
@@ -282,18 +340,19 @@ async def delete_contract_fee(
|
||||
contract = await contract_fee_crud.get_contract_fee(db, contract_id)
|
||||
if not contract:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
|
||||
await _ensure_project_access(db, contract.project_id, current_user, write=True)
|
||||
await _ensure_center_active(db, contract.project_id, contract.center_id)
|
||||
await _ensure_study_access(db, contract.study_id, current_user, "fees_contracts:delete")
|
||||
await _ensure_center_active(db, contract.study_id, contract.center_id)
|
||||
contract_detail = await _contract_audit_detail(db, "删除", contract)
|
||||
await contract_fee_crud.delete_contract_fee(db, contract)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=contract.project_id,
|
||||
study_id=contract.study_id,
|
||||
entity_type="contract_fee",
|
||||
entity_id=contract_id,
|
||||
action="DELETE_CONTRACT_FEE",
|
||||
detail="合同费用已删除",
|
||||
detail=contract_detail,
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
operator_role=await get_operator_role_label(db, contract.study_id, current_user),
|
||||
)
|
||||
|
||||
|
||||
@@ -312,18 +371,18 @@ async def create_contract_payment(
|
||||
contract = await contract_fee_crud.get_contract_fee(db, contract_id)
|
||||
if not contract:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
|
||||
await _ensure_project_access(db, contract.project_id, current_user, write=True)
|
||||
await _ensure_study_access(db, contract.study_id, current_user, "fees_contracts:update")
|
||||
_validate_payment_rules(payment_in)
|
||||
payment = await payment_crud.create_payment(db, contract_id, payment_in)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=contract.project_id,
|
||||
study_id=contract.study_id,
|
||||
entity_type="contract_fee_payment",
|
||||
entity_id=payment.id,
|
||||
action="CREATE_CONTRACT_FEE_PAYMENT",
|
||||
detail="合同费用分期已创建",
|
||||
detail=await _payment_audit_detail(db, "创建", contract, payment),
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
operator_role=await get_operator_role_label(db, contract.study_id, current_user),
|
||||
)
|
||||
return FeeApiResponse(data=ContractFeePaymentRead.model_validate(payment))
|
||||
|
||||
@@ -345,7 +404,7 @@ async def update_contract_payment(
|
||||
contract = await contract_fee_crud.get_contract_fee(db, payment.contract_fee_id)
|
||||
if not contract:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
|
||||
await _ensure_project_access(db, contract.project_id, current_user, write=True)
|
||||
await _ensure_study_access(db, contract.study_id, current_user, "fees_contracts:update")
|
||||
merged_payment = ContractFeePaymentCreate(
|
||||
amount=payment_in.amount if payment_in.amount is not None else payment.amount,
|
||||
paid_date=payment_in.paid_date if payment_in.paid_date is not None else payment.paid_date,
|
||||
@@ -358,13 +417,13 @@ async def update_contract_payment(
|
||||
payment = await payment_crud.update_payment(db, payment, payment_in)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=contract.project_id,
|
||||
study_id=contract.study_id,
|
||||
entity_type="contract_fee_payment",
|
||||
entity_id=payment_id,
|
||||
action="UPDATE_CONTRACT_FEE_PAYMENT",
|
||||
detail="合同费用分期已更新",
|
||||
detail=await _payment_audit_detail(db, "更新", contract, payment),
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
operator_role=await get_operator_role_label(db, contract.study_id, current_user),
|
||||
)
|
||||
return FeeApiResponse(data=ContractFeePaymentRead.model_validate(payment))
|
||||
|
||||
@@ -385,16 +444,17 @@ async def delete_contract_payment(
|
||||
contract = await contract_fee_crud.get_contract_fee(db, payment.contract_fee_id)
|
||||
if not contract:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
|
||||
await _ensure_project_access(db, contract.project_id, current_user, write=True)
|
||||
await _ensure_study_access(db, contract.study_id, current_user, "fees_contracts:update")
|
||||
payment_detail = await _payment_audit_detail(db, "删除", contract, payment)
|
||||
await payment_crud.delete_payment(db, payment)
|
||||
await payment_crud.resequence_payments(db, contract.id)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=contract.project_id,
|
||||
study_id=contract.study_id,
|
||||
entity_type="contract_fee_payment",
|
||||
entity_id=payment_id,
|
||||
action="DELETE_CONTRACT_FEE_PAYMENT",
|
||||
detail="合同费用分期已删除",
|
||||
detail=payment_detail,
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
operator_role=await get_operator_role_label(db, contract.study_id, current_user),
|
||||
)
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
import uuid
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_not_locked
|
||||
from app.crud import audit as audit_crud
|
||||
from app.crud import member as member_crud
|
||||
from app.crud import special_expense as special_crud
|
||||
from app.crud import study as study_crud
|
||||
from app.crud import site as site_crud
|
||||
from app.schemas.fee_common import FeeApiResponse
|
||||
from app.schemas.special_expense import (
|
||||
SpecialExpenseCreate,
|
||||
SpecialExpenseListItem,
|
||||
SpecialExpenseRead,
|
||||
SpecialExpenseUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
ALLOWED_CATEGORIES = {"travel", "meal", "meeting", "supplies", "other"}
|
||||
|
||||
|
||||
async def _ensure_project_access(db: AsyncSession, project_id: uuid.UUID, current_user, write: bool = False):
|
||||
study = await study_crud.get(db, project_id)
|
||||
if not study:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
|
||||
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
|
||||
if role_value == "ADMIN":
|
||||
return None
|
||||
membership = await member_crud.get_member(db, project_id, current_user.id)
|
||||
if not membership or not membership.is_active:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员")
|
||||
if write and membership.role_in_study != "PM":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="项目权限不足")
|
||||
return membership
|
||||
|
||||
|
||||
def _validate_special_rules(payload: SpecialExpenseCreate | SpecialExpenseUpdate):
|
||||
if payload.is_verified is True and payload.is_paid is False:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="核销需先打款")
|
||||
if payload.is_paid and not payload.paid_date:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="已打款需填写打款日期")
|
||||
if payload.is_verified and not payload.verified_date:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="已核销需填写核销日期")
|
||||
|
||||
|
||||
def _validate_category(value: str | None):
|
||||
if value and value not in ALLOWED_CATEGORIES:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="费用类别无效")
|
||||
|
||||
|
||||
async def _ensure_center_active(db: AsyncSession, project_id: uuid.UUID, center_id: uuid.UUID | None):
|
||||
if not center_id:
|
||||
return
|
||||
site = await site_crud.get_site(db, center_id)
|
||||
if not site or site.study_id != project_id or not site.is_active:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="中心已停用")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/special",
|
||||
response_model=FeeApiResponse[list[SpecialExpenseListItem]],
|
||||
dependencies=[Depends(get_current_user)],
|
||||
)
|
||||
async def list_special_expenses(
|
||||
project_id: uuid.UUID = Query(..., alias="projectId"),
|
||||
center_id: uuid.UUID | None = Query(None, alias="centerId"),
|
||||
category: str | None = None,
|
||||
date_from: date | None = Query(None, alias="dateFrom"),
|
||||
date_to: date | None = Query(None, alias="dateTo"),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> FeeApiResponse[list[SpecialExpenseListItem]]:
|
||||
await _ensure_project_access(db, project_id, current_user, write=False)
|
||||
_validate_category(category)
|
||||
cra_scope = await get_cra_site_scope(db, project_id, current_user)
|
||||
center_ids = cra_scope[0] if cra_scope else None
|
||||
if center_id and center_ids is not None and center_id not in center_ids:
|
||||
return FeeApiResponse(data=[], meta={"total": 0})
|
||||
rows = await special_crud.list_special_expenses(
|
||||
db,
|
||||
project_id,
|
||||
center_id=center_id,
|
||||
center_ids=center_ids,
|
||||
category=category,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
)
|
||||
items: list[SpecialExpenseListItem] = []
|
||||
for expense, center_name, attachments_count in rows:
|
||||
items.append(
|
||||
SpecialExpenseListItem(
|
||||
id=expense.id,
|
||||
project_id=expense.project_id,
|
||||
center_id=expense.center_id,
|
||||
category=expense.category,
|
||||
amount=expense.amount,
|
||||
happen_date=expense.happen_date,
|
||||
description=expense.description,
|
||||
is_paid=expense.is_paid,
|
||||
paid_date=expense.paid_date,
|
||||
is_verified=expense.is_verified,
|
||||
verified_date=expense.verified_date,
|
||||
created_by=expense.created_by,
|
||||
created_at=expense.created_at,
|
||||
updated_at=expense.updated_at,
|
||||
center_name=center_name,
|
||||
attachments_count=attachments_count or 0,
|
||||
)
|
||||
)
|
||||
return FeeApiResponse(data=items, meta={"total": len(items)})
|
||||
|
||||
|
||||
@router.get(
|
||||
"/special/{expense_id}",
|
||||
response_model=FeeApiResponse[SpecialExpenseRead],
|
||||
dependencies=[Depends(get_current_user)],
|
||||
)
|
||||
async def get_special_expense(
|
||||
expense_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> FeeApiResponse[SpecialExpenseRead]:
|
||||
expense = await special_crud.get_special_expense(db, expense_id)
|
||||
if not expense:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="特殊费用不存在")
|
||||
await _ensure_project_access(db, expense.project_id, current_user, write=False)
|
||||
cra_scope = await get_cra_site_scope(db, expense.project_id, current_user)
|
||||
if cra_scope and expense.center_id not in cra_scope[0]:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
|
||||
return FeeApiResponse(data=SpecialExpenseRead.model_validate(expense))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/special",
|
||||
response_model=FeeApiResponse[SpecialExpenseRead],
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(get_current_user), Depends(require_study_not_locked())],
|
||||
)
|
||||
async def create_special_expense(
|
||||
expense_in: SpecialExpenseCreate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> FeeApiResponse[SpecialExpenseRead]:
|
||||
await _ensure_project_access(db, expense_in.project_id, current_user, write=True)
|
||||
_validate_category(expense_in.category)
|
||||
_validate_special_rules(expense_in)
|
||||
if expense_in.center_id:
|
||||
site = await site_crud.get_site(db, expense_in.center_id)
|
||||
if not site or site.study_id != expense_in.project_id or not site.is_active:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="中心不存在或已停用")
|
||||
await _ensure_center_active(db, expense_in.project_id, expense_in.center_id)
|
||||
expense = await special_crud.create_special_expense(db, expense_in, created_by=current_user.id)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=expense.project_id,
|
||||
entity_type="special_expense",
|
||||
entity_id=expense.id,
|
||||
action="CREATE_SPECIAL_EXPENSE",
|
||||
detail="特殊费用已创建",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
return FeeApiResponse(data=SpecialExpenseRead.model_validate(expense))
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/special/{expense_id}",
|
||||
response_model=FeeApiResponse[SpecialExpenseRead],
|
||||
dependencies=[Depends(get_current_user), Depends(require_study_not_locked())],
|
||||
)
|
||||
async def update_special_expense(
|
||||
expense_id: uuid.UUID,
|
||||
expense_in: SpecialExpenseUpdate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> FeeApiResponse[SpecialExpenseRead]:
|
||||
expense = await special_crud.get_special_expense(db, expense_id)
|
||||
if not expense:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="特殊费用不存在")
|
||||
await _ensure_project_access(db, expense.project_id, current_user, write=True)
|
||||
_validate_category(expense_in.category)
|
||||
merged = SpecialExpenseCreate(
|
||||
project_id=expense.project_id,
|
||||
center_id=expense_in.center_id if expense_in.center_id is not None else expense.center_id,
|
||||
category=expense_in.category if expense_in.category is not None else expense.category,
|
||||
amount=expense_in.amount if expense_in.amount is not None else expense.amount,
|
||||
happen_date=expense_in.happen_date if expense_in.happen_date is not None else expense.happen_date,
|
||||
description=expense_in.description if expense_in.description is not None else expense.description,
|
||||
is_paid=expense_in.is_paid if expense_in.is_paid is not None else expense.is_paid,
|
||||
paid_date=expense_in.paid_date if expense_in.paid_date is not None else expense.paid_date,
|
||||
is_verified=expense_in.is_verified if expense_in.is_verified is not None else expense.is_verified,
|
||||
verified_date=expense_in.verified_date if expense_in.verified_date is not None else expense.verified_date,
|
||||
)
|
||||
_validate_special_rules(merged)
|
||||
if expense_in.center_id:
|
||||
site = await site_crud.get_site(db, expense_in.center_id)
|
||||
if not site or site.study_id != expense.project_id or not site.is_active:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="中心不存在或已停用")
|
||||
await _ensure_center_active(db, expense.project_id, expense_in.center_id)
|
||||
else:
|
||||
await _ensure_center_active(db, expense.project_id, expense.center_id)
|
||||
expense = await special_crud.update_special_expense(db, expense, expense_in)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=expense.project_id,
|
||||
entity_type="special_expense",
|
||||
entity_id=expense_id,
|
||||
action="UPDATE_SPECIAL_EXPENSE",
|
||||
detail="特殊费用已更新",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
return FeeApiResponse(data=SpecialExpenseRead.model_validate(expense))
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/special/{expense_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(get_current_user), Depends(require_study_not_locked())],
|
||||
)
|
||||
async def delete_special_expense(
|
||||
expense_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> None:
|
||||
expense = await special_crud.get_special_expense(db, expense_id)
|
||||
if not expense:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="特殊费用不存在")
|
||||
await _ensure_project_access(db, expense.project_id, current_user, write=True)
|
||||
await _ensure_center_active(db, expense.project_id, expense.center_id)
|
||||
await special_crud.delete_special_expense(db, expense)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=expense.project_id,
|
||||
entity_type="special_expense",
|
||||
entity_id=expense_id,
|
||||
action="DELETE_SPECIAL_EXPENSE",
|
||||
detail="特殊费用已删除",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
@@ -1,177 +0,0 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_member, require_study_roles, require_study_not_locked
|
||||
from app.crud import audit as audit_crud
|
||||
from app.crud import finance_contract as contract_crud
|
||||
from app.crud import site as site_crud
|
||||
from app.crud import study as study_crud
|
||||
from app.schemas.finance_contract import FinanceContractCreate, FinanceContractRead, FinanceContractUpdate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
|
||||
study = await study_crud.get(db, study_id)
|
||||
if not study:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
|
||||
return study
|
||||
|
||||
|
||||
async def _ensure_site_name_active(db: AsyncSession, study_id: uuid.UUID, site_name: str | None):
|
||||
if not site_name:
|
||||
return
|
||||
active_names = await site_crud.list_active_names(db, study_id)
|
||||
if site_name not in active_names:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="中心已停用")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/contracts",
|
||||
response_model=FinanceContractRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||
)
|
||||
async def create_contract(
|
||||
study_id: uuid.UUID,
|
||||
contract_in: FinanceContractCreate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> FinanceContractRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
cra_scope = await get_cra_site_scope(db, study_id, current_user)
|
||||
if cra_scope and contract_in.site_name not in cra_scope[1]:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
|
||||
await _ensure_site_name_active(db, study_id, contract_in.site_name)
|
||||
contract = await contract_crud.create_contract(db, study_id, contract_in, created_by=current_user.id)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="finance_contract",
|
||||
entity_id=contract.id,
|
||||
action="CREATE_FINANCE_CONTRACT",
|
||||
detail=f"合同费用 {contract.contract_no} 已创建",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
return FinanceContractRead.model_validate(contract)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/contracts",
|
||||
response_model=list[FinanceContractRead],
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def list_contracts(
|
||||
study_id: uuid.UUID,
|
||||
site_name: str | None = None,
|
||||
contract_no: str | None = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> list[FinanceContractRead]:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
cra_scope = await get_cra_site_scope(db, study_id, current_user)
|
||||
site_names = cra_scope[1] if cra_scope else None
|
||||
if site_name and site_names is not None and site_name not in site_names:
|
||||
return []
|
||||
items = await contract_crud.list_contracts(
|
||||
db,
|
||||
study_id,
|
||||
site_name=site_name,
|
||||
site_names=site_names,
|
||||
contract_no=contract_no,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
return [FinanceContractRead.model_validate(item) for item in items]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/contracts/{contract_id}",
|
||||
response_model=FinanceContractRead,
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def get_contract(
|
||||
study_id: uuid.UUID,
|
||||
contract_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> FinanceContractRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
contract = await contract_crud.get_contract(db, contract_id)
|
||||
if not contract or contract.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同不存在")
|
||||
cra_scope = await get_cra_site_scope(db, study_id, current_user)
|
||||
if cra_scope and contract.site_name not in cra_scope[1]:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
|
||||
return FinanceContractRead.model_validate(contract)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/contracts/{contract_id}",
|
||||
response_model=FinanceContractRead,
|
||||
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||
)
|
||||
async def update_contract(
|
||||
study_id: uuid.UUID,
|
||||
contract_id: uuid.UUID,
|
||||
contract_in: FinanceContractUpdate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> FinanceContractRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
contract = await contract_crud.get_contract(db, contract_id)
|
||||
if not contract or contract.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同不存在")
|
||||
cra_scope = await get_cra_site_scope(db, study_id, current_user)
|
||||
if cra_scope and contract.site_name not in cra_scope[1]:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
|
||||
await _ensure_site_name_active(db, study_id, contract.site_name)
|
||||
contract = await contract_crud.update_contract(db, contract, contract_in)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="finance_contract",
|
||||
entity_id=contract_id,
|
||||
action="UPDATE_FINANCE_CONTRACT",
|
||||
detail=f"合同费用 {contract_id} 已更新",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
return FinanceContractRead.model_validate(contract)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/contracts/{contract_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||
)
|
||||
async def delete_contract(
|
||||
study_id: uuid.UUID,
|
||||
contract_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> None:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
contract = await contract_crud.get_contract(db, contract_id)
|
||||
if not contract or contract.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同不存在")
|
||||
cra_scope = await get_cra_site_scope(db, study_id, current_user)
|
||||
if cra_scope and contract.site_name not in cra_scope[1]:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
|
||||
await _ensure_site_name_active(db, study_id, contract.site_name)
|
||||
await contract_crud.delete_contract(db, contract)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="finance_contract",
|
||||
entity_id=contract_id,
|
||||
action="DELETE_FINANCE_CONTRACT",
|
||||
detail=f"合同费用 {contract_id} 已删除",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
@@ -4,7 +4,7 @@ from datetime import date
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_db_session, require_study_member
|
||||
from app.core.deps import get_db_session, require_api_permission
|
||||
from app.crud import finance as finance_crud
|
||||
from app.crud import study as study_crud
|
||||
from app.schemas.finance import FinanceSummaryRead
|
||||
@@ -12,11 +12,12 @@ from app.schemas.finance import FinanceSummaryRead
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/summary", response_model=FinanceSummaryRead, dependencies=[Depends(require_study_member())])
|
||||
@router.get("/summary", response_model=FinanceSummaryRead)
|
||||
async def finance_summary(
|
||||
study_id: uuid.UUID,
|
||||
date_from: date | None = None,
|
||||
date_to: date | None = None,
|
||||
_=Depends(require_api_permission("fees_contracts:read")),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> FinanceSummaryRead:
|
||||
# ensure study exists
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_member, require_study_roles, require_study_not_locked
|
||||
from app.crud import audit as audit_crud
|
||||
from app.crud import finance_special as special_crud
|
||||
from app.crud import site as site_crud
|
||||
from app.crud import study as study_crud
|
||||
from app.schemas.finance_special import FinanceSpecialCreate, FinanceSpecialRead, FinanceSpecialUpdate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
|
||||
study = await study_crud.get(db, study_id)
|
||||
if not study:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
|
||||
return study
|
||||
|
||||
|
||||
async def _ensure_site_name_active(db: AsyncSession, study_id: uuid.UUID, site_name: str | None):
|
||||
if not site_name:
|
||||
return
|
||||
active_names = await site_crud.list_active_names(db, study_id)
|
||||
if site_name not in active_names:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="中心已停用")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/specials",
|
||||
response_model=FinanceSpecialRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||
)
|
||||
async def create_special(
|
||||
study_id: uuid.UUID,
|
||||
special_in: FinanceSpecialCreate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> FinanceSpecialRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
cra_scope = await get_cra_site_scope(db, study_id, current_user)
|
||||
if cra_scope and special_in.site_name not in cra_scope[1]:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
|
||||
await _ensure_site_name_active(db, study_id, special_in.site_name)
|
||||
item = await special_crud.create_special(db, study_id, special_in, created_by=current_user.id)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="finance_special",
|
||||
entity_id=item.id,
|
||||
action="CREATE_FINANCE_SPECIAL",
|
||||
detail=f"特殊费用 {item.id} 已创建",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
return FinanceSpecialRead.model_validate(item)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/specials",
|
||||
response_model=list[FinanceSpecialRead],
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def list_specials(
|
||||
study_id: uuid.UUID,
|
||||
site_name: str | None = None,
|
||||
fee_type: str | None = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> list[FinanceSpecialRead]:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
cra_scope = await get_cra_site_scope(db, study_id, current_user)
|
||||
site_names = cra_scope[1] if cra_scope else None
|
||||
if site_name and site_names is not None and site_name not in site_names:
|
||||
return []
|
||||
items = await special_crud.list_specials(
|
||||
db,
|
||||
study_id,
|
||||
site_name=site_name,
|
||||
site_names=site_names,
|
||||
fee_type=fee_type,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
return [FinanceSpecialRead.model_validate(item) for item in items]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/specials/{special_id}",
|
||||
response_model=FinanceSpecialRead,
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def get_special(
|
||||
study_id: uuid.UUID,
|
||||
special_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> FinanceSpecialRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
item = await special_crud.get_special(db, special_id)
|
||||
if not item or item.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="特殊费用不存在")
|
||||
cra_scope = await get_cra_site_scope(db, study_id, current_user)
|
||||
if cra_scope and item.site_name not in cra_scope[1]:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
|
||||
return FinanceSpecialRead.model_validate(item)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/specials/{special_id}",
|
||||
response_model=FinanceSpecialRead,
|
||||
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||
)
|
||||
async def update_special(
|
||||
study_id: uuid.UUID,
|
||||
special_id: uuid.UUID,
|
||||
special_in: FinanceSpecialUpdate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> FinanceSpecialRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
item = await special_crud.get_special(db, special_id)
|
||||
if not item or item.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="特殊费用不存在")
|
||||
cra_scope = await get_cra_site_scope(db, study_id, current_user)
|
||||
if cra_scope and item.site_name not in cra_scope[1]:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
|
||||
await _ensure_site_name_active(db, study_id, item.site_name)
|
||||
item = await special_crud.update_special(db, item, special_in)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="finance_special",
|
||||
entity_id=special_id,
|
||||
action="UPDATE_FINANCE_SPECIAL",
|
||||
detail=f"特殊费用 {special_id} 已更新",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
return FinanceSpecialRead.model_validate(item)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/specials/{special_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||
)
|
||||
async def delete_special(
|
||||
study_id: uuid.UUID,
|
||||
special_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> None:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
item = await special_crud.get_special(db, special_id)
|
||||
if not item or item.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="特殊费用不存在")
|
||||
cra_scope = await get_cra_site_scope(db, study_id, current_user)
|
||||
if cra_scope and item.site_name not in cra_scope[1]:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
|
||||
await _ensure_site_name_active(db, study_id, item.site_name)
|
||||
await special_crud.delete_special(db, item)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="finance_special",
|
||||
entity_id=special_id,
|
||||
action="DELETE_FINANCE_SPECIAL",
|
||||
detail=f"特殊费用 {special_id} 已删除",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
@@ -1,151 +0,0 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles, require_study_not_locked
|
||||
from app.crud import audit as audit_crud
|
||||
from app.crud import knowledge_note as note_crud
|
||||
from app.crud import site as site_crud
|
||||
from app.crud import study as study_crud
|
||||
from app.schemas.knowledge_note import KnowledgeNoteCreate, KnowledgeNoteRead, KnowledgeNoteUpdate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
|
||||
study = await study_crud.get(db, study_id)
|
||||
if not study:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
|
||||
return study
|
||||
|
||||
|
||||
async def _ensure_site_name_active(db: AsyncSession, study_id: uuid.UUID, site_name: str | None):
|
||||
if not site_name:
|
||||
return
|
||||
active_names = await site_crud.list_active_names(db, study_id)
|
||||
if site_name not in active_names:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="中心已停用")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/notes",
|
||||
response_model=KnowledgeNoteRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||
)
|
||||
async def create_note(
|
||||
study_id: uuid.UUID,
|
||||
note_in: KnowledgeNoteCreate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> KnowledgeNoteRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
await _ensure_site_name_active(db, study_id, note_in.site_name)
|
||||
note = await note_crud.create_note(db, study_id, note_in, created_by=current_user.id)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="knowledge_note",
|
||||
entity_id=note.id,
|
||||
action="CREATE_KNOWLEDGE_NOTE",
|
||||
detail=f"注意事项 {note.title} 已创建",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
return KnowledgeNoteRead.model_validate(note)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/notes",
|
||||
response_model=list[KnowledgeNoteRead],
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def list_notes(
|
||||
study_id: uuid.UUID,
|
||||
site_name: str | None = None,
|
||||
keyword: str | None = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> list[KnowledgeNoteRead]:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
items = await note_crud.list_notes(db, study_id, site_name=site_name, keyword=keyword, skip=skip, limit=limit)
|
||||
return [KnowledgeNoteRead.model_validate(item) for item in items]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/notes/{note_id}",
|
||||
response_model=KnowledgeNoteRead,
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def get_note(
|
||||
study_id: uuid.UUID,
|
||||
note_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> KnowledgeNoteRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
note = await note_crud.get_note(db, note_id)
|
||||
if not note or note.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="注意事项不存在")
|
||||
return KnowledgeNoteRead.model_validate(note)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/notes/{note_id}",
|
||||
response_model=KnowledgeNoteRead,
|
||||
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||
)
|
||||
async def update_note(
|
||||
study_id: uuid.UUID,
|
||||
note_id: uuid.UUID,
|
||||
note_in: KnowledgeNoteUpdate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> KnowledgeNoteRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
note = await note_crud.get_note(db, note_id)
|
||||
if not note or note.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="注意事项不存在")
|
||||
await _ensure_site_name_active(db, study_id, note.site_name)
|
||||
note = await note_crud.update_note(db, note, note_in)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="knowledge_note",
|
||||
entity_id=note_id,
|
||||
action="UPDATE_KNOWLEDGE_NOTE",
|
||||
detail=f"注意事项 {note_id} 已更新",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
return KnowledgeNoteRead.model_validate(note)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/notes/{note_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||
)
|
||||
async def delete_note(
|
||||
study_id: uuid.UUID,
|
||||
note_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> None:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
note = await note_crud.get_note(db, note_id)
|
||||
if not note or note.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="注意事项不存在")
|
||||
await _ensure_site_name_active(db, study_id, note.site_name)
|
||||
await note_crud.delete_note(db, note)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="knowledge_note",
|
||||
entity_id=note_id,
|
||||
action="DELETE_KNOWLEDGE_NOTE",
|
||||
detail=f"注意事项 {note_id} 已删除",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
@@ -1,9 +1,10 @@
|
||||
import uuid
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_not_locked, require_study_roles
|
||||
from app.core.deps import get_operator_role_label, get_current_user, get_db_session, require_study_not_locked, require_api_permission
|
||||
from app.crud import audit as audit_crud
|
||||
from app.crud import material_equipment as equipment_crud
|
||||
from app.crud import study as study_crud
|
||||
@@ -24,11 +25,23 @@ def _validate_calibration(need_calibration: bool, calibration_cycle_days: int |
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="需要校准时校准周期必须大于0")
|
||||
|
||||
|
||||
def _equipment_audit_name(item) -> str:
|
||||
parts = [item.name, item.spec_model, item.brand]
|
||||
return " / ".join(str(part).strip() for part in parts if str(part or "").strip()) or "设备记录"
|
||||
|
||||
|
||||
def _equipment_audit_detail(action: str, item) -> str:
|
||||
return json.dumps(
|
||||
{"targetName": _equipment_audit_name(item), "description": f"{action}设备“{_equipment_audit_name(item)}”"},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/equipment",
|
||||
response_model=MaterialEquipmentRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
|
||||
dependencies=[Depends(require_api_permission("material_equipments:create")), Depends(require_study_not_locked())],
|
||||
)
|
||||
async def create_equipment(
|
||||
study_id: uuid.UUID,
|
||||
@@ -47,9 +60,9 @@ async def create_equipment(
|
||||
entity_type="material_equipment",
|
||||
entity_id=item.id,
|
||||
action="CREATE_MATERIAL_EQUIPMENT",
|
||||
detail=f"设备 {item.id} 已创建",
|
||||
detail=_equipment_audit_detail("创建", item),
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
)
|
||||
return MaterialEquipmentRead.model_validate(item)
|
||||
|
||||
@@ -57,7 +70,7 @@ async def create_equipment(
|
||||
@router.get(
|
||||
"/equipment",
|
||||
response_model=list[MaterialEquipmentRead],
|
||||
dependencies=[Depends(require_study_member())],
|
||||
dependencies=[Depends(require_api_permission("material_equipments:read"))],
|
||||
)
|
||||
async def list_equipments(
|
||||
study_id: uuid.UUID,
|
||||
@@ -74,7 +87,7 @@ async def list_equipments(
|
||||
@router.get(
|
||||
"/equipment/{equipment_id}",
|
||||
response_model=MaterialEquipmentRead,
|
||||
dependencies=[Depends(require_study_member())],
|
||||
dependencies=[Depends(require_api_permission("material_equipments:read"))],
|
||||
)
|
||||
async def get_equipment(
|
||||
study_id: uuid.UUID,
|
||||
@@ -91,7 +104,7 @@ async def get_equipment(
|
||||
@router.patch(
|
||||
"/equipment/{equipment_id}",
|
||||
response_model=MaterialEquipmentRead,
|
||||
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
|
||||
dependencies=[Depends(require_api_permission("material_equipments:update")), Depends(require_study_not_locked())],
|
||||
)
|
||||
async def update_equipment(
|
||||
study_id: uuid.UUID,
|
||||
@@ -118,9 +131,9 @@ async def update_equipment(
|
||||
entity_type="material_equipment",
|
||||
entity_id=equipment_id,
|
||||
action="UPDATE_MATERIAL_EQUIPMENT",
|
||||
detail=f"设备 {equipment_id} 已更新",
|
||||
detail=_equipment_audit_detail("更新", item),
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
)
|
||||
return MaterialEquipmentRead.model_validate(item)
|
||||
|
||||
@@ -128,7 +141,7 @@ async def update_equipment(
|
||||
@router.delete(
|
||||
"/equipment/{equipment_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
|
||||
dependencies=[Depends(require_api_permission("material_equipments:delete")), Depends(require_study_not_locked())],
|
||||
)
|
||||
async def delete_equipment(
|
||||
study_id: uuid.UUID,
|
||||
@@ -140,6 +153,7 @@ async def delete_equipment(
|
||||
item = await equipment_crud.get_equipment(db, equipment_id)
|
||||
if not item or item.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="设备记录不存在")
|
||||
equipment_detail = _equipment_audit_detail("删除", item)
|
||||
await equipment_crud.delete_equipment(db, item)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
@@ -147,7 +161,7 @@ async def delete_equipment(
|
||||
entity_type="material_equipment",
|
||||
entity_id=equipment_id,
|
||||
action="DELETE_MATERIAL_EQUIPMENT",
|
||||
detail=f"设备 {equipment_id} 已删除",
|
||||
detail=equipment_detail,
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
)
|
||||
|
||||
+161
-15
@@ -4,16 +4,26 @@ import json
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles, require_study_not_locked
|
||||
from app.core.deps import get_current_user, get_db_session, is_system_admin, require_api_permission, require_study_not_locked
|
||||
from app.core.decorators import register_api_endpoint
|
||||
from app.crud import audit as audit_crud
|
||||
from app.crud import member as member_crud
|
||||
from app.crud import study as study_crud
|
||||
from app.crud import user as user_crud
|
||||
from app.schemas.member import StudyMemberCreate, StudyMemberRead, StudyMemberReadWithUser, StudyMemberUpdate
|
||||
from app.schemas.user import UserDisplay
|
||||
from app.schemas.user import UserResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
PROJECT_ROLE_RANK = {
|
||||
"ADMIN": 100,
|
||||
"PM": 80,
|
||||
"PV": 50,
|
||||
"QA": 50,
|
||||
"CRA": 40,
|
||||
"CTA": 40,
|
||||
}
|
||||
|
||||
|
||||
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
|
||||
study = await study_crud.get(db, study_id)
|
||||
@@ -22,11 +32,73 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
|
||||
return study
|
||||
|
||||
|
||||
async def _operator_project_role(db: AsyncSession, study_id: uuid.UUID, current_user) -> str:
|
||||
if is_system_admin(current_user):
|
||||
return "ADMIN"
|
||||
member = await member_crud.get_member(db, study_id, current_user.id)
|
||||
return member.role_in_study if member and member.is_active else ""
|
||||
|
||||
|
||||
def _role_rank(role: str | None) -> int:
|
||||
return PROJECT_ROLE_RANK.get(role or "", 0)
|
||||
|
||||
|
||||
def _user_audit_name(user, user_id: uuid.UUID) -> str:
|
||||
if user:
|
||||
for attr in ("full_name", "username", "email"):
|
||||
value = getattr(user, attr, None)
|
||||
if value:
|
||||
return str(value)
|
||||
return str(user_id)
|
||||
|
||||
|
||||
async def _member_audit_name(db: AsyncSession, user_id: uuid.UUID) -> str:
|
||||
user = await user_crud.get_by_id(db, user_id)
|
||||
return _user_audit_name(user, user_id)
|
||||
|
||||
|
||||
async def _ensure_member_mutation_allowed(
|
||||
db: AsyncSession,
|
||||
study_id: uuid.UUID,
|
||||
current_user,
|
||||
*,
|
||||
target_member=None,
|
||||
target_role: str | None = None,
|
||||
) -> None:
|
||||
if is_system_admin(current_user):
|
||||
return
|
||||
|
||||
actor_member = await member_crud.get_member(db, study_id, current_user.id)
|
||||
actor_role = actor_member.role_in_study if actor_member else None
|
||||
actor_rank = _role_rank(actor_role)
|
||||
|
||||
if target_member and target_member.user_id == current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不能修改自己的项目成员权限")
|
||||
|
||||
if target_member:
|
||||
target_user = await user_crud.get_by_id(db, target_member.user_id)
|
||||
if is_system_admin(target_user):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不能修改系统管理员账号的项目权限")
|
||||
|
||||
if target_member and _role_rank(target_member.role_in_study) >= actor_rank:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只能修改下属项目角色成员")
|
||||
|
||||
if target_role and _role_rank(target_role) >= actor_rank:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只能授予下属项目角色")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/",
|
||||
response_model=StudyMemberRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
|
||||
dependencies=[Depends(require_api_permission("project_members:create")), Depends(require_study_not_locked())],
|
||||
)
|
||||
@register_api_endpoint(
|
||||
endpoint_key="project_members:create",
|
||||
module="project_members",
|
||||
action="write",
|
||||
description="添加项目成员",
|
||||
default_roles=["PM"],
|
||||
)
|
||||
async def add_member(
|
||||
study_id: uuid.UUID,
|
||||
@@ -35,8 +107,16 @@ async def add_member(
|
||||
current_user=Depends(get_current_user),
|
||||
) -> StudyMemberRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
await _ensure_member_mutation_allowed(db, study_id, current_user, target_role=member_in.role_in_study)
|
||||
existing = await member_crud.get_member(db, study_id, member_in.user_id)
|
||||
if existing:
|
||||
await _ensure_member_mutation_allowed(
|
||||
db,
|
||||
study_id,
|
||||
current_user,
|
||||
target_member=existing,
|
||||
target_role=member_in.role_in_study,
|
||||
)
|
||||
if not existing.is_active:
|
||||
updated = await member_crud.update_member(
|
||||
db,
|
||||
@@ -50,12 +130,12 @@ async def add_member(
|
||||
entity_id=updated.id,
|
||||
action="PROJECT_MEMBER_UPDATED",
|
||||
detail=json.dumps({
|
||||
"targetName": str(updated.user_id),
|
||||
"targetName": await _member_audit_name(db, updated.user_id),
|
||||
"before": {"is_active": existing.is_active, "role_in_study": existing.role_in_study},
|
||||
"after": {"is_active": updated.is_active, "role_in_study": updated.role_in_study},
|
||||
}, ensure_ascii=False),
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
operator_role=await _operator_project_role(db, study_id, current_user),
|
||||
)
|
||||
return updated
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="成员已存在")
|
||||
@@ -66,9 +146,12 @@ async def add_member(
|
||||
entity_type="study_member",
|
||||
entity_id=member.id,
|
||||
action="PROJECT_MEMBER_ADDED",
|
||||
detail=json.dumps({"targetName": str(member.user_id), "after": {"role_in_study": member.role_in_study, "is_active": member.is_active}}, ensure_ascii=False),
|
||||
detail=json.dumps({
|
||||
"targetName": await _member_audit_name(db, member.user_id),
|
||||
"after": {"role_in_study": member.role_in_study, "is_active": member.is_active},
|
||||
}, ensure_ascii=False),
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
operator_role=await _operator_project_role(db, study_id, current_user),
|
||||
)
|
||||
return member
|
||||
|
||||
@@ -76,7 +159,14 @@ async def add_member(
|
||||
@router.get(
|
||||
"/",
|
||||
response_model=list[StudyMemberReadWithUser],
|
||||
dependencies=[Depends(require_study_member())],
|
||||
dependencies=[Depends(require_api_permission("project_members:read"))],
|
||||
)
|
||||
@register_api_endpoint(
|
||||
endpoint_key="project_members:read",
|
||||
module="project_members",
|
||||
action="read",
|
||||
description="查询项目成员列表",
|
||||
default_roles=["PM"],
|
||||
)
|
||||
async def list_members(
|
||||
study_id: uuid.UUID,
|
||||
@@ -106,16 +196,46 @@ async def list_members(
|
||||
role_in_study=m.role_in_study,
|
||||
is_active=m.is_active,
|
||||
added_at=m.added_at,
|
||||
user=UserDisplay.model_validate(user) if user else None,
|
||||
user=UserResponse.model_validate(user) if user else None,
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.get(
|
||||
"/candidates",
|
||||
response_model=list[UserResponse],
|
||||
dependencies=[Depends(require_api_permission("project_members:read"))],
|
||||
)
|
||||
@register_api_endpoint(
|
||||
endpoint_key="project_members:read",
|
||||
module="project_members",
|
||||
action="read",
|
||||
description="查询项目成员候选人",
|
||||
default_roles=["PM"],
|
||||
)
|
||||
async def list_member_candidates(
|
||||
study_id: uuid.UUID,
|
||||
skip: int = 0,
|
||||
limit: int = 500,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> list[UserResponse]:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
users = await user_crud.list_active_member_candidates_for_study(db, study_id, skip=skip, limit=limit)
|
||||
return [UserResponse.model_validate(user) for user in users]
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{member_id}",
|
||||
response_model=StudyMemberRead,
|
||||
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
|
||||
dependencies=[Depends(require_api_permission("project_members:update")), Depends(require_study_not_locked())],
|
||||
)
|
||||
@register_api_endpoint(
|
||||
endpoint_key="project_members:update",
|
||||
module="project_members",
|
||||
action="write",
|
||||
description="更新项目成员",
|
||||
default_roles=["PM"],
|
||||
)
|
||||
async def update_member(
|
||||
study_id: uuid.UUID,
|
||||
@@ -128,6 +248,13 @@ async def update_member(
|
||||
member = await member_crud.get_member_by_id(db, member_id)
|
||||
if not member or member.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="成员不存在")
|
||||
await _ensure_member_mutation_allowed(
|
||||
db,
|
||||
study_id,
|
||||
current_user,
|
||||
target_member=member,
|
||||
target_role=member_in.role_in_study,
|
||||
)
|
||||
before_data = {"role_in_study": member.role_in_study, "is_active": member.is_active}
|
||||
updated = await member_crud.update_member(db, member, member_in)
|
||||
after_data = {"role_in_study": updated.role_in_study, "is_active": updated.is_active}
|
||||
@@ -137,9 +264,13 @@ async def update_member(
|
||||
entity_type="study_member",
|
||||
entity_id=updated.id,
|
||||
action="PROJECT_MEMBER_UPDATED",
|
||||
detail=json.dumps({"targetName": str(updated.user_id), "before": before_data, "after": after_data}, ensure_ascii=False),
|
||||
detail=json.dumps({
|
||||
"targetName": await _member_audit_name(db, updated.user_id),
|
||||
"before": before_data,
|
||||
"after": after_data,
|
||||
}, ensure_ascii=False),
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
operator_role=await _operator_project_role(db, study_id, current_user),
|
||||
)
|
||||
return updated
|
||||
|
||||
@@ -147,7 +278,14 @@ async def update_member(
|
||||
@router.delete(
|
||||
"/{member_id}",
|
||||
response_model=StudyMemberRead,
|
||||
dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())],
|
||||
dependencies=[Depends(require_api_permission("project_members:delete")), Depends(require_study_not_locked())],
|
||||
)
|
||||
@register_api_endpoint(
|
||||
endpoint_key="project_members:delete",
|
||||
module="project_members",
|
||||
action="write",
|
||||
description="删除项目成员",
|
||||
default_roles=["PM"],
|
||||
)
|
||||
async def remove_member(
|
||||
study_id: uuid.UUID,
|
||||
@@ -159,6 +297,10 @@ async def remove_member(
|
||||
member = await member_crud.get_member_by_id(db, member_id)
|
||||
if not member or member.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="成员不存在")
|
||||
target_user = await user_crud.get_by_id(db, member.user_id)
|
||||
if target_user and is_system_admin(target_user):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="系统管理员不可从项目中移除")
|
||||
await _ensure_member_mutation_allowed(db, study_id, current_user, target_member=member)
|
||||
before_data = {"role_in_study": member.role_in_study, "is_active": member.is_active}
|
||||
removed = await member_crud.remove_member(db, member)
|
||||
await audit_crud.log_action(
|
||||
@@ -167,8 +309,12 @@ async def remove_member(
|
||||
entity_type="study_member",
|
||||
entity_id=removed.id,
|
||||
action="PROJECT_MEMBER_REMOVED",
|
||||
detail=json.dumps({"targetName": str(removed.user_id), "before": before_data, "after": {"is_active": removed.is_active}}, ensure_ascii=False),
|
||||
detail=json.dumps({
|
||||
"targetName": _user_audit_name(target_user, removed.user_id),
|
||||
"before": before_data,
|
||||
"after": {"is_active": removed.is_active},
|
||||
}, ensure_ascii=False),
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
operator_role=await _operator_project_role(db, study_id, current_user),
|
||||
)
|
||||
return removed
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user