Compare commits
48
Commits
b7872f29a9
...
agent-mvp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eac6b78598 | ||
|
|
1d88f8efbe | ||
|
|
ba947b616b | ||
|
|
ae1a657554 | ||
|
|
3fbb17bb30 | ||
|
|
ddbb50173c | ||
|
|
437eb5a19a | ||
|
|
31e2728db1 | ||
|
|
03bb2d75c2 | ||
|
|
b977bf6725 | ||
|
|
045d6c5b49 | ||
|
|
db6032bd84 | ||
|
|
b4ecfbb87a | ||
|
|
a204980944 | ||
|
|
2b5f9b8514 | ||
|
|
ca1579dcc2 | ||
|
|
775ecb8a58 | ||
|
|
f72b56845f | ||
|
|
baeaa8a2e1 | ||
|
|
ca97de2e51 | ||
|
|
71fa2ae18c | ||
|
|
76cf6c32bc | ||
|
|
5a91da0904 | ||
|
|
d62bcae85e | ||
|
|
4c0a4b29e9 | ||
|
|
80ca985c28 | ||
|
|
5a55d65002 | ||
|
|
d99f4cec6a | ||
|
|
a6e7a2e75c | ||
|
|
23c008f602 | ||
|
|
2a762e63a7 | ||
|
|
f6939f5516 | ||
|
|
bbf6a0f7ba | ||
|
|
5fd82b8e7c | ||
|
|
2a823b2616 | ||
|
|
2af89eea1c | ||
|
|
26643d68c7 | ||
|
|
f35287d3cf | ||
|
|
4fa8e55748 | ||
|
|
7a9fcaae81 | ||
|
|
a1e9673d9a | ||
|
|
e588d1cf33 | ||
|
|
1712ecd4c7 | ||
|
|
441979f581 | ||
|
|
e336ffcd46 | ||
|
|
52b8f07abd | ||
|
|
7efaeb41e8 | ||
|
|
9a7aad2d36 |
@@ -0,0 +1,19 @@
|
||||
.git
|
||||
.github
|
||||
.gitea
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.venv/
|
||||
venv/
|
||||
build/
|
||||
dist/
|
||||
package/
|
||||
temp/
|
||||
data/
|
||||
# db_inp/
|
||||
inp/
|
||||
# .env
|
||||
*.pyc
|
||||
*.dump
|
||||
app/algorithms/health/model/my_survival_forest_model_quxi.joblib
|
||||
+20
-8
@@ -1,19 +1,16 @@
|
||||
# TJWater Server 环境变量配置模板
|
||||
# 复制此文件为 .env 并填写实际值
|
||||
# CI/CD: 将生产 .env 的完整内容保存为 Gitea 仓库密钥 TJWATER_SERVER_ENV。
|
||||
ENVIRONMENT="production"
|
||||
NETWORK_NAME="tjwater"
|
||||
# ============================================
|
||||
# 安全配置 (必填)
|
||||
# 敏感配置加密 (必填)
|
||||
# ============================================
|
||||
|
||||
# JWT 密钥 - 用于生成和验证 Token
|
||||
# 生成方式: openssl rand -hex 32
|
||||
SECRET_KEY=your-secret-key-here-change-in-production-use-openssl-rand-hex-32
|
||||
|
||||
# 数据加密密钥 - 用于敏感数据加密
|
||||
# Fernet 格式,生产环境必须替换为独立密钥
|
||||
# 生成方式: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
ENCRYPTION_KEY=
|
||||
DATABASE_ENCRYPTION_KEY="rJC2VqLg4KrlSq+DGJcYm869q4v5KB2dFAeuQTe0I50="
|
||||
# 用于项目数据库 DSN、GeoServer 管理密码等敏感配置
|
||||
DATABASE_ENCRYPTION_KEY="replace-with-generated-fernet-key"
|
||||
|
||||
# ============================================
|
||||
# 数据库配置 (PostgreSQL)
|
||||
@@ -48,3 +45,18 @@ METADATA_DB_PASSWORD="password"
|
||||
KEYCLOAK_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
|
||||
KEYCLOAK_ALGORITHM=RS256
|
||||
KEYCLOAK_AUDIENCE="account"
|
||||
|
||||
|
||||
# ============================================
|
||||
# Bocha Web Search API
|
||||
# ============================================
|
||||
BOCHA_API_KEY="sk-your-bocha-api-key"
|
||||
BOCHA_WEB_SEARCH_URL="https://api.bochaai.com/v1/web-search"
|
||||
BOCHA_WEB_SEARCH_TIMEOUT_SECONDS=30
|
||||
|
||||
# ============================================
|
||||
# Tianditu Geocoding API
|
||||
# ============================================
|
||||
TIANDITU_GEOCODER_TOKEN="your-tianditu-geocoder-token"
|
||||
TIANDITU_GEOCODER_URL="https://api.tianditu.gov.cn/geocoder"
|
||||
TIANDITU_GEOCODER_TIMEOUT_SECONDS=30
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
name: Server CI/CD
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
- "latest"
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
docker-image:
|
||||
runs-on: ubuntu-22.04
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
permissions:
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: https://gitea.waternetwork.cn/actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Normalize image metadata
|
||||
env:
|
||||
RAW_REGISTRY_HOST: ${{ vars.REGISTRY_HOST }}
|
||||
RAW_REPOSITORY: ${{ github.repository }}
|
||||
RAW_REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
RAW_REGISTRY_HOST="$(printf '%s' "${RAW_REGISTRY_HOST}" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
|
||||
|
||||
if [ -z "${RAW_REGISTRY_HOST}" ]; then
|
||||
echo "Missing required repository variable: REGISTRY_HOST"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REGISTRY_HOST="${RAW_REGISTRY_HOST#http://}"
|
||||
REGISTRY_HOST="${REGISTRY_HOST#https://}"
|
||||
REGISTRY_HOST="${REGISTRY_HOST%/}"
|
||||
|
||||
if [ -z "${REGISTRY_HOST}" ]; then
|
||||
echo "Repository variable REGISTRY_HOST resolves to an empty host"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REPOSITORY_PATH="${RAW_REPOSITORY#/}"
|
||||
IMAGE_OWNER="${REPOSITORY_PATH%%/*}"
|
||||
IMAGE_REPOSITORY_PATH="$(printf '%s' "${IMAGE_OWNER}/tjwater-backend" | tr '[:upper:]' '[:lower:]')"
|
||||
IMAGE_NAME="${REGISTRY_HOST}/${IMAGE_REPOSITORY_PATH}"
|
||||
IMAGE_TAG="${RAW_REF_NAME}"
|
||||
{
|
||||
echo "REGISTRY_HOST=${REGISTRY_HOST}"
|
||||
echo "REPOSITORY_PATH=${REPOSITORY_PATH}"
|
||||
echo "IMAGE_REPOSITORY_PATH=${IMAGE_REPOSITORY_PATH}"
|
||||
echo "IMAGE_NAME=${IMAGE_NAME}"
|
||||
echo "IMAGE_TAG=${IMAGE_TAG}"
|
||||
echo "IMAGE_REF=${IMAGE_NAME}:${IMAGE_TAG}"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Login to Gitea Container Registry
|
||||
env:
|
||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
GITEA_SERVER_URL: ${{ github.server_url }}
|
||||
run: |
|
||||
if [ -z "${REGISTRY_HOST:-}" ]; then
|
||||
echo "Missing resolved environment value: REGISTRY_HOST"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "${REGISTRY_USERNAME}" ]; then
|
||||
echo "Missing required repository secret: REGISTRY_USERNAME"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "${REGISTRY_PASSWORD}" ]; then
|
||||
echo "Missing required repository secret: REGISTRY_PASSWORD"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Registry username: ${REGISTRY_USERNAME}"
|
||||
echo "Image target: ${IMAGE_REF}"
|
||||
|
||||
API_SERVER_URL="${GITEA_SERVER_URL%/}"
|
||||
api_user="$(curl -fsS \
|
||||
-H "Authorization: token ${REGISTRY_PASSWORD}" \
|
||||
"${API_SERVER_URL}/api/v1/user" \
|
||||
| sed -n 's/.*"login"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \
|
||||
| head -n 1 || true)"
|
||||
|
||||
if [ -n "${api_user}" ]; then
|
||||
echo "Registry token resolves to Gitea user: ${api_user}"
|
||||
else
|
||||
echo "Could not resolve Gitea user from REGISTRY_PASSWORD token; docker login may still use a password or a token without API access."
|
||||
fi
|
||||
|
||||
echo "Logging into registry host: ${REGISTRY_HOST}"
|
||||
echo "${REGISTRY_PASSWORD}" | docker login "$REGISTRY_HOST" \
|
||||
--username "${REGISTRY_USERNAME}" \
|
||||
--password-stdin
|
||||
|
||||
- name: Materialize runtime env file
|
||||
env:
|
||||
TJWATER_SERVER_ENV: ${{ secrets.TJWATER_SERVER_ENV }}
|
||||
run: |
|
||||
if [ -z "${TJWATER_SERVER_ENV}" ]; then
|
||||
echo "Missing required repository secret: TJWATER_SERVER_ENV"
|
||||
echo "Store the backend .env file content as a multiline Gitea repository secret named TJWATER_SERVER_ENV."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf '%s\n' "${TJWATER_SERVER_ENV}" > .env
|
||||
chmod 600 .env
|
||||
|
||||
required_env_keys=(
|
||||
ENVIRONMENT
|
||||
NETWORK_NAME
|
||||
DB_NAME
|
||||
DB_HOST
|
||||
DB_PORT
|
||||
DB_USER
|
||||
DB_PASSWORD
|
||||
TIMESCALEDB_DB_NAME
|
||||
TIMESCALEDB_DB_HOST
|
||||
TIMESCALEDB_DB_PORT
|
||||
TIMESCALEDB_DB_USER
|
||||
TIMESCALEDB_DB_PASSWORD
|
||||
METADATA_DB_NAME
|
||||
METADATA_DB_HOST
|
||||
METADATA_DB_PORT
|
||||
METADATA_DB_USER
|
||||
METADATA_DB_PASSWORD
|
||||
DATABASE_ENCRYPTION_KEY
|
||||
)
|
||||
|
||||
missing_keys=()
|
||||
for key in "${required_env_keys[@]}"; do
|
||||
if ! grep -Eq "^[[:space:]]*(export[[:space:]]+)?${key}[[:space:]]*=" .env; then
|
||||
missing_keys+=("$key")
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "${#missing_keys[@]}" -gt 0 ]; then
|
||||
echo "TJWATER_SERVER_ENV is missing required keys: ${missing_keys[*]}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Validate workspace
|
||||
run: |
|
||||
if [ ! -f ./Dockerfile ]; then
|
||||
echo "Dockerfile not found in workspace. Repository checkout may have failed or produced an unexpected workspace."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build and Push Image
|
||||
run: |
|
||||
if [ -z "${IMAGE_NAME:-}" ] || [ -z "${IMAGE_TAG:-}" ]; then
|
||||
echo "Missing resolved image metadata: IMAGE_NAME or IMAGE_TAG"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
push_with_retry() {
|
||||
image_ref="$1"
|
||||
attempt=1
|
||||
max_attempts=3
|
||||
|
||||
while [ "$attempt" -le "$max_attempts" ]; do
|
||||
if docker push "$image_ref"; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$attempt" -eq "$max_attempts" ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "Push failed for $image_ref (attempt $attempt/$max_attempts); retrying in 10s..."
|
||||
attempt=$((attempt + 1))
|
||||
sleep 10
|
||||
done
|
||||
}
|
||||
|
||||
if [ "${IMAGE_TAG}" = "latest" ]; then
|
||||
docker build \
|
||||
-f ./Dockerfile \
|
||||
-t "${IMAGE_NAME}:latest" \
|
||||
.
|
||||
push_with_retry "${IMAGE_NAME}:latest"
|
||||
else
|
||||
docker build \
|
||||
-f ./Dockerfile \
|
||||
-t "${IMAGE_NAME}:${IMAGE_TAG}" \
|
||||
-t "${IMAGE_NAME}:latest" \
|
||||
.
|
||||
push_with_retry "${IMAGE_NAME}:${IMAGE_TAG}"
|
||||
push_with_retry "${IMAGE_NAME}:latest"
|
||||
fi
|
||||
|
||||
- name: Notify Deploy Server
|
||||
run: |
|
||||
post_deploy_webhook() {
|
||||
label="$1"
|
||||
payload="$2"
|
||||
webhook_url="${{ vars.DEPLOY_WEBHOOK_URL }}"
|
||||
token="${{ secrets.DEPLOY_WEBHOOK_TOKEN }}"
|
||||
|
||||
webhook_url=$(echo "$webhook_url" | xargs)
|
||||
|
||||
if [ -z "$webhook_url" ]; then
|
||||
echo "Missing required repository variable: DEPLOY_WEBHOOK_URL"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ -z "$token" ]; then
|
||||
echo "Missing required repository secret: DEPLOY_WEBHOOK_TOKEN"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "[$label] Calling webhook: $webhook_url"
|
||||
|
||||
http_code=$(curl -sS -D /tmp/deploy_headers.txt -o /tmp/deploy_response.txt -w "%{http_code}" -X POST "$webhook_url" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $token" \
|
||||
-d "$payload")
|
||||
|
||||
echo "[$label] webhook HTTP status: ${http_code}"
|
||||
if [ "$http_code" -ge 200 ] && [ "$http_code" -lt 300 ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "[$label] response headers:"
|
||||
cat /tmp/deploy_headers.txt
|
||||
echo "[$label] response body:"
|
||||
cat /tmp/deploy_response.txt
|
||||
return 1
|
||||
}
|
||||
|
||||
PRIMARY_PAYLOAD="{\"image\":\"${IMAGE_REF}\",\"tag\":\"${IMAGE_TAG}\",\"repo\":\"${REPOSITORY_PATH}\"}"
|
||||
FALLBACK_PAYLOAD="{\"image\":\"${IMAGE_REF}\",\"tag\":\"${IMAGE_TAG}\",\"repo\":\"${IMAGE_REPOSITORY_PATH}\"}"
|
||||
|
||||
echo "Deploy webhook target: ${{ vars.DEPLOY_WEBHOOK_URL }}"
|
||||
echo "Deploy payload(primary): image=${IMAGE_REF}, tag=${IMAGE_TAG}, repo=${REPOSITORY_PATH}"
|
||||
if post_deploy_webhook "primary" "$PRIMARY_PAYLOAD"; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Primary webhook request failed, retrying with lowercase repo path..."
|
||||
echo "Deploy payload(fallback): image=${IMAGE_REF}, tag=${IMAGE_TAG}, repo=${IMAGE_REPOSITORY_PATH}"
|
||||
if post_deploy_webhook "fallback" "$FALLBACK_PAYLOAD"; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Deploy webhook failed after primary and fallback attempts."
|
||||
exit 1
|
||||
|
||||
deploy-fallback-log:
|
||||
runs-on: ubuntu-22.04
|
||||
needs: docker-image
|
||||
if: failure()
|
||||
steps:
|
||||
- name: Deployment not triggered
|
||||
run: echo "Image build/push failed, deployment webhook was not called."
|
||||
@@ -1,82 +0,0 @@
|
||||
# Copilot Instructions for TJWater Server
|
||||
|
||||
This repository contains the backend code for the TJWater Server, a water distribution network management system built with FastAPI.
|
||||
|
||||
## High-Level Architecture
|
||||
|
||||
The application follows a layered architecture:
|
||||
|
||||
- **Entry Point**: `app/main.py` initializes the FastAPI application, database connections (PostgreSQL & TimescaleDB), and middleware.
|
||||
- **API Layer**: `app/api/v1` contains the route handlers.
|
||||
- **Service Layer**: `app/services` contains business logic and orchestration.
|
||||
- **Infrastructure Layer**: `app/infra` handles database connections (`db`), audit logging (`audit`), and external integrations.
|
||||
- **Domain Layer**: `app/domain` likely contains core domain models.
|
||||
- **Native/Algorithms**: `app/native` and `app/algorithms` handle specialized water network calculations (possibly using EPANET/WNTR).
|
||||
|
||||
## Build, Test, and Run Commands
|
||||
|
||||
### Environment Setup
|
||||
|
||||
- Dependencies are listed in `requirements.txt`.
|
||||
- Configuration is managed via environment variables (see `.env.example` if available, or `app/core/config.py`).
|
||||
- **Important**: Ensure `.env` is configured with correct database credentials for both PostgreSQL and TimescaleDB.
|
||||
|
||||
If first time setting up, you may want to create a Conda environment:
|
||||
|
||||
```bash
|
||||
conda create -n server python=3.12
|
||||
conda activate server
|
||||
pip install uv
|
||||
uv pip install -r requirements.txt
|
||||
conda install -c conda-forge pymetis
|
||||
```
|
||||
|
||||
### Running the Server
|
||||
|
||||
The preferred way to run the server locally is using the helper script which sets up the Python path correctly:
|
||||
|
||||
```bash
|
||||
conda activate server
|
||||
python scripts/run_server.py
|
||||
```
|
||||
|
||||
Alternatively, you can run directly with uvicorn (ensure PYTHONPATH includes the root):
|
||||
|
||||
```bash
|
||||
conda activate server
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
Use `pytest` to run tests. The `tests/conftest.py` handles path setup.
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
pytest
|
||||
|
||||
# Run a specific test file
|
||||
pytest tests/unit/test_specific_file.py
|
||||
|
||||
# Run a specific test case
|
||||
pytest tests/unit/test_specific_file.py::test_function_name
|
||||
```
|
||||
|
||||
### Building (Optional)
|
||||
|
||||
The project includes scripts to compile Python modules to `.pyd` files using Cython (see `scripts/build_pyd.py`). This is likely for distribution/performance but not required for standard development.
|
||||
|
||||
## Key Conventions
|
||||
|
||||
- **Async/Await**: The codebase heavily uses `async` and `await` for I/O operations, especially database interactions.
|
||||
- **Database Management**:
|
||||
- Connections are managed globally in `app.infra.db` and initialized in `lifespan` (app/main.py).
|
||||
- Use `app.infra.db.dynamic_manager` for project-specific database connections (multi-tenancy/dynamic projects).
|
||||
- **Pydantic**: extensively used for data validation and settings management.
|
||||
- **Scripts**: The `scripts/` directory contains many utility scripts for maintenance, data processing, and server management. Check there before writing new operational scripts.
|
||||
- **Water Network Modeling**: Interactions with water network models often involve `epanet` or `wntr` libraries. Be aware of domain-specific terminology (nodes, links, junctions, tanks).
|
||||
|
||||
## Code Style
|
||||
|
||||
- Follow standard PEP 8 guidelines.
|
||||
- No specific linter configuration was found, so default to standard Python formatting.
|
||||
@@ -1,128 +0,0 @@
|
||||
name: Build And Package
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
build-package:
|
||||
runs-on: ${{ matrix.os }}
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
|
||||
steps:
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install system build tools
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential
|
||||
|
||||
- name: Install compile dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install cython setuptools wheel
|
||||
|
||||
- name: Run Cython compile
|
||||
run: |
|
||||
python scripts/compile.py
|
||||
|
||||
- name: Prepare package and archive
|
||||
run: |
|
||||
python - <<'PY'
|
||||
import os
|
||||
import shutil
|
||||
import tarfile
|
||||
import zipfile
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
root = Path.cwd()
|
||||
package_dir = root / "package"
|
||||
dist_dir = root / "dist"
|
||||
|
||||
for d in [package_dir, dist_dir]:
|
||||
if d.exists():
|
||||
shutil.rmtree(d)
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Define directories with compiled artifacts
|
||||
compile_dirs = ["app/services", "app/native/wndb", "app/algorithms"]
|
||||
# Global ignore list
|
||||
ignore_names = {
|
||||
".git",
|
||||
".github",
|
||||
"__pycache__",
|
||||
".pytest_cache",
|
||||
".mypy_cache",
|
||||
".venv",
|
||||
"venv",
|
||||
"temp",
|
||||
"tests",
|
||||
"package",
|
||||
"dist",
|
||||
}
|
||||
|
||||
def ignore_func(directory, names):
|
||||
rel_dir = os.path.relpath(directory, root).replace("\\", "/")
|
||||
is_in_compile_path = any(rel_dir.startswith(d) for d in compile_dirs)
|
||||
|
||||
ignored = []
|
||||
for name in names:
|
||||
if name in ignore_names or name.endswith(".pyc"):
|
||||
ignored.append(name)
|
||||
# Exclude source .py files only in compiled directories
|
||||
elif is_in_compile_path and name.endswith(".py"):
|
||||
ignored.append(name)
|
||||
return ignored
|
||||
|
||||
for item in root.iterdir():
|
||||
if item.name in ignore_names:
|
||||
continue
|
||||
target = package_dir / item.name
|
||||
if item.is_dir():
|
||||
shutil.copytree(item, target, ignore=ignore_func)
|
||||
else:
|
||||
shutil.copy2(item, target)
|
||||
|
||||
# Safety guard: ensure no .github directory remains
|
||||
github_paths = [p for p in package_dir.rglob(".github") if p.is_dir()]
|
||||
for p in github_paths:
|
||||
shutil.rmtree(p, ignore_errors=True)
|
||||
|
||||
sha = os.environ["GITHUB_SHA"]
|
||||
run_os = os.environ["RUNNER_OS"].lower()
|
||||
|
||||
if run_os == "windows":
|
||||
archive_path = dist_dir / f"tjwater-server-{run_os}-{sha}.zip"
|
||||
with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
for f in package_dir.rglob("*"):
|
||||
if f.is_file():
|
||||
zf.write(f, f.relative_to(package_dir))
|
||||
else:
|
||||
archive_path = dist_dir / f"tjwater-server-{run_os}-{sha}.tar.gz"
|
||||
with tarfile.open(archive_path, "w:gz") as tf:
|
||||
tf.add(package_dir, arcname=".")
|
||||
|
||||
print(f"Archive created: {archive_path}")
|
||||
PY
|
||||
shell: bash
|
||||
|
||||
- name: Upload package artifact
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: tjwater-server-package-${{ runner.os }}
|
||||
path: dist/*
|
||||
retention-days: 14
|
||||
@@ -0,0 +1,38 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
|
||||
This repository contains the TJWater Python backend. Main application code lives in `app/`: API routes under `app/api`, authentication in `app/auth`, configuration in `app/core`, database and repository code in `app/infra`, domain models/schemas in `app/domain`, and business logic in `app/services` and `app/algorithms`.
|
||||
|
||||
Tests are under `tests/`, split into `tests/unit`, `tests/api`, and `tests/auth`. CLI code lives in `cli/tjwater_cli`, with CLI tests in `cli/tests`. SQL and sample assets are stored in `resources/`; deployment files are in `Dockerfile`, `.gitea/workflows/package.yml`, and `infra/docker/docker-compose.yml`. Local data directories such as `db_inp/`, `temp/`, `data/`, and `.env` are ignored and should not be committed.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
|
||||
Use the existing conda environment when available:
|
||||
|
||||
```bash
|
||||
conda run -n server python -m pytest tests/unit tests/auth -q
|
||||
conda run -n server uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
docker build -t tjwater-server:local .
|
||||
docker compose -f infra/docker/docker-compose.yml config
|
||||
```
|
||||
|
||||
`pytest` runs backend tests. `uvicorn` starts the FastAPI app locally. `docker build` verifies the container image. `docker compose config` validates compose syntax and variable expansion.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
|
||||
Use Python 3.12, four-space indentation, type hints for new public functions, and explicit imports. Keep API endpoint modules grouped by domain under `app/api/v1/endpoints`. Use `snake_case` for files, functions, and variables; `PascalCase` for classes and Pydantic models. Prefer existing repository/service patterns in `app/infra/db` and `app/services` over introducing new abstractions.
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
The project uses `pytest`. Name test files `test_*.py` and test functions `test_*`. Keep unit tests isolated with fakes or monkeypatching from `tests/conftest.py`. Some existing tests depend on local data outside the repository; avoid adding new tests that require untracked files. For API changes, add or update tests in `tests/api`.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
|
||||
History uses a mix of Conventional Commit prefixes and concise Chinese messages, for example `feat(api): add Tianditu geocoding`, `fix(cli): constrain timeseries option values`, or `更新 cli 命令...`. Prefer `feat(scope): ...`, `fix(scope): ...`, or a clear Chinese summary.
|
||||
|
||||
Pull requests should describe the behavior change, list verification commands, mention configuration or migration impacts, and link related issues. Include API examples or screenshots only when they clarify user-facing behavior.
|
||||
|
||||
## Security & Configuration Tips
|
||||
|
||||
Do not commit `.env`, database dumps, generated caches, or local project data. Use `.env.example` as the configuration template. Secrets for CI/CD belong in Gitea repository secrets such as `REGISTRY_USERNAME`, `REGISTRY_PASSWORD`, and deploy webhook credentials.
|
||||
@@ -0,0 +1,117 @@
|
||||
# TJWater Authentication and Metadata Management
|
||||
|
||||
## Ownership
|
||||
|
||||
Keycloak owns login identity, credentials, token issuance, and token expiry.
|
||||
TJWater metadata stores only business snapshots and authorization data:
|
||||
|
||||
- `users.keycloak_id` is the stable identity binding.
|
||||
- `users.username`, `users.email`, and `users.last_login_at` are Keycloak claim caches.
|
||||
- `users.role`, `users.is_active`, and `users.is_superuser` control TJWater system access.
|
||||
- `user_project_membership.project_role` controls project access.
|
||||
|
||||
The backend does not accept passwords, does not issue local JWTs, and does not
|
||||
trust frontend-supplied user IDs.
|
||||
|
||||
## Fixed Project RBAC
|
||||
|
||||
Project roles are stored directly in
|
||||
`user_project_membership.project_role`; there is no separate role table or
|
||||
user-defined permission editor in this delivery.
|
||||
|
||||
| Role | Main access |
|
||||
| --- | --- |
|
||||
| `modeler` | Model upload/import, simulation, burst, risk, and optimization analysis |
|
||||
| `dispatcher` | SCADA cleaning, simulation and burst analysis |
|
||||
| `auditor` | Project read access and project-scoped audit logs |
|
||||
| `viewer` | WebGIS and read-only risk results |
|
||||
|
||||
Legacy `owner`, `admin`, and `member` values remain supported for existing
|
||||
records. The backend is the authorization boundary; the frontend uses
|
||||
`GET /api/v1/access/context` only to hide unavailable menus and guard routes.
|
||||
System admins receive environment, membership, and global-audit permissions,
|
||||
but still need a project membership for project business APIs.
|
||||
|
||||
## Login Snapshot Refresh
|
||||
|
||||
Every authenticated metadata-user resolution validates the Keycloak access token
|
||||
and reads `sub`, `preferred_username`, and `email` claims. The
|
||||
backend finds `users` by `keycloak_id = sub`, rejects inactive or missing users,
|
||||
then refreshes `username`, `email`, and `last_login_at`.
|
||||
|
||||
This keeps local display data current without changing the identity binding.
|
||||
There is no Keycloak webhook requirement; second-level user or permission sync is
|
||||
out of scope unless explicitly requested later.
|
||||
|
||||
## Admin APIs
|
||||
|
||||
All admin APIs require metadata admin access: `users.is_superuser = true` or
|
||||
`users.role = 'admin'`.
|
||||
|
||||
User and membership management:
|
||||
|
||||
- `GET /api/v1/admin/me`
|
||||
- `POST /api/v1/admin/users/sync`
|
||||
- `POST /api/v1/admin/users/sync/batch`
|
||||
- `GET /api/v1/admin/users`
|
||||
- `GET /api/v1/admin/users/{user_id}`
|
||||
- `PATCH /api/v1/admin/users/{user_id}`
|
||||
- `GET /api/v1/admin/projects/{project_id}/members`
|
||||
- `POST /api/v1/admin/projects/{project_id}/members`
|
||||
- `PATCH /api/v1/admin/projects/{project_id}/members/{user_id}`
|
||||
- `DELETE /api/v1/admin/projects/{project_id}/members/{user_id}`
|
||||
|
||||
Project configuration:
|
||||
|
||||
- `GET /api/v1/admin/projects`
|
||||
- `POST /api/v1/admin/projects`
|
||||
- `PATCH /api/v1/admin/projects/{project_id}`
|
||||
- `GET /api/v1/admin/projects/{project_id}/databases`
|
||||
- `PUT /api/v1/admin/projects/{project_id}/databases`
|
||||
- `DELETE /api/v1/admin/projects/{project_id}/databases/{db_role}`
|
||||
- `POST /api/v1/admin/projects/{project_id}/databases/{db_role}/health`
|
||||
|
||||
## Secret Handling
|
||||
|
||||
Admins submit plaintext DSNs only through HTTPS admin APIs. Operators should not
|
||||
write encrypted columns manually.
|
||||
|
||||
- `project_databases.dsn_encrypted` is encrypted with `DATABASE_ENCRYPTION_KEY`.
|
||||
- Admin responses return only `has_dsn`.
|
||||
- Audit logs record whether a secret was updated, but never store plaintext DSNs
|
||||
or other secrets.
|
||||
|
||||
Generate the database encryption key with:
|
||||
|
||||
```bash
|
||||
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
```
|
||||
|
||||
Keep keys stable for the lifetime of encrypted metadata. Rotating a key requires
|
||||
decrypting with the old key and re-encrypting with the new key.
|
||||
|
||||
## Metadata Schema Patches
|
||||
|
||||
Apply metadata patches in order:
|
||||
|
||||
1. `resources/sql/004_metadata_auth_management.sql`
|
||||
2. `resources/sql/005_metadata_project_configuration.sql`
|
||||
3. `resources/sql/006_metadata_rbac_roles.sql`
|
||||
|
||||
`004` creates Keycloak-backed metadata users and project memberships. `005`
|
||||
creates project and project database routing tables with uniqueness, role/type,
|
||||
and pool-size constraints. `006` extends existing membership constraints with
|
||||
the fixed delivery roles.
|
||||
|
||||
## Frontend System Management
|
||||
|
||||
`/system-admin` is shown only when `GET /api/v1/access/context` returns
|
||||
`environment.manage`. The page lets admins maintain metadata users, project
|
||||
members, projects, project database routing for `biz_data` and `iot_data`, and
|
||||
connection health checks. This replaces direct SQL editing for normal project
|
||||
onboarding.
|
||||
|
||||
Hydraulic model authoring is outside the Web application. Models are prepared
|
||||
in the desktop modeling client and uploaded/imported by an authorized modeler;
|
||||
the system administrator configures the project environment and database
|
||||
routing.
|
||||
@@ -0,0 +1,77 @@
|
||||
# Backend Naming Audit
|
||||
|
||||
DOC-003 audit for the internal `TJWaterServerBinary` backend.
|
||||
|
||||
## Scope
|
||||
|
||||
Reviewed FastAPI route decorators under `app/api/v1/endpoints`, router prefixes in `app/api/v1/router.py`, and public request/response schema fields in `app/api` and `app/domain`.
|
||||
|
||||
The backend is mounted only under `/api/v1` from `app/main.py`; the old no-prefix router include remains commented out.
|
||||
|
||||
## Current Good Surface
|
||||
|
||||
These newer routes already follow the naming rule for public HTTP paths:
|
||||
|
||||
- Metadata/admin: `/api/v1/admin/projects`, `/api/v1/admin/users/sync`, `/api/v1/admin/projects/{project_id}/members`
|
||||
- Audit: `/api/v1/audit/logs`, `/api/v1/audit/logs/count`
|
||||
- Agent auth: `/api/v1/agent/auth/context`
|
||||
- Business APIs: `/api/v1/burst-detection/detect`, `/api/v1/burst-location/locate`, `/api/v1/leakage/identify`
|
||||
- Time-series APIs: `/api/v1/scada/by-ids-time-range`, `/api/v1/scada/by-ids-field-time-range`, `/api/v1/composite/clean-scada`
|
||||
- Project data APIs: `/api/v1/scada-info`, `/api/v1/scheme-list`, `/api/v1/burst-locate-result`
|
||||
- Web integrations: `/api/v1/web-search`, `/api/v1/geocode`
|
||||
|
||||
Path template parameters such as `{project_id}`, `{user_id}`, `{device_id}`, `{scheme_name}`, and `{link_id}` intentionally remain `snake_case`.
|
||||
|
||||
## Legacy URL Categories
|
||||
|
||||
### Keep With Compatibility
|
||||
|
||||
These now have `kebab-case` aliases. The frontend has been migrated to the replacement paths; keep the old paths as deprecated compatibility aliases for Agent planning, tests, customer scripts, or external callers:
|
||||
|
||||
| Current URL | Suggested replacement |
|
||||
| --- | --- |
|
||||
| `/api/v1/openproject/` | `/api/v1/projects/open` |
|
||||
| `/api/v1/project_info/` | `/api/v1/project-info` |
|
||||
| `/api/v1/getallschemes/` | `/api/v1/schemes` |
|
||||
| `/api/v1/getallsensorplacements/` | `/api/v1/sensor-placement-schemes` |
|
||||
| `/api/v1/sensorplacementscheme/create` | `/api/v1/sensor-placement-schemes` |
|
||||
| `/api/v1/burst_analysis/` | `/api/v1/burst-analysis` |
|
||||
| `/api/v1/valve_isolation_analysis/` | `/api/v1/valve-isolation-analysis` |
|
||||
| `/api/v1/flushing_analysis/` | `/api/v1/flushing-analysis` |
|
||||
| `/api/v1/contaminant_simulation/` | `/api/v1/contaminant-simulation` |
|
||||
| `/api/v1/runsimulationmanuallybydate/` | `/api/v1/simulations/run-by-date` |
|
||||
|
||||
### Broad Legacy Surface
|
||||
|
||||
These route groups expose many command-style concatenated paths. They should not be copied into new work; replace only when a caller migration is planned:
|
||||
|
||||
- Project lifecycle: `listprojects`, `createproject`, `deleteproject`, `isprojectopen`, `closeproject`, `copyproject`, `importinp`, `exportinp`, `readinp`, `dumpinp`, `lockproject`, `unlockproject`
|
||||
- Network object CRUD: `addjunction`, `getjunctionelevation`, `setpipediameter`, `getvalvesetting`, and similar junction/pipe/pump/tank/reservoir/valve routes
|
||||
- Region/DMA/VD commands: `calculatedistrictmeteringareaforregion`, `getdistrictmeteringarea`, `generatevirtualdistrict`, and related routes
|
||||
- SCADA native CRUD: `getscadadevice`, `setscadadevicedata`, `cleanscadaelement`, and related routes
|
||||
- Snapshot/cache utilities: `takesnapshotforoperation`, `syncwithserver`, `clearrediskey`, `queryredis`
|
||||
- Advanced simulation endpoints with underscore paths: `pressure_regulation`, `daily_scheduling_analysis`, `network_update`, `pressure_sensor_placement_kmeans`
|
||||
|
||||
### Direct Cleanup Candidates
|
||||
|
||||
These are likely safe only after confirming no caller uses them:
|
||||
|
||||
- `/api/v1/test_dict/`: development/test utility in `misc.py`.
|
||||
- `/api/v1/takenapshotforcurrentoperation`: typo compatibility path; keep deprecated if any client may still call it.
|
||||
- `/api/v1/getpumpenergyproperties//` and `/api/v1/setpumpenergyproperties//`: double-slash paths in options endpoints.
|
||||
|
||||
## Field Naming
|
||||
|
||||
Most public JSON, query, and SSE fields are already `snake_case`, including `project_id`, `user_id`, `scheme_name`, `scheme_type`, `start_time`, `end_time`, `device_ids`, `session_id`, and `request_id`.
|
||||
|
||||
Known legacy exception:
|
||||
|
||||
- `BurstAnalysis.burst_ID` in `app/api/v1/endpoints/simulation.py` should become `burst_id` on a new API contract. Preserve `burst_ID` only for the legacy body shape.
|
||||
|
||||
Headers keep standard HTTP casing:
|
||||
|
||||
- `X-Project-Id`
|
||||
|
||||
## Recommendation
|
||||
|
||||
Do not rename existing legacy routes in place. For each active legacy route, keep the new `kebab-case` alias as the documented path, keep the old route marked deprecated, migrate remaining Agent/customer/script callers, then remove only after a documented compatibility window.
|
||||
+9
-2
@@ -2,19 +2,26 @@ FROM condaforge/miniforge3:latest
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple \
|
||||
PIP_TRUSTED_HOST=pypi.tuna.tsinghua.edu.cn \
|
||||
UV_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
|
||||
# 安装 Python 3.12 和 pymetis (通过 conda-forge 避免编译问题)
|
||||
RUN mamba install -y python=3.12 pymetis && \
|
||||
mamba clean -afy
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install uv
|
||||
RUN pip install --no-cache-dir uv
|
||||
RUN uv pip install --system --no-cache-dir -r requirements.txt
|
||||
|
||||
# 将代码放入子目录 'app',将数据放入子目录 'db_inp'
|
||||
# 这样临时文件默认会生成在 /app 下,而代码在 /app/app 下,实现了分离
|
||||
COPY app ./app
|
||||
COPY db_inp ./db_inp
|
||||
RUN python -c "from pathlib import Path; from zipfile import ZipFile; model_dir = Path('app/algorithms/health/model'); zip_path = model_dir / 'my_survival_forest_model_quxi.zip'; joblib_name = 'my_survival_forest_model_quxi.joblib'; joblib_path = model_dir / joblib_name; assert zip_path.exists(), f'Model archive not found: {zip_path}'; archive = ZipFile(zip_path); archive.extract(joblib_name, model_dir); archive.close(); assert joblib_path.exists(), f'Model file not extracted: {joblib_path}'" && \
|
||||
rm -f app/algorithms/health/model/my_survival_forest_model_quxi.zip
|
||||
# COPY db_inp ./db_inp
|
||||
COPY .env .
|
||||
RUN mkdir -p db_inp temp data inp
|
||||
|
||||
# 设置 PYTHONPATH 以便 uvicorn 找到 app 模块
|
||||
ENV PYTHONPATH=/app
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# TJWaterServerBinary 内部后端
|
||||
|
||||
`TJWaterServerBinary` 是 TJWater 内部版 Python 后端,基于 FastAPI 提供认证、项目、管网、模拟、爆管、漏损、SCADA、地图服务集成和命令行工具能力。该仓库用于内部开发和完整功能维护。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- Python 3.12
|
||||
- FastAPI / Uvicorn
|
||||
- Pydantic / SQLAlchemy / psycopg
|
||||
- Redis、PostgreSQL、PostGIS、TimescaleDB
|
||||
- WNTR、EPANET、Cython、科学计算与空间分析依赖
|
||||
- pytest
|
||||
|
||||
## 目录结构
|
||||
|
||||
```text
|
||||
app/main.py FastAPI 入口
|
||||
app/api/ HTTP API 路由
|
||||
app/auth/ 认证和权限上下文
|
||||
app/core/ 配置、日志和基础设施初始化
|
||||
app/domain/ 领域模型和 Pydantic schema
|
||||
app/infra/ 数据库、缓存、EPANET 和外部集成
|
||||
app/services/ 业务服务编排
|
||||
app/algorithms/ 管网算法、模拟、爆管、漏损、清洗和健康分析
|
||||
app/native/ 本地管网数据读写与转换
|
||||
cli/ tjwater-cli 命令行工具
|
||||
tests/ 后端测试
|
||||
resources/ SQL、模板和示例资源
|
||||
infra/docker/ Docker Compose 编排
|
||||
```
|
||||
|
||||
## 本地开发
|
||||
|
||||
推荐使用已有 conda 环境:
|
||||
|
||||
```bash
|
||||
conda run -n server python -m pytest tests/unit tests/auth -q
|
||||
conda run -n server uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
```
|
||||
|
||||
如需要进入环境:
|
||||
|
||||
```bash
|
||||
conda activate server
|
||||
```
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
conda run -n server python -m pytest tests -q
|
||||
conda run -n server python scripts/run_server.py
|
||||
docker build -t tjwater-server:local .
|
||||
docker compose -f infra/docker/docker-compose.yml config
|
||||
```
|
||||
|
||||
- `pytest`:运行自动化测试。
|
||||
- `scripts/run_server.py`:使用项目脚本启动服务。
|
||||
- `docker build`:构建后端镜像。
|
||||
- `docker compose config`:检查 compose 配置和变量展开。
|
||||
|
||||
## CLI
|
||||
|
||||
CLI 位于 `cli/tjwater_cli`,说明见:
|
||||
|
||||
```text
|
||||
cli/README.md
|
||||
```
|
||||
|
||||
修改 CLI 参数、输出结构或后端接口适配时,应同步更新 CLI 测试和文档。
|
||||
|
||||
## 开发规范
|
||||
|
||||
- Python 文件、函数、变量、Pydantic 字段、JSON body 字段和 query 参数使用 `snake_case`。
|
||||
- Python 类和 Pydantic 模型使用 `PascalCase`。
|
||||
- 新 HTTP 路径使用 `kebab-case`,例如 `/api/v1/pressure-status/analyze`。
|
||||
- 优先复用现有 FastAPI/service/repository 边界。
|
||||
- 不要把临时数据、数据库 dump、日志或本地运行产物纳入提交。
|
||||
|
||||
## 测试与发布
|
||||
|
||||
提交前根据改动范围运行最小有效测试:
|
||||
|
||||
```bash
|
||||
conda run -n server python -m pytest tests/unit tests/auth -q
|
||||
```
|
||||
|
||||
发布镜像前建议运行:
|
||||
|
||||
```bash
|
||||
docker build -t tjwater-server:local .
|
||||
```
|
||||
|
||||
Gitea 包工作流位于 `.gitea/workflows/package.yml`,通常由 tag 触发构建、推送镜像并通知部署 webhook。
|
||||
|
||||
## 安全规则
|
||||
|
||||
不要提交 `.env`、客户数据、数据库 dump、日志、生成缓存、`db_inp/`、`temp/`、`data/` 或本地密钥。CI/CD 凭据应放在 Gitea secrets 和仓库变量中。
|
||||
+44
-35
@@ -1,36 +1,45 @@
|
||||
from app.algorithms.cleaning import flow_data_clean, pressure_data_clean
|
||||
from app.algorithms.sensor import (
|
||||
pressure_sensor_placement_sensitivity,
|
||||
pressure_sensor_placement_kmeans,
|
||||
)
|
||||
from app.algorithms.isolation.valve import valve_isolation_analysis
|
||||
from app.algorithms.leakage import LeakageIdentifier
|
||||
from app.algorithms.health import PipelineHealthAnalyzer
|
||||
from app.algorithms.burst_location import run_burst_location
|
||||
from app.algorithms.simulation.scenarios import (
|
||||
convert_to_local_unit,
|
||||
burst_analysis,
|
||||
valve_close_analysis,
|
||||
flushing_analysis,
|
||||
contaminant_simulation,
|
||||
age_analysis,
|
||||
pressure_regulation,
|
||||
)
|
||||
"""Algorithm package with side-effect-free, lazy compatibility exports."""
|
||||
|
||||
__all__ = [
|
||||
"flow_data_clean",
|
||||
"pressure_data_clean",
|
||||
"pressure_sensor_placement_sensitivity",
|
||||
"pressure_sensor_placement_kmeans",
|
||||
"convert_to_local_unit",
|
||||
"burst_analysis",
|
||||
"valve_close_analysis",
|
||||
"flushing_analysis",
|
||||
"contaminant_simulation",
|
||||
"age_analysis",
|
||||
"pressure_regulation",
|
||||
"valve_isolation_analysis",
|
||||
"LeakageIdentifier",
|
||||
"PipelineHealthAnalyzer",
|
||||
"run_burst_location",
|
||||
]
|
||||
from importlib import import_module
|
||||
from typing import Any
|
||||
|
||||
|
||||
_EXPORT_MODULES = {
|
||||
"flow_data_clean": "app.algorithms.cleaning",
|
||||
"pressure_data_clean": "app.algorithms.cleaning",
|
||||
"pressure_sensor_placement_sensitivity": "app.algorithms.sensor",
|
||||
"pressure_sensor_placement_kmeans": "app.algorithms.sensor",
|
||||
"valve_isolation_analysis": "app.algorithms.isolation.valve",
|
||||
"LeakageIdentifier": "app.algorithms.leakage",
|
||||
"PipelineHealthAnalyzer": "app.algorithms.health",
|
||||
"run_burst_location": "app.algorithms.burst_location",
|
||||
**{
|
||||
name: "app.algorithms.simulation.scenarios"
|
||||
for name in (
|
||||
"convert_to_local_unit",
|
||||
"burst_analysis",
|
||||
"valve_close_analysis",
|
||||
"flushing_analysis",
|
||||
"contaminant_simulation",
|
||||
"age_analysis",
|
||||
"pressure_regulation",
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
__all__ = list(_EXPORT_MODULES)
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
try:
|
||||
module_name = _EXPORT_MODULES[name]
|
||||
except KeyError as exc:
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from exc
|
||||
|
||||
value = getattr(import_module(module_name), name)
|
||||
globals()[name] = value
|
||||
return value
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
return sorted({*globals(), *__all__})
|
||||
|
||||
@@ -121,7 +121,7 @@ def run_burst_location(
|
||||
basic_pressure: float = 10.0,
|
||||
n_workers: int = DEFAULT_N_WORKERS,
|
||||
partition_on_full_graph: bool = True,
|
||||
visualize_partition: bool = True,
|
||||
visualize_partition: bool = False,
|
||||
visualize_pause_seconds: float = 0.3,
|
||||
final_candidates_csv_path: (
|
||||
str | None
|
||||
|
||||
@@ -149,14 +149,16 @@ def valve_isolation_analysis(
|
||||
|
||||
must_close_valves.sort()
|
||||
optional_valves.sort()
|
||||
isolatable = bool(must_close_valves)
|
||||
|
||||
result = {
|
||||
"accident_elements": target_elements,
|
||||
"disabled_valves": disabled_valves,
|
||||
"affected_nodes": sorted(affected_nodes),
|
||||
"affected_nodes": sorted(affected_nodes) if isolatable else [],
|
||||
"affected_node_count": len(affected_nodes),
|
||||
"must_close_valves": must_close_valves,
|
||||
"optional_valves": optional_valves,
|
||||
"isolatable": len(must_close_valves) > 0,
|
||||
"isolatable": isolatable,
|
||||
}
|
||||
|
||||
if len(target_elements) == 1:
|
||||
|
||||
@@ -121,13 +121,15 @@ def _worker_evaluate(raw_ratios: np.ndarray) -> float:
|
||||
_cleanup_temp_files(prefix)
|
||||
|
||||
|
||||
class LeakageIdentifier:
|
||||
FLOW_UNIT_TO_M3S = {
|
||||
"m3/s": 1.0,
|
||||
"m3/h": 1.0 / 3600.0,
|
||||
"L/s": 1.0 / 1000.0,
|
||||
"L/min": 1.0 / 60000.0,
|
||||
}
|
||||
class LeakageIdentifier:
|
||||
FLOW_UNIT_TO_M3S = {
|
||||
"m3/s": 1.0,
|
||||
"m³/s": 1.0,
|
||||
"m3/h": 1.0 / 3600.0,
|
||||
"m³/h": 1.0 / 3600.0,
|
||||
"L/s": 1.0 / 1000.0,
|
||||
"L/min": 1.0 / 60000.0,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _flow_to_m3s(cls, value: float, unit: str) -> float:
|
||||
|
||||
@@ -1,14 +1,77 @@
|
||||
import psycopg
|
||||
from contextlib import contextmanager
|
||||
import fcntl
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.algorithms.sensor import kmeans as kmeans_sensor
|
||||
from app.algorithms.sensor import sensitivity
|
||||
from app.core.config import get_pgconn_string
|
||||
from app.native.wndb.s42_sensor_placement import create_sensor_placement
|
||||
from app.services.sensor_placement import (
|
||||
SensorPlacementConflictError,
|
||||
SensorPlacementValidationError,
|
||||
validate_sensor_placement_nodes,
|
||||
)
|
||||
from app.services.tjnetwork import dump_inp
|
||||
|
||||
|
||||
def _sensor_inp_path(name: str) -> Path:
|
||||
if (
|
||||
not name
|
||||
or name in {".", ".."}
|
||||
or "/" in name
|
||||
or "\\" in name
|
||||
or "\x00" in name
|
||||
):
|
||||
raise SensorPlacementValidationError("管网名称不是有效的项目标识")
|
||||
return Path("db_inp") / f"{name}.db.inp"
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _sensor_inp_lock(name: str):
|
||||
inp_path = _sensor_inp_path(name)
|
||||
inp_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
lock_path = inp_path.with_suffix(".sensor.lock")
|
||||
with lock_path.open("w", encoding="utf-8") as lock_file:
|
||||
try:
|
||||
fcntl.flock(
|
||||
lock_file.fileno(),
|
||||
fcntl.LOCK_EX | fcntl.LOCK_NB,
|
||||
)
|
||||
except BlockingIOError as exc:
|
||||
raise SensorPlacementConflictError(
|
||||
"当前项目已有监测点优化任务正在运行,请稍后重试"
|
||||
) from exc
|
||||
try:
|
||||
yield inp_path
|
||||
finally:
|
||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
||||
|
||||
|
||||
def _create_validated_placement(
|
||||
name: str,
|
||||
*,
|
||||
scheme_name: str,
|
||||
min_diameter: int,
|
||||
username: str,
|
||||
sensor_location: list[str],
|
||||
) -> dict[str, Any]:
|
||||
validate_sensor_placement_nodes(name, sensor_location)
|
||||
return create_sensor_placement(
|
||||
name,
|
||||
scheme_name=scheme_name,
|
||||
min_diameter=min_diameter,
|
||||
username=username,
|
||||
sensor_location=sensor_location,
|
||||
)
|
||||
|
||||
|
||||
def pressure_sensor_placement_sensitivity(
|
||||
name: str, scheme_name: str, sensor_number: int, min_diameter: int, username: str
|
||||
) -> None:
|
||||
name: str,
|
||||
scheme_name: str,
|
||||
sensor_number: int,
|
||||
min_diameter: int,
|
||||
username: str,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
基于改进灵敏度法进行压力监测点优化布置
|
||||
:param name: 数据库名称
|
||||
@@ -16,41 +79,32 @@ def pressure_sensor_placement_sensitivity(
|
||||
:param sensor_number: 传感器数目
|
||||
:param min_diameter: 最小管径
|
||||
:param username: 用户名
|
||||
:return:
|
||||
:return: 新建的监测点方案
|
||||
"""
|
||||
sensor_location = sensitivity.get_ID(
|
||||
name=name, sensor_num=sensor_number, min_diameter=min_diameter
|
||||
with _sensor_inp_lock(name):
|
||||
sensor_location = sensitivity.get_ID(
|
||||
name=name,
|
||||
sensor_num=sensor_number,
|
||||
min_diameter=min_diameter,
|
||||
)
|
||||
return _create_validated_placement(
|
||||
name,
|
||||
scheme_name=scheme_name,
|
||||
min_diameter=min_diameter,
|
||||
username=username,
|
||||
sensor_location=sensor_location,
|
||||
)
|
||||
try:
|
||||
conn_string = get_pgconn_string(db_name=name)
|
||||
with psycopg.connect(conn_string) as conn:
|
||||
with conn.cursor() as cur:
|
||||
sql = """
|
||||
INSERT INTO sensor_placement (scheme_name, sensor_number, min_diameter, username, sensor_location)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
"""
|
||||
|
||||
cur.execute(
|
||||
sql,
|
||||
(
|
||||
scheme_name,
|
||||
sensor_number,
|
||||
min_diameter,
|
||||
username,
|
||||
sensor_location,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
print("方案信息存储成功!")
|
||||
except Exception as e:
|
||||
print(f"存储方案信息时出错:{e}")
|
||||
|
||||
|
||||
# 2025/08/21
|
||||
# 基于kmeans聚类法进行压力监测点优化布置
|
||||
def pressure_sensor_placement_kmeans(
|
||||
name: str, scheme_name: str, sensor_number: int, min_diameter: int, username: str
|
||||
) -> None:
|
||||
name: str,
|
||||
scheme_name: str,
|
||||
sensor_number: int,
|
||||
min_diameter: int,
|
||||
username: str,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
基于聚类法进行压力监测点优化布置
|
||||
:param name: 数据库名称(注意,此处数据库名称也是inp文件名称,inp文件与pg库名要一样)
|
||||
@@ -58,34 +112,20 @@ def pressure_sensor_placement_kmeans(
|
||||
:param sensor_number: 传感器数目
|
||||
:param min_diameter: 最小管径
|
||||
:param username: 用户名
|
||||
:return:
|
||||
:return: 新建的监测点方案
|
||||
"""
|
||||
# dump_inp
|
||||
inp_name = f"./db_inp/{name}.db.inp"
|
||||
dump_inp(name, inp_name, "2")
|
||||
sensor_location = kmeans_sensor.kmeans_sensor_placement(
|
||||
name=name, sensor_num=sensor_number, min_diameter=min_diameter
|
||||
with _sensor_inp_lock(name) as inp_path:
|
||||
dump_inp(name, str(inp_path), "2")
|
||||
sensor_location = kmeans_sensor.kmeans_sensor_placement(
|
||||
name=name,
|
||||
sensor_num=sensor_number,
|
||||
min_diameter=min_diameter,
|
||||
)
|
||||
return _create_validated_placement(
|
||||
name,
|
||||
scheme_name=scheme_name,
|
||||
min_diameter=min_diameter,
|
||||
username=username,
|
||||
sensor_location=sensor_location,
|
||||
)
|
||||
try:
|
||||
conn_string = get_pgconn_string(db_name=name)
|
||||
with psycopg.connect(conn_string) as conn:
|
||||
with conn.cursor() as cur:
|
||||
sql = """
|
||||
INSERT INTO sensor_placement (scheme_name, sensor_number, min_diameter, username, sensor_location)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
"""
|
||||
|
||||
cur.execute(
|
||||
sql,
|
||||
(
|
||||
scheme_name,
|
||||
sensor_number,
|
||||
min_diameter,
|
||||
username,
|
||||
sensor_location,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
print("方案信息存储成功!")
|
||||
except Exception as e:
|
||||
print(f"存储方案信息时出错:{e}")
|
||||
|
||||
@@ -29,6 +29,7 @@ import pytz
|
||||
import requests
|
||||
import time
|
||||
import app.services.project_info as project_info
|
||||
from app.services.time_api import parse_clock_duration_seconds
|
||||
|
||||
url_path = 'http://10.101.15.16:9000/loong' # 内网
|
||||
# url_path = 'http://183.64.62.100:9057/loong' # 外网
|
||||
@@ -551,21 +552,11 @@ def from_clock_to_seconds (clock: str)->int:
|
||||
return hr*3600+mnt*60+seconds
|
||||
|
||||
def from_clock_to_seconds_2 (clock: str)->int:
|
||||
str_format="%H:%M:%S"
|
||||
dt=datetime.strptime(clock,str_format)
|
||||
hr=dt.hour
|
||||
mnt=dt.minute
|
||||
seconds=dt.second
|
||||
return hr*3600+mnt*60+seconds
|
||||
return parse_clock_duration_seconds(clock)
|
||||
|
||||
|
||||
def from_clock_to_seconds_3 (clock: str)->int:
|
||||
str_format = "%H:%M" # 更新时间格式以适应 "小时:分钟" 格式
|
||||
dt = datetime.strptime(clock,str_format)
|
||||
hr = dt.hour
|
||||
mnt = dt.minute
|
||||
seconds = dt.second
|
||||
return hr * 3600 + mnt * 60
|
||||
return parse_clock_duration_seconds(clock)
|
||||
|
||||
|
||||
###convert datetimestring
|
||||
|
||||
@@ -72,6 +72,7 @@ def burst_analysis(
|
||||
modify_variable_pump_pattern: dict[str, list] = None,
|
||||
modify_valve_opening: dict[str, float] = None,
|
||||
scheme_name: str = None,
|
||||
username: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
爆管模拟
|
||||
@@ -86,6 +87,9 @@ def burst_analysis(
|
||||
:param scheme_name: 方案名称
|
||||
:return:
|
||||
"""
|
||||
if not username:
|
||||
raise ValueError("username is required when storing burst analysis scheme")
|
||||
|
||||
scheme_detail: dict = {
|
||||
"burst_ID": burst_ID,
|
||||
"burst_size": burst_size,
|
||||
@@ -211,7 +215,7 @@ def burst_analysis(
|
||||
name=name,
|
||||
scheme_name=scheme_name,
|
||||
scheme_type="burst_analysis",
|
||||
username="admin",
|
||||
username=username,
|
||||
scheme_start_time=modify_pattern_start_time,
|
||||
scheme_detail=scheme_detail,
|
||||
)
|
||||
@@ -311,6 +315,7 @@ def flushing_analysis(
|
||||
drainage_node_ID: str = None,
|
||||
flushing_flow: float = 0,
|
||||
scheme_name: str = None,
|
||||
username: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
管道冲洗模拟
|
||||
@@ -323,6 +328,9 @@ def flushing_analysis(
|
||||
:param scheme_name: 方案名称
|
||||
:return:
|
||||
"""
|
||||
if not username:
|
||||
raise ValueError("username is required when storing flushing analysis scheme")
|
||||
|
||||
scheme_detail: dict = {
|
||||
"duration": modify_total_duration,
|
||||
"valve_opening": modify_valve_opening,
|
||||
@@ -455,7 +463,7 @@ def flushing_analysis(
|
||||
name=name,
|
||||
scheme_name=scheme_name,
|
||||
scheme_type="flushing_analysis",
|
||||
username="admin",
|
||||
username=username,
|
||||
scheme_start_time=modify_pattern_start_time,
|
||||
scheme_detail=scheme_detail,
|
||||
)
|
||||
@@ -473,6 +481,7 @@ def contaminant_simulation(
|
||||
concentration: float, # 污染源浓度,单位mg/L
|
||||
scheme_name: str = None,
|
||||
source_pattern: str = None, # 污染源时间变化模式名称
|
||||
username: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
污染模拟
|
||||
@@ -486,6 +495,9 @@ def contaminant_simulation(
|
||||
:param scheme_name: 方案名称
|
||||
:return:
|
||||
"""
|
||||
if not username:
|
||||
raise ValueError("username is required when storing contaminant analysis scheme")
|
||||
|
||||
scheme_detail: dict = {
|
||||
"source": source,
|
||||
"concentration": concentration,
|
||||
@@ -608,7 +620,7 @@ def contaminant_simulation(
|
||||
name=name,
|
||||
scheme_name=scheme_name,
|
||||
scheme_type="contaminant_analysis",
|
||||
username="admin",
|
||||
username=username,
|
||||
scheme_start_time=modify_pattern_start_time,
|
||||
scheme_detail=scheme_detail,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ProblemDetails(BaseModel):
|
||||
"""RFC 9457 compatible error response used by the REST contract."""
|
||||
|
||||
type: str
|
||||
title: str
|
||||
status: int
|
||||
detail: str
|
||||
instance: str
|
||||
code: str
|
||||
trace_id: str
|
||||
errors: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
def _trace_id(request: Request) -> str:
|
||||
return request.headers.get("X-Request-Id") or str(uuid4())
|
||||
|
||||
|
||||
def _problem_response(
|
||||
request: Request,
|
||||
*,
|
||||
status_code: int,
|
||||
title: str,
|
||||
detail: str,
|
||||
code: str,
|
||||
errors: list[dict[str, Any]] | None = None,
|
||||
) -> JSONResponse:
|
||||
problem = ProblemDetails(
|
||||
type=f"https://tjwater.example/problems/{code.replace('_', '-')}",
|
||||
title=title,
|
||||
status=status_code,
|
||||
detail=detail,
|
||||
instance=request.url.path,
|
||||
code=code,
|
||||
trace_id=_trace_id(request),
|
||||
errors=errors or [],
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=status_code,
|
||||
content=problem.model_dump(mode="json"),
|
||||
media_type="application/problem+json",
|
||||
)
|
||||
|
||||
|
||||
def install_problem_details_handlers(app: FastAPI) -> None:
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_error_handler(
|
||||
request: Request,
|
||||
exc: RequestValidationError,
|
||||
) -> JSONResponse:
|
||||
return _problem_response(
|
||||
request,
|
||||
status_code=422,
|
||||
title="Validation error",
|
||||
detail="Request validation failed",
|
||||
code="validation_error",
|
||||
errors=exc.errors(),
|
||||
)
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
async def http_error_handler(request: Request, exc: HTTPException) -> JSONResponse:
|
||||
detail = exc.detail if isinstance(exc.detail, str) else str(exc.detail)
|
||||
code_by_status = {
|
||||
401: "unauthenticated",
|
||||
403: "forbidden",
|
||||
404: "not_found",
|
||||
409: "conflict",
|
||||
422: "validation_error",
|
||||
503: "dependency_unavailable",
|
||||
}
|
||||
return _problem_response(
|
||||
request,
|
||||
status_code=exc.status_code,
|
||||
title=code_by_status.get(exc.status_code, "request_error")
|
||||
.replace("_", " ")
|
||||
.title(),
|
||||
detail=detail,
|
||||
code=code_by_status.get(exc.status_code, "request_error"),
|
||||
)
|
||||
@@ -0,0 +1,39 @@
|
||||
from fastapi import APIRouter, Depends, Header
|
||||
|
||||
from app.auth.metadata_dependencies import (
|
||||
get_current_metadata_user,
|
||||
get_metadata_repository,
|
||||
)
|
||||
from app.auth.permissions import resolve_permissions
|
||||
from app.auth.project_dependencies import resolve_project_context
|
||||
from app.domain.schemas.access import AccessContextResponse
|
||||
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/access-context", response_model=AccessContextResponse)
|
||||
async def get_access_context(
|
||||
x_project_id: str | None = Header(default=None, alias="X-Project-Id"),
|
||||
current_user=Depends(get_current_metadata_user),
|
||||
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||
) -> AccessContextResponse:
|
||||
project_context = (
|
||||
await resolve_project_context(x_project_id, current_user, metadata_repo)
|
||||
if x_project_id
|
||||
else None
|
||||
)
|
||||
permissions = resolve_permissions(
|
||||
project_role=project_context.project_role if project_context else None,
|
||||
system_role=current_user.role,
|
||||
is_superuser=current_user.is_superuser,
|
||||
)
|
||||
return AccessContextResponse(
|
||||
user_id=current_user.id,
|
||||
username=current_user.username,
|
||||
system_role=current_user.role,
|
||||
is_system_admin=current_user.is_superuser or current_user.role == "admin",
|
||||
project_id=project_context.project_id if project_context else None,
|
||||
project_role=project_context.project_role if project_context else None,
|
||||
permissions=sorted(permissions),
|
||||
)
|
||||
@@ -0,0 +1,696 @@
|
||||
from typing import List
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Response, status
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.engine.url import make_url
|
||||
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from app.auth.metadata_dependencies import (
|
||||
get_current_metadata_admin,
|
||||
get_metadata_repository,
|
||||
)
|
||||
from app.core.audit import AuditAction, log_audit_event
|
||||
from app.domain.schemas.admin_metadata import (
|
||||
AdminProjectCreateRequest,
|
||||
AdminProjectResponse,
|
||||
AdminProjectUpdateRequest,
|
||||
MetadataUsersBatchSyncRequest,
|
||||
MetadataUserResponse,
|
||||
MetadataUserSyncRequest,
|
||||
MetadataUserSyncResult,
|
||||
MetadataUserUpdateRequest,
|
||||
ProjectDatabaseHealthResponse,
|
||||
ProjectDatabaseHealthRequest,
|
||||
ProjectDatabaseResponse,
|
||||
ProjectDatabaseUpsertRequest,
|
||||
ProjectDbRole,
|
||||
ProjectMemberCreateRequest,
|
||||
ProjectMemberResponse,
|
||||
ProjectMemberUpdateRequest,
|
||||
)
|
||||
from app.infra.db.metadb import models
|
||||
from app.infra.db.metadb.repositories.metadata_repository import (
|
||||
MetadataRepository,
|
||||
ProjectDbRouting,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _project_response(project: models.Project) -> AdminProjectResponse:
|
||||
return AdminProjectResponse(
|
||||
project_id=project.id,
|
||||
name=project.name,
|
||||
code=project.code,
|
||||
description=project.description,
|
||||
gs_workspace=project.gs_workspace,
|
||||
map_extent=project.map_extent,
|
||||
status=project.status,
|
||||
created_at=project.created_at,
|
||||
updated_at=project.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _project_database_response(
|
||||
record: models.ProjectDatabase,
|
||||
) -> ProjectDatabaseResponse:
|
||||
return ProjectDatabaseResponse(
|
||||
id=record.id,
|
||||
project_id=record.project_id,
|
||||
db_role=record.db_role,
|
||||
db_type=record.db_type,
|
||||
pool_min_size=record.pool_min_size,
|
||||
pool_max_size=record.pool_max_size,
|
||||
has_dsn=bool(record.dsn_encrypted),
|
||||
)
|
||||
|
||||
|
||||
def _database_audit_payload(payload: ProjectDatabaseUpsertRequest) -> dict:
|
||||
return {
|
||||
"db_role": payload.db_role,
|
||||
"db_type": _db_type_for_role(payload.db_role),
|
||||
"pool_min_size": payload.pool_min_size,
|
||||
"pool_max_size": payload.pool_max_size,
|
||||
"dsn_updated": payload.dsn is not None,
|
||||
}
|
||||
|
||||
|
||||
def _to_async_sqlalchemy_url(dsn: str) -> str:
|
||||
parsed = make_url(dsn)
|
||||
if parsed.drivername in {"postgresql", "postgres"}:
|
||||
parsed = parsed.set(drivername="postgresql+psycopg")
|
||||
return parsed.render_as_string(hide_password=False)
|
||||
|
||||
|
||||
def _db_type_for_role(db_role: str) -> str:
|
||||
if db_role == "iot_data":
|
||||
return "timescaledb"
|
||||
return "postgresql"
|
||||
|
||||
|
||||
def _status_for_config_value_error(exc: ValueError) -> int:
|
||||
if "DATABASE_ENCRYPTION_KEY" in str(exc):
|
||||
return status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
return status.HTTP_400_BAD_REQUEST
|
||||
|
||||
|
||||
async def _check_database_connection(routing: ProjectDbRouting) -> None:
|
||||
engine = create_async_engine(
|
||||
_to_async_sqlalchemy_url(routing.dsn),
|
||||
pool_size=1,
|
||||
max_overflow=0,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
try:
|
||||
async with engine.connect() as conn:
|
||||
await conn.execute(text("SELECT 1"))
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
def _database_health_error_detail(exc: Exception) -> str:
|
||||
message = str(exc)
|
||||
lower_message = message.lower()
|
||||
if "password authentication failed" in lower_message:
|
||||
return "连通性测试失败:用户名或密码错误,请检查 DSN 中的账号密码。"
|
||||
if "connection refused" in lower_message:
|
||||
return "连通性测试失败:目标主机或端口拒绝连接,请检查地址、端口和服务状态。"
|
||||
if "timeout" in lower_message or "timed out" in lower_message:
|
||||
return "连通性测试失败:连接超时,请检查网络、防火墙和数据库服务状态。"
|
||||
if "could not translate host name" in lower_message or "name or service not known" in lower_message:
|
||||
return "连通性测试失败:数据库主机名无法解析,请检查 DSN 中的主机地址。"
|
||||
first_line = message.splitlines()[0] if message else exc.__class__.__name__
|
||||
return f"连通性测试失败:{first_line}"
|
||||
|
||||
|
||||
async def _upsert_and_audit_metadata_user(
|
||||
payload: MetadataUserSyncRequest,
|
||||
*,
|
||||
current_user,
|
||||
metadata_repo: MetadataRepository,
|
||||
response_status: int,
|
||||
) -> MetadataUserResponse:
|
||||
user = await metadata_repo.upsert_user_from_keycloak(
|
||||
keycloak_id=payload.keycloak_id,
|
||||
username=payload.username,
|
||||
email=str(payload.email),
|
||||
role=payload.role,
|
||||
is_active=payload.is_active,
|
||||
)
|
||||
await log_audit_event(
|
||||
action=AuditAction.UPDATE,
|
||||
user_id=current_user.id,
|
||||
resource_type="metadata_user",
|
||||
resource_id=str(user.id),
|
||||
request_data=payload.model_dump(mode="json"),
|
||||
response_status=response_status,
|
||||
session=metadata_repo.session,
|
||||
)
|
||||
return MetadataUserResponse.model_validate(user)
|
||||
|
||||
|
||||
@router.get("/admin/users/me", response_model=MetadataUserResponse)
|
||||
async def get_metadata_admin_me(
|
||||
current_user=Depends(get_current_metadata_admin),
|
||||
) -> MetadataUserResponse:
|
||||
return MetadataUserResponse.model_validate(current_user)
|
||||
|
||||
|
||||
@router.post("/admin/user-syncs", response_model=MetadataUserResponse)
|
||||
async def sync_metadata_user(
|
||||
payload: MetadataUserSyncRequest,
|
||||
current_user=Depends(get_current_metadata_admin),
|
||||
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||
) -> MetadataUserResponse:
|
||||
try:
|
||||
return await _upsert_and_audit_metadata_user(
|
||||
payload,
|
||||
current_user=current_user,
|
||||
metadata_repo=metadata_repo,
|
||||
response_status=status.HTTP_200_OK,
|
||||
)
|
||||
except IntegrityError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="User keycloak_id, username, or email conflicts with an existing user",
|
||||
) from exc
|
||||
except SQLAlchemyError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=f"Metadata database error: {exc}",
|
||||
) from exc
|
||||
|
||||
|
||||
|
||||
@router.post("/admin/user-syncs/batches", response_model=List[MetadataUserSyncResult])
|
||||
async def sync_metadata_users_batch(
|
||||
payload: MetadataUsersBatchSyncRequest,
|
||||
current_user=Depends(get_current_metadata_admin),
|
||||
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||
) -> List[MetadataUserSyncResult]:
|
||||
results: list[MetadataUserSyncResult] = []
|
||||
for item in payload.users:
|
||||
try:
|
||||
user = await _upsert_and_audit_metadata_user(
|
||||
item,
|
||||
current_user=current_user,
|
||||
metadata_repo=metadata_repo,
|
||||
response_status=status.HTTP_200_OK,
|
||||
)
|
||||
except IntegrityError as exc:
|
||||
results.append(
|
||||
MetadataUserSyncResult(
|
||||
keycloak_id=item.keycloak_id,
|
||||
success=False,
|
||||
error="User keycloak_id, username, or email conflicts with an existing user",
|
||||
)
|
||||
)
|
||||
await metadata_repo.session.rollback()
|
||||
except SQLAlchemyError as exc:
|
||||
results.append(
|
||||
MetadataUserSyncResult(
|
||||
keycloak_id=item.keycloak_id,
|
||||
success=False,
|
||||
error=f"Metadata database error: {exc}",
|
||||
)
|
||||
)
|
||||
await metadata_repo.session.rollback()
|
||||
else:
|
||||
results.append(
|
||||
MetadataUserSyncResult(
|
||||
keycloak_id=item.keycloak_id,
|
||||
success=True,
|
||||
user=user,
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
@router.get("/admin/users", response_model=List[MetadataUserResponse])
|
||||
async def list_metadata_users(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
current_user=Depends(get_current_metadata_admin),
|
||||
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||
) -> List[MetadataUserResponse]:
|
||||
users = await metadata_repo.list_users(skip=skip, limit=limit)
|
||||
return [MetadataUserResponse.model_validate(user) for user in users]
|
||||
|
||||
|
||||
@router.get("/admin/projects", response_model=List[AdminProjectResponse])
|
||||
async def list_admin_projects(
|
||||
current_user=Depends(get_current_metadata_admin),
|
||||
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||
) -> List[AdminProjectResponse]:
|
||||
projects = await metadata_repo.list_project_records()
|
||||
return [_project_response(project) for project in projects]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/admin/projects",
|
||||
response_model=AdminProjectResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_admin_project(
|
||||
payload: AdminProjectCreateRequest,
|
||||
current_user=Depends(get_current_metadata_admin),
|
||||
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||
) -> AdminProjectResponse:
|
||||
try:
|
||||
project = await metadata_repo.create_project(
|
||||
name=payload.name,
|
||||
code=payload.code,
|
||||
description=payload.description,
|
||||
gs_workspace=payload.gs_workspace,
|
||||
map_extent=payload.map_extent,
|
||||
status=payload.status,
|
||||
creator_user_id=current_user.id,
|
||||
)
|
||||
except IntegrityError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Project code or workspace conflicts with an existing project",
|
||||
) from exc
|
||||
except SQLAlchemyError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=f"Metadata database error: {exc}",
|
||||
) from exc
|
||||
|
||||
await log_audit_event(
|
||||
action=AuditAction.CREATE,
|
||||
user_id=current_user.id,
|
||||
project_id=project.id,
|
||||
resource_type="project",
|
||||
resource_id=str(project.id),
|
||||
request_data=payload.model_dump(mode="json"),
|
||||
response_status=status.HTTP_201_CREATED,
|
||||
session=metadata_repo.session,
|
||||
)
|
||||
return _project_response(project)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/admin/projects/{project_id}",
|
||||
response_model=AdminProjectResponse,
|
||||
)
|
||||
async def update_admin_project(
|
||||
payload: AdminProjectUpdateRequest,
|
||||
project_id: UUID = Path(...),
|
||||
current_user=Depends(get_current_metadata_admin),
|
||||
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||
) -> AdminProjectResponse:
|
||||
updates = payload.model_dump(mode="json", exclude_unset=True)
|
||||
try:
|
||||
project = await metadata_repo.update_project(project_id, updates=updates)
|
||||
except IntegrityError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Project code or workspace conflicts with an existing project",
|
||||
) from exc
|
||||
except SQLAlchemyError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=f"Metadata database error: {exc}",
|
||||
) from exc
|
||||
if project is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||
|
||||
await log_audit_event(
|
||||
action=AuditAction.UPDATE,
|
||||
user_id=current_user.id,
|
||||
project_id=project.id,
|
||||
resource_type="project",
|
||||
resource_id=str(project.id),
|
||||
request_data=updates,
|
||||
response_status=status.HTTP_200_OK,
|
||||
session=metadata_repo.session,
|
||||
)
|
||||
return _project_response(project)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/admin/projects/{project_id}/databases",
|
||||
response_model=List[ProjectDatabaseResponse],
|
||||
)
|
||||
async def list_project_databases(
|
||||
project_id: UUID = Path(...),
|
||||
current_user=Depends(get_current_metadata_admin),
|
||||
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||
) -> List[ProjectDatabaseResponse]:
|
||||
project = await metadata_repo.get_project_by_id(project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||
records = await metadata_repo.list_project_databases(project_id)
|
||||
return [_project_database_response(record) for record in records]
|
||||
|
||||
|
||||
@router.put(
|
||||
"/admin/projects/{project_id}/databases",
|
||||
response_model=ProjectDatabaseResponse,
|
||||
)
|
||||
async def upsert_project_database(
|
||||
payload: ProjectDatabaseUpsertRequest,
|
||||
project_id: UUID = Path(...),
|
||||
current_user=Depends(get_current_metadata_admin),
|
||||
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||
) -> ProjectDatabaseResponse:
|
||||
project = await metadata_repo.get_project_by_id(project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||
try:
|
||||
routing = (
|
||||
ProjectDbRouting(
|
||||
project_id=project_id,
|
||||
db_role=payload.db_role,
|
||||
db_type=_db_type_for_role(payload.db_role),
|
||||
dsn=payload.dsn,
|
||||
pool_min_size=payload.pool_min_size,
|
||||
pool_max_size=payload.pool_max_size,
|
||||
)
|
||||
if payload.dsn
|
||||
else await metadata_repo.get_project_db_routing(project_id, payload.db_role)
|
||||
)
|
||||
if routing is None:
|
||||
raise ValueError("dsn is required when creating project database config")
|
||||
await _check_database_connection(routing)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=_status_for_config_value_error(exc),
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=_database_health_error_detail(exc),
|
||||
) from exc
|
||||
|
||||
try:
|
||||
record = await metadata_repo.upsert_project_database_config(
|
||||
project_id,
|
||||
db_role=payload.db_role,
|
||||
db_type=_db_type_for_role(payload.db_role),
|
||||
dsn=payload.dsn,
|
||||
pool_min_size=payload.pool_min_size,
|
||||
pool_max_size=payload.pool_max_size,
|
||||
)
|
||||
except IntegrityError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Project database role conflicts with an existing config",
|
||||
) from exc
|
||||
except SQLAlchemyError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=f"Metadata database error: {exc}",
|
||||
) from exc
|
||||
|
||||
await log_audit_event(
|
||||
action=AuditAction.CONFIG_CHANGE,
|
||||
user_id=current_user.id,
|
||||
project_id=project_id,
|
||||
resource_type="project_database",
|
||||
resource_id=payload.db_role,
|
||||
request_data=_database_audit_payload(payload),
|
||||
response_status=status.HTTP_200_OK,
|
||||
session=metadata_repo.session,
|
||||
)
|
||||
return _project_database_response(record)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/admin/projects/{project_id}/databases/{db_role}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
)
|
||||
async def delete_project_database(
|
||||
project_id: UUID = Path(...),
|
||||
db_role: ProjectDbRole = Path(...),
|
||||
current_user=Depends(get_current_metadata_admin),
|
||||
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||
) -> None:
|
||||
removed = await metadata_repo.delete_project_database_config(project_id, db_role)
|
||||
if not removed:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Project database config not found",
|
||||
)
|
||||
await log_audit_event(
|
||||
action=AuditAction.CONFIG_CHANGE,
|
||||
user_id=current_user.id,
|
||||
project_id=project_id,
|
||||
resource_type="project_database",
|
||||
resource_id=db_role,
|
||||
request_data={"deleted": True},
|
||||
response_status=status.HTTP_204_NO_CONTENT,
|
||||
session=metadata_repo.session,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/admin/projects/{project_id}/databases/{db_role}/health-checks",
|
||||
response_model=ProjectDatabaseHealthResponse,
|
||||
)
|
||||
async def check_project_database_health(
|
||||
response: Response,
|
||||
project_id: UUID = Path(...),
|
||||
db_role: ProjectDbRole = Path(...),
|
||||
payload: ProjectDatabaseHealthRequest | None = None,
|
||||
current_user=Depends(get_current_metadata_admin),
|
||||
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||
) -> ProjectDatabaseHealthResponse:
|
||||
dsn_to_test = payload.dsn if payload and payload.dsn else None
|
||||
if dsn_to_test:
|
||||
routing = ProjectDbRouting(
|
||||
project_id=project_id,
|
||||
db_role=db_role,
|
||||
db_type=_db_type_for_role(db_role),
|
||||
dsn=dsn_to_test,
|
||||
pool_min_size=1,
|
||||
pool_max_size=1,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
routing = await metadata_repo.get_project_db_routing(project_id, db_role)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=f"Project database routing DSN is invalid: {exc}",
|
||||
) from exc
|
||||
if routing is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Project database config not found",
|
||||
)
|
||||
|
||||
try:
|
||||
await _check_database_connection(routing)
|
||||
except Exception as exc: # health endpoint should return diagnostic status
|
||||
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
return ProjectDatabaseHealthResponse(
|
||||
project_id=project_id,
|
||||
db_role=db_role,
|
||||
db_type=routing.db_type,
|
||||
ok=False,
|
||||
detail=_database_health_error_detail(exc),
|
||||
)
|
||||
return ProjectDatabaseHealthResponse(
|
||||
project_id=project_id,
|
||||
db_role=db_role,
|
||||
db_type=routing.db_type,
|
||||
ok=True,
|
||||
detail="连通性测试通过",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/admin/users/{user_id}", response_model=MetadataUserResponse)
|
||||
async def get_metadata_user(
|
||||
user_id: UUID = Path(...),
|
||||
current_user=Depends(get_current_metadata_admin),
|
||||
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||
) -> MetadataUserResponse:
|
||||
user = await metadata_repo.get_user_by_id(user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
return MetadataUserResponse.model_validate(user)
|
||||
|
||||
|
||||
@router.patch("/admin/users/{user_id}", response_model=MetadataUserResponse)
|
||||
async def update_metadata_user(
|
||||
payload: MetadataUserUpdateRequest,
|
||||
user_id: UUID = Path(...),
|
||||
current_user=Depends(get_current_metadata_admin),
|
||||
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||
) -> MetadataUserResponse:
|
||||
updates = payload.model_dump(mode="json", exclude_unset=True)
|
||||
if user_id == current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Users cannot modify themselves",
|
||||
)
|
||||
user = await metadata_repo.update_user_admin(
|
||||
user_id,
|
||||
updates=updates,
|
||||
)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
|
||||
await log_audit_event(
|
||||
action=AuditAction.UPDATE,
|
||||
user_id=current_user.id,
|
||||
resource_type="metadata_user",
|
||||
resource_id=str(user.id),
|
||||
request_data=updates,
|
||||
response_status=status.HTTP_200_OK,
|
||||
session=metadata_repo.session,
|
||||
)
|
||||
return MetadataUserResponse.model_validate(user)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/admin/projects/{project_id}/members",
|
||||
response_model=List[ProjectMemberResponse],
|
||||
)
|
||||
async def list_project_members(
|
||||
project_id: UUID = Path(...),
|
||||
current_user=Depends(get_current_metadata_admin),
|
||||
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||
) -> List[ProjectMemberResponse]:
|
||||
project = await metadata_repo.get_project_by_id(project_id)
|
||||
if project is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Project not found"
|
||||
)
|
||||
members = await metadata_repo.list_project_members(project_id)
|
||||
return [ProjectMemberResponse(**member.__dict__) for member in members]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/admin/projects/{project_id}/members",
|
||||
response_model=ProjectMemberResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def add_project_member(
|
||||
payload: ProjectMemberCreateRequest,
|
||||
project_id: UUID = Path(...),
|
||||
current_user=Depends(get_current_metadata_admin),
|
||||
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||
) -> ProjectMemberResponse:
|
||||
if payload.user_id == current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Users cannot modify their own project membership",
|
||||
)
|
||||
project = await metadata_repo.get_project_by_id(project_id)
|
||||
if project is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Project not found"
|
||||
)
|
||||
user = await metadata_repo.get_user_by_id(payload.user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
existing = await metadata_repo.get_project_membership(project_id, payload.user_id)
|
||||
if existing is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="User is already a project member",
|
||||
)
|
||||
|
||||
membership = await metadata_repo.add_project_member(
|
||||
project_id, payload.user_id, payload.project_role
|
||||
)
|
||||
await log_audit_event(
|
||||
action=AuditAction.PERMISSION_CHANGE,
|
||||
user_id=current_user.id,
|
||||
project_id=project_id,
|
||||
resource_type="project_member",
|
||||
resource_id=str(payload.user_id),
|
||||
request_data=payload.model_dump(mode="json"),
|
||||
response_status=status.HTTP_201_CREATED,
|
||||
session=metadata_repo.session,
|
||||
)
|
||||
return ProjectMemberResponse(
|
||||
id=membership.id,
|
||||
user_id=membership.user_id,
|
||||
project_id=membership.project_id,
|
||||
project_role=membership.project_role,
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
is_active=user.is_active,
|
||||
)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/admin/projects/{project_id}/members/{user_id}",
|
||||
response_model=ProjectMemberResponse,
|
||||
)
|
||||
async def update_project_member(
|
||||
payload: ProjectMemberUpdateRequest,
|
||||
project_id: UUID = Path(...),
|
||||
user_id: UUID = Path(...),
|
||||
current_user=Depends(get_current_metadata_admin),
|
||||
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||
) -> ProjectMemberResponse:
|
||||
if user_id == current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Users cannot modify their own project membership",
|
||||
)
|
||||
user = await metadata_repo.get_user_by_id(user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
membership = await metadata_repo.update_project_member_role(
|
||||
project_id, user_id, payload.project_role
|
||||
)
|
||||
if membership is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Project member not found"
|
||||
)
|
||||
await log_audit_event(
|
||||
action=AuditAction.PERMISSION_CHANGE,
|
||||
user_id=current_user.id,
|
||||
project_id=project_id,
|
||||
resource_type="project_member",
|
||||
resource_id=str(user_id),
|
||||
request_data=payload.model_dump(mode="json"),
|
||||
response_status=status.HTTP_200_OK,
|
||||
session=metadata_repo.session,
|
||||
)
|
||||
return ProjectMemberResponse(
|
||||
id=membership.id,
|
||||
user_id=membership.user_id,
|
||||
project_id=membership.project_id,
|
||||
project_role=membership.project_role,
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
is_active=user.is_active,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/admin/projects/{project_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def remove_project_member(
|
||||
project_id: UUID = Path(...),
|
||||
user_id: UUID = Path(...),
|
||||
current_user=Depends(get_current_metadata_admin),
|
||||
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||
) -> None:
|
||||
if user_id == current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Users cannot modify their own project membership",
|
||||
)
|
||||
removed = await metadata_repo.remove_project_member(project_id, user_id)
|
||||
if not removed:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Project member not found"
|
||||
)
|
||||
await log_audit_event(
|
||||
action=AuditAction.PERMISSION_CHANGE,
|
||||
user_id=current_user.id,
|
||||
project_id=project_id,
|
||||
resource_type="project_member",
|
||||
resource_id=str(user_id),
|
||||
response_status=status.HTTP_204_NO_CONTENT,
|
||||
session=metadata_repo.session,
|
||||
)
|
||||
@@ -0,0 +1,53 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.auth.keycloak_dependencies import get_current_keycloak_payload
|
||||
from app.auth.metadata_dependencies import get_current_metadata_user
|
||||
from app.auth.project_dependencies import (
|
||||
ProjectContext,
|
||||
get_project_context,
|
||||
)
|
||||
from app.auth.permissions import permissions_for_context
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class AgentAuthContextResponse(BaseModel):
|
||||
user_id: str
|
||||
keycloak_sub: str
|
||||
username: str
|
||||
role: str
|
||||
is_superuser: bool
|
||||
project_id: str
|
||||
network: str
|
||||
project_role: str
|
||||
permissions: list[str]
|
||||
token_expires_at: str | None = None
|
||||
|
||||
|
||||
@router.get("/agent-auth-context", response_model=AgentAuthContextResponse)
|
||||
async def get_agent_auth_context(
|
||||
ctx: ProjectContext = Depends(get_project_context),
|
||||
current_user=Depends(get_current_metadata_user),
|
||||
keycloak_payload: dict = Depends(get_current_keycloak_payload),
|
||||
) -> AgentAuthContextResponse:
|
||||
exp = keycloak_payload.get("exp")
|
||||
token_expires_at = (
|
||||
datetime.fromtimestamp(exp, tz=timezone.utc).isoformat()
|
||||
if isinstance(exp, int)
|
||||
else None
|
||||
)
|
||||
return AgentAuthContextResponse(
|
||||
user_id=str(current_user.id),
|
||||
keycloak_sub=str(current_user.keycloak_id),
|
||||
username=current_user.username,
|
||||
role=current_user.role,
|
||||
is_superuser=current_user.is_superuser,
|
||||
project_id=str(ctx.project_id),
|
||||
network=ctx.project_code,
|
||||
project_role=ctx.project_role,
|
||||
permissions=sorted(permissions_for_context(ctx)),
|
||||
token_expires_at=token_expires_at,
|
||||
)
|
||||
@@ -1,56 +1,52 @@
|
||||
"""
|
||||
审计日志 API 接口
|
||||
|
||||
仅管理员可访问
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, Query, Path
|
||||
from app.domain.schemas.audit import AuditLogResponse
|
||||
from app.infra.db.metadb.repositories.audit_repository import AuditRepository
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.metadata_dependencies import (
|
||||
get_current_metadata_admin,
|
||||
get_current_metadata_user,
|
||||
)
|
||||
from app.core.audit import AuditAction, log_audit_event
|
||||
from app.domain.schemas.audit import AuditLogResponse
|
||||
from app.infra.db.metadb.database import get_metadata_session
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.infra.db.metadb.repositories.audit_repository import AuditRepository
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class SessionAuditEventRequest(BaseModel):
|
||||
event: Literal["login", "logout"]
|
||||
|
||||
|
||||
async def get_audit_repository(
|
||||
session: AsyncSession = Depends(get_metadata_session),
|
||||
) -> AuditRepository:
|
||||
"""获取审计日志仓储"""
|
||||
return AuditRepository(session)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/logs",
|
||||
"/audit-logs",
|
||||
summary="查询审计日志",
|
||||
description="查询审计日志(仅管理员)",
|
||||
response_model=List[AuditLogResponse],
|
||||
response_model=list[AuditLogResponse],
|
||||
)
|
||||
async def get_audit_logs(
|
||||
user_id: Optional[UUID] = Query(None, description="按用户ID过滤"),
|
||||
project_id: Optional[UUID] = Query(None, description="按项目ID过滤"),
|
||||
action: Optional[str] = Query(None, description="按操作类型过滤"),
|
||||
resource_type: Optional[str] = Query(None, description="按资源类型过滤"),
|
||||
start_time: Optional[datetime] = Query(None, description="开始时间"),
|
||||
end_time: Optional[datetime] = Query(None, description="结束时间"),
|
||||
user_id: UUID | None = Query(None, description="按用户ID过滤"),
|
||||
project_id: UUID | None = Query(None, description="按项目ID过滤"),
|
||||
action: str | None = Query(None, description="按操作类型过滤"),
|
||||
resource_type: str | None = Query(None, description="按资源类型过滤"),
|
||||
start_time: datetime | None = Query(None, description="开始时间"),
|
||||
end_time: datetime | None = Query(None, description="结束时间"),
|
||||
skip: int = Query(0, ge=0, description="跳过记录数"),
|
||||
limit: int = Query(100, ge=1, le=1000, description="限制记录数"),
|
||||
current_user=Depends(get_current_metadata_admin),
|
||||
_current_user=Depends(get_current_metadata_admin),
|
||||
audit_repo: AuditRepository = Depends(get_audit_repository),
|
||||
) -> List[AuditLogResponse]:
|
||||
"""
|
||||
查询审计日志
|
||||
|
||||
支持按用户、时间、操作类型等条件过滤,仅管理员可访问
|
||||
"""
|
||||
logs = await audit_repo.get_logs(
|
||||
) -> list[AuditLogResponse]:
|
||||
return await audit_repo.get_logs(
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
action=action,
|
||||
@@ -60,29 +56,23 @@ async def get_audit_logs(
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
return logs
|
||||
|
||||
|
||||
@router.get(
|
||||
"/logs/count",
|
||||
"/audit-logs/count",
|
||||
summary="获取审计日志总数",
|
||||
description="获取审计日志总数(仅管理员)",
|
||||
)
|
||||
async def get_audit_logs_count(
|
||||
user_id: Optional[UUID] = Query(None, description="按用户ID过滤"),
|
||||
project_id: Optional[UUID] = Query(None, description="按项目ID过滤"),
|
||||
action: Optional[str] = Query(None, description="按操作类型过滤"),
|
||||
resource_type: Optional[str] = Query(None, description="按资源类型过滤"),
|
||||
start_time: Optional[datetime] = Query(None, description="开始时间"),
|
||||
end_time: Optional[datetime] = Query(None, description="结束时间"),
|
||||
current_user=Depends(get_current_metadata_admin),
|
||||
user_id: UUID | None = Query(None, description="按用户ID过滤"),
|
||||
project_id: UUID | None = Query(None, description="按项目ID过滤"),
|
||||
action: str | None = Query(None, description="按操作类型过滤"),
|
||||
resource_type: str | None = Query(None, description="按资源类型过滤"),
|
||||
start_time: datetime | None = Query(None, description="开始时间"),
|
||||
end_time: datetime | None = Query(None, description="结束时间"),
|
||||
_current_user=Depends(get_current_metadata_admin),
|
||||
audit_repo: AuditRepository = Depends(get_audit_repository),
|
||||
) -> dict:
|
||||
"""
|
||||
获取审计日志总数
|
||||
|
||||
获取符合条件的审计日志的总数,仅管理员可访问
|
||||
"""
|
||||
count = await audit_repo.get_log_count(
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
@@ -94,27 +84,42 @@ async def get_audit_logs_count(
|
||||
return {"count": count}
|
||||
|
||||
|
||||
@router.post("/audit-events", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def record_session_event(
|
||||
payload: SessionAuditEventRequest,
|
||||
request: Request,
|
||||
current_user=Depends(get_current_metadata_user),
|
||||
session: AsyncSession = Depends(get_metadata_session),
|
||||
) -> None:
|
||||
await log_audit_event(
|
||||
action=AuditAction.LOGIN if payload.event == "login" else AuditAction.LOGOUT,
|
||||
user_id=current_user.id,
|
||||
resource_type="session",
|
||||
resource_id=str(current_user.keycloak_id),
|
||||
ip_address=request.client.host if request.client else None,
|
||||
request_method=request.method,
|
||||
request_path=request.url.path,
|
||||
response_status=status.HTTP_204_NO_CONTENT,
|
||||
session=session,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/logs/my",
|
||||
"/audit-logs/mine",
|
||||
summary="查询我的审计日志",
|
||||
description="查询当前用户的审计日志",
|
||||
response_model=List[AuditLogResponse],
|
||||
response_model=list[AuditLogResponse],
|
||||
)
|
||||
async def get_my_audit_logs(
|
||||
action: Optional[str] = Query(None, description="按操作类型过滤"),
|
||||
start_time: Optional[datetime] = Query(None, description="开始时间"),
|
||||
end_time: Optional[datetime] = Query(None, description="结束时间"),
|
||||
action: str | None = Query(None, description="按操作类型过滤"),
|
||||
start_time: datetime | None = Query(None, description="开始时间"),
|
||||
end_time: datetime | None = Query(None, description="结束时间"),
|
||||
skip: int = Query(0, ge=0, description="跳过记录数"),
|
||||
limit: int = Query(100, ge=1, le=1000, description="限制记录数"),
|
||||
current_user=Depends(get_current_metadata_user),
|
||||
audit_repo: AuditRepository = Depends(get_audit_repository),
|
||||
) -> List[AuditLogResponse]:
|
||||
"""
|
||||
查询当前用户的审计日志
|
||||
|
||||
普通用户只能查看自己的操作记录
|
||||
"""
|
||||
logs = await audit_repo.get_logs(
|
||||
) -> list[AuditLogResponse]:
|
||||
return await audit_repo.get_logs(
|
||||
user_id=current_user.id,
|
||||
action=action,
|
||||
start_time=start_time,
|
||||
@@ -122,4 +127,3 @@ async def get_my_audit_logs(
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
return logs
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
from typing import Annotated
|
||||
from datetime import timedelta
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from app.core.config import settings
|
||||
from app.core.security import create_access_token, create_refresh_token, verify_password
|
||||
from app.domain.schemas.user import UserCreate, UserResponse, UserLogin, Token
|
||||
from app.infra.db.metadb.repositories.user_repository import UserRepository
|
||||
from app.auth.dependencies import get_user_repository, get_current_active_user
|
||||
from app.domain.schemas.user import UserInDB
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
async def register(
|
||||
user_data: UserCreate, user_repo: UserRepository = Depends(get_user_repository)
|
||||
) -> UserResponse:
|
||||
"""
|
||||
用户注册
|
||||
|
||||
创建新用户账号
|
||||
"""
|
||||
# 检查用户名和邮箱是否已存在
|
||||
if await user_repo.user_exists(username=user_data.username):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Username already registered",
|
||||
)
|
||||
|
||||
if await user_repo.user_exists(email=user_data.email):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered"
|
||||
)
|
||||
|
||||
# 创建用户
|
||||
try:
|
||||
user = await user_repo.create_user(user_data)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to create user",
|
||||
)
|
||||
return UserResponse.model_validate(user)
|
||||
except Exception as e:
|
||||
logger.error(f"Error during user registration: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Registration failed",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
async def login(
|
||||
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
|
||||
user_repo: UserRepository = Depends(get_user_repository),
|
||||
) -> Token:
|
||||
"""
|
||||
用户登录(OAuth2 标准格式)
|
||||
|
||||
返回 JWT Access Token 和 Refresh Token
|
||||
"""
|
||||
# 验证用户(支持用户名或邮箱登录)
|
||||
user = await user_repo.get_user_by_username(form_data.username)
|
||||
if not user:
|
||||
# 尝试用邮箱登录
|
||||
user = await user_repo.get_user_by_email(form_data.username)
|
||||
|
||||
if not user or not verify_password(form_data.password, user.hashed_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user account"
|
||||
)
|
||||
|
||||
# 生成 Token
|
||||
access_token = create_access_token(subject=user.username)
|
||||
refresh_token = create_refresh_token(subject=user.username)
|
||||
|
||||
return Token(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
token_type="bearer",
|
||||
expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login/simple", response_model=Token)
|
||||
async def login_simple(
|
||||
username: str,
|
||||
password: str,
|
||||
user_repo: UserRepository = Depends(get_user_repository),
|
||||
) -> Token:
|
||||
"""
|
||||
简化版登录接口(保持向后兼容)
|
||||
|
||||
直接使用 username 和 password 参数
|
||||
"""
|
||||
# 验证用户
|
||||
user = await user_repo.get_user_by_username(username)
|
||||
if not user:
|
||||
user = await user_repo.get_user_by_email(username)
|
||||
|
||||
if not user or not verify_password(password, user.hashed_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
)
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user account"
|
||||
)
|
||||
|
||||
# 生成 Token
|
||||
access_token = create_access_token(subject=user.username)
|
||||
refresh_token = create_refresh_token(subject=user.username)
|
||||
|
||||
return Token(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
token_type="bearer",
|
||||
expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def get_current_user_info(
|
||||
current_user: UserInDB = Depends(get_current_active_user),
|
||||
) -> UserResponse:
|
||||
"""
|
||||
获取当前登录用户信息
|
||||
"""
|
||||
return UserResponse.model_validate(current_user)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=Token)
|
||||
async def refresh_token(
|
||||
refresh_token: str, user_repo: UserRepository = Depends(get_user_repository)
|
||||
) -> Token:
|
||||
"""
|
||||
刷新 Access Token
|
||||
|
||||
使用 Refresh Token 获取新的 Access Token
|
||||
"""
|
||||
from jose import jwt, JWTError
|
||||
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate refresh token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
refresh_token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
|
||||
)
|
||||
username: str = payload.get("sub")
|
||||
token_type: str = payload.get("type")
|
||||
|
||||
if username is None or token_type != "refresh":
|
||||
raise credentials_exception
|
||||
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
# 验证用户仍然存在且激活
|
||||
user = await user_repo.get_user_by_username(username)
|
||||
if not user or not user.is_active:
|
||||
raise credentials_exception
|
||||
|
||||
# 生成新的 Access Token
|
||||
new_access_token = create_access_token(subject=user.username)
|
||||
|
||||
return Token(
|
||||
access_token=new_access_token,
|
||||
refresh_token=refresh_token, # 保持原 refresh token
|
||||
token_type="bearer",
|
||||
expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
|
||||
)
|
||||
@@ -1,13 +1,11 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Path, Body
|
||||
from fastapi import APIRouter, Depends, HTTPException, Body
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.auth.keycloak_dependencies import get_current_keycloak_username
|
||||
from app.services.burst_detection import (
|
||||
get_burst_detection_scheme_detail,
|
||||
list_burst_detection_schemes,
|
||||
run_burst_detection,
|
||||
)
|
||||
|
||||
@@ -30,6 +28,16 @@ class BurstDetectionRequest(BaseModel):
|
||||
points_per_day: int = Field(1440, description="每天的数据点数")
|
||||
mu: int = Field(100, description="异常值检测的参数")
|
||||
iforest_params: dict[str, Any] | None = Field(None, description="隔离森林算法参数")
|
||||
target_time: datetime | None = Field(
|
||||
None,
|
||||
description="目标侦测时刻;为空时自动使用最近一个完整的监测时刻",
|
||||
)
|
||||
sampling_interval_minutes: int | None = Field(
|
||||
None,
|
||||
ge=1,
|
||||
le=1440,
|
||||
description="采样间隔(分钟);为空时根据压力 SCADA 传输频率自动推断",
|
||||
)
|
||||
scada_start: datetime | None = Field(None, description="SCADA数据起始时间")
|
||||
scada_end: datetime | None = Field(None, description="SCADA数据结束时间")
|
||||
sensor_nodes: list[str] | None = Field(None, description="传感器节点列表")
|
||||
@@ -40,7 +48,7 @@ class BurstDetectionRequest(BaseModel):
|
||||
|
||||
|
||||
@router.post(
|
||||
"/detect/",
|
||||
"/burst-detections",
|
||||
summary="执行爆管检测",
|
||||
description="基于压力观测数据和其他参数执行爆管检测分析"
|
||||
)
|
||||
@@ -68,64 +76,3 @@ async def detect_burst(
|
||||
return run_burst_detection(**data.model_dump(), username=username)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/schemes/",
|
||||
summary="查询爆管检测方案列表",
|
||||
description="获取指定网络的所有爆管检测方案"
|
||||
)
|
||||
async def query_burst_detection_schemes(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
query_date: datetime | None = Query(None, description="查询日期(可选)"),
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
获取爆管检测方案列表。
|
||||
|
||||
查询指定网络的所有已配置的爆管检测方案,
|
||||
可按日期进行筛选。
|
||||
|
||||
Args:
|
||||
network: 管网名称(或数据库名称)
|
||||
query_date: 查询日期(可选)
|
||||
|
||||
Returns:
|
||||
爆管检测方案列表
|
||||
|
||||
Raises:
|
||||
HTTPException: 当查询失败时
|
||||
"""
|
||||
try:
|
||||
return list_burst_detection_schemes(network=network, query_date=query_date)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/schemes/{scheme_name}",
|
||||
summary="获取爆管检测方案详情",
|
||||
description="获取指定爆管检测方案的详细信息"
|
||||
)
|
||||
async def query_burst_detection_scheme_detail(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
scheme_name: str = Path(..., description="爆管检测方案名称"),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
获取爆管检测方案详情。
|
||||
|
||||
查询指定爆管检测方案的完整配置和参数信息。
|
||||
|
||||
Args:
|
||||
network: 管网名称(或数据库名称)
|
||||
scheme_name: 爆管检测方案名称
|
||||
|
||||
Returns:
|
||||
包含方案详情的字典
|
||||
|
||||
Raises:
|
||||
HTTPException: 当查询失败时
|
||||
"""
|
||||
try:
|
||||
return get_burst_detection_scheme_detail(network=network, scheme_name=scheme_name)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
|
||||
@@ -3,13 +3,11 @@ from datetime import datetime
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Path, Body
|
||||
from fastapi import APIRouter, Depends, HTTPException, Body
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.auth.keycloak_dependencies import get_current_keycloak_username
|
||||
from app.services.burst_location import (
|
||||
get_burst_location_scheme_detail,
|
||||
list_burst_location_schemes,
|
||||
run_burst_location_by_network,
|
||||
)
|
||||
|
||||
@@ -29,8 +27,10 @@ class BurstLocationRequest(BaseModel):
|
||||
normal_flow: dict[str, float] | list[dict[str, Any]] | None = Field(None, description="正常时的流量数据")
|
||||
min_dpressure: float = Field(2.0, description="最小压力差(bar)")
|
||||
basic_pressure: float = Field(10.0, description="基准压力(bar)")
|
||||
scada_burst_start: datetime | None = Field(None, description="SCADA爆管开始时间")
|
||||
scada_burst_end: datetime | None = Field(None, description="SCADA爆管结束时间")
|
||||
scada_burst_start: datetime | None = Field(None, description="爆管/模拟方案开始时间")
|
||||
scada_burst_end: datetime | None = Field(None, description="爆管/模拟方案结束时间")
|
||||
scada_normal_start: datetime | None = Field(None, description="监测数据正常工况开始时间")
|
||||
scada_normal_end: datetime | None = Field(None, description="监测数据正常工况结束时间")
|
||||
use_scada_flow: bool = Field(False, description="是否使用SCADA流量数据")
|
||||
scheme_name: str | None = Field(None, description="方案名称")
|
||||
simulation_scheme_name: str | None = Field(None, description="模拟方案名称")
|
||||
@@ -38,7 +38,7 @@ class BurstLocationRequest(BaseModel):
|
||||
|
||||
|
||||
@router.post(
|
||||
"/locate/",
|
||||
"/burst-locations",
|
||||
summary="执行爆管定位",
|
||||
description="基于压力和流量数据定位管网中的爆管位置"
|
||||
)
|
||||
@@ -66,64 +66,3 @@ async def locate_burst(
|
||||
return run_burst_location_by_network(**data.model_dump(), username=username)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/schemes/",
|
||||
summary="查询爆管定位方案列表",
|
||||
description="获取指定网络的所有爆管定位方案"
|
||||
)
|
||||
async def query_burst_schemes(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
query_date: datetime | None = Query(None, description="查询日期(可选)")
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
获取爆管定位方案列表。
|
||||
|
||||
查询指定网络的所有已配置的爆管定位方案,
|
||||
可按日期进行筛选。
|
||||
|
||||
Args:
|
||||
network: 管网名称(或数据库名称)
|
||||
query_date: 查询日期(可选)
|
||||
|
||||
Returns:
|
||||
爆管定位方案列表
|
||||
|
||||
Raises:
|
||||
HTTPException: 当查询失败时
|
||||
"""
|
||||
try:
|
||||
return list_burst_location_schemes(network=network, query_date=query_date)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/schemes/{scheme_name}",
|
||||
summary="获取爆管定位方案详情",
|
||||
description="获取指定爆管定位方案的详细信息"
|
||||
)
|
||||
async def query_burst_scheme_detail(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
scheme_name: str = Path(..., description="爆管定位方案名称")
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
获取爆管定位方案详情。
|
||||
|
||||
查询指定爆管定位方案的完整配置和参数信息。
|
||||
|
||||
Args:
|
||||
network: 管网名称(或数据库名称)
|
||||
scheme_name: 爆管定位方案名称
|
||||
|
||||
Returns:
|
||||
包含方案详情的字典
|
||||
|
||||
Raises:
|
||||
HTTPException: 当查询失败时
|
||||
"""
|
||||
try:
|
||||
return get_burst_location_scheme_detail(network=network, scheme_name=scheme_name)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
|
||||
@@ -3,7 +3,7 @@ from app.infra.cache.redis_client import redis_client
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/clearrediskey/", summary="清除单个缓存键", description="根据键名清除单个Redis缓存")
|
||||
@router.delete("/redis-keys/detail", summary="清除单个缓存键", description="根据键名清除单个Redis缓存")
|
||||
async def fastapi_clear_redis_key(key: str = Query(..., description="缓存键名")):
|
||||
"""
|
||||
清除单个缓存键
|
||||
@@ -14,7 +14,7 @@ async def fastapi_clear_redis_key(key: str = Query(..., description="缓存键
|
||||
return True
|
||||
|
||||
|
||||
@router.post("/clearrediskeys/", summary="清除匹配的缓存键", description="根据模式清除匹配的Redis缓存键")
|
||||
@router.delete("/redis-keys", summary="清除匹配的缓存键", description="根据模式清除匹配的Redis缓存键")
|
||||
async def fastapi_clear_redis_keys(keys: str = Query(..., description="缓存键模式(支持通配符)")):
|
||||
"""
|
||||
清除匹配的缓存键
|
||||
@@ -29,7 +29,7 @@ async def fastapi_clear_redis_keys(keys: str = Query(..., description="缓存键
|
||||
return True
|
||||
|
||||
|
||||
@router.post("/clearallredis/", summary="清除所有缓存", description="清空整个Redis数据库的所有缓存")
|
||||
@router.delete("/all-redis", summary="清除所有缓存", description="清空整个Redis数据库的所有缓存")
|
||||
async def fastapi_clear_all_redis():
|
||||
"""
|
||||
清除所有缓存
|
||||
@@ -40,7 +40,7 @@ async def fastapi_clear_all_redis():
|
||||
return True
|
||||
|
||||
|
||||
@router.get("/queryredis/", summary="查询缓存键列表", description="获取Redis中所有的缓存键")
|
||||
@router.get("/redis", summary="查询缓存键列表", description="获取Redis中所有的缓存键")
|
||||
async def fastapi_query_redis():
|
||||
"""
|
||||
查询缓存键列表
|
||||
|
||||
@@ -13,7 +13,7 @@ from app.services.tjnetwork import (
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/getcontrolschema/", summary="获取控制架构", description="获取网络中控制对象的架构定义")
|
||||
@router.get("/network-schemas/control", summary="获取控制架构", description="获取网络中控制对象的架构定义")
|
||||
async def fastapi_get_control_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
|
||||
"""获取控制架构。
|
||||
|
||||
@@ -21,7 +21,7 @@ async def fastapi_get_control_schema(network: str = Query(..., description="管
|
||||
"""
|
||||
return get_control_schema(network)
|
||||
|
||||
@router.get("/getcontrolproperties/", summary="获取控制属性", description="获取指定网络中的控制属性信息")
|
||||
@router.get("/controls/properties", summary="获取控制属性", description="获取指定网络中的控制属性信息")
|
||||
async def fastapi_get_control_properties(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
|
||||
"""获取控制属性。
|
||||
|
||||
@@ -29,7 +29,7 @@ async def fastapi_get_control_properties(network: str = Query(..., description="
|
||||
"""
|
||||
return get_control(network)
|
||||
|
||||
@router.post("/setcontrolproperties/", response_model=None, summary="设置控制属性", description="更新指定网络中的控制属性")
|
||||
@router.patch("/controls/properties", response_model=None, summary="设置控制属性", description="更新指定网络中的控制属性")
|
||||
async def fastapi_set_control_properties(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
req: Request = None
|
||||
@@ -41,7 +41,7 @@ async def fastapi_set_control_properties(
|
||||
props = await req.json()
|
||||
return set_control(network, ChangeSet(props))
|
||||
|
||||
@router.get("/getruleschema/", summary="获取规则架构", description="获取网络中规则对象的架构定义")
|
||||
@router.get("/rule-schemas", summary="获取规则架构", description="获取网络中规则对象的架构定义")
|
||||
async def fastapi_get_rule_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
|
||||
"""获取规则架构。
|
||||
|
||||
@@ -49,7 +49,7 @@ async def fastapi_get_rule_schema(network: str = Query(..., description="管网
|
||||
"""
|
||||
return get_rule_schema(network)
|
||||
|
||||
@router.get("/getruleproperties/", summary="获取规则属性", description="获取指定网络中的规则属性信息")
|
||||
@router.get("/rule-properties", summary="获取规则属性", description="获取指定网络中的规则属性信息")
|
||||
async def fastapi_get_rule_properties(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
|
||||
"""获取规则属性。
|
||||
|
||||
@@ -57,7 +57,7 @@ async def fastapi_get_rule_properties(network: str = Query(..., description="管
|
||||
"""
|
||||
return get_rule(network)
|
||||
|
||||
@router.post("/setruleproperties/", response_model=None, summary="设置规则属性", description="更新指定网络中的规则属性")
|
||||
@router.patch("/rule-properties", response_model=None, summary="设置规则属性", description="更新指定网络中的规则属性")
|
||||
async def fastapi_set_rule_properties(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
req: Request = None
|
||||
|
||||
@@ -14,7 +14,7 @@ from app.services.tjnetwork import (
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/getcurveschema", summary="获取曲线架构", description="获取网络中曲线对象的架构定义")
|
||||
@router.get("/network-schemas/curve", summary="获取曲线架构", description="获取网络中曲线对象的架构定义")
|
||||
async def fastapi_get_curve_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
|
||||
"""获取曲线架构。
|
||||
|
||||
@@ -22,7 +22,7 @@ async def fastapi_get_curve_schema(network: str = Query(..., description="管网
|
||||
"""
|
||||
return get_curve_schema(network)
|
||||
|
||||
@router.post("/addcurve/", response_model=None, summary="添加曲线", description="在网络中添加一条新的曲线")
|
||||
@router.post("/curves", response_model=None, summary="添加曲线", description="在网络中添加一条新的曲线")
|
||||
async def fastapi_add_curve(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
curve: str = Query(..., description="曲线ID"),
|
||||
@@ -38,7 +38,7 @@ async def fastapi_add_curve(
|
||||
} | props
|
||||
return add_curve(network, ChangeSet(ps))
|
||||
|
||||
@router.post("/deletecurve/", response_model=None, summary="删除曲线", description="从网络中删除指定的曲线")
|
||||
@router.delete("/curves", response_model=None, summary="删除曲线", description="从网络中删除指定的曲线")
|
||||
async def fastapi_delete_curve(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
curve: str = Query(..., description="曲线ID")
|
||||
@@ -50,7 +50,7 @@ async def fastapi_delete_curve(
|
||||
ps = {"id": curve}
|
||||
return delete_curve(network, ChangeSet(ps))
|
||||
|
||||
@router.get("/getcurveproperties/", summary="获取曲线属性", description="获取指定曲线的属性信息")
|
||||
@router.get("/curves/properties", summary="获取曲线属性", description="获取指定曲线的属性信息")
|
||||
async def fastapi_get_curve_properties(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
curve: str = Query(..., description="曲线ID")
|
||||
@@ -61,7 +61,7 @@ async def fastapi_get_curve_properties(
|
||||
"""
|
||||
return get_curve(network, curve)
|
||||
|
||||
@router.post("/setcurveproperties/", response_model=None, summary="设置曲线属性", description="更新指定曲线的属性")
|
||||
@router.patch("/curves/properties", response_model=None, summary="设置曲线属性", description="更新指定曲线的属性")
|
||||
async def fastapi_set_curve_properties(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
curve: str = Query(..., description="曲线ID"),
|
||||
@@ -75,7 +75,7 @@ async def fastapi_set_curve_properties(
|
||||
ps = {"id": curve} | props
|
||||
return set_curve(network, ChangeSet(ps))
|
||||
|
||||
@router.get("/getcurves/", summary="获取所有曲线", description="获取网络中的所有曲线列表")
|
||||
@router.get("/curves", summary="获取所有曲线", description="获取网络中的所有曲线列表")
|
||||
async def fastapi_get_curves(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[str]:
|
||||
"""获取所有曲线。
|
||||
|
||||
@@ -83,7 +83,7 @@ async def fastapi_get_curves(network: str = Query(..., description="管网名称
|
||||
"""
|
||||
return get_curves(network)
|
||||
|
||||
@router.get("/iscurve/", summary="检查曲线存在性", description="检查指定的曲线是否存在")
|
||||
@router.get("/curves/existence", summary="检查曲线存在性", description="检查指定的曲线是否存在")
|
||||
async def fastapi_is_curve(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
curve: str = Query(..., description="曲线ID")
|
||||
|
||||
@@ -19,7 +19,7 @@ from app.services.tjnetwork import (
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/gettimeschema", summary="获取时间选项架构", description="获取网络中时间选项的架构定义")
|
||||
@router.get("/network-schemas/time", summary="获取时间选项架构", description="获取网络中时间选项的架构定义")
|
||||
async def fastapi_get_time_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
|
||||
"""获取时间选项架构。
|
||||
|
||||
@@ -27,7 +27,7 @@ async def fastapi_get_time_schema(network: str = Query(..., description="管网
|
||||
"""
|
||||
return get_time_schema(network)
|
||||
|
||||
@router.get("/gettimeproperties/", summary="获取时间选项属性", description="获取指定网络中的时间选项属性信息")
|
||||
@router.get("/network-options/time", summary="获取时间选项属性", description="获取指定网络中的时间选项属性信息")
|
||||
async def fastapi_get_time_properties(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
|
||||
"""获取时间选项属性。
|
||||
|
||||
@@ -35,7 +35,7 @@ async def fastapi_get_time_properties(network: str = Query(..., description="管
|
||||
"""
|
||||
return get_time(network)
|
||||
|
||||
@router.post("/settimeproperties/", response_model=None, summary="设置时间选项属性", description="更新指定网络中的时间选项属性")
|
||||
@router.patch("/time-properties", response_model=None, summary="设置时间选项属性", description="更新指定网络中的时间选项属性")
|
||||
async def fastapi_set_time_properties(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
req: Request = None
|
||||
@@ -47,7 +47,7 @@ async def fastapi_set_time_properties(
|
||||
props = await req.json()
|
||||
return set_time(network, ChangeSet(props))
|
||||
|
||||
@router.get("/getenergyschema/", summary="获取能耗选项架构", description="获取网络中能耗选项的架构定义")
|
||||
@router.get("/network-schemas/energy", summary="获取能耗选项架构", description="获取网络中能耗选项的架构定义")
|
||||
async def fastapi_get_energy_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
|
||||
"""获取能耗选项架构。
|
||||
|
||||
@@ -55,7 +55,7 @@ async def fastapi_get_energy_schema(network: str = Query(..., description="管
|
||||
"""
|
||||
return get_energy_schema(network)
|
||||
|
||||
@router.get("/getenergyproperties/", summary="获取能耗选项属性", description="获取指定网络中的能耗选项属性信息")
|
||||
@router.get("/network-options/energy", summary="获取能耗选项属性", description="获取指定网络中的能耗选项属性信息")
|
||||
async def fastapi_get_energy_properties(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
|
||||
"""获取能耗选项属性。
|
||||
|
||||
@@ -63,7 +63,7 @@ async def fastapi_get_energy_properties(network: str = Query(..., description="
|
||||
"""
|
||||
return get_energy(network)
|
||||
|
||||
@router.post("/setenergyproperties/", response_model=None, summary="设置能耗选项属性", description="更新指定网络中的能耗选项属性")
|
||||
@router.patch("/energy-properties", response_model=None, summary="设置能耗选项属性", description="更新指定网络中的能耗选项属性")
|
||||
async def fastapi_set_energy_properties(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
req: Request = None
|
||||
@@ -75,7 +75,7 @@ async def fastapi_set_energy_properties(
|
||||
props = await req.json()
|
||||
return set_energy(network, ChangeSet(props))
|
||||
|
||||
@router.get("/getpumpenergyschema/", summary="获取泵能耗选项架构", description="获取网络中泵能耗选项的架构定义")
|
||||
@router.get("/network-schemas/pump-energy", summary="获取泵能耗选项架构", description="获取网络中泵能耗选项的架构定义")
|
||||
async def fastapi_get_pump_energy_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
|
||||
"""获取泵能耗选项架构。
|
||||
|
||||
@@ -83,7 +83,7 @@ async def fastapi_get_pump_energy_schema(network: str = Query(..., description="
|
||||
"""
|
||||
return get_pump_energy_schema(network)
|
||||
|
||||
@router.get("/getpumpenergyproperties//", summary="获取泵能耗属性", description="获取指定泵的能耗属性信息")
|
||||
@router.get("/network-options/pump-energy", summary="获取泵能耗属性", description="获取指定泵的能耗属性信息")
|
||||
async def fastapi_get_pump_energy_proeprties(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
pump: str = Query(..., description="泵ID")
|
||||
@@ -94,7 +94,7 @@ async def fastapi_get_pump_energy_proeprties(
|
||||
"""
|
||||
return get_pump_energy(network, pump)
|
||||
|
||||
@router.get("/setpumpenergyproperties//", response_model=None, summary="设置泵能耗属性", description="更新指定泵的能耗属性")
|
||||
@router.patch("/network-options/pump-energy", response_model=None, summary="设置泵能耗属性", description="更新指定泵的能耗属性")
|
||||
async def fastapi_set_pump_energy_properties(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
pump: str = Query(..., description="泵ID"),
|
||||
@@ -108,7 +108,7 @@ async def fastapi_set_pump_energy_properties(
|
||||
ps = {"id": pump} | props
|
||||
return set_pump_energy(network, ChangeSet(ps))
|
||||
|
||||
@router.get("/getoptionschema/", summary="获取选项架构", description="获取网络中选项对象的架构定义")
|
||||
@router.get("/network-schemas/option", summary="获取选项架构", description="获取网络中选项对象的架构定义")
|
||||
async def fastapi_get_option_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
|
||||
"""获取选项架构。
|
||||
|
||||
@@ -116,7 +116,7 @@ async def fastapi_get_option_schema(network: str = Query(..., description="管
|
||||
"""
|
||||
return get_option_v3_schema(network)
|
||||
|
||||
@router.get("/getoptionproperties/", summary="获取选项属性", description="获取指定网络中的选项属性信息")
|
||||
@router.get("/network-options", summary="获取选项属性", description="获取指定网络中的选项属性信息")
|
||||
async def fastapi_get_option_properties(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
|
||||
"""获取选项属性。
|
||||
|
||||
@@ -124,7 +124,7 @@ async def fastapi_get_option_properties(network: str = Query(..., description="
|
||||
"""
|
||||
return get_option_v3(network)
|
||||
|
||||
@router.post("/setoptionproperties/", response_model=None, summary="设置选项属性", description="更新指定网络中的选项属性")
|
||||
@router.patch("/network-options", response_model=None, summary="设置选项属性", description="更新指定网络中的选项属性")
|
||||
async def fastapi_set_option_properties(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
req: Request = None
|
||||
|
||||
@@ -14,7 +14,7 @@ from app.services.tjnetwork import (
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/getpatternschema", summary="获取模式架构", description="获取网络中模式对象的架构定义")
|
||||
@router.get("/network-schemas/pattern", summary="获取模式架构", description="获取网络中模式对象的架构定义")
|
||||
async def fastapi_get_pattern_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
|
||||
"""获取模式架构。
|
||||
|
||||
@@ -22,7 +22,7 @@ async def fastapi_get_pattern_schema(network: str = Query(..., description="管
|
||||
"""
|
||||
return get_pattern_schema(network)
|
||||
|
||||
@router.post("/addpattern/", response_model=None, summary="添加模式", description="在网络中添加一个新的模式")
|
||||
@router.post("/patterns", response_model=None, summary="添加模式", description="在网络中添加一个新的模式")
|
||||
async def fastapi_add_pattern(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
pattern: str = Query(..., description="模式ID"),
|
||||
@@ -38,7 +38,7 @@ async def fastapi_add_pattern(
|
||||
} | props
|
||||
return add_pattern(network, ChangeSet(ps))
|
||||
|
||||
@router.post("/deletepattern/", response_model=None, summary="删除模式", description="从网络中删除指定的模式")
|
||||
@router.delete("/patterns", response_model=None, summary="删除模式", description="从网络中删除指定的模式")
|
||||
async def fastapi_delete_pattern(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
pattern: str = Query(..., description="模式ID")
|
||||
@@ -50,7 +50,7 @@ async def fastapi_delete_pattern(
|
||||
ps = {"id": pattern}
|
||||
return delete_pattern(network, ChangeSet(ps))
|
||||
|
||||
@router.get("/getpatternproperties/", summary="获取模式属性", description="获取指定模式的属性信息")
|
||||
@router.get("/patterns/properties", summary="获取模式属性", description="获取指定模式的属性信息")
|
||||
async def fastapi_get_pattern_properties(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
pattern: str = Query(..., description="模式ID")
|
||||
@@ -61,7 +61,7 @@ async def fastapi_get_pattern_properties(
|
||||
"""
|
||||
return get_pattern(network, pattern)
|
||||
|
||||
@router.post("/setpatternproperties/", response_model=None, summary="设置模式属性", description="更新指定模式的属性")
|
||||
@router.patch("/patterns/properties", response_model=None, summary="设置模式属性", description="更新指定模式的属性")
|
||||
async def fastapi_set_pattern_properties(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
pattern: str = Query(..., description="模式ID"),
|
||||
@@ -75,7 +75,7 @@ async def fastapi_set_pattern_properties(
|
||||
ps = {"id": pattern} | props
|
||||
return set_pattern(network, ChangeSet(ps))
|
||||
|
||||
@router.get("/ispattern/", summary="检查模式存在性", description="检查指定的模式是否存在")
|
||||
@router.get("/patterns/existence", summary="检查模式存在性", description="检查指定的模式是否存在")
|
||||
async def fastapi_is_pattern(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
pattern: str = Query(..., description="模式ID")
|
||||
@@ -86,7 +86,7 @@ async def fastapi_is_pattern(
|
||||
"""
|
||||
return is_pattern(network, pattern)
|
||||
|
||||
@router.get("/getpatterns/", summary="获取所有模式", description="获取网络中的所有模式列表")
|
||||
@router.get("/patterns", summary="获取所有模式", description="获取网络中的所有模式列表")
|
||||
async def fastapi_get_patterns(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[str]:
|
||||
"""获取所有模式。
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ from app.services.tjnetwork import (
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/getqualityschema/", summary="获取水质架构", description="获取网络中水质对象的架构定义")
|
||||
@router.get("/network-schemas/quality", summary="获取水质架构", description="获取网络中水质对象的架构定义")
|
||||
async def fastapi_get_quality_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
|
||||
"""获取水质架构。
|
||||
|
||||
@@ -40,7 +40,7 @@ async def fastapi_get_quality_schema(network: str = Query(..., description="管
|
||||
"""
|
||||
return get_quality_schema(network)
|
||||
|
||||
@router.get("/getqualityproperties/", summary="获取水质属性", description="获取指定节点的水质属性信息")
|
||||
@router.get("/quality-configurations/properties", summary="获取水质属性", description="获取指定节点的水质属性信息")
|
||||
async def fastapi_get_quality_properties(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
node: str = Query(..., description="节点ID")
|
||||
@@ -51,7 +51,7 @@ async def fastapi_get_quality_properties(
|
||||
"""
|
||||
return get_quality(network, node)
|
||||
|
||||
@router.post("/setqualityproperties/", response_model=None, summary="设置水质属性", description="更新指定节点的水质属性")
|
||||
@router.patch("/quality-configurations/properties", response_model=None, summary="设置水质属性", description="更新指定节点的水质属性")
|
||||
async def fastapi_set_quality_properties(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
req: Request = None
|
||||
@@ -63,7 +63,7 @@ async def fastapi_set_quality_properties(
|
||||
props = await req.json()
|
||||
return set_quality(network, ChangeSet(props))
|
||||
|
||||
@router.get("/getemitterschema", summary="获取发射器架构", description="获取网络中发射器对象的架构定义")
|
||||
@router.get("/network-schemas/emitter", summary="获取发射器架构", description="获取网络中发射器对象的架构定义")
|
||||
async def fastapi_get_emitter_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
|
||||
"""获取发射器架构。
|
||||
|
||||
@@ -71,7 +71,7 @@ async def fastapi_get_emitter_schema(network: str = Query(..., description="管
|
||||
"""
|
||||
return get_emitter_schema(network)
|
||||
|
||||
@router.get("/getemitterproperties/", summary="获取发射器属性", description="获取指定连接点的发射器属性信息")
|
||||
@router.get("/emitters/properties", summary="获取发射器属性", description="获取指定连接点的发射器属性信息")
|
||||
async def fastapi_get_emitter_properties(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
junction: str = Query(..., description="连接点ID")
|
||||
@@ -82,7 +82,7 @@ async def fastapi_get_emitter_properties(
|
||||
"""
|
||||
return get_emitter(network, junction)
|
||||
|
||||
@router.post("/setemitterproperties/", response_model=None, summary="设置发射器属性", description="更新指定连接点的发射器属性")
|
||||
@router.patch("/emitters/properties", response_model=None, summary="设置发射器属性", description="更新指定连接点的发射器属性")
|
||||
async def fastapi_set_emitter_properties(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
junction: str = Query(..., description="连接点ID"),
|
||||
@@ -96,7 +96,7 @@ async def fastapi_set_emitter_properties(
|
||||
ps = {"junction": junction} | props
|
||||
return set_emitter(network, ChangeSet(ps))
|
||||
|
||||
@router.get("/getsourcechema/", summary="获取水源架构", description="获取网络中水源对象的架构定义")
|
||||
@router.get("/network-schemas/source", summary="获取水源架构", description="获取网络中水源对象的架构定义")
|
||||
async def fastapi_get_source_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
|
||||
"""获取水源架构。
|
||||
|
||||
@@ -104,7 +104,7 @@ async def fastapi_get_source_schema(network: str = Query(..., description="管
|
||||
"""
|
||||
return get_source_schema(network)
|
||||
|
||||
@router.get("/getsource/", summary="获取水源属性", description="获取指定节点的水源属性信息")
|
||||
@router.get("/sources/detail", summary="获取水源属性", description="获取指定节点的水源属性信息")
|
||||
async def fastapi_get_source(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
node: str = Query(..., description="节点ID")
|
||||
@@ -115,7 +115,7 @@ async def fastapi_get_source(
|
||||
"""
|
||||
return get_source(network, node)
|
||||
|
||||
@router.post("/setsource/", response_model=None, summary="设置水源属性", description="更新指定节点的水源属性")
|
||||
@router.patch("/sources", response_model=None, summary="设置水源属性", description="更新指定节点的水源属性")
|
||||
async def fastapi_set_source(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
req: Request = None
|
||||
@@ -127,7 +127,7 @@ async def fastapi_set_source(
|
||||
props = await req.json()
|
||||
return set_source(network, ChangeSet(props))
|
||||
|
||||
@router.post("/addsource/", response_model=None, summary="添加水源", description="在网络中添加一个新的水源")
|
||||
@router.post("/sources", response_model=None, summary="添加水源", description="在网络中添加一个新的水源")
|
||||
async def fastapi_add_source(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
req: Request = None
|
||||
@@ -139,7 +139,7 @@ async def fastapi_add_source(
|
||||
props = await req.json()
|
||||
return add_source(network, ChangeSet(props))
|
||||
|
||||
@router.post("/deletesource/", response_model=None, summary="删除水源", description="从网络中删除指定节点的水源")
|
||||
@router.delete("/sources", response_model=None, summary="删除水源", description="从网络中删除指定节点的水源")
|
||||
async def fastapi_delete_source(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
node: str = Query(..., description="节点ID")
|
||||
@@ -151,7 +151,7 @@ async def fastapi_delete_source(
|
||||
props = {"node": node}
|
||||
return delete_source(network, ChangeSet(props))
|
||||
|
||||
@router.get("/getreactionschema/", summary="获取反应架构", description="获取网络中反应对象的架构定义")
|
||||
@router.get("/network-schemas/reaction", summary="获取反应架构", description="获取网络中反应对象的架构定义")
|
||||
async def fastapi_get_reaction_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
|
||||
"""获取反应架构。
|
||||
|
||||
@@ -159,7 +159,7 @@ async def fastapi_get_reaction_schema(network: str = Query(..., description="管
|
||||
"""
|
||||
return get_reaction_schema(network)
|
||||
|
||||
@router.get("/getreaction/", summary="获取反应属性", description="获取指定网络中的反应属性信息")
|
||||
@router.get("/reactions/detail", summary="获取反应属性", description="获取指定网络中的反应属性信息")
|
||||
async def fastapi_get_reaction(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
|
||||
"""获取反应属性。
|
||||
|
||||
@@ -167,7 +167,7 @@ async def fastapi_get_reaction(network: str = Query(..., description="管网名
|
||||
"""
|
||||
return get_reaction(network)
|
||||
|
||||
@router.post("/setreaction/", response_model=None, summary="设置反应属性", description="更新指定网络中的反应属性")
|
||||
@router.patch("/reactions", response_model=None, summary="设置反应属性", description="更新指定网络中的反应属性")
|
||||
async def fastapi_set_reaction(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
req: Request = None
|
||||
@@ -179,7 +179,7 @@ async def fastapi_set_reaction(
|
||||
props = await req.json()
|
||||
return set_reaction(network, ChangeSet(props))
|
||||
|
||||
@router.get("/getpipereactionschema/", summary="获取管道反应架构", description="获取网络中管道反应对象的架构定义")
|
||||
@router.get("/network-schemas/pipe-reaction", summary="获取管道反应架构", description="获取网络中管道反应对象的架构定义")
|
||||
async def fastapi_get_pipe_reaction_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
|
||||
"""获取管道反应架构。
|
||||
|
||||
@@ -187,7 +187,7 @@ async def fastapi_get_pipe_reaction_schema(network: str = Query(..., description
|
||||
"""
|
||||
return get_pipe_reaction_schema(network)
|
||||
|
||||
@router.get("/getpipereaction/", summary="获取管道反应属性", description="获取指定管道的反应属性信息")
|
||||
@router.get("/pipe-reactions/detail", summary="获取管道反应属性", description="获取指定管道的反应属性信息")
|
||||
async def fastapi_get_pipe_reaction(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
pipe: str = Query(..., description="管道ID")
|
||||
@@ -198,7 +198,7 @@ async def fastapi_get_pipe_reaction(
|
||||
"""
|
||||
return get_pipe_reaction(network, pipe)
|
||||
|
||||
@router.post("/setpipereaction/", response_model=None, summary="设置管道反应属性", description="更新指定管道的反应属性")
|
||||
@router.patch("/pipe-reactions", response_model=None, summary="设置管道反应属性", description="更新指定管道的反应属性")
|
||||
async def fastapi_set_pipe_reaction(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
req: Request = None
|
||||
@@ -210,7 +210,7 @@ async def fastapi_set_pipe_reaction(
|
||||
props = await req.json()
|
||||
return set_pipe_reaction(network, ChangeSet(props))
|
||||
|
||||
@router.get("/gettankreactionschema/", summary="获取水池反应架构", description="获取网络中水池反应对象的架构定义")
|
||||
@router.get("/network-schemas/tank-reaction", summary="获取水池反应架构", description="获取网络中水池反应对象的架构定义")
|
||||
async def fastapi_get_tank_reaction_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
|
||||
"""获取水池反应架构。
|
||||
|
||||
@@ -218,7 +218,7 @@ async def fastapi_get_tank_reaction_schema(network: str = Query(..., description
|
||||
"""
|
||||
return get_tank_reaction_schema(network)
|
||||
|
||||
@router.get("/gettankreaction/", summary="获取水池反应属性", description="获取指定水池的反应属性信息")
|
||||
@router.get("/tank-reactions/detail", summary="获取水池反应属性", description="获取指定水池的反应属性信息")
|
||||
async def fastapi_get_tank_reaction(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
tank: str = Query(..., description="水池ID")
|
||||
@@ -229,7 +229,7 @@ async def fastapi_get_tank_reaction(
|
||||
"""
|
||||
return get_tank_reaction(network, tank)
|
||||
|
||||
@router.post("/settankreaction/", response_model=None, summary="设置水池反应属性", description="更新指定水池的反应属性")
|
||||
@router.patch("/tank-reactions", response_model=None, summary="设置水池反应属性", description="更新指定水池的反应属性")
|
||||
async def fastapi_set_tank_reaction(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
req: Request = None
|
||||
@@ -241,7 +241,7 @@ async def fastapi_set_tank_reaction(
|
||||
props = await req.json()
|
||||
return set_tank_reaction(network, ChangeSet(props))
|
||||
|
||||
@router.get("/getmixingschema/", summary="获取混合架构", description="获取网络中混合对象的架构定义")
|
||||
@router.get("/network-schemas/mixing", summary="获取混合架构", description="获取网络中混合对象的架构定义")
|
||||
async def fastapi_get_mixing_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
|
||||
"""获取混合架构。
|
||||
|
||||
@@ -249,7 +249,7 @@ async def fastapi_get_mixing_schema(network: str = Query(..., description="管
|
||||
"""
|
||||
return get_mixing_schema(network)
|
||||
|
||||
@router.get("/getmixing/", summary="获取混合属性", description="获取指定水池的混合属性信息")
|
||||
@router.get("/mixing-configurations/detail", summary="获取混合属性", description="获取指定水池的混合属性信息")
|
||||
async def fastapi_get_mixing(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
tank: str = Query(..., description="水池ID")
|
||||
@@ -260,7 +260,7 @@ async def fastapi_get_mixing(
|
||||
"""
|
||||
return get_mixing(network, tank)
|
||||
|
||||
@router.post("/setmixing/", response_model=None, summary="设置混合属性", description="更新指定水池的混合属性")
|
||||
@router.patch("/mixing-configurations", response_model=None, summary="设置混合属性", description="更新指定水池的混合属性")
|
||||
async def fastapi_set_mixing(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
req: Request = None
|
||||
@@ -272,7 +272,7 @@ async def fastapi_set_mixing(
|
||||
props = await req.json()
|
||||
return api.set_mixing(network, ChangeSet(props))
|
||||
|
||||
@router.post("/addmixing/", response_model=None, summary="添加混合", description="在网络中添加一个新的混合")
|
||||
@router.post("/mixing-configurations", response_model=None, summary="添加混合", description="在网络中添加一个新的混合")
|
||||
async def fastapi_add_mixing(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
req: Request = None
|
||||
@@ -284,7 +284,7 @@ async def fastapi_add_mixing(
|
||||
props = await req.json()
|
||||
return add_mixing(network, ChangeSet(props))
|
||||
|
||||
@router.post("/deletemixing/", response_model=None, summary="删除混合", description="从网络中删除指定的混合")
|
||||
@router.delete("/mixing-configurations", response_model=None, summary="删除混合", description="从网络中删除指定的混合")
|
||||
async def fastapi_delete_mixing(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
req: Request = None
|
||||
|
||||
@@ -24,7 +24,7 @@ import json
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/getvertexschema/", summary="获取图形元素架构", description="获取网络中图形元素对象的架构定义")
|
||||
@router.get("/network-schemas/vertex", summary="获取图形元素架构", description="获取网络中图形元素对象的架构定义")
|
||||
async def fastapi_get_vertex_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
|
||||
"""获取图形元素架构。
|
||||
|
||||
@@ -32,7 +32,7 @@ async def fastapi_get_vertex_schema(network: str = Query(..., description="管
|
||||
"""
|
||||
return get_vertex_schema(network)
|
||||
|
||||
@router.get("/getvertexproperties/", summary="获取图形元素属性", description="获取指定图形元素的属性信息")
|
||||
@router.get("/visual-elements/properties", summary="获取图形元素属性", description="获取指定图形元素的属性信息")
|
||||
async def fastapi_get_vertex_properties(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
link: str = Query(..., description="图形元素链接")
|
||||
@@ -43,7 +43,7 @@ async def fastapi_get_vertex_properties(
|
||||
"""
|
||||
return get_vertex(network, link)
|
||||
|
||||
@router.post("/setvertexproperties/", response_model=None, summary="设置图形元素属性", description="更新指定图形元素的属性")
|
||||
@router.patch("/visual-elements/properties", response_model=None, summary="设置图形元素属性", description="更新指定图形元素的属性")
|
||||
async def fastapi_set_vertex_properties(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
req: Request = None
|
||||
@@ -55,7 +55,7 @@ async def fastapi_set_vertex_properties(
|
||||
props = await req.json()
|
||||
return set_vertex(network, ChangeSet(props))
|
||||
|
||||
@router.post("/addvertex/", response_model=None, summary="添加图形元素", description="在网络中添加一个新的图形元素")
|
||||
@router.post("/visual-elements", response_model=None, summary="添加图形元素", description="在网络中添加一个新的图形元素")
|
||||
async def fastapi_add_vertex(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
req: Request = None
|
||||
@@ -67,7 +67,7 @@ async def fastapi_add_vertex(
|
||||
props = await req.json()
|
||||
return add_vertex(network, ChangeSet(props))
|
||||
|
||||
@router.post("/deletevertex/", response_model=None, summary="删除图形元素", description="从网络中删除指定的图形元素")
|
||||
@router.delete("/visual-elements", response_model=None, summary="删除图形元素", description="从网络中删除指定的图形元素")
|
||||
async def fastapi_delete_vertex(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
req: Request = None
|
||||
@@ -79,7 +79,7 @@ async def fastapi_delete_vertex(
|
||||
props = await req.json()
|
||||
return delete_vertex(network, ChangeSet(props))
|
||||
|
||||
@router.get("/getallvertexlinks/", response_class=PlainTextResponse, summary="获取所有图形元素链接", description="获取网络中的所有图形元素链接列表")
|
||||
@router.get("/visual-elements/links", response_class=PlainTextResponse, summary="获取所有图形元素链接", description="获取网络中的所有图形元素链接列表")
|
||||
async def fastapi_get_all_vertex_links(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[str]:
|
||||
"""获取所有图形元素链接。
|
||||
|
||||
@@ -87,7 +87,7 @@ async def fastapi_get_all_vertex_links(network: str = Query(..., description="
|
||||
"""
|
||||
return json.dumps(get_all_vertex_links(network))
|
||||
|
||||
@router.get("/getallvertices/", response_class=PlainTextResponse, summary="获取所有图形元素", description="获取网络中的所有图形元素详细信息")
|
||||
@router.get("/all-vertices", response_class=PlainTextResponse, summary="获取所有图形元素", description="获取网络中的所有图形元素详细信息")
|
||||
async def fastapi_get_all_vertices(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[dict[str, Any]]:
|
||||
"""获取所有图形元素。
|
||||
|
||||
@@ -95,7 +95,7 @@ async def fastapi_get_all_vertices(network: str = Query(..., description="管网
|
||||
"""
|
||||
return json.dumps(get_all_vertices(network))
|
||||
|
||||
@router.get("/getlabelschema/", summary="获取标签架构", description="获取网络中标签对象的架构定义")
|
||||
@router.get("/network-schemas/label", summary="获取标签架构", description="获取网络中标签对象的架构定义")
|
||||
async def fastapi_get_label_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
|
||||
"""获取标签架构。
|
||||
|
||||
@@ -103,7 +103,7 @@ async def fastapi_get_label_schema(network: str = Query(..., description="管网
|
||||
"""
|
||||
return get_label_schema(network)
|
||||
|
||||
@router.get("/getlabelproperties/", summary="获取标签属性", description="获取指定坐标处的标签属性信息")
|
||||
@router.get("/labels/properties", summary="获取标签属性", description="获取指定坐标处的标签属性信息")
|
||||
async def fastapi_get_label_properties(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
x: float = Query(..., description="X坐标"),
|
||||
@@ -115,7 +115,7 @@ async def fastapi_get_label_properties(
|
||||
"""
|
||||
return get_label(network, x, y)
|
||||
|
||||
@router.post("/setlabelproperties/", response_model=None, summary="设置标签属性", description="更新指定标签的属性")
|
||||
@router.patch("/labels/properties", response_model=None, summary="设置标签属性", description="更新指定标签的属性")
|
||||
async def fastapi_set_label_properties(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
req: Request = None
|
||||
@@ -127,7 +127,7 @@ async def fastapi_set_label_properties(
|
||||
props = await req.json()
|
||||
return set_label(network, ChangeSet(props))
|
||||
|
||||
@router.post("/addlabel/", response_model=None, summary="添加标签", description="在网络中添加一个新的标签")
|
||||
@router.post("/labels", response_model=None, summary="添加标签", description="在网络中添加一个新的标签")
|
||||
async def fastapi_add_label(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
req: Request = None
|
||||
@@ -139,7 +139,7 @@ async def fastapi_add_label(
|
||||
props = await req.json()
|
||||
return add_label(network, ChangeSet(props))
|
||||
|
||||
@router.post("/deletelabel/", response_model=None, summary="删除标签", description="从网络中删除指定的标签")
|
||||
@router.delete("/labels", response_model=None, summary="删除标签", description="从网络中删除指定的标签")
|
||||
async def fastapi_delete_label(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
req: Request = None
|
||||
@@ -151,7 +151,7 @@ async def fastapi_delete_label(
|
||||
props = await req.json()
|
||||
return delete_label(network, ChangeSet(props))
|
||||
|
||||
@router.get("/getbackdropschema/", summary="获取背景架构", description="获取网络中背景对象的架构定义")
|
||||
@router.get("/network-schemas/backdrop", summary="获取背景架构", description="获取网络中背景对象的架构定义")
|
||||
async def fastapi_get_backdrop_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
|
||||
"""获取背景架构。
|
||||
|
||||
@@ -159,7 +159,7 @@ async def fastapi_get_backdrop_schema(network: str = Query(..., description="管
|
||||
"""
|
||||
return get_backdrop_schema(network)
|
||||
|
||||
@router.get("/getbackdropproperties/", summary="获取背景属性", description="获取指定网络的背景属性信息")
|
||||
@router.get("/backdrops/properties", summary="获取背景属性", description="获取指定网络的背景属性信息")
|
||||
async def fastapi_get_backdrop_properties(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
|
||||
"""获取背景属性。
|
||||
|
||||
@@ -167,7 +167,7 @@ async def fastapi_get_backdrop_properties(network: str = Query(..., description=
|
||||
"""
|
||||
return get_backdrop(network)
|
||||
|
||||
@router.post("/setbackdropproperties/", response_model=None, summary="设置背景属性", description="更新指定网络的背景属性")
|
||||
@router.patch("/backdrops/properties", response_model=None, summary="设置背景属性", description="更新指定网络的背景属性")
|
||||
async def fastapi_set_backdrop_properties(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
req: Request = None
|
||||
|
||||
@@ -11,7 +11,7 @@ from app.services.tjnetwork import (
|
||||
router = APIRouter()
|
||||
|
||||
@router.get(
|
||||
"/getallextensiondatakeys/",
|
||||
"/all-extension-data-keys",
|
||||
summary="获取所有扩展数据键",
|
||||
description="获取指定网络的所有扩展数据的键列表"
|
||||
)
|
||||
@@ -32,7 +32,7 @@ async def get_all_extension_data_keys_endpoint(
|
||||
return get_all_extension_data_keys(network)
|
||||
|
||||
@router.get(
|
||||
"/getallextensiondata/",
|
||||
"/all-extension-datas",
|
||||
summary="获取所有扩展数据",
|
||||
description="获取指定网络的所有扩展数据"
|
||||
)
|
||||
@@ -53,7 +53,7 @@ async def get_all_extension_data_endpoint(
|
||||
return get_all_extension_data(network)
|
||||
|
||||
@router.get(
|
||||
"/getextensiondata/",
|
||||
"/extension-datas",
|
||||
summary="获取指定扩展数据",
|
||||
description="获取指定网络中指定键的扩展数据值"
|
||||
)
|
||||
@@ -75,8 +75,8 @@ async def get_extension_data_endpoint(
|
||||
"""
|
||||
return get_extension_data(network, key)
|
||||
|
||||
@router.post(
|
||||
"/setextensiondata/",
|
||||
@router.patch(
|
||||
"/extension-datas",
|
||||
response_model=None,
|
||||
summary="设置扩展数据",
|
||||
description="设置指定网络中的扩展数据"
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
from app.services.geocoding import (
|
||||
TiandituGeocodeRequest,
|
||||
TiandituGeocodingAPIError,
|
||||
TiandituGeocodingConfigError,
|
||||
geocode_tianditu,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/geocoding-requests",
|
||||
summary="Tianditu Geocoding",
|
||||
description="调用天地图地理编码服务,将结构化地址转换为经纬度",
|
||||
)
|
||||
async def tianditu_geocode(request: TiandituGeocodeRequest) -> dict[str, Any]:
|
||||
try:
|
||||
return await geocode_tianditu(request)
|
||||
except TiandituGeocodingConfigError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
except TiandituGeocodingAPIError as exc:
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
|
||||
@@ -2,13 +2,11 @@ import os
|
||||
from typing import Any
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Path, Body
|
||||
from fastapi import APIRouter, Depends, HTTPException, Body
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.auth.keycloak_dependencies import get_current_keycloak_username
|
||||
from app.services.leakage_identifier import (
|
||||
get_leakage_identify_scheme_detail,
|
||||
list_leakage_identify_schemes,
|
||||
run_leakage_identification,
|
||||
)
|
||||
|
||||
@@ -40,7 +38,7 @@ class LeakageIdentifyRequest(BaseModel):
|
||||
|
||||
|
||||
@router.post(
|
||||
"/identify/",
|
||||
"/leakage-identifications",
|
||||
summary="执行漏损识别",
|
||||
description="基于压力观测数据和遗传算法识别管网中的漏损位置和大小"
|
||||
)
|
||||
@@ -68,66 +66,3 @@ async def identify_leakage(
|
||||
return run_leakage_identification(**data.model_dump(), username=username)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/schemes/",
|
||||
summary="查询漏损识别方案列表",
|
||||
description="获取指定网络的所有漏损识别方案"
|
||||
)
|
||||
async def query_leakage_schemes(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
query_date: datetime | None = Query(None, description="查询日期(可选)")
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
获取漏损识别方案列表。
|
||||
|
||||
查询指定网络的所有已配置的漏损识别方案,
|
||||
可按日期进行筛选。
|
||||
|
||||
Args:
|
||||
network: 管网名称(或数据库名称)
|
||||
query_date: 查询日期(可选)
|
||||
|
||||
Returns:
|
||||
漏损识别方案列表
|
||||
|
||||
Raises:
|
||||
HTTPException: 当查询失败时
|
||||
"""
|
||||
try:
|
||||
return list_leakage_identify_schemes(network=network, query_date=query_date)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/schemes/{scheme_name}",
|
||||
summary="获取漏损识别方案详情",
|
||||
description="获取指定漏损识别方案的详细信息"
|
||||
)
|
||||
async def query_leakage_scheme_detail(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
scheme_name: str = Path(..., description="漏损识别方案名称")
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
获取漏损识别方案详情。
|
||||
|
||||
查询指定漏损识别方案的完整配置和参数信息。
|
||||
|
||||
Args:
|
||||
network: 管网名称(或数据库名称)
|
||||
scheme_name: 漏损识别方案名称
|
||||
|
||||
Returns:
|
||||
包含方案详情的字典
|
||||
|
||||
Raises:
|
||||
HTTPException: 当查询失败时
|
||||
"""
|
||||
try:
|
||||
return get_leakage_identify_scheme_detail(
|
||||
network=network, scheme_name=scheme_name
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
|
||||
@@ -16,7 +16,6 @@ from app.auth.project_dependencies import (
|
||||
from app.auth.metadata_dependencies import get_current_metadata_user
|
||||
from app.core.config import settings
|
||||
from app.domain.schemas.metadata import (
|
||||
GeoServerConfigResponse,
|
||||
ProjectMetaResponse,
|
||||
ProjectSummaryResponse,
|
||||
)
|
||||
@@ -26,7 +25,7 @@ router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.get("/meta/project", summary="获取项目元数据", description="获取当前项目的元数据和配置信息", response_model=ProjectMetaResponse)
|
||||
@router.get("/projects/current/metadata", summary="获取项目元数据", description="获取当前项目的元数据和配置信息", response_model=ProjectMetaResponse)
|
||||
async def get_project_metadata(
|
||||
ctx: ProjectContext = Depends(get_project_context),
|
||||
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||
@@ -34,25 +33,13 @@ async def get_project_metadata(
|
||||
"""
|
||||
获取项目元数据
|
||||
|
||||
返回当前项目的完整元数据,包括项目基本信息和GeoServer配置
|
||||
返回当前项目的完整元数据,包括项目基本信息和项目权限
|
||||
"""
|
||||
project = await metadata_repo.get_project_by_id(ctx.project_id)
|
||||
if not project:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Project not found"
|
||||
)
|
||||
geoserver = await metadata_repo.get_geoserver_config(ctx.project_id)
|
||||
geoserver_payload = (
|
||||
GeoServerConfigResponse(
|
||||
gs_base_url=geoserver.gs_base_url,
|
||||
gs_admin_user=geoserver.gs_admin_user,
|
||||
gs_datastore_name=geoserver.gs_datastore_name,
|
||||
default_extent=geoserver.default_extent,
|
||||
srid=geoserver.srid,
|
||||
)
|
||||
if geoserver
|
||||
else None
|
||||
)
|
||||
return ProjectMetaResponse(
|
||||
project_id=project.id,
|
||||
name=project.name,
|
||||
@@ -62,11 +49,10 @@ async def get_project_metadata(
|
||||
map_extent=project.map_extent,
|
||||
status=project.status,
|
||||
project_role=ctx.project_role,
|
||||
geoserver=geoserver_payload,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/meta/projects", summary="列出用户项目", description="获取当前用户有权限的所有项目列表", response_model=list[ProjectSummaryResponse])
|
||||
@router.get("/projects", summary="列出用户项目", description="获取当前用户有权限的所有项目列表", response_model=list[ProjectSummaryResponse])
|
||||
async def list_user_projects(
|
||||
current_user=Depends(get_current_metadata_user),
|
||||
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||
@@ -102,7 +88,7 @@ async def list_user_projects(
|
||||
]
|
||||
|
||||
|
||||
@router.get("/meta/db/health", summary="检查数据库健康状态", description="检查项目数据库连接的健康状况")
|
||||
@router.get("/projects/current/database-health", summary="检查数据库健康状态", description="检查项目数据库连接的健康状况")
|
||||
async def project_db_health(
|
||||
pg_session: AsyncSession = Depends(get_project_pg_session),
|
||||
ts_conn: AsyncConnection = Depends(get_project_timescale_connection),
|
||||
|
||||
@@ -11,7 +11,6 @@ from app.services.tjnetwork import (
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/getjson/", summary="获取JSON示例", description="获取JSON格式响应示例")
|
||||
async def fastapi_get_json():
|
||||
"""
|
||||
获取JSON示例
|
||||
@@ -28,7 +27,7 @@ async def fastapi_get_json():
|
||||
)
|
||||
|
||||
|
||||
@router.get("/getallsensorplacements/", summary="获取所有传感器位置", description="获取网络中所有传感器的放置位置信息")
|
||||
@router.get("/sensor-placement-schemes", summary="获取所有传感器位置", description="获取网络中所有传感器的放置位置信息")
|
||||
async def fastapi_get_all_sensor_placements(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[dict[Any, Any]]:
|
||||
"""
|
||||
获取所有传感器位置
|
||||
@@ -38,7 +37,7 @@ async def fastapi_get_all_sensor_placements(network: str = Query(..., descriptio
|
||||
return get_all_sensor_placements(network)
|
||||
|
||||
|
||||
@router.get("/getallburstlocateresults/", summary="获取所有爆管定位结果", description="获取网络中所有爆管定位的分析结果")
|
||||
@router.get("/burst-locations", summary="获取所有爆管定位结果", description="获取网络中所有爆管定位的分析结果")
|
||||
async def fastapi_get_all_burst_locate_results(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[dict[Any, Any]]:
|
||||
"""
|
||||
获取所有爆管定位结果
|
||||
@@ -53,7 +52,6 @@ class Item(BaseModel):
|
||||
str_info: str
|
||||
|
||||
|
||||
@router.post("/test_dict/", summary="测试字典处理", description="测试处理字典类型数据")
|
||||
async def fastapi_test_dict(data: Item) -> dict[str, str]:
|
||||
"""
|
||||
测试字典处理
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
from pathlib import Path
|
||||
from tempfile import NamedTemporaryFile
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
File,
|
||||
HTTPException,
|
||||
Path as ApiPath,
|
||||
Request,
|
||||
UploadFile,
|
||||
status,
|
||||
)
|
||||
|
||||
from app.auth.metadata_dependencies import (
|
||||
get_current_metadata_admin,
|
||||
get_metadata_repository,
|
||||
)
|
||||
from app.core.audit import AuditAction, log_audit_event
|
||||
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
|
||||
from app.services.network_import import network_update
|
||||
from app.services.tjnetwork import run_inp
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
MAX_INP_FILE_BYTES = 50 * 1024 * 1024
|
||||
INP_SECTIONS = ("[TITLE]", "[JUNCTIONS]", "[RESERVOIRS]", "[TANKS]", "[PIPES]")
|
||||
|
||||
|
||||
async def _get_active_project(project_id: UUID, metadata_repo: MetadataRepository):
|
||||
project = await metadata_repo.get_project_by_id(project_id)
|
||||
if project is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Project not found",
|
||||
)
|
||||
if project.status != "active":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Project is not active",
|
||||
)
|
||||
return project
|
||||
|
||||
|
||||
def _validate_inp_bytes(content: bytes, filename: str) -> str:
|
||||
if Path(filename).suffix.lower() != ".inp":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Only .inp model files are accepted",
|
||||
)
|
||||
if not content:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="INP file is empty",
|
||||
)
|
||||
if len(content) > MAX_INP_FILE_BYTES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
detail="INP file exceeds the 50 MiB limit",
|
||||
)
|
||||
for encoding in ("utf-8-sig", "gb18030"):
|
||||
try:
|
||||
text = content.decode(encoding)
|
||||
break
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="INP file encoding is not supported",
|
||||
)
|
||||
upper_text = text.upper()
|
||||
if not any(section in upper_text for section in INP_SECTIONS):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid INP file structure",
|
||||
)
|
||||
return text
|
||||
|
||||
|
||||
async def _read_upload(file: UploadFile) -> tuple[bytes, str]:
|
||||
filename = Path(file.filename or "").name
|
||||
content = await file.read(MAX_INP_FILE_BYTES + 1)
|
||||
_validate_inp_bytes(content, filename)
|
||||
return content, filename
|
||||
|
||||
|
||||
async def _audit_model_change(
|
||||
*,
|
||||
request: Request,
|
||||
current_user,
|
||||
metadata_repo: MetadataRepository,
|
||||
project_id: UUID,
|
||||
action: str,
|
||||
) -> None:
|
||||
await log_audit_event(
|
||||
action=AuditAction.UPDATE,
|
||||
user_id=current_user.id,
|
||||
project_id=project_id,
|
||||
resource_type="hydraulic_model",
|
||||
resource_id=action,
|
||||
request_data={"operation": action},
|
||||
ip_address=request.client.host if request.client else None,
|
||||
request_method=request.method,
|
||||
request_path=request.url.path,
|
||||
response_status=status.HTTP_200_OK,
|
||||
session=metadata_repo.session,
|
||||
)
|
||||
|
||||
|
||||
async def _run_uploaded_inp(content: bytes) -> str:
|
||||
target_dir = Path("inp")
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
model_name = f"admin_model_{uuid4().hex}"
|
||||
target_path = target_dir / f"{model_name}.inp"
|
||||
target_path.write_bytes(content)
|
||||
return run_inp(model_name)
|
||||
|
||||
|
||||
async def _update_from_inp(content: bytes) -> None:
|
||||
temp_path: Path | None = None
|
||||
try:
|
||||
with NamedTemporaryFile(suffix=".inp", delete=False) as temp_file:
|
||||
temp_file.write(content)
|
||||
temp_path = Path(temp_file.name)
|
||||
network_update(str(temp_path))
|
||||
finally:
|
||||
if temp_path is not None:
|
||||
temp_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
async def _apply_model_update(content: bytes) -> None:
|
||||
try:
|
||||
await _update_from_inp(content)
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"数据库操作失败: {exc}",
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/admin/projects/{project_id}/model-imports",
|
||||
summary="导入桌面端水力模型",
|
||||
)
|
||||
async def import_project_model(
|
||||
request: Request,
|
||||
project_id: UUID = ApiPath(...),
|
||||
file: UploadFile = File(..., description="桌面端导出的 INP 模型文件"),
|
||||
current_user=Depends(get_current_metadata_admin),
|
||||
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||
) -> dict:
|
||||
project = await _get_active_project(project_id, metadata_repo)
|
||||
content, filename = await _read_upload(file)
|
||||
result = await _run_uploaded_inp(content)
|
||||
await _audit_model_change(
|
||||
request=request,
|
||||
current_user=current_user,
|
||||
metadata_repo=metadata_repo,
|
||||
project_id=project.id,
|
||||
action="import",
|
||||
)
|
||||
return {"project_id": str(project.id), "filename": filename, "result": result}
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/admin/projects/{project_id}/model-imports",
|
||||
summary="更新桌面端水力模型",
|
||||
)
|
||||
async def update_project_model(
|
||||
request: Request,
|
||||
project_id: UUID = ApiPath(...),
|
||||
file: UploadFile = File(..., description="桌面端导出的 INP 模型文件"),
|
||||
current_user=Depends(get_current_metadata_admin),
|
||||
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||
) -> dict:
|
||||
project = await _get_active_project(project_id, metadata_repo)
|
||||
content, filename = await _read_upload(file)
|
||||
await _apply_model_update(content)
|
||||
await _audit_model_change(
|
||||
request=request,
|
||||
current_user=current_user,
|
||||
metadata_repo=metadata_repo,
|
||||
project_id=project.id,
|
||||
action="update",
|
||||
)
|
||||
return {"project_id": str(project.id), "filename": filename, "updated": True}
|
||||
@@ -18,7 +18,7 @@ router = APIRouter()
|
||||
############################################################
|
||||
|
||||
@router.get(
|
||||
"/getdemandschema",
|
||||
"/network-schemas/demand",
|
||||
summary="获取需水量属性架构",
|
||||
description="获取指定水网中需水量(Demand)的属性架构定义"
|
||||
)
|
||||
@@ -32,7 +32,7 @@ async def fastapi_get_demand_schema(network: str = Query(..., description="管
|
||||
|
||||
|
||||
@router.get(
|
||||
"/getdemandproperties/",
|
||||
"/demands/properties",
|
||||
summary="获取需水量属性",
|
||||
description="获取指定水网中节点的需水量属性信息"
|
||||
)
|
||||
@@ -49,8 +49,8 @@ async def fastapi_get_demand_properties(
|
||||
|
||||
|
||||
# example: set_demand(p, ChangeSet({'junction': 'j1', 'demands': [{'demand': 10.0, 'pattern': None, 'category': 'x'}, {'demand': 20.0, 'pattern': None, 'category': None}]}))
|
||||
@router.post(
|
||||
"/setdemandproperties/",
|
||||
@router.patch(
|
||||
"/demands/properties",
|
||||
response_model=None,
|
||||
summary="设置需水量属性",
|
||||
description="设置指定水网中节点的需水量属性信息"
|
||||
@@ -72,8 +72,8 @@ async def fastapi_set_demand_properties(
|
||||
############################################################
|
||||
# water distribution 36.[Water Distribution]
|
||||
############################################################
|
||||
@router.get(
|
||||
"/calculatedemandtonodes/",
|
||||
@router.post(
|
||||
"/demands/to-nodes",
|
||||
summary="计算需水量到节点分配",
|
||||
description="将总需水量按指定方式分配到多个节点"
|
||||
)
|
||||
@@ -97,8 +97,8 @@ async def fastapi_calculate_demand_to_nodes(
|
||||
nodes = props["nodes"]
|
||||
return calculate_demand_to_nodes(network, demand, nodes)
|
||||
|
||||
@router.get(
|
||||
"/calculatedemandtoregion/",
|
||||
@router.post(
|
||||
"/demands/to-region",
|
||||
summary="计算需水量到区域分配",
|
||||
description="将总需水量按区域特征分配到该区域内的节点"
|
||||
)
|
||||
@@ -122,8 +122,8 @@ async def fastapi_calculate_demand_to_region(
|
||||
region = props["region"]
|
||||
return calculate_demand_to_region(network, demand, region)
|
||||
|
||||
@router.get(
|
||||
"/calculatedemandtonetwork/",
|
||||
@router.post(
|
||||
"/demands/to-network",
|
||||
summary="计算需水量到整网分配",
|
||||
description="将需水量均匀分配到整个水网的所有需水节点"
|
||||
)
|
||||
|
||||
@@ -45,7 +45,7 @@ router = APIRouter()
|
||||
############################################################
|
||||
|
||||
@router.get(
|
||||
"/isnode/",
|
||||
"/nodes/existence",
|
||||
summary="检查节点有效性",
|
||||
description="检查指定ID是否为水网中的有效节点"
|
||||
)
|
||||
@@ -57,7 +57,7 @@ async def fastapi_is_node(
|
||||
return is_node(network, node)
|
||||
|
||||
@router.get(
|
||||
"/isjunction/",
|
||||
"/junctions/existence",
|
||||
summary="检查是否为接点",
|
||||
description="检查指定ID是否为水网中的接点(需求点)"
|
||||
)
|
||||
@@ -69,7 +69,7 @@ async def fastapi_is_junction(
|
||||
return is_junction(network, node)
|
||||
|
||||
@router.get(
|
||||
"/isreservoir/",
|
||||
"/reservoirs/existence",
|
||||
summary="检查是否为水源",
|
||||
description="检查指定ID是否为水网中的水源(水库/河流)"
|
||||
)
|
||||
@@ -81,7 +81,7 @@ async def fastapi_is_reservoir(
|
||||
return is_reservoir(network, node)
|
||||
|
||||
@router.get(
|
||||
"/istank/",
|
||||
"/tanks/existence",
|
||||
summary="检查是否为蓄水池",
|
||||
description="检查指定ID是否为水网中的蓄水池"
|
||||
)
|
||||
@@ -93,7 +93,7 @@ async def fastapi_is_tank(
|
||||
return is_tank(network, node)
|
||||
|
||||
@router.get(
|
||||
"/islink/",
|
||||
"/links/existence",
|
||||
summary="检查管线有效性",
|
||||
description="检查指定ID是否为水网中的有效管线"
|
||||
)
|
||||
@@ -105,7 +105,7 @@ async def fastapi_is_link(
|
||||
return is_link(network, link)
|
||||
|
||||
@router.get(
|
||||
"/ispipe/",
|
||||
"/pipes/existence",
|
||||
summary="检查是否为管道",
|
||||
description="检查指定ID是否为水网中的管道"
|
||||
)
|
||||
@@ -117,7 +117,7 @@ async def fastapi_is_pipe(
|
||||
return is_pipe(network, link)
|
||||
|
||||
@router.get(
|
||||
"/ispump/",
|
||||
"/pumps/existence",
|
||||
summary="检查是否为泵",
|
||||
description="检查指定ID是否为水网中的泵"
|
||||
)
|
||||
@@ -129,7 +129,7 @@ async def fastapi_is_pump(
|
||||
return is_pump(network, link)
|
||||
|
||||
@router.get(
|
||||
"/isvalve/",
|
||||
"/valves/existence",
|
||||
summary="检查是否为阀门",
|
||||
description="检查指定ID是否为水网中的阀门"
|
||||
)
|
||||
@@ -141,7 +141,7 @@ async def fastapi_is_valve(
|
||||
return is_valve(network, link)
|
||||
|
||||
@router.get(
|
||||
"/getnodetype/",
|
||||
"/node-types",
|
||||
summary="获取节点类型",
|
||||
description="获取指定节点的类型(接点/水源/蓄水池)"
|
||||
)
|
||||
@@ -153,7 +153,7 @@ async def fastapi_get_node_type(
|
||||
return get_node_type(network, node)
|
||||
|
||||
@router.get(
|
||||
"/getlinktype/",
|
||||
"/link-types",
|
||||
summary="获取管线类型",
|
||||
description="获取指定管线的类型(管道/泵/阀门)"
|
||||
)
|
||||
@@ -165,7 +165,7 @@ async def fastapi_get_link_type(
|
||||
return get_link_type(network, link)
|
||||
|
||||
@router.get(
|
||||
"/getelementtype/",
|
||||
"/element-types",
|
||||
summary="获取元素类型",
|
||||
description="获取指定元素的类型(节点或管线)"
|
||||
)
|
||||
@@ -177,7 +177,7 @@ async def fastapi_get_element_type(
|
||||
return get_element_type(network, element)
|
||||
|
||||
@router.get(
|
||||
"/getelementtypevalue/",
|
||||
"/element-type-values",
|
||||
summary="获取元素类型值",
|
||||
description="获取指定元素的类型数值标识"
|
||||
)
|
||||
@@ -189,7 +189,7 @@ async def fastapi_get_element_type_value(
|
||||
return get_element_type_value(network, element)
|
||||
|
||||
@router.get(
|
||||
"/getnodes/",
|
||||
"/nodes",
|
||||
summary="获取所有节点",
|
||||
description="获取指定水网中的所有节点ID列表"
|
||||
)
|
||||
@@ -198,7 +198,7 @@ async def fastapi_get_nodes(network: str = Query(..., description="管网名称
|
||||
return get_nodes(network)
|
||||
|
||||
@router.get(
|
||||
"/getlinks/",
|
||||
"/links",
|
||||
summary="获取所有管线",
|
||||
description="获取指定水网中的所有管线ID列表"
|
||||
)
|
||||
@@ -207,7 +207,7 @@ async def fastapi_get_links(network: str = Query(..., description="管网名称
|
||||
return get_links(network)
|
||||
|
||||
@router.get(
|
||||
"/getnodelinks/",
|
||||
"/node-links",
|
||||
summary="获取节点的关联管线",
|
||||
description="获取指定节点连接的所有管线ID列表"
|
||||
)
|
||||
@@ -223,7 +223,7 @@ def get_node_links_endpoint(
|
||||
############################################################
|
||||
|
||||
@router.get(
|
||||
"/getnodeproperties/",
|
||||
"/node-properties",
|
||||
summary="获取节点属性",
|
||||
description="获取指定节点的所有属性信息"
|
||||
)
|
||||
@@ -235,7 +235,7 @@ async def fast_get_node_properties(
|
||||
return get_node_properties(network, node)
|
||||
|
||||
@router.get(
|
||||
"/getlinkproperties/",
|
||||
"/link-properties",
|
||||
summary="获取管线属性",
|
||||
description="获取指定管线的所有属性信息"
|
||||
)
|
||||
@@ -247,7 +247,7 @@ async def fast_get_link_properties(
|
||||
return get_link_properties(network, link)
|
||||
|
||||
@router.get(
|
||||
"/getscadaproperties/",
|
||||
"/scada-properties",
|
||||
summary="获取SCADA点属性",
|
||||
description="获取指定SCADA点的属性信息"
|
||||
)
|
||||
@@ -259,7 +259,7 @@ async def fast_get_scada_properties(
|
||||
return get_scada_info(network, scada)
|
||||
|
||||
@router.get(
|
||||
"/getallscadaproperties/",
|
||||
"/all-scada-properties",
|
||||
summary="获取所有SCADA点属性",
|
||||
description="获取指定水网中所有SCADA点的属性信息"
|
||||
)
|
||||
@@ -270,7 +270,7 @@ async def fast_get_all_scada_properties(
|
||||
return get_all_scada_info(network)
|
||||
|
||||
@router.get(
|
||||
"/getelementpropertieswithtype/",
|
||||
"/element-properties-with-types",
|
||||
summary="获取指定类型元素属性",
|
||||
description="获取指定类型的元素属性信息"
|
||||
)
|
||||
@@ -283,7 +283,7 @@ async def fast_get_element_properties_with_type(
|
||||
return get_element_properties_with_type(network, elementtype, element)
|
||||
|
||||
@router.get(
|
||||
"/getelementproperties/",
|
||||
"/element-properties",
|
||||
summary="获取元素属性",
|
||||
description="获取指定元素的属性信息"
|
||||
)
|
||||
@@ -299,7 +299,7 @@ async def fast_get_element_properties(
|
||||
############################################################
|
||||
|
||||
@router.get(
|
||||
"/gettitleschema/",
|
||||
"/title-schemas",
|
||||
summary="获取标题属性架构",
|
||||
description="获取指定水网的标题(标题)属性架构定义"
|
||||
)
|
||||
@@ -310,7 +310,7 @@ async def fast_get_title_schema(
|
||||
return get_title_schema(network)
|
||||
|
||||
@router.get(
|
||||
"/gettitle/",
|
||||
"/titles",
|
||||
summary="获取水网标题属性",
|
||||
description="获取指定水网的标题(Title)信息"
|
||||
)
|
||||
@@ -318,8 +318,8 @@ async def fast_get_title(network: str = Query(..., description="管网名称(
|
||||
"""获取水网的标题属性。"""
|
||||
return get_title(network)
|
||||
|
||||
@router.get(
|
||||
"/settitle/",
|
||||
@router.patch(
|
||||
"/titles",
|
||||
response_model=None,
|
||||
summary="设置水网标题属性",
|
||||
description="设置指定水网的标题(Title)信息"
|
||||
@@ -337,7 +337,7 @@ async def fastapi_set_title(
|
||||
############################################################
|
||||
|
||||
@router.get(
|
||||
"/getstatusschema",
|
||||
"/status-schemas",
|
||||
summary="获取状态属性架构",
|
||||
description="获取指定水网的状态(Status)属性架构定义"
|
||||
)
|
||||
@@ -348,7 +348,7 @@ async def fastapi_get_status_schema(
|
||||
return get_status_schema(network)
|
||||
|
||||
@router.get(
|
||||
"/getstatus/",
|
||||
"/status",
|
||||
summary="获取管线状态",
|
||||
description="获取指定管线的状态信息"
|
||||
)
|
||||
@@ -359,8 +359,8 @@ async def fastapi_get_status(
|
||||
"""获取管线的状态属性。"""
|
||||
return get_status(network, link)
|
||||
|
||||
@router.post(
|
||||
"/setstatus/",
|
||||
@router.patch(
|
||||
"/status-properties",
|
||||
response_model=None,
|
||||
summary="设置管线状态",
|
||||
description="设置指定管线的状态信息"
|
||||
@@ -379,8 +379,8 @@ async def fastapi_set_status_properties(
|
||||
# General Deletion
|
||||
############################################################
|
||||
|
||||
@router.post(
|
||||
"/deletenode/",
|
||||
@router.delete(
|
||||
"/nodes",
|
||||
response_model=None,
|
||||
summary="删除节点",
|
||||
description="删除指定的节点(接点/水源/蓄水池)"
|
||||
@@ -399,8 +399,8 @@ async def fastapi_delete_node(
|
||||
return delete_tank(network, ChangeSet(ps))
|
||||
return ChangeSet() # Should probably raise error or return empty
|
||||
|
||||
@router.post(
|
||||
"/deletelink/",
|
||||
@router.delete(
|
||||
"/links",
|
||||
response_model=None,
|
||||
summary="删除管线",
|
||||
description="删除指定的管线(管道/泵/阀门)"
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
from fastapi import APIRouter, Request, Depends, Query, Path, Body
|
||||
from typing import Any, List, Dict, Union
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from app.services.tjnetwork import (
|
||||
Any,
|
||||
get_all_scada_info,
|
||||
get_major_node_coords,
|
||||
get_major_pipe_nodes,
|
||||
get_network_in_extent,
|
||||
get_network_link_nodes,
|
||||
get_network_node_coords,
|
||||
get_node_coord,
|
||||
)
|
||||
from app.auth.dependencies import get_current_user as verify_token
|
||||
from app.infra.cache.redis_client import redis_client, encode_datetime, decode_datetime
|
||||
import msgpack
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -35,7 +31,7 @@ router = APIRouter()
|
||||
# return set_coord(network, ChangeSet(props))
|
||||
|
||||
@router.get(
|
||||
"/getnodecoord/",
|
||||
"/node-coords",
|
||||
summary="获取节点坐标",
|
||||
description="获取指定节点的地理坐标(X, Y)"
|
||||
)
|
||||
@@ -48,7 +44,7 @@ async def fastapi_get_node_coord(
|
||||
|
||||
# Additional geometry queries found in main.py logic (implicit or explicit)
|
||||
@router.get(
|
||||
"/getnetworkinextent/",
|
||||
"/network-in-extents",
|
||||
summary="获取范围内的网络元素",
|
||||
description="获取指定地理范围内的网络节点和管线"
|
||||
)
|
||||
@@ -63,34 +59,7 @@ async def fastapi_get_network_in_extent(
|
||||
return get_network_in_extent(network, x1, y1, x2, y2)
|
||||
|
||||
@router.get(
|
||||
"/getnetworkgeometries/",
|
||||
dependencies=[Depends(verify_token)],
|
||||
summary="获取完整网络几何信息",
|
||||
description="获取整个水网的所有节点、管线和SCADA点的几何信息(需要身份验证)"
|
||||
)
|
||||
async def fastapi_get_network_geometries(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)")
|
||||
) -> dict[str, Any] | None:
|
||||
"""获取完整的网络几何信息,包括所有节点、管线和SCADA点。结果从缓存返回。"""
|
||||
cache_key = f"getnetworkgeometries_{network}"
|
||||
data = redis_client.get(cache_key)
|
||||
if data:
|
||||
loaded_dict = msgpack.unpackb(data, object_hook=decode_datetime)
|
||||
return loaded_dict
|
||||
|
||||
coords = get_network_node_coords(network)
|
||||
nodes = []
|
||||
for node_id, coord in coords.items():
|
||||
nodes.append(f"{node_id}:{coord['type']}:{coord['x']}:{coord['y']}")
|
||||
links = get_network_link_nodes(network)
|
||||
scadas = get_all_scada_info(network)
|
||||
|
||||
results = {"nodes": nodes, "links": links, "scadas": scadas}
|
||||
redis_client.set(cache_key, msgpack.packb(results, default=encode_datetime))
|
||||
return results
|
||||
|
||||
@router.get(
|
||||
"/getmajornodecoords/",
|
||||
"/majornode-coords",
|
||||
summary="获取主要节点坐标",
|
||||
description="获取直径大于等于指定值的节点坐标"
|
||||
)
|
||||
@@ -102,7 +71,7 @@ async def fastapi_get_majornode_coords(
|
||||
return get_major_node_coords(network, diameter)
|
||||
|
||||
@router.get(
|
||||
"/getmajorpipenodes/",
|
||||
"/major-pipe-nodes",
|
||||
summary="获取主要管道节点",
|
||||
description="获取直径大于等于指定值的管道的节点ID"
|
||||
)
|
||||
@@ -114,7 +83,7 @@ async def fastapi_get_major_pipe_nodes(
|
||||
return get_major_pipe_nodes(network, diameter)
|
||||
|
||||
@router.get(
|
||||
"/getnetworklinknodes/",
|
||||
"/network-link-nodes",
|
||||
summary="获取网络管线节点",
|
||||
description="获取指定水网所有管线的起点和终点节点"
|
||||
)
|
||||
|
||||
@@ -13,7 +13,7 @@ from app.services.tjnetwork import (
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/getjunctionschema", summary="获取节点架构", description="获取指定项目的节点属性架构和数据类型定义。")
|
||||
@router.get("/network-schemas/junction", summary="获取节点架构", description="获取指定项目的节点属性架构和数据类型定义。")
|
||||
async def fast_get_junction_schema(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)")
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
@@ -27,7 +27,7 @@ async def fast_get_junction_schema(
|
||||
"""
|
||||
return get_junction_schema(network)
|
||||
|
||||
@router.post("/addjunction/", response_model=None, summary="添加节点", description="在供水网络中添加新的节点,指定节点ID和空间坐标。")
|
||||
@router.post("/junctions", response_model=None, summary="添加节点", description="在供水网络中添加新的节点,指定节点ID和空间坐标。")
|
||||
async def fastapi_add_junction(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
junction: str = Query(..., description="节点 ID"),
|
||||
@@ -51,7 +51,7 @@ async def fastapi_add_junction(
|
||||
ps = {"id": junction, "x": x, "y": y, "elevation": z}
|
||||
return add_junction(network, ChangeSet(ps))
|
||||
|
||||
@router.post("/deletejunction/", response_model=None, summary="删除节点", description="从供水网络中删除指定的节点。")
|
||||
@router.delete("/junctions", response_model=None, summary="删除节点", description="从供水网络中删除指定的节点。")
|
||||
async def fastapi_delete_junction(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
junction: str = Query(..., description="节点 ID")
|
||||
@@ -69,7 +69,7 @@ async def fastapi_delete_junction(
|
||||
ps = {"id": junction}
|
||||
return delete_junction(network, ChangeSet(ps))
|
||||
|
||||
@router.get("/getjunctionelevation/", summary="获取节点标高", description="获取指定节点的标高(海拔高度)。")
|
||||
@router.get("/junctions/elevation", summary="获取节点标高", description="获取指定节点的标高(海拔高度)。")
|
||||
async def fastapi_get_junction_elevation(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
junction: str = Query(..., description="节点 ID")
|
||||
@@ -87,7 +87,7 @@ async def fastapi_get_junction_elevation(
|
||||
ps = get_junction(network, junction)
|
||||
return ps["elevation"]
|
||||
|
||||
@router.get("/getjunctionx/", summary="获取节点 X 坐标", description="获取指定节点的 X 坐标值。")
|
||||
@router.get("/junctions/x", summary="获取节点 X 坐标", description="获取指定节点的 X 坐标值。")
|
||||
async def fastapi_get_junction_x(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
junction: str = Query(..., description="节点 ID")
|
||||
@@ -105,7 +105,7 @@ async def fastapi_get_junction_x(
|
||||
ps = get_junction(network, junction)
|
||||
return ps["x"]
|
||||
|
||||
@router.get("/getjunctiony/", summary="获取节点 Y 坐标", description="获取指定节点的 Y 坐标值。")
|
||||
@router.get("/junctions/y", summary="获取节点 Y 坐标", description="获取指定节点的 Y 坐标值。")
|
||||
async def fastapi_get_junction_y(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
junction: str = Query(..., description="节点 ID")
|
||||
@@ -123,7 +123,7 @@ async def fastapi_get_junction_y(
|
||||
ps = get_junction(network, junction)
|
||||
return ps["y"]
|
||||
|
||||
@router.get("/getjunctioncoord/", summary="获取节点坐标", description="获取指定节点的 X 和 Y 坐标。")
|
||||
@router.get("/junctions/coord", summary="获取节点坐标", description="获取指定节点的 X 和 Y 坐标。")
|
||||
async def fastapi_get_junction_coord(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
junction: str = Query(..., description="节点 ID")
|
||||
@@ -142,7 +142,7 @@ async def fastapi_get_junction_coord(
|
||||
coord = {"x": ps["x"], "y": ps["y"]}
|
||||
return coord
|
||||
|
||||
@router.get("/getjunctiondemand/", summary="获取节点需水量", description="获取指定节点的需水量。")
|
||||
@router.get("/junctions/demand", summary="获取节点需水量", description="获取指定节点的需水量。")
|
||||
async def fastapi_get_junction_demand(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
junction: str = Query(..., description="节点 ID")
|
||||
@@ -160,7 +160,7 @@ async def fastapi_get_junction_demand(
|
||||
ps = get_junction(network, junction)
|
||||
return ps["demand"]
|
||||
|
||||
@router.get("/getjunctionpattern/", summary="获取节点需水模式", description="获取指定节点的需水模式标识。")
|
||||
@router.get("/junctions/pattern", summary="获取节点需水模式", description="获取指定节点的需水模式标识。")
|
||||
async def fastapi_get_junction_pattern(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
junction: str = Query(..., description="节点 ID")
|
||||
@@ -178,7 +178,7 @@ async def fastapi_get_junction_pattern(
|
||||
ps = get_junction(network, junction)
|
||||
return ps["pattern"]
|
||||
|
||||
@router.post("/setjunctionelevation/", response_model=None, summary="设置节点标高", description="设置指定节点的标高值。")
|
||||
@router.patch("/junctions/elevation", response_model=None, summary="设置节点标高", description="设置指定节点的标高值。")
|
||||
async def fastapi_set_junction_elevation(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
junction: str = Query(..., description="节点 ID"),
|
||||
@@ -198,7 +198,7 @@ async def fastapi_set_junction_elevation(
|
||||
ps = {"id": junction, "elevation": elevation}
|
||||
return set_junction(network, ChangeSet(ps))
|
||||
|
||||
@router.post("/setjunctionx/", response_model=None, summary="设置节点 X 坐标", description="设置指定节点的 X 坐标值。")
|
||||
@router.patch("/junctions/x", response_model=None, summary="设置节点 X 坐标", description="设置指定节点的 X 坐标值。")
|
||||
async def fastapi_set_junction_x(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
junction: str = Query(..., description="节点 ID"),
|
||||
@@ -218,7 +218,7 @@ async def fastapi_set_junction_x(
|
||||
ps = {"id": junction, "x": x}
|
||||
return set_junction(network, ChangeSet(ps))
|
||||
|
||||
@router.post("/setjunctiony/", response_model=None, summary="设置节点 Y 坐标", description="设置指定节点的 Y 坐标值。")
|
||||
@router.patch("/junctions/y", response_model=None, summary="设置节点 Y 坐标", description="设置指定节点的 Y 坐标值。")
|
||||
async def fastapi_set_junction_y(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
junction: str = Query(..., description="节点 ID"),
|
||||
@@ -238,7 +238,7 @@ async def fastapi_set_junction_y(
|
||||
ps = {"id": junction, "y": y}
|
||||
return set_junction(network, ChangeSet(ps))
|
||||
|
||||
@router.post("/setjunctioncoord/", response_model=None, summary="设置节点坐标", description="设置指定节点的 X 和 Y 坐标。")
|
||||
@router.patch("/junctions/coord", response_model=None, summary="设置节点坐标", description="设置指定节点的 X 和 Y 坐标。")
|
||||
async def fastapi_set_junction_coord(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
junction: str = Query(..., description="节点 ID"),
|
||||
@@ -260,7 +260,7 @@ async def fastapi_set_junction_coord(
|
||||
ps = {"id": junction, "x": x, "y": y}
|
||||
return set_junction(network, ChangeSet(ps))
|
||||
|
||||
@router.post("/setjunctiondemand/", response_model=None, summary="设置节点需水量", description="设置指定节点的需水量。")
|
||||
@router.patch("/junctions/demand", response_model=None, summary="设置节点需水量", description="设置指定节点的需水量。")
|
||||
async def fastapi_set_junction_demand(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
junction: str = Query(..., description="节点 ID"),
|
||||
@@ -280,7 +280,7 @@ async def fastapi_set_junction_demand(
|
||||
ps = {"id": junction, "demand": demand}
|
||||
return set_junction(network, ChangeSet(ps))
|
||||
|
||||
@router.post("/setjunctionpattern/", response_model=None, summary="设置节点需水模式", description="设置指定节点的需水模式标识。")
|
||||
@router.patch("/junctions/pattern", response_model=None, summary="设置节点需水模式", description="设置指定节点的需水模式标识。")
|
||||
async def fastapi_set_junction_pattern(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
junction: str = Query(..., description="节点 ID"),
|
||||
@@ -300,7 +300,7 @@ async def fastapi_set_junction_pattern(
|
||||
ps = {"id": junction, "pattern": pattern}
|
||||
return set_junction(network, ChangeSet(ps))
|
||||
|
||||
@router.get("/getjunctionproperties/", summary="获取节点属性", description="获取指定节点的所有属性信息。")
|
||||
@router.get("/junctions/properties", summary="获取节点属性", description="获取指定节点的所有属性信息。")
|
||||
async def fastapi_get_junction_properties(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
junction: str = Query(..., description="节点 ID")
|
||||
@@ -317,7 +317,7 @@ async def fastapi_get_junction_properties(
|
||||
"""
|
||||
return get_junction(network, junction)
|
||||
|
||||
@router.get("/getalljunctionproperties/", summary="获取所有节点属性", description="获取指定项目中所有节点的属性信息。")
|
||||
@router.get("/junctions", summary="获取所有节点属性", description="获取指定项目中所有节点的属性信息。")
|
||||
async def fastapi_get_all_junction_properties(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)")
|
||||
) -> list[dict[str, Any]]:
|
||||
@@ -337,7 +337,7 @@ async def fastapi_get_all_junction_properties(
|
||||
results = get_all_junctions(network)
|
||||
return results
|
||||
|
||||
@router.post("/setjunctionproperties/", response_model=None, summary="批量设置节点属性", description="批量设置指定节点的多个属性。")
|
||||
@router.patch("/junctions/properties", response_model=None, summary="批量设置节点属性", description="批量设置指定节点的多个属性。")
|
||||
async def fastapi_set_junction_properties(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
junction: str = Query(..., description="节点 ID"),
|
||||
|
||||
@@ -14,7 +14,7 @@ from app.services.tjnetwork import (
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/getpipeschema", summary="获取管道模式", description="获取管道对象的模式定义,包含所有可用字段及其类型")
|
||||
@router.get("/network-schemas/pipe", summary="获取管道模式", description="获取管道对象的模式定义,包含所有可用字段及其类型")
|
||||
async def fastapi_get_pipe_schema(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)")
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
@@ -29,7 +29,7 @@ async def fastapi_get_pipe_schema(
|
||||
"""
|
||||
return get_pipe_schema(network)
|
||||
|
||||
@router.post("/addpipe/", response_model=None, summary="添加管道", description="向网络中添加新的管道,需要提供管道的基本参数如长度、管径、粗糙度等")
|
||||
@router.post("/pipes", response_model=None, summary="添加管道", description="向网络中添加新的管道,需要提供管道的基本参数如长度、管径、粗糙度等")
|
||||
async def fastapi_add_pipe(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
pipe: str = Query(..., description="管道标识符"),
|
||||
@@ -70,7 +70,7 @@ async def fastapi_add_pipe(
|
||||
}
|
||||
return add_pipe(network, ChangeSet(ps))
|
||||
|
||||
@router.post("/deletepipe/", response_model=None, summary="删除管道", description="从网络中删除指定的管道")
|
||||
@router.delete("/pipes", response_model=None, summary="删除管道", description="从网络中删除指定的管道")
|
||||
async def fastapi_delete_pipe(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
pipe: str = Query(..., description="要删除的管道ID")
|
||||
@@ -88,7 +88,7 @@ async def fastapi_delete_pipe(
|
||||
ps = {"id": pipe}
|
||||
return delete_pipe(network, ChangeSet(ps))
|
||||
|
||||
@router.get("/getpipenode1/", summary="获取管道起始节点", description="获取指定管道的起始节点ID")
|
||||
@router.get("/pipes/node1", summary="获取管道起始节点", description="获取指定管道的起始节点ID")
|
||||
async def fastapi_get_pipe_node1(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
pipe: str = Query(..., description="管道ID")
|
||||
@@ -106,7 +106,7 @@ async def fastapi_get_pipe_node1(
|
||||
ps = get_pipe(network, pipe)
|
||||
return ps["node1"]
|
||||
|
||||
@router.get("/getpipenode2/", summary="获取管道终止节点", description="获取指定管道的终止节点ID")
|
||||
@router.get("/pipes/node2", summary="获取管道终止节点", description="获取指定管道的终止节点ID")
|
||||
async def fastapi_get_pipe_node2(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
pipe: str = Query(..., description="管道ID")
|
||||
@@ -124,7 +124,7 @@ async def fastapi_get_pipe_node2(
|
||||
ps = get_pipe(network, pipe)
|
||||
return ps["node2"]
|
||||
|
||||
@router.get("/getpipelength/", summary="获取管道长度", description="获取指定管道的长度")
|
||||
@router.get("/pipes/length", summary="获取管道长度", description="获取指定管道的长度")
|
||||
async def fastapi_get_pipe_length(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
pipe: str = Query(..., description="管道ID")
|
||||
@@ -142,7 +142,7 @@ async def fastapi_get_pipe_length(
|
||||
ps = get_pipe(network, pipe)
|
||||
return ps["length"]
|
||||
|
||||
@router.get("/getpipediameter/", summary="获取管道管径", description="获取指定管道的管径")
|
||||
@router.get("/pipes/diameter", summary="获取管道管径", description="获取指定管道的管径")
|
||||
async def fastapi_get_pipe_diameter(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
pipe: str = Query(..., description="管道ID")
|
||||
@@ -160,7 +160,7 @@ async def fastapi_get_pipe_diameter(
|
||||
ps = get_pipe(network, pipe)
|
||||
return ps["diameter"]
|
||||
|
||||
@router.get("/getpiperoughness/", summary="获取管道粗糙度", description="获取指定管道的粗糙度")
|
||||
@router.get("/pipes/roughness", summary="获取管道粗糙度", description="获取指定管道的粗糙度")
|
||||
async def fastapi_get_pipe_roughness(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
pipe: str = Query(..., description="管道ID")
|
||||
@@ -178,7 +178,7 @@ async def fastapi_get_pipe_roughness(
|
||||
ps = get_pipe(network, pipe)
|
||||
return ps["roughness"]
|
||||
|
||||
@router.get("/getpipeminorloss/", summary="获取管道局部阻力系数", description="获取指定管道的局部阻力系数")
|
||||
@router.get("/pipes/minor-loss", summary="获取管道局部阻力系数", description="获取指定管道的局部阻力系数")
|
||||
async def fastapi_get_pipe_minor_loss(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
pipe: str = Query(..., description="管道ID")
|
||||
@@ -196,7 +196,7 @@ async def fastapi_get_pipe_minor_loss(
|
||||
ps = get_pipe(network, pipe)
|
||||
return ps["minor_loss"]
|
||||
|
||||
@router.get("/getpipestatus/", summary="获取管道状态", description="获取指定管道的状态(开启或关闭)")
|
||||
@router.get("/pipes/status", summary="获取管道状态", description="获取指定管道的状态(开启或关闭)")
|
||||
async def fastapi_get_pipe_status(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
pipe: str = Query(..., description="管道ID")
|
||||
@@ -214,7 +214,7 @@ async def fastapi_get_pipe_status(
|
||||
ps = get_pipe(network, pipe)
|
||||
return ps["status"]
|
||||
|
||||
@router.post("/setpipenode1/", response_model=None, summary="设置管道起始节点", description="设置指定管道的起始节点")
|
||||
@router.patch("/pipes/node1", response_model=None, summary="设置管道起始节点", description="设置指定管道的起始节点")
|
||||
async def fastapi_set_pipe_node1(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
pipe: str = Query(..., description="管道ID"),
|
||||
@@ -234,7 +234,7 @@ async def fastapi_set_pipe_node1(
|
||||
ps = {"id": pipe, "node1": node1}
|
||||
return set_pipe(network, ChangeSet(ps))
|
||||
|
||||
@router.post("/setpipenode2/", response_model=None, summary="设置管道终止节点", description="设置指定管道的终止节点")
|
||||
@router.patch("/pipes/node2", response_model=None, summary="设置管道终止节点", description="设置指定管道的终止节点")
|
||||
async def fastapi_set_pipe_node2(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
pipe: str = Query(..., description="管道ID"),
|
||||
@@ -254,7 +254,7 @@ async def fastapi_set_pipe_node2(
|
||||
ps = {"id": pipe, "node2": node2}
|
||||
return set_pipe(network, ChangeSet(ps))
|
||||
|
||||
@router.post("/setpipelength/", response_model=None, summary="设置管道长度", description="设置指定管道的长度")
|
||||
@router.patch("/pipes/length", response_model=None, summary="设置管道长度", description="设置指定管道的长度")
|
||||
async def fastapi_set_pipe_length(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
pipe: str = Query(..., description="管道ID"),
|
||||
@@ -274,7 +274,7 @@ async def fastapi_set_pipe_length(
|
||||
ps = {"id": pipe, "length": length}
|
||||
return set_pipe(network, ChangeSet(ps))
|
||||
|
||||
@router.post("/setpipediameter/", response_model=None, summary="设置管道管径", description="设置指定管道的管径")
|
||||
@router.patch("/pipes/diameter", response_model=None, summary="设置管道管径", description="设置指定管道的管径")
|
||||
async def fastapi_set_pipe_diameter(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
pipe: str = Query(..., description="管道ID"),
|
||||
@@ -294,7 +294,7 @@ async def fastapi_set_pipe_diameter(
|
||||
ps = {"id": pipe, "diameter": diameter}
|
||||
return set_pipe(network, ChangeSet(ps))
|
||||
|
||||
@router.post("/setpiperoughness/", response_model=None, summary="设置管道粗糙度", description="设置指定管道的粗糙度")
|
||||
@router.patch("/pipes/roughness", response_model=None, summary="设置管道粗糙度", description="设置指定管道的粗糙度")
|
||||
async def fastapi_set_pipe_roughness(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
pipe: str = Query(..., description="管道ID"),
|
||||
@@ -314,7 +314,7 @@ async def fastapi_set_pipe_roughness(
|
||||
ps = {"id": pipe, "roughness": roughness}
|
||||
return set_pipe(network, ChangeSet(ps))
|
||||
|
||||
@router.post("/setpipeminorloss/", response_model=None, summary="设置管道局部阻力系数", description="设置指定管道的局部阻力系数")
|
||||
@router.patch("/pipes/minor-loss", response_model=None, summary="设置管道局部阻力系数", description="设置指定管道的局部阻力系数")
|
||||
async def fastapi_set_pipe_minor_loss(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
pipe: str = Query(..., description="管道ID"),
|
||||
@@ -334,7 +334,7 @@ async def fastapi_set_pipe_minor_loss(
|
||||
ps = {"id": pipe, "minor_loss": minor_loss}
|
||||
return set_pipe(network, ChangeSet(ps))
|
||||
|
||||
@router.post("/setpipestatus/", response_model=None, summary="设置管道状态", description="设置指定管道的状态(开启或关闭)")
|
||||
@router.patch("/pipes/status", response_model=None, summary="设置管道状态", description="设置指定管道的状态(开启或关闭)")
|
||||
async def fastapi_set_pipe_status(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
pipe: str = Query(..., description="管道ID"),
|
||||
@@ -354,7 +354,7 @@ async def fastapi_set_pipe_status(
|
||||
ps = {"id": pipe, "status": status}
|
||||
return set_pipe(network, ChangeSet(ps))
|
||||
|
||||
@router.get("/getpipeproperties/", summary="获取管道属性", description="获取指定管道的所有属性信息")
|
||||
@router.get("/pipes/properties", summary="获取管道属性", description="获取指定管道的所有属性信息")
|
||||
async def fastapi_get_pipe_properties(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
pipe: str = Query(..., description="管道ID")
|
||||
@@ -371,7 +371,7 @@ async def fastapi_get_pipe_properties(
|
||||
"""
|
||||
return get_pipe(network, pipe)
|
||||
|
||||
@router.get("/getallpipeproperties/", summary="获取所有管道属性", description="获取网络中所有管道的属性信息列表")
|
||||
@router.get("/pipes", summary="获取所有管道属性", description="获取网络中所有管道的属性信息列表")
|
||||
async def fastapi_get_all_pipe_properties(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)")
|
||||
) -> list[dict[str, Any]]:
|
||||
@@ -389,7 +389,7 @@ async def fastapi_get_all_pipe_properties(
|
||||
results = get_all_pipes(network)
|
||||
return results
|
||||
|
||||
@router.post("/setpipeproperties/", response_model=None, summary="设置管道属性", description="批量设置指定管道的多个属性")
|
||||
@router.patch("/pipes/properties", response_model=None, summary="设置管道属性", description="批量设置指定管道的多个属性")
|
||||
async def fastapi_set_pipe_properties(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
pipe: str = Query(..., description="管道ID"),
|
||||
|
||||
@@ -13,7 +13,7 @@ from app.services.tjnetwork import (
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/getpumpschema", summary="获取水泵模式", description="获取水泵对象的模式定义,包含所有可用字段及其类型")
|
||||
@router.get("/network-schemas/pump", summary="获取水泵模式", description="获取水泵对象的模式定义,包含所有可用字段及其类型")
|
||||
async def fastapi_get_pump_schema(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)")
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
@@ -28,7 +28,7 @@ async def fastapi_get_pump_schema(
|
||||
"""
|
||||
return get_pump_schema(network)
|
||||
|
||||
@router.post("/addpump/", response_model=None, summary="添加水泵", description="向网络中添加新的水泵,需要提供水泵的基本参数如功率等")
|
||||
@router.post("/pumps", response_model=None, summary="添加水泵", description="向网络中添加新的水泵,需要提供水泵的基本参数如功率等")
|
||||
async def fastapi_add_pump(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
pump: str = Query(..., description="水泵标识符"),
|
||||
@@ -52,7 +52,7 @@ async def fastapi_add_pump(
|
||||
ps = {"id": pump, "node1": node1, "node2": node2, "power": power}
|
||||
return add_pump(network, ChangeSet(ps))
|
||||
|
||||
@router.post("/deletepump/", response_model=None, summary="删除水泵", description="从网络中删除指定的水泵")
|
||||
@router.delete("/pumps", response_model=None, summary="删除水泵", description="从网络中删除指定的水泵")
|
||||
async def fastapi_delete_pump(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
pump: str = Query(..., description="要删除的水泵ID")
|
||||
@@ -70,7 +70,7 @@ async def fastapi_delete_pump(
|
||||
ps = {"id": pump}
|
||||
return delete_pump(network, ChangeSet(ps))
|
||||
|
||||
@router.get("/getpumpnode1/", summary="获取水泵起始节点", description="获取指定水泵的起始节点ID")
|
||||
@router.get("/pumps/node1", summary="获取水泵起始节点", description="获取指定水泵的起始节点ID")
|
||||
async def fastapi_get_pump_node1(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
pump: str = Query(..., description="水泵ID")
|
||||
@@ -88,7 +88,7 @@ async def fastapi_get_pump_node1(
|
||||
ps = get_pump(network, pump)
|
||||
return ps["node1"]
|
||||
|
||||
@router.get("/getpumpnode2/", summary="获取水泵终止节点", description="获取指定水泵的终止节点ID")
|
||||
@router.get("/pumps/node2", summary="获取水泵终止节点", description="获取指定水泵的终止节点ID")
|
||||
async def fastapi_get_pump_node2(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
pump: str = Query(..., description="水泵ID")
|
||||
@@ -106,7 +106,7 @@ async def fastapi_get_pump_node2(
|
||||
ps = get_pump(network, pump)
|
||||
return ps["node2"]
|
||||
|
||||
@router.post("/setpumpnode1/", response_model=None, summary="设置水泵起始节点", description="设置指定水泵的起始节点")
|
||||
@router.patch("/pumps/node1", response_model=None, summary="设置水泵起始节点", description="设置指定水泵的起始节点")
|
||||
async def fastapi_set_pump_node1(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
pump: str = Query(..., description="水泵ID"),
|
||||
@@ -126,7 +126,7 @@ async def fastapi_set_pump_node1(
|
||||
ps = {"id": pump, "node1": node1}
|
||||
return set_pump(network, ChangeSet(ps))
|
||||
|
||||
@router.post("/setpumpnode2/", response_model=None, summary="设置水泵终止节点", description="设置指定水泵的终止节点")
|
||||
@router.patch("/pumps/node2", response_model=None, summary="设置水泵终止节点", description="设置指定水泵的终止节点")
|
||||
async def fastapi_set_pump_node2(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
pump: str = Query(..., description="水泵ID"),
|
||||
@@ -146,7 +146,7 @@ async def fastapi_set_pump_node2(
|
||||
ps = {"id": pump, "node2": node2}
|
||||
return set_pump(network, ChangeSet(ps))
|
||||
|
||||
@router.get("/getpumpproperties/", summary="获取水泵属性", description="获取指定水泵的所有属性信息")
|
||||
@router.get("/pumps/properties", summary="获取水泵属性", description="获取指定水泵的所有属性信息")
|
||||
async def fastapi_get_pump_properties(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
pump: str = Query(..., description="水泵ID")
|
||||
@@ -163,7 +163,7 @@ async def fastapi_get_pump_properties(
|
||||
"""
|
||||
return get_pump(network, pump)
|
||||
|
||||
@router.get("/getallpumpproperties/", summary="获取所有水泵属性", description="获取网络中所有水泵的属性信息列表")
|
||||
@router.get("/pumps", summary="获取所有水泵属性", description="获取网络中所有水泵的属性信息列表")
|
||||
async def fastapi_get_all_pump_properties(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)")
|
||||
) -> list[dict[str, Any]]:
|
||||
@@ -181,7 +181,7 @@ async def fastapi_get_all_pump_properties(
|
||||
results = get_all_pumps(network)
|
||||
return results
|
||||
|
||||
@router.post("/setpumpproperties/", response_model=None, summary="设置水泵属性", description="批量设置指定水泵的多个属性")
|
||||
@router.patch("/pumps/properties", response_model=None, summary="设置水泵属性", description="批量设置指定水泵的多个属性")
|
||||
async def fastapi_set_pump_properties(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
pump: str = Query(..., description="水泵ID"),
|
||||
|
||||
@@ -45,7 +45,7 @@ router = APIRouter()
|
||||
############################################################
|
||||
|
||||
@router.get(
|
||||
"/getregionschema/",
|
||||
"/network-schemas/region",
|
||||
summary="获取区域属性架构",
|
||||
description="获取指定水网的区域属性架构定义"
|
||||
)
|
||||
@@ -56,7 +56,7 @@ async def fastapi_get_region_schema(
|
||||
return get_region_schema(network)
|
||||
|
||||
@router.get(
|
||||
"/getregion/",
|
||||
"/regions/detail",
|
||||
summary="获取区域信息",
|
||||
description="获取指定ID的区域详细信息"
|
||||
)
|
||||
@@ -67,8 +67,8 @@ async def fastapi_get_region(
|
||||
"""获取区域的详细信息。"""
|
||||
return get_region(network, id)
|
||||
|
||||
@router.post(
|
||||
"/setregion/",
|
||||
@router.patch(
|
||||
"/regions",
|
||||
response_model=None,
|
||||
summary="设置区域属性",
|
||||
description="修改指定区域的属性信息"
|
||||
@@ -82,7 +82,7 @@ async def fastapi_set_region(
|
||||
return set_region(network, ChangeSet(props))
|
||||
|
||||
@router.post(
|
||||
"/addregion/",
|
||||
"/regions",
|
||||
response_model=None,
|
||||
summary="添加新区域",
|
||||
description="向水网添加一个新的区域"
|
||||
@@ -95,8 +95,8 @@ async def fastapi_add_region(
|
||||
props = await req.json()
|
||||
return add_region(network, ChangeSet(props))
|
||||
|
||||
@router.post(
|
||||
"/deleteregion/",
|
||||
@router.delete(
|
||||
"/regions",
|
||||
response_model=None,
|
||||
summary="删除区域",
|
||||
description="删除指定的区域"
|
||||
@@ -114,8 +114,8 @@ async def fastapi_delete_region(
|
||||
# district_metering_area 33
|
||||
############################################################
|
||||
|
||||
@router.get(
|
||||
"/calculatedistrictmeteringareaforregion/",
|
||||
@router.post(
|
||||
"/district-metering-areas/for-region",
|
||||
summary="计算区域内DMA分区",
|
||||
description="为指定区域计算区域计量(DMA)分区方案"
|
||||
)
|
||||
@@ -141,8 +141,8 @@ async def fastapi_calculate_district_metering_area_for_region(
|
||||
network, region, part_count, part_type
|
||||
)
|
||||
|
||||
@router.get(
|
||||
"/calculatedistrictmeteringareafornetwork/",
|
||||
@router.post(
|
||||
"/district-metering-areas/for-network",
|
||||
summary="计算整网DMA分区",
|
||||
description="为整个水网计算区域计量(DMA)分区方案"
|
||||
)
|
||||
@@ -165,7 +165,7 @@ async def fastapi_calculate_district_metering_area_for_network(
|
||||
return calculate_district_metering_area_for_network(network, part_count, part_type)
|
||||
|
||||
@router.get(
|
||||
"/getdistrictmeteringareaschema/",
|
||||
"/network-schemas/district-metering-area",
|
||||
summary="获取DMA属性架构",
|
||||
description="获取指定水网的区域计量(DMA)属性架构定义"
|
||||
)
|
||||
@@ -176,7 +176,7 @@ async def fastapi_get_district_metering_area_schema(
|
||||
return get_district_metering_area_schema(network)
|
||||
|
||||
@router.get(
|
||||
"/getdistrictmeteringarea/",
|
||||
"/district-metering-areas/detail",
|
||||
summary="获取DMA信息",
|
||||
description="获取指定ID的区域计量(DMA)详细信息"
|
||||
)
|
||||
@@ -187,8 +187,8 @@ async def fastapi_get_district_metering_area(
|
||||
"""获取DMA的详细信息。"""
|
||||
return get_district_metering_area(network, id)
|
||||
|
||||
@router.post(
|
||||
"/setdistrictmeteringarea/",
|
||||
@router.patch(
|
||||
"/district-metering-areas",
|
||||
response_model=None,
|
||||
summary="设置DMA属性",
|
||||
description="修改指定DMA的属性信息"
|
||||
@@ -202,7 +202,7 @@ async def fastapi_set_district_metering_area(
|
||||
return set_district_metering_area(network, ChangeSet(props))
|
||||
|
||||
@router.post(
|
||||
"/adddistrictmeteringarea/",
|
||||
"/district-metering-areas",
|
||||
response_model=None,
|
||||
summary="添加新DMA",
|
||||
description="向水网添加一个新的区域计量(DMA)"
|
||||
@@ -222,8 +222,8 @@ async def fastapi_add_district_metering_area(
|
||||
props["boundary"] = newBoundary
|
||||
return add_district_metering_area(network, ChangeSet(props))
|
||||
|
||||
@router.post(
|
||||
"/deletedistrictmeteringarea/",
|
||||
@router.delete(
|
||||
"/district-metering-areas",
|
||||
response_model=None,
|
||||
summary="删除DMA",
|
||||
description="删除指定的区域计量(DMA)"
|
||||
@@ -237,7 +237,7 @@ async def fastapi_delete_district_metering_area(
|
||||
return delete_district_metering_area(network, ChangeSet(props))
|
||||
|
||||
@router.get(
|
||||
"/getalldistrictmeteringareaids/",
|
||||
"/district-metering-areas/ids",
|
||||
summary="获取所有DMA ID",
|
||||
description="获取指定水网中所有DMA的ID列表"
|
||||
)
|
||||
@@ -248,7 +248,7 @@ async def fastapi_get_all_district_metering_area_ids(
|
||||
return get_all_district_metering_area_ids(network)
|
||||
|
||||
@router.get(
|
||||
"/getalldistrictmeteringareas/",
|
||||
"/district-metering-areas",
|
||||
summary="获取所有DMA",
|
||||
description="获取指定水网中所有DMA的详细信息"
|
||||
)
|
||||
@@ -259,7 +259,7 @@ async def getalldistrictmeteringareas(
|
||||
return get_all_district_metering_areas(network)
|
||||
|
||||
@router.post(
|
||||
"/generatedistrictmeteringarea/",
|
||||
"/district-metering-area-generation-runs",
|
||||
response_model=None,
|
||||
summary="生成DMA分区",
|
||||
description="根据参数自动生成水网的DMA分区方案"
|
||||
@@ -276,7 +276,7 @@ async def fastapi_generate_district_metering_area(
|
||||
)
|
||||
|
||||
@router.post(
|
||||
"/generatesubdistrictmeteringarea/",
|
||||
"/sub-district-metering-areas",
|
||||
response_model=None,
|
||||
summary="生成DMA子分区",
|
||||
description="为指定DMA生成子DMA分区"
|
||||
@@ -298,8 +298,8 @@ async def fastapi_generate_sub_district_metering_area(
|
||||
# service_area 34
|
||||
############################################################
|
||||
|
||||
@router.get(
|
||||
"/calculateservicearea/",
|
||||
@router.post(
|
||||
"/service-area-calculations",
|
||||
summary="计算服务区",
|
||||
description="计算指定水网的服务区分区,返回全部时间步结果"
|
||||
)
|
||||
@@ -310,7 +310,7 @@ async def fastapi_calculate_service_area(
|
||||
return calculate_service_area(network)
|
||||
|
||||
@router.get(
|
||||
"/getserviceareaschema/",
|
||||
"/network-schemas/service-area",
|
||||
summary="获取服务区属性架构",
|
||||
description="获取指定水网的服务区属性架构定义"
|
||||
)
|
||||
@@ -321,7 +321,7 @@ async def fastapi_get_service_area_schema(
|
||||
return get_service_area_schema(network)
|
||||
|
||||
@router.get(
|
||||
"/getservicearea/",
|
||||
"/service-areas/detail",
|
||||
summary="获取服务区信息",
|
||||
description="获取指定ID的服务区详细信息"
|
||||
)
|
||||
@@ -332,8 +332,8 @@ async def fastapi_get_service_area(
|
||||
"""获取服务区的详细信息。"""
|
||||
return get_service_area(network, id)
|
||||
|
||||
@router.post(
|
||||
"/setservicearea/",
|
||||
@router.patch(
|
||||
"/service-areas",
|
||||
response_model=None,
|
||||
summary="设置服务区属性",
|
||||
description="修改指定服务区的属性信息"
|
||||
@@ -347,7 +347,7 @@ async def fastapi_set_service_area(
|
||||
return set_service_area(network, ChangeSet(props))
|
||||
|
||||
@router.post(
|
||||
"/addservicearea/",
|
||||
"/service-areas",
|
||||
response_model=None,
|
||||
summary="添加新服务区",
|
||||
description="向水网添加一个新的服务区"
|
||||
@@ -360,8 +360,8 @@ async def fastapi_add_service_area(
|
||||
props = await req.json()
|
||||
return add_service_area(network, ChangeSet(props))
|
||||
|
||||
@router.post(
|
||||
"/deleteservicearea/",
|
||||
@router.delete(
|
||||
"/service-areas",
|
||||
response_model=None,
|
||||
summary="删除服务区",
|
||||
description="删除指定的服务区"
|
||||
@@ -375,7 +375,7 @@ async def fastapi_delete_service_area(
|
||||
return delete_service_area(network, ChangeSet(props))
|
||||
|
||||
@router.get(
|
||||
"/getallserviceareas/",
|
||||
"/service-areas",
|
||||
summary="获取所有服务区",
|
||||
description="获取指定水网中的所有服务区信息"
|
||||
)
|
||||
@@ -386,7 +386,7 @@ async def fastapi_get_all_service_areas(
|
||||
return get_all_service_areas(network)
|
||||
|
||||
@router.post(
|
||||
"/generateservicearea/",
|
||||
"/service-area-generation-runs",
|
||||
response_model=None,
|
||||
summary="生成服务区分区",
|
||||
description="根据参数自动生成水网的服务区分区"
|
||||
@@ -403,8 +403,8 @@ async def fastapi_generate_service_area(
|
||||
# virtual_district 35
|
||||
############################################################
|
||||
|
||||
@router.get(
|
||||
"/calculatevirtualdistrict/",
|
||||
@router.post(
|
||||
"/virtual-district-calculations",
|
||||
summary="计算虚拟分区",
|
||||
description="根据指定的压力监测节点作为中心节点计算虚拟分区方案"
|
||||
)
|
||||
@@ -416,7 +416,7 @@ async def fastapi_calculate_virtual_district(
|
||||
return calculate_virtual_district(network, centers)
|
||||
|
||||
@router.get(
|
||||
"/getvirtualdistrictschema/",
|
||||
"/network-schemas/virtual-district",
|
||||
summary="获取虚拟分区属性架构",
|
||||
description="获取指定水网的虚拟分区属性架构定义"
|
||||
)
|
||||
@@ -427,7 +427,7 @@ async def fastapi_get_virtual_district_schema(
|
||||
return get_virtual_district_schema(network)
|
||||
|
||||
@router.get(
|
||||
"/getvirtualdistrict/",
|
||||
"/virtual-districts/detail",
|
||||
summary="获取虚拟分区信息",
|
||||
description="获取指定ID的虚拟分区详细信息"
|
||||
)
|
||||
@@ -438,8 +438,8 @@ async def fastapi_get_virtual_district(
|
||||
"""获取虚拟分区的详细信息。"""
|
||||
return get_virtual_district(network, id)
|
||||
|
||||
@router.post(
|
||||
"/setvirtualdistrict/",
|
||||
@router.patch(
|
||||
"/virtual-districts",
|
||||
response_model=None,
|
||||
summary="设置虚拟分区属性",
|
||||
description="修改指定虚拟分区的属性信息"
|
||||
@@ -453,7 +453,7 @@ async def fastapi_set_virtual_district(
|
||||
return set_virtual_district(network, ChangeSet(props))
|
||||
|
||||
@router.post(
|
||||
"/addvirtualdistrict/",
|
||||
"/virtual-districts",
|
||||
response_model=None,
|
||||
summary="添加新虚拟分区",
|
||||
description="向水网添加一个新的虚拟分区"
|
||||
@@ -466,8 +466,8 @@ async def fastapi_add_virtual_district(
|
||||
props = await req.json()
|
||||
return add_virtual_district(network, ChangeSet(props))
|
||||
|
||||
@router.post(
|
||||
"/deletevirtualdistrict/",
|
||||
@router.delete(
|
||||
"/virtual-districts",
|
||||
response_model=None,
|
||||
summary="删除虚拟分区",
|
||||
description="删除指定的虚拟分区"
|
||||
@@ -481,7 +481,7 @@ async def fastapi_delete_virtual_district(
|
||||
return delete_virtual_district(network, ChangeSet(props))
|
||||
|
||||
@router.get(
|
||||
"/getallvirtualdistrict/",
|
||||
"/virtual-districts",
|
||||
summary="获取所有虚拟分区",
|
||||
description="获取指定水网中的所有虚拟分区信息"
|
||||
)
|
||||
@@ -492,7 +492,7 @@ async def fastapi_get_all_virtual_district(
|
||||
return get_all_virtual_districts(network)
|
||||
|
||||
@router.post(
|
||||
"/generatevirtualdistrict/",
|
||||
"/virtual-district-generation-runs",
|
||||
response_model=None,
|
||||
summary="生成虚拟分区",
|
||||
description="根据参数自动生成虚拟分区方案"
|
||||
@@ -506,8 +506,8 @@ async def fastapi_generate_virtual_district(
|
||||
props = await req.json()
|
||||
return generate_virtual_district(network, props["centers"], inflate_delta)
|
||||
|
||||
@router.get(
|
||||
"/calculatedistrictmeteringareafornodes/",
|
||||
@router.post(
|
||||
"/district-metering-areas/for-nodes",
|
||||
summary="计算节点DMA分区",
|
||||
description="为指定节点集计算区域计量(DMA)分区方案"
|
||||
)
|
||||
|
||||
@@ -14,7 +14,7 @@ from app.services.tjnetwork import (
|
||||
router = APIRouter()
|
||||
|
||||
@router.get(
|
||||
"/getreservoirschema",
|
||||
"/network-schemas/reservoir",
|
||||
summary="获取水库模式",
|
||||
description="获取指定供水网络中所有水库的模式/属性字段定义"
|
||||
)
|
||||
@@ -35,7 +35,7 @@ async def fast_get_reservoir_schema(
|
||||
return get_reservoir_schema(network)
|
||||
|
||||
@router.post(
|
||||
"/addreservoir/",
|
||||
"/reservoirs",
|
||||
response_model=None,
|
||||
summary="添加水库",
|
||||
description="在指定供水网络中添加新的水库/水源节点"
|
||||
@@ -65,8 +65,8 @@ async def fastapi_add_reservoir(
|
||||
ps = {"id": reservoir, "x": x, "y": y, "head": head}
|
||||
return add_reservoir(network, ChangeSet(ps))
|
||||
|
||||
@router.post(
|
||||
"/deletereservoir/",
|
||||
@router.delete(
|
||||
"/reservoirs",
|
||||
response_model=None,
|
||||
summary="删除水库",
|
||||
description="从指定供水网络中删除指定的水库/水源节点"
|
||||
@@ -91,7 +91,7 @@ async def fastapi_delete_reservoir(
|
||||
return delete_reservoir(network, ChangeSet(ps))
|
||||
|
||||
@router.get(
|
||||
"/getreservoirhead/",
|
||||
"/reservoirs/head",
|
||||
summary="获取水库水头",
|
||||
description="获取指定水库的供水水头/总水头值"
|
||||
)
|
||||
@@ -115,7 +115,7 @@ async def fastapi_get_reservoir_head(
|
||||
return ps["head"]
|
||||
|
||||
@router.get(
|
||||
"/getreservoirpattern/",
|
||||
"/reservoirs/pattern",
|
||||
summary="获取水库模式",
|
||||
description="获取指定水库的运行模式/供水模式"
|
||||
)
|
||||
@@ -139,7 +139,7 @@ async def fastapi_get_reservoir_pattern(
|
||||
return ps["pattern"]
|
||||
|
||||
@router.get(
|
||||
"/getreservoirx/",
|
||||
"/reservoirs/x",
|
||||
summary="获取水库X坐标",
|
||||
description="获取指定水库的X坐标位置"
|
||||
)
|
||||
@@ -163,7 +163,7 @@ async def fastapi_get_reservoir_x(
|
||||
return ps["x"]
|
||||
|
||||
@router.get(
|
||||
"/getreservoiry/",
|
||||
"/reservoirs/y",
|
||||
summary="获取水库Y坐标",
|
||||
description="获取指定水库的Y坐标位置"
|
||||
)
|
||||
@@ -187,7 +187,7 @@ async def fastapi_get_reservoir_y(
|
||||
return ps["y"]
|
||||
|
||||
@router.get(
|
||||
"/getreservoircoord/",
|
||||
"/reservoirs/coord",
|
||||
summary="获取水库坐标",
|
||||
description="获取指定水库的平面坐标(X和Y坐标)"
|
||||
)
|
||||
@@ -211,8 +211,8 @@ async def fastapi_get_reservoir_coord(
|
||||
coord = {"id": reservoir, "x": ps["x"], "y": ps["y"]}
|
||||
return coord
|
||||
|
||||
@router.post(
|
||||
"/setreservoirhead/",
|
||||
@router.patch(
|
||||
"/reservoirs/head",
|
||||
response_model=None,
|
||||
summary="设置水库水头",
|
||||
description="更新指定水库的供水水头/总水头值"
|
||||
@@ -238,8 +238,8 @@ async def fastapi_set_reservoir_head(
|
||||
ps = {"id": reservoir, "head": head}
|
||||
return set_reservoir(network, ChangeSet(ps))
|
||||
|
||||
@router.post(
|
||||
"/setreservoirpattern/",
|
||||
@router.patch(
|
||||
"/reservoirs/pattern",
|
||||
response_model=None,
|
||||
summary="设置水库模式",
|
||||
description="更新指定水库的运行模式/供水模式"
|
||||
@@ -265,8 +265,8 @@ async def fastapi_set_reservoir_pattern(
|
||||
ps = {"id": reservoir, "pattern": pattern}
|
||||
return set_reservoir(network, ChangeSet(ps))
|
||||
|
||||
@router.post(
|
||||
"/setreservoirx/",
|
||||
@router.patch(
|
||||
"/reservoirs/x",
|
||||
response_model=None,
|
||||
summary="设置水库X坐标",
|
||||
description="更新指定水库的X坐标位置"
|
||||
@@ -292,8 +292,8 @@ async def fastapi_set_reservoir_x(
|
||||
ps = {"id": reservoir, "x": x}
|
||||
return set_reservoir(network, ChangeSet(ps))
|
||||
|
||||
@router.post(
|
||||
"/setreservoiry/",
|
||||
@router.patch(
|
||||
"/reservoirs/y",
|
||||
response_model=None,
|
||||
summary="设置水库Y坐标",
|
||||
description="更新指定水库的Y坐标位置"
|
||||
@@ -319,8 +319,8 @@ async def fastapi_set_reservoir_y(
|
||||
ps = {"id": reservoir, "y": y}
|
||||
return set_reservoir(network, ChangeSet(ps))
|
||||
|
||||
@router.post(
|
||||
"/setreservoircoord/",
|
||||
@router.patch(
|
||||
"/reservoirs/coord",
|
||||
response_model=None,
|
||||
summary="设置水库坐标",
|
||||
description="更新指定水库的平面坐标(X和Y坐标)"
|
||||
@@ -349,7 +349,7 @@ async def fastapi_set_reservoir_coord(
|
||||
return set_reservoir(network, ChangeSet(ps))
|
||||
|
||||
@router.get(
|
||||
"/getreservoirproperties/",
|
||||
"/reservoirs/properties",
|
||||
summary="获取水库属性",
|
||||
description="获取指定水库的所有属性"
|
||||
)
|
||||
@@ -372,7 +372,7 @@ async def fastapi_get_reservoir_properties(
|
||||
return get_reservoir(network, reservoir)
|
||||
|
||||
@router.get(
|
||||
"/getallreservoirproperties/",
|
||||
"/reservoirs",
|
||||
summary="获取所有水库属性",
|
||||
description="获取指定供水网络中所有水库的属性"
|
||||
)
|
||||
@@ -393,8 +393,8 @@ async def fastapi_get_all_reservoir_properties(
|
||||
results = get_all_reservoirs(network)
|
||||
return results
|
||||
|
||||
@router.post(
|
||||
"/setreservoirproperties/",
|
||||
@router.patch(
|
||||
"/reservoirs/properties",
|
||||
response_model=None,
|
||||
summary="设置水库属性",
|
||||
description="批量更新指定水库的多个属性"
|
||||
|
||||
@@ -16,7 +16,7 @@ router = APIRouter()
|
||||
############################################################
|
||||
|
||||
@router.get(
|
||||
"/gettagschema/",
|
||||
"/network-schemas/tag",
|
||||
summary="获取标签属性架构",
|
||||
description="获取指定水网的标签(Tag)属性架构定义"
|
||||
)
|
||||
@@ -27,7 +27,7 @@ async def fastapi_get_tag_schema(
|
||||
return get_tag_schema(network)
|
||||
|
||||
@router.get(
|
||||
"/gettag/",
|
||||
"/tags/detail",
|
||||
summary="获取标签信息",
|
||||
description="获取指定类型和ID的标签信息"
|
||||
)
|
||||
@@ -40,7 +40,7 @@ async def fastapi_get_tag(
|
||||
return get_tag(network, t_type, id)
|
||||
|
||||
@router.get(
|
||||
"/gettags/",
|
||||
"/tags",
|
||||
summary="获取所有标签",
|
||||
description="获取指定水网中的所有标签信息"
|
||||
)
|
||||
@@ -51,8 +51,8 @@ async def fastapi_get_tags(
|
||||
tags = get_tags(network)
|
||||
return tags
|
||||
|
||||
@router.post(
|
||||
"/settag/",
|
||||
@router.patch(
|
||||
"/tags",
|
||||
response_model=None,
|
||||
summary="设置标签",
|
||||
description="为指定元素设置或修改标签信息"
|
||||
|
||||
@@ -13,7 +13,7 @@ from app.services.tjnetwork import (
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/gettankschema", summary="获取水箱模式", description="获取指定网络的水箱数据结构模式定义")
|
||||
@router.get("/network-schemas/tank", summary="获取水箱模式", description="获取指定网络的水箱数据结构模式定义")
|
||||
async def fast_get_tank_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[str, Any]]:
|
||||
"""
|
||||
获取水箱的数据结构模式。
|
||||
@@ -26,7 +26,7 @@ async def fast_get_tank_schema(network: str = Query(..., description="管网名
|
||||
"""
|
||||
return get_tank_schema(network)
|
||||
|
||||
@router.post("/addtank/", summary="新增水箱", description="向指定网络中新增一个水箱", response_model=None)
|
||||
@router.post("/tanks", summary="新增水箱", description="向指定网络中新增一个水箱", response_model=None)
|
||||
async def fastapi_add_tank(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
tank: str = Query(..., description="水箱ID"),
|
||||
@@ -70,7 +70,7 @@ async def fastapi_add_tank(
|
||||
}
|
||||
return add_tank(network, ChangeSet(ps))
|
||||
|
||||
@router.post("/deletetank/", summary="删除水箱", description="删除指定网络中的水箱", response_model=None)
|
||||
@router.delete("/tanks", summary="删除水箱", description="删除指定网络中的水箱", response_model=None)
|
||||
async def fastapi_delete_tank(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
tank: str = Query(..., description="水箱ID")
|
||||
@@ -88,7 +88,7 @@ async def fastapi_delete_tank(
|
||||
ps = {"id": tank}
|
||||
return delete_tank(network, ChangeSet(ps))
|
||||
|
||||
@router.get("/gettankelevation/", summary="获取水箱标高", description="获取指定水箱的标高值")
|
||||
@router.get("/tanks/elevation", summary="获取水箱标高", description="获取指定水箱的标高值")
|
||||
async def fastapi_get_tank_elevation(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
tank: str = Query(..., description="水箱ID")
|
||||
@@ -106,7 +106,7 @@ async def fastapi_get_tank_elevation(
|
||||
ps = get_tank(network, tank)
|
||||
return ps["elevation"]
|
||||
|
||||
@router.get("/gettankinitlevel/", summary="获取水箱初始水位", description="获取指定水箱的初始水位值")
|
||||
@router.get("/tanks/init-level", summary="获取水箱初始水位", description="获取指定水箱的初始水位值")
|
||||
async def fastapi_get_tank_init_level(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
tank: str = Query(..., description="水箱ID")
|
||||
@@ -124,7 +124,7 @@ async def fastapi_get_tank_init_level(
|
||||
ps = get_tank(network, tank)
|
||||
return ps["init_level"]
|
||||
|
||||
@router.get("/gettankminlevel/", summary="获取水箱最小水位", description="获取指定水箱的最小水位值")
|
||||
@router.get("/tanks/min-level", summary="获取水箱最小水位", description="获取指定水箱的最小水位值")
|
||||
async def fastapi_get_tank_min_level(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
tank: str = Query(..., description="水箱ID")
|
||||
@@ -142,7 +142,7 @@ async def fastapi_get_tank_min_level(
|
||||
ps = get_tank(network, tank)
|
||||
return ps["min_level"]
|
||||
|
||||
@router.get("/gettankmaxlevel/", summary="获取水箱最大水位", description="获取指定水箱的最大水位值")
|
||||
@router.get("/tanks/max-level", summary="获取水箱最大水位", description="获取指定水箱的最大水位值")
|
||||
async def fastapi_get_tank_max_level(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
tank: str = Query(..., description="水箱ID")
|
||||
@@ -160,7 +160,7 @@ async def fastapi_get_tank_max_level(
|
||||
ps = get_tank(network, tank)
|
||||
return ps["max_level"]
|
||||
|
||||
@router.get("/gettankdiameter/", summary="获取水箱直径", description="获取指定水箱的直径值")
|
||||
@router.get("/tanks/diameter", summary="获取水箱直径", description="获取指定水箱的直径值")
|
||||
async def fastapi_get_tank_diameter(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
tank: str = Query(..., description="水箱ID")
|
||||
@@ -178,7 +178,7 @@ async def fastapi_get_tank_diameter(
|
||||
ps = get_tank(network, tank)
|
||||
return ps["diameter"]
|
||||
|
||||
@router.get("/gettankminvol/", summary="获取水箱最小体积", description="获取指定水箱的最小体积值")
|
||||
@router.get("/tanks/min-vol", summary="获取水箱最小体积", description="获取指定水箱的最小体积值")
|
||||
async def fastapi_get_tank_min_vol(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
tank: str = Query(..., description="水箱ID")
|
||||
@@ -196,7 +196,7 @@ async def fastapi_get_tank_min_vol(
|
||||
ps = get_tank(network, tank)
|
||||
return ps["min_vol"]
|
||||
|
||||
@router.get("/gettankvolcurve/", summary="获取水箱容积曲线", description="获取指定水箱的容积曲线标识")
|
||||
@router.get("/tanks/vol-curve", summary="获取水箱容积曲线", description="获取指定水箱的容积曲线标识")
|
||||
async def fastapi_get_tank_vol_curve(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
tank: str = Query(..., description="水箱ID")
|
||||
@@ -214,7 +214,7 @@ async def fastapi_get_tank_vol_curve(
|
||||
ps = get_tank(network, tank)
|
||||
return ps["vol_curve"]
|
||||
|
||||
@router.get("/gettankoverflow/", summary="获取水箱溢流口", description="获取指定水箱的溢流口配置")
|
||||
@router.get("/tanks/overflow", summary="获取水箱溢流口", description="获取指定水箱的溢流口配置")
|
||||
async def fastapi_get_tank_overflow(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
tank: str = Query(..., description="水箱ID")
|
||||
@@ -232,7 +232,7 @@ async def fastapi_get_tank_overflow(
|
||||
ps = get_tank(network, tank)
|
||||
return ps["overflow"]
|
||||
|
||||
@router.get("/gettankx/", summary="获取水箱X坐标", description="获取指定水箱的X坐标值")
|
||||
@router.get("/tanks/x", summary="获取水箱X坐标", description="获取指定水箱的X坐标值")
|
||||
async def fastapi_get_tank_x(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
tank: str = Query(..., description="水箱ID")
|
||||
@@ -250,7 +250,7 @@ async def fastapi_get_tank_x(
|
||||
ps = get_tank(network, tank)
|
||||
return ps["x"]
|
||||
|
||||
@router.get("/gettanky/", summary="获取水箱Y坐标", description="获取指定水箱的Y坐标值")
|
||||
@router.get("/tanks/y", summary="获取水箱Y坐标", description="获取指定水箱的Y坐标值")
|
||||
async def fastapi_get_tank_y(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
tank: str = Query(..., description="水箱ID")
|
||||
@@ -268,7 +268,7 @@ async def fastapi_get_tank_y(
|
||||
ps = get_tank(network, tank)
|
||||
return ps["y"]
|
||||
|
||||
@router.get("/gettankcoord/", summary="获取水箱坐标", description="获取指定水箱的X和Y坐标")
|
||||
@router.get("/tanks/coord", summary="获取水箱坐标", description="获取指定水箱的X和Y坐标")
|
||||
async def fastapi_get_tank_coord(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
tank: str = Query(..., description="水箱ID")
|
||||
@@ -287,7 +287,7 @@ async def fastapi_get_tank_coord(
|
||||
coord = {"x": ps["x"], "y": ps["y"]}
|
||||
return coord
|
||||
|
||||
@router.post("/settankelevation/", summary="设置水箱标高", description="设置指定水箱的标高值", response_model=None)
|
||||
@router.patch("/tanks/elevation", summary="设置水箱标高", description="设置指定水箱的标高值", response_model=None)
|
||||
async def fastapi_set_tank_elevation(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
tank: str = Query(..., description="水箱ID"),
|
||||
@@ -307,7 +307,7 @@ async def fastapi_set_tank_elevation(
|
||||
ps = {"id": tank, "elevation": elevation}
|
||||
return set_tank(network, ChangeSet(ps))
|
||||
|
||||
@router.post("/settankinitlevel/", summary="设置水箱初始水位", description="设置指定水箱的初始水位值", response_model=None)
|
||||
@router.patch("/tanks/init-level", summary="设置水箱初始水位", description="设置指定水箱的初始水位值", response_model=None)
|
||||
async def fastapi_set_tank_init_level(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
tank: str = Query(..., description="水箱ID"),
|
||||
@@ -327,7 +327,7 @@ async def fastapi_set_tank_init_level(
|
||||
ps = {"id": tank, "init_level": init_level}
|
||||
return set_tank(network, ChangeSet(ps))
|
||||
|
||||
@router.post("/settankminlevel/", summary="设置水箱最小水位", description="设置指定水箱的最小水位值", response_model=None)
|
||||
@router.patch("/tanks/min-level", summary="设置水箱最小水位", description="设置指定水箱的最小水位值", response_model=None)
|
||||
async def fastapi_set_tank_min_level(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
tank: str = Query(..., description="水箱ID"),
|
||||
@@ -347,7 +347,7 @@ async def fastapi_set_tank_min_level(
|
||||
ps = {"id": tank, "min_level": min_level}
|
||||
return set_tank(network, ChangeSet(ps))
|
||||
|
||||
@router.post("/settankmaxlevel/", summary="设置水箱最大水位", description="设置指定水箱的最大水位值", response_model=None)
|
||||
@router.patch("/tanks/max-level", summary="设置水箱最大水位", description="设置指定水箱的最大水位值", response_model=None)
|
||||
async def fastapi_set_tank_max_level(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
tank: str = Query(..., description="水箱ID"),
|
||||
@@ -367,7 +367,7 @@ async def fastapi_set_tank_max_level(
|
||||
ps = {"id": tank, "max_level": max_level}
|
||||
return set_tank(network, ChangeSet(ps))
|
||||
|
||||
@router.post("/settankdiameter/", summary="设置水箱直径", description="设置指定水箱的直径值", response_model=None)
|
||||
@router.patch("/tanks/diameter", summary="设置水箱直径", description="设置指定水箱的直径值", response_model=None)
|
||||
async def fastapi_set_tank_diameter(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
tank: str = Query(..., description="水箱ID"),
|
||||
@@ -387,7 +387,7 @@ async def fastapi_set_tank_diameter(
|
||||
ps = {"id": tank, "diameter": diameter}
|
||||
return set_tank(network, ChangeSet(ps))
|
||||
|
||||
@router.post("/settankminvol/", summary="设置水箱最小体积", description="设置指定水箱的最小体积值", response_model=None)
|
||||
@router.patch("/tanks/min-vol", summary="设置水箱最小体积", description="设置指定水箱的最小体积值", response_model=None)
|
||||
async def fastapi_set_tank_min_vol(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
tank: str = Query(..., description="水箱ID"),
|
||||
@@ -407,7 +407,7 @@ async def fastapi_set_tank_min_vol(
|
||||
ps = {"id": tank, "min_vol": min_vol}
|
||||
return set_tank(network, ChangeSet(ps))
|
||||
|
||||
@router.post("/settankvolcurve/", summary="设置水箱容积曲线", description="设置指定水箱的容积曲线标识", response_model=None)
|
||||
@router.patch("/tanks/vol-curve", summary="设置水箱容积曲线", description="设置指定水箱的容积曲线标识", response_model=None)
|
||||
async def fastapi_set_tank_vol_curve(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
tank: str = Query(..., description="水箱ID"),
|
||||
@@ -427,7 +427,7 @@ async def fastapi_set_tank_vol_curve(
|
||||
ps = {"id": tank, "vol_curve": vol_curve}
|
||||
return set_tank(network, ChangeSet(ps))
|
||||
|
||||
@router.post("/settankoverflow/", summary="设置水箱溢流口", description="设置指定水箱的溢流口配置", response_model=None)
|
||||
@router.patch("/tanks/overflow", summary="设置水箱溢流口", description="设置指定水箱的溢流口配置", response_model=None)
|
||||
async def fastapi_set_tank_overflow(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
tank: str = Query(..., description="水箱ID"),
|
||||
@@ -447,7 +447,7 @@ async def fastapi_set_tank_overflow(
|
||||
ps = {"id": tank, "overflow": overflow}
|
||||
return set_tank(network, ChangeSet(ps))
|
||||
|
||||
@router.post("/settankx/", summary="设置水箱X坐标", description="设置指定水箱的X坐标值", response_model=None)
|
||||
@router.patch("/tanks/x", summary="设置水箱X坐标", description="设置指定水箱的X坐标值", response_model=None)
|
||||
async def fastapi_set_tank_x(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
tank: str = Query(..., description="水箱ID"),
|
||||
@@ -467,7 +467,7 @@ async def fastapi_set_tank_x(
|
||||
ps = {"id": tank, "x": x}
|
||||
return set_tank(network, ChangeSet(ps))
|
||||
|
||||
@router.post("/settanky/", summary="设置水箱Y坐标", description="设置指定水箱的Y坐标值", response_model=None)
|
||||
@router.patch("/tanks/y", summary="设置水箱Y坐标", description="设置指定水箱的Y坐标值", response_model=None)
|
||||
async def fastapi_set_tank_y(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
tank: str = Query(..., description="水箱ID"),
|
||||
@@ -487,7 +487,7 @@ async def fastapi_set_tank_y(
|
||||
ps = {"id": tank, "y": y}
|
||||
return set_tank(network, ChangeSet(ps))
|
||||
|
||||
@router.post("/settankcoord/", summary="设置水箱坐标", description="设置指定水箱的X和Y坐标", response_model=None)
|
||||
@router.patch("/tanks/coord", summary="设置水箱坐标", description="设置指定水箱的X和Y坐标", response_model=None)
|
||||
async def fastapi_set_tank_coord(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
tank: str = Query(..., description="水箱ID"),
|
||||
@@ -509,7 +509,7 @@ async def fastapi_set_tank_coord(
|
||||
ps = {"id": tank, "x": x, "y": y}
|
||||
return set_tank(network, ChangeSet(ps))
|
||||
|
||||
@router.get("/gettankproperties/", summary="获取水箱属性", description="获取指定水箱的所有属性")
|
||||
@router.get("/tanks/properties", summary="获取水箱属性", description="获取指定水箱的所有属性")
|
||||
async def fastapi_get_tank_properties(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
tank: str = Query(..., description="水箱ID")
|
||||
@@ -526,7 +526,7 @@ async def fastapi_get_tank_properties(
|
||||
"""
|
||||
return get_tank(network, tank)
|
||||
|
||||
@router.get("/getalltankproperties/", summary="获取所有水箱属性", description="获取指定网络中所有水箱的属性")
|
||||
@router.get("/tanks", summary="获取所有水箱属性", description="获取指定网络中所有水箱的属性")
|
||||
async def fastapi_get_all_tank_properties(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)")
|
||||
) -> list[dict[str, Any]]:
|
||||
@@ -544,7 +544,7 @@ async def fastapi_get_all_tank_properties(
|
||||
results = get_all_tanks(network)
|
||||
return results
|
||||
|
||||
@router.post("/settankproperties/", summary="设置水箱属性", description="批量设置指定水箱的多个属性", response_model=None)
|
||||
@router.patch("/tanks/properties", summary="设置水箱属性", description="批量设置指定水箱的多个属性", response_model=None)
|
||||
async def fastapi_set_tank_properties(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
tank: str = Query(..., description="水箱ID"),
|
||||
|
||||
@@ -15,7 +15,7 @@ from app.services.tjnetwork import (
|
||||
router = APIRouter()
|
||||
|
||||
@router.get(
|
||||
"/getvalveschema",
|
||||
"/network-schemas/valve",
|
||||
summary="获取阀门架构",
|
||||
description="获取指定水网中所有阀门的架构和字段定义",
|
||||
)
|
||||
@@ -30,7 +30,7 @@ async def fastapi_get_valve_schema(
|
||||
return get_valve_schema(network)
|
||||
|
||||
@router.post(
|
||||
"/addvalve/",
|
||||
"/valves",
|
||||
response_model=None,
|
||||
summary="添加阀门",
|
||||
description="在指定的水网中添加新的阀门",
|
||||
@@ -62,8 +62,8 @@ async def fastapi_add_valve(
|
||||
|
||||
return add_valve(network, ChangeSet(ps))
|
||||
|
||||
@router.post(
|
||||
"/deletevalve/",
|
||||
@router.delete(
|
||||
"/valves",
|
||||
response_model=None,
|
||||
summary="删除阀门",
|
||||
description="从指定的水网中删除指定的阀门",
|
||||
@@ -81,7 +81,7 @@ async def fastapi_delete_valve(
|
||||
return delete_valve(network, ChangeSet(ps))
|
||||
|
||||
@router.get(
|
||||
"/getvalvenode1/",
|
||||
"/valves/node1",
|
||||
summary="获取阀门起点节点",
|
||||
description="获取指定阀门连接的起点节点ID",
|
||||
)
|
||||
@@ -98,7 +98,7 @@ async def fastapi_get_valve_node1(
|
||||
return ps["node1"]
|
||||
|
||||
@router.get(
|
||||
"/getvalvenode2/",
|
||||
"/valves/node2",
|
||||
summary="获取阀门终点节点",
|
||||
description="获取指定阀门连接的终点节点ID",
|
||||
)
|
||||
@@ -115,7 +115,7 @@ async def fastapi_get_valve_node2(
|
||||
return ps["node2"]
|
||||
|
||||
@router.get(
|
||||
"/getvalvediameter/",
|
||||
"/valves/diameter",
|
||||
summary="获取阀门直径",
|
||||
description="获取指定阀门的直径",
|
||||
)
|
||||
@@ -132,7 +132,7 @@ async def fastapi_get_valve_diameter(
|
||||
return ps["diameter"]
|
||||
|
||||
@router.get(
|
||||
"/getvalvetype/",
|
||||
"/valves/type",
|
||||
summary="获取阀门类型",
|
||||
description="获取指定阀门的类型",
|
||||
)
|
||||
@@ -149,7 +149,7 @@ async def fastapi_get_valve_type(
|
||||
return ps["type"]
|
||||
|
||||
@router.get(
|
||||
"/getvalvesetting/",
|
||||
"/valves/setting",
|
||||
summary="获取阀门开度",
|
||||
description="获取指定阀门的开度/设置值",
|
||||
)
|
||||
@@ -166,7 +166,7 @@ async def fastapi_get_valve_setting(
|
||||
return ps["setting"]
|
||||
|
||||
@router.get(
|
||||
"/getvalveminorloss/",
|
||||
"/valves/minor-loss",
|
||||
summary="获取阀门损失系数",
|
||||
description="获取指定阀门的损失系数",
|
||||
)
|
||||
@@ -182,8 +182,8 @@ async def fastapi_get_valve_minor_loss(
|
||||
ps = get_valve(network, valve)
|
||||
return ps["minor_loss"]
|
||||
|
||||
@router.post(
|
||||
"/setvalvenode1/",
|
||||
@router.patch(
|
||||
"/valves/node1",
|
||||
response_model=None,
|
||||
summary="设置阀门起点节点",
|
||||
description="设置指定阀门的起点节点",
|
||||
@@ -201,8 +201,8 @@ async def fastapi_set_valve_node1(
|
||||
ps = {"id": valve, "node1": node1}
|
||||
return set_valve(network, ChangeSet(ps))
|
||||
|
||||
@router.post(
|
||||
"/setvalvenode2/",
|
||||
@router.patch(
|
||||
"/valves/node2",
|
||||
response_model=None,
|
||||
summary="设置阀门终点节点",
|
||||
description="设置指定阀门的终点节点",
|
||||
@@ -220,8 +220,8 @@ async def fastapi_set_valve_node2(
|
||||
ps = {"id": valve, "node2": node2}
|
||||
return set_valve(network, ChangeSet(ps))
|
||||
|
||||
@router.post(
|
||||
"/setvalvenodediameter/",
|
||||
@router.patch(
|
||||
"/valves/diameter",
|
||||
response_model=None,
|
||||
summary="设置阀门直径",
|
||||
description="设置指定阀门的直径",
|
||||
@@ -239,8 +239,8 @@ async def fastapi_set_valve_diameter(
|
||||
ps = {"id": valve, "diameter": diameter}
|
||||
return set_valve(network, ChangeSet(ps))
|
||||
|
||||
@router.post(
|
||||
"/setvalvetype/",
|
||||
@router.patch(
|
||||
"/valves/type",
|
||||
response_model=None,
|
||||
summary="设置阀门类型",
|
||||
description="设置指定阀门的类型",
|
||||
@@ -258,8 +258,8 @@ async def fastapi_set_valve_type(
|
||||
ps = {"id": valve, "type": type}
|
||||
return set_valve(network, ChangeSet(ps))
|
||||
|
||||
@router.post(
|
||||
"/setvalvesetting/",
|
||||
@router.patch(
|
||||
"/valves/setting",
|
||||
response_model=None,
|
||||
summary="设置阀门开度",
|
||||
description="设置指定阀门的开度/设置值",
|
||||
@@ -278,7 +278,7 @@ async def fastapi_set_valve_setting(
|
||||
return set_valve(network, ChangeSet(ps))
|
||||
|
||||
@router.get(
|
||||
"/getvalveproperties/",
|
||||
"/valves/properties",
|
||||
summary="获取阀门所有属性",
|
||||
description="获取指定阀门的所有属性",
|
||||
)
|
||||
@@ -294,7 +294,7 @@ async def fastapi_get_valve_properties(
|
||||
return get_valve(network, valve)
|
||||
|
||||
@router.get(
|
||||
"/getallvalveproperties/",
|
||||
"/valves",
|
||||
summary="获取所有阀门属性",
|
||||
description="获取指定水网中所有阀门的属性",
|
||||
)
|
||||
@@ -311,8 +311,8 @@ async def fastapi_get_all_valve_properties(
|
||||
results = get_all_valves(network)
|
||||
return results
|
||||
|
||||
@router.post(
|
||||
"/setvalveproperties/",
|
||||
@router.patch(
|
||||
"/valves/properties",
|
||||
response_model=None,
|
||||
summary="批量设置阀门属性",
|
||||
description="批量设置指定阀门的多个属性",
|
||||
|
||||
+30
-102
@@ -1,10 +1,14 @@
|
||||
import json
|
||||
from fastapi import APIRouter, Request, HTTPException, Query, Path, Body, Depends
|
||||
from fastapi import APIRouter, Request, HTTPException, Query, Path, Depends
|
||||
from fastapi.responses import PlainTextResponse
|
||||
from typing import Any, Dict, List
|
||||
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
|
||||
from app.auth.project_dependencies import get_metadata_repository
|
||||
from app.domain.schemas.metadata import ProjectMetaResponse, GeoServerConfigResponse
|
||||
from app.auth.permissions import (
|
||||
ENVIRONMENT_MANAGE,
|
||||
require_permission,
|
||||
)
|
||||
from app.domain.schemas.metadata import ProjectMetaResponse
|
||||
import app.services.project_info as project_info
|
||||
from app.infra.db.postgresql.database import get_database_instance as get_pg_db
|
||||
from app.infra.db.timescaledb.database import get_database_instance as get_ts_db
|
||||
@@ -18,7 +22,6 @@ from app.services.tjnetwork import (
|
||||
open_project,
|
||||
close_project,
|
||||
copy_project,
|
||||
import_inp,
|
||||
export_inp,
|
||||
read_inp,
|
||||
dump_inp,
|
||||
@@ -42,7 +45,7 @@ inpDir = "data/" # Assuming data directory exists or is defined somewhere.
|
||||
router = APIRouter()
|
||||
lockedPrjs: Dict[str, str] = {}
|
||||
|
||||
@router.get("/project_info/", summary="获取项目信息", description="从数据库获取项目的详细信息,包括地图范围等。", response_model=ProjectMetaResponse)
|
||||
@router.get("/projects/current", summary="获取项目信息", description="从数据库获取项目的详细信息,包括地图范围等。", response_model=ProjectMetaResponse)
|
||||
async def get_project_info_endpoint(
|
||||
network: str = Query(..., description="管网名称(或项目代码)"),
|
||||
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||
@@ -55,17 +58,6 @@ async def get_project_info_endpoint(
|
||||
project_detail = await metadata_repo.get_project_detail_by_code(network)
|
||||
if not project_detail:
|
||||
raise HTTPException(status_code=404, detail=f"Project {network} not found")
|
||||
|
||||
geoserver_payload = None
|
||||
if project_detail.geoserver:
|
||||
geoserver_payload = GeoServerConfigResponse(
|
||||
gs_base_url=project_detail.geoserver.gs_base_url,
|
||||
gs_admin_user=project_detail.geoserver.gs_admin_user,
|
||||
gs_datastore_name=project_detail.geoserver.gs_datastore_name,
|
||||
default_extent=project_detail.geoserver.default_extent,
|
||||
srid=project_detail.geoserver.srid,
|
||||
)
|
||||
|
||||
return ProjectMetaResponse(
|
||||
project_id=project_detail.project_id,
|
||||
name=project_detail.name,
|
||||
@@ -75,10 +67,9 @@ async def get_project_info_endpoint(
|
||||
map_extent=project_detail.map_extent,
|
||||
status=project_detail.status,
|
||||
project_role="viewer", # Default role for public access
|
||||
geoserver=geoserver_payload
|
||||
)
|
||||
|
||||
@router.get("/listprojects/", summary="获取项目列表", description="获取服务器上所有可用的供水管网项目名称列表。")
|
||||
@router.get("/project-codes", summary="获取项目列表", description="获取服务器上所有可用的供水管网项目名称列表。")
|
||||
async def list_projects_endpoint() -> list[str]:
|
||||
"""
|
||||
获取项目列表
|
||||
@@ -87,7 +78,7 @@ async def list_projects_endpoint() -> list[str]:
|
||||
"""
|
||||
return list_project()
|
||||
|
||||
@router.get("/haveproject/", summary="检查项目是否存在", description="检查指定名称的项目是否存在。")
|
||||
@router.get("/projects/existence", summary="检查项目是否存在", description="检查指定名称的项目是否存在。")
|
||||
async def have_project_endpoint(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)")
|
||||
):
|
||||
@@ -98,9 +89,10 @@ async def have_project_endpoint(
|
||||
"""
|
||||
return have_project(network)
|
||||
|
||||
@router.post("/createproject/", summary="创建新项目", description="创建一个新的供水管网项目。如果项目已存在,可能会覆盖或报错(取决于底层实现)。")
|
||||
@router.post("/projects", summary="创建新项目", description="创建一个新的供水管网项目。如果项目已存在,可能会覆盖或报错(取决于底层实现)。")
|
||||
async def create_project_endpoint(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)")
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
_=Depends(require_permission(ENVIRONMENT_MANAGE)),
|
||||
):
|
||||
"""
|
||||
创建新项目
|
||||
@@ -110,9 +102,10 @@ async def create_project_endpoint(
|
||||
create_project(network)
|
||||
return network
|
||||
|
||||
@router.post("/deleteproject/", summary="删除项目", description="永久删除指定的供水管网项目。此操作不可恢复。")
|
||||
@router.delete("/projects", summary="删除项目", description="永久删除指定的供水管网项目。此操作不可恢复。")
|
||||
async def delete_project_endpoint(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)")
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
_=Depends(require_permission(ENVIRONMENT_MANAGE)),
|
||||
):
|
||||
"""
|
||||
删除项目
|
||||
@@ -122,7 +115,7 @@ async def delete_project_endpoint(
|
||||
delete_project(network)
|
||||
return True
|
||||
|
||||
@router.get("/isprojectopen/", summary="检查项目是否已打开", description="检查指定项目是否已被加载到内存中。")
|
||||
@router.get("/projects/current/status", summary="检查项目是否已打开", description="检查指定项目是否已被加载到内存中。")
|
||||
async def is_project_open_endpoint(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)")
|
||||
):
|
||||
@@ -133,7 +126,7 @@ async def is_project_open_endpoint(
|
||||
"""
|
||||
return is_project_open(network)
|
||||
|
||||
@router.post("/openproject/", summary="打开项目", description="将指定项目加载到内存中,并初始化数据库连接池。")
|
||||
@router.post("/projects/current", summary="打开项目", description="将指定项目加载到内存中,并初始化数据库连接池。")
|
||||
async def open_project_endpoint(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)")
|
||||
):
|
||||
@@ -167,7 +160,7 @@ async def open_project_endpoint(
|
||||
|
||||
return network
|
||||
|
||||
@router.post("/closeproject/", summary="关闭项目", description="将指定项目从内存中卸载,释放资源。")
|
||||
@router.delete("/projects/current", summary="关闭项目", description="将指定项目从内存中卸载,释放资源。")
|
||||
async def close_project_endpoint(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)")
|
||||
):
|
||||
@@ -179,10 +172,11 @@ async def close_project_endpoint(
|
||||
close_project(network)
|
||||
return True
|
||||
|
||||
@router.post("/copyproject/", summary="复制项目", description="将现有项目复制为新项目。")
|
||||
@router.post("/project-copies", summary="复制项目", description="将现有项目复制为新项目。")
|
||||
async def copy_project_endpoint(
|
||||
source: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
target: str = Query(..., description="管网名称(或数据库名称)")
|
||||
target: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
_=Depends(require_permission(ENVIRONMENT_MANAGE)),
|
||||
):
|
||||
"""
|
||||
复制项目
|
||||
@@ -193,25 +187,7 @@ async def copy_project_endpoint(
|
||||
copy_project(source, target)
|
||||
return True
|
||||
|
||||
@router.post("/importinp/", summary="导入 INP 文件内容", description="将 INP 格式的文本内容导入到指定项目中。")
|
||||
async def import_inp_endpoint(
|
||||
req: Request,
|
||||
network: str = Query(..., description="管网名称(或数据库名称)")
|
||||
):
|
||||
"""
|
||||
导入 INP 文件内容
|
||||
|
||||
- **network**: 管网名称(或数据库名称)
|
||||
- **req**: 请求体,需包含 `{"inp": "..."}` 结构
|
||||
"""
|
||||
jo_root = await req.json()
|
||||
inp_text = jo_root["inp"]
|
||||
ps = {"inp": inp_text}
|
||||
ret = import_inp(network, ChangeSet(ps))
|
||||
print(ret)
|
||||
return ret
|
||||
|
||||
@router.get("/exportinp/", response_model=None, summary="导出项目为 ChangeSet", description="导出项目的变更集 (ChangeSet),包含顶点、SCADA 元素、DMA、SA、VD 等信息。")
|
||||
@router.get("/projects/current/exports/change-set", response_model=None, summary="导出项目为 ChangeSet", description="导出项目的变更集 (ChangeSet),包含顶点、SCADA 元素、DMA、SA、VD 等信息。")
|
||||
async def export_inp_endpoint(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
version: str = Query(..., description="版本号 (通常用于增量更新)")
|
||||
@@ -244,7 +220,7 @@ async def export_inp_endpoint(
|
||||
|
||||
return cs
|
||||
|
||||
@router.post("/readinp/", summary="读取 INP 文件到项目", description="从服务器文件系统中读取指定的 INP 文件并加载到项目中。")
|
||||
@router.post("/projects/current/imports", summary="读取 INP 文件到项目", description="从服务器文件系统中读取指定的 INP 文件并加载到项目中。")
|
||||
async def read_inp_endpoint(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
inp: str = Query(..., description="INP 文件名 (不包含路径)")
|
||||
@@ -258,7 +234,7 @@ async def read_inp_endpoint(
|
||||
read_inp(network, inp)
|
||||
return True
|
||||
|
||||
@router.get("/dumpinp/", summary="导出项目到 INP 文件", description="将项目当前状态保存为 INP 文件到服务器文件系统。")
|
||||
@router.post("/projects/current/exports/inp", summary="导出项目到 INP 文件", description="将项目当前状态保存为 INP 文件到服务器文件系统。")
|
||||
async def dump_inp_endpoint(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
inp: str = Query(..., description="目标文件名")
|
||||
@@ -272,7 +248,7 @@ async def dump_inp_endpoint(
|
||||
dump_inp(network, inp)
|
||||
return True
|
||||
|
||||
@router.get("/isprojectlocked/", summary="检查项目是否被锁定", description="检查指定项目是否处于锁定状态。")
|
||||
@router.get("/projects/current/lock", summary="检查项目是否被锁定", description="检查指定项目是否处于锁定状态。")
|
||||
async def is_project_locked_endpoint(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
req: Request = None
|
||||
@@ -284,7 +260,7 @@ async def is_project_locked_endpoint(
|
||||
"""
|
||||
return network in lockedPrjs.keys()
|
||||
|
||||
@router.get("/isprojectlockedbyme/", summary="检查项目是否被当前用户锁定", description="检查指定项目是否被当前客户端 (IP) 锁定。")
|
||||
@router.get("/projects/current/lock/ownership", summary="检查项目是否被当前用户锁定", description="检查指定项目是否被当前客户端 (IP) 锁定。")
|
||||
async def is_project_locked_by_me_endpoint(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
req: Request = None
|
||||
@@ -300,7 +276,7 @@ async def is_project_locked_by_me_endpoint(
|
||||
# 0 successfully locked
|
||||
# 1 already locked by you
|
||||
# 2 locked by others
|
||||
@router.post("/lockproject/", summary="锁定项目", description="锁定指定项目以防止并发修改。")
|
||||
@router.post("/projects/current/lock", summary="锁定项目", description="锁定指定项目以防止并发修改。")
|
||||
async def lock_project_endpoint(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
req: Request = None
|
||||
@@ -323,7 +299,7 @@ async def lock_project_endpoint(
|
||||
else:
|
||||
return 2
|
||||
|
||||
@router.post("/unlockproject/", summary="解锁项目", description="释放对项目的锁定。")
|
||||
@router.delete("/projects/current/lock", summary="解锁项目", description="释放对项目的锁定。")
|
||||
def unlock_project_endpoint(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
req: Request = None
|
||||
@@ -341,27 +317,7 @@ def unlock_project_endpoint(
|
||||
|
||||
return False
|
||||
|
||||
# inp file operations
|
||||
@router.post("/uploadinp/", status_code=status.HTTP_200_OK, summary="上传 INP 文件", description="上传 INP 文件到服务器数据目录。")
|
||||
async def fastapi_upload_inp(
|
||||
afile: bytes = Body(..., description="文件二进制内容"),
|
||||
name: str = Query(..., description="保存的文件名")
|
||||
):
|
||||
"""
|
||||
上传 INP 文件
|
||||
|
||||
- **afile**: 文件内容
|
||||
- **name**: 文件名
|
||||
"""
|
||||
if not os.path.exists(inpDir):
|
||||
os.makedirs(inpDir, exist_ok=True)
|
||||
|
||||
filePath = inpDir + str(name)
|
||||
with open(filePath, "wb") as f:
|
||||
f.write(afile)
|
||||
return True
|
||||
|
||||
@router.get("/downloadinp/", status_code=status.HTTP_200_OK, summary="下载 INP 文件", description="从服务器数据目录下载指定的 INP 文件。")
|
||||
@router.get("/projects/current/files/inp", status_code=status.HTTP_200_OK, summary="下载 INP 文件", description="从服务器数据目录下载指定的 INP 文件。")
|
||||
async def fastapi_download_inp(
|
||||
name: str = Query(..., description="文件名"),
|
||||
response: Response = None
|
||||
@@ -381,7 +337,7 @@ async def fastapi_download_inp(
|
||||
return True
|
||||
|
||||
# DingZQ, 2024-12-28, convert v3 to v2
|
||||
@router.get("/convertv3tov2/", response_model=None, summary="转换 INP V3 为 V2", description="将 EPANET 3.0 格式的 INP 内容转换为 2.x 格式。")
|
||||
@router.post("/project-conversions", response_model=None, summary="转换 INP V3 为 V2", description="将 EPANET 3.0 格式的 INP 内容转换为 2.x 格式。")
|
||||
async def fastapi_convert_v3_to_v2(
|
||||
req: Request
|
||||
) -> ChangeSet:
|
||||
@@ -415,7 +371,6 @@ async def fastapi_convert_v3_to_v2(
|
||||
|
||||
return cs
|
||||
|
||||
@router.post("/readinp/", summary="读取 INP 文件到项目", description="从服务器文件系统中读取指定的 INP 文件并加载到项目中。")
|
||||
async def read_inp_endpoint(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
inp: str = Query(..., description="INP 文件名 (不包含路径)")
|
||||
@@ -429,7 +384,6 @@ async def read_inp_endpoint(
|
||||
read_inp(network, inp)
|
||||
return True
|
||||
|
||||
@router.get("/dumpinp/", summary="导出项目到 INP 文件", description="将项目当前状态保存为 INP 文件到服务器文件系统。")
|
||||
async def dump_inp_endpoint(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
inp: str = Query(..., description="目标文件名")
|
||||
@@ -443,7 +397,6 @@ async def dump_inp_endpoint(
|
||||
dump_inp(network, inp)
|
||||
return True
|
||||
|
||||
@router.get("/isprojectlocked/", summary="检查项目是否被锁定", description="检查指定项目是否处于锁定状态。")
|
||||
async def is_project_locked_endpoint(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
req: Request = None
|
||||
@@ -455,7 +408,6 @@ async def is_project_locked_endpoint(
|
||||
"""
|
||||
return network in lockedPrjs.keys()
|
||||
|
||||
@router.get("/isprojectlockedbyme/", summary="检查项目是否被当前用户锁定", description="检查指定项目是否被当前客户端 (IP) 锁定。")
|
||||
async def is_project_locked_by_me_endpoint(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
req: Request = None
|
||||
@@ -471,7 +423,6 @@ async def is_project_locked_by_me_endpoint(
|
||||
# 0 successfully locked
|
||||
# 1 already locked by you
|
||||
# 2 locked by others
|
||||
@router.post("/lockproject/", summary="锁定项目", description="锁定指定项目以防止并发修改。")
|
||||
async def lock_project_endpoint(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
req: Request = None
|
||||
@@ -494,7 +445,6 @@ async def lock_project_endpoint(
|
||||
else:
|
||||
return 2
|
||||
|
||||
@router.post("/unlockproject/", summary="解锁项目", description="释放对项目的锁定。")
|
||||
def unlock_project_endpoint(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
req: Request = None
|
||||
@@ -512,27 +462,6 @@ def unlock_project_endpoint(
|
||||
|
||||
return False
|
||||
|
||||
# inp file operations
|
||||
@router.post("/uploadinp/", status_code=status.HTTP_200_OK, summary="上传 INP 文件", description="上传 INP 文件到服务器数据目录。")
|
||||
async def fastapi_upload_inp(
|
||||
afile: bytes = Body(..., description="文件二进制内容"),
|
||||
name: str = Query(..., description="保存的文件名")
|
||||
):
|
||||
"""
|
||||
上传 INP 文件
|
||||
|
||||
- **afile**: 文件内容
|
||||
- **name**: 文件名
|
||||
"""
|
||||
if not os.path.exists(inpDir):
|
||||
os.makedirs(inpDir, exist_ok=True)
|
||||
|
||||
filePath = inpDir + str(name)
|
||||
with open(filePath, "wb") as f:
|
||||
f.write(afile)
|
||||
return True
|
||||
|
||||
@router.get("/downloadinp/", status_code=status.HTTP_200_OK, summary="下载 INP 文件", description="从服务器数据目录下载指定的 INP 文件。")
|
||||
async def fastapi_download_inp(
|
||||
name: str = Query(..., description="文件名"),
|
||||
response: Response = None
|
||||
@@ -552,7 +481,6 @@ async def fastapi_download_inp(
|
||||
return True
|
||||
|
||||
# DingZQ, 2024-12-28, convert v3 to v2
|
||||
@router.get("/convertv3tov2/", response_model=None, summary="转换 INP V3 为 V2", description="将 EPANET 3.0 格式的 INP 内容转换为 2.x 格式。")
|
||||
async def fastapi_convert_v3_to_v2(
|
||||
req: Request
|
||||
) -> ChangeSet:
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query
|
||||
from psycopg import AsyncConnection
|
||||
|
||||
import app.native.wndb as wndb
|
||||
from app.infra.db.postgresql.scada import ScadaInfoRepository
|
||||
from app.infra.db.postgresql.scheme import SchemeRepository
|
||||
from app.auth.project_dependencies import get_project_pg_connection
|
||||
from app.services import project_info
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -16,7 +15,7 @@ async def get_database_connection(
|
||||
yield conn
|
||||
|
||||
|
||||
@router.get("/scada-info", summary="获取SCADA信息", description="使用连接池查询所有SCADA信息")
|
||||
@router.get("/scada-info/database-view", summary="获取SCADA信息", description="使用连接池查询所有SCADA信息")
|
||||
async def get_scada_info_with_connection(
|
||||
conn: AsyncConnection = Depends(get_database_connection),
|
||||
):
|
||||
@@ -26,9 +25,7 @@ async def get_scada_info_with_connection(
|
||||
返回项目中所有的SCADA设备信息
|
||||
"""
|
||||
try:
|
||||
_ = conn
|
||||
network_name = project_info.name
|
||||
scada_data = wndb.get_all_scada_info(network_name) if network_name else []
|
||||
scada_data = await ScadaInfoRepository.get_scadas(conn)
|
||||
return {"success": True, "data": scada_data, "count": len(scada_data)}
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
@@ -36,7 +33,7 @@ async def get_scada_info_with_connection(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/scheme-list", summary="获取方案列表", description="使用连接池查询所有方案信息")
|
||||
@router.get("/schemes/list-with-connection", summary="获取方案列表", description="使用连接池查询所有方案信息")
|
||||
async def get_scheme_list_with_connection(
|
||||
conn: AsyncConnection = Depends(get_database_connection),
|
||||
):
|
||||
@@ -52,7 +49,7 @@ async def get_scheme_list_with_connection(
|
||||
raise HTTPException(status_code=500, detail=f"查询方案信息时发生错误: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/burst-locate-result", summary="获取爆管定位结果", description="使用连接池查询所有爆管定位结果")
|
||||
@router.get("/burst-locations/database-view", summary="获取爆管定位结果", description="使用连接池查询所有爆管定位结果")
|
||||
async def get_burst_locate_result_with_connection(
|
||||
conn: AsyncConnection = Depends(get_database_connection),
|
||||
):
|
||||
@@ -70,7 +67,7 @@ async def get_burst_locate_result_with_connection(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/burst-locate-result/{burst_incident}", summary="按事件查询爆管定位结果", description="根据爆管事件ID查询对应的爆管定位结果")
|
||||
@router.get("/burst-locations/{burst_incident}", summary="按事件查询爆管定位结果", description="根据爆管事件ID查询对应的爆管定位结果")
|
||||
async def get_burst_locate_result_by_incident(
|
||||
burst_incident: str = Path(..., description="爆管事件ID"),
|
||||
conn: AsyncConnection = Depends(get_database_connection),
|
||||
|
||||
@@ -11,7 +11,7 @@ from app.services.tjnetwork import (
|
||||
router = APIRouter()
|
||||
|
||||
@router.get(
|
||||
"/getpiperiskprobabilitynow/",
|
||||
"/pipes/risk-probability-now",
|
||||
summary="获取管道当前风险概率",
|
||||
description="获取指定管道当前时刻的风险概率值"
|
||||
)
|
||||
@@ -35,7 +35,7 @@ async def fastapi_get_pipe_risk_probability_now(
|
||||
|
||||
|
||||
@router.get(
|
||||
"/getpiperiskprobability/",
|
||||
"/pipes/risk-probability",
|
||||
summary="获取管道风险概率历史",
|
||||
description="获取指定管道的风险概率历史数据"
|
||||
)
|
||||
@@ -59,7 +59,7 @@ async def fastapi_get_pipe_risk_probability(
|
||||
|
||||
|
||||
@router.get(
|
||||
"/getpipesriskprobability/",
|
||||
"/pipes-risk-probabilities",
|
||||
summary="批量获取多条管道风险概率",
|
||||
description="批量获取多条管道的风险概率值"
|
||||
)
|
||||
@@ -84,7 +84,7 @@ async def fastapi_get_pipes_risk_probability(
|
||||
|
||||
|
||||
@router.get(
|
||||
"/getnetworkpiperiskprobabilitynow/",
|
||||
"/network-pipe-risk-probability-nows",
|
||||
summary="获取整个网络的管道风险概率",
|
||||
description="获取指定网络中所有管道的当前风险概率值"
|
||||
)
|
||||
@@ -106,7 +106,7 @@ async def fastapi_get_network_pipe_risk_probability_now(
|
||||
|
||||
|
||||
@router.get(
|
||||
"/getpiperiskprobabilitygeometries/",
|
||||
"/pipes/risk-probability-geometries",
|
||||
summary="获取管道风险几何信息",
|
||||
description="获取指定网络中管道的风险相关几何数据"
|
||||
)
|
||||
|
||||
@@ -31,7 +31,6 @@ from app.services.tjnetwork import (
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/getscadaproperties/", summary="获取SCADA属性", tags=["SCADA基础"])
|
||||
async def fast_get_scada_properties(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
scada: str = Query(..., description="SCADA设备ID")
|
||||
@@ -50,7 +49,6 @@ async def fast_get_scada_properties(
|
||||
"""
|
||||
return get_scada_info(network, scada)
|
||||
|
||||
@router.get("/getallscadaproperties/", summary="获取所有SCADA属性", tags=["SCADA基础"])
|
||||
async def fast_get_all_scada_properties(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)")
|
||||
) -> list[dict[str, Any]]:
|
||||
@@ -72,7 +70,7 @@ async def fast_get_all_scada_properties(
|
||||
# scada_device 设备管理
|
||||
############################################################
|
||||
|
||||
@router.get("/getscadadeviceschema/", summary="获取SCADA设备架构", tags=["SCADA设备"])
|
||||
@router.get("/network-schemas/scada-device", summary="获取SCADA设备架构", tags=["SCADA设备"])
|
||||
async def fastapi_get_scada_device_schema(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)")
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
@@ -89,7 +87,7 @@ async def fastapi_get_scada_device_schema(
|
||||
"""
|
||||
return get_scada_device_schema(network)
|
||||
|
||||
@router.get("/getscadadevice/", summary="获取SCADA设备", tags=["SCADA设备"])
|
||||
@router.get("/scada-devices/detail", summary="获取SCADA设备", tags=["SCADA设备"])
|
||||
async def fastapi_get_scada_device(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
id: str = Query(..., description="SCADA设备ID")
|
||||
@@ -108,7 +106,7 @@ async def fastapi_get_scada_device(
|
||||
"""
|
||||
return get_scada_device(network, id)
|
||||
|
||||
@router.post("/setscadadevice/", response_model=None, summary="更新SCADA设备", tags=["SCADA设备"])
|
||||
@router.patch("/scada-devices", response_model=None, summary="更新SCADA设备", tags=["SCADA设备"])
|
||||
async def fastapi_set_scada_device(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
req: Request = None
|
||||
@@ -128,7 +126,7 @@ async def fastapi_set_scada_device(
|
||||
props = await req.json()
|
||||
return set_scada_device(network, ChangeSet(props))
|
||||
|
||||
@router.post("/addscadadevice/", response_model=None, summary="添加SCADA设备", tags=["SCADA设备"])
|
||||
@router.post("/scada-devices", response_model=None, summary="添加SCADA设备", tags=["SCADA设备"])
|
||||
async def fastapi_add_scada_device(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
req: Request = None
|
||||
@@ -148,7 +146,7 @@ async def fastapi_add_scada_device(
|
||||
props = await req.json()
|
||||
return add_scada_device(network, ChangeSet(props))
|
||||
|
||||
@router.post("/deletescadadevice/", response_model=None, summary="删除SCADA设备", tags=["SCADA设备"])
|
||||
@router.delete("/scada-devices", response_model=None, summary="删除SCADA设备", tags=["SCADA设备"])
|
||||
async def fastapi_delete_scada_device(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
req: Request = None
|
||||
@@ -168,7 +166,7 @@ async def fastapi_delete_scada_device(
|
||||
props = await req.json()
|
||||
return delete_scada_device(network, ChangeSet(props))
|
||||
|
||||
@router.post("/cleanscadadevice/", response_model=None, summary="清空SCADA设备表", tags=["SCADA设备"])
|
||||
@router.post("/scada-device-cleaning-runs", response_model=None, summary="清空SCADA设备表", tags=["SCADA设备"])
|
||||
async def fastapi_clean_scada_device(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)")
|
||||
) -> ChangeSet:
|
||||
@@ -185,7 +183,7 @@ async def fastapi_clean_scada_device(
|
||||
"""
|
||||
return clean_scada_device(network)
|
||||
|
||||
@router.get("/getallscadadeviceids/", summary="获取所有SCADA设备ID", tags=["SCADA设备"])
|
||||
@router.get("/scada-devices/ids", summary="获取所有SCADA设备ID", tags=["SCADA设备"])
|
||||
async def fastapi_get_all_scada_device_ids(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)")
|
||||
) -> list[str]:
|
||||
@@ -200,7 +198,7 @@ async def fastapi_get_all_scada_device_ids(
|
||||
"""
|
||||
return get_all_scada_device_ids(network)
|
||||
|
||||
@router.get("/getallscadadevices/", summary="获取所有SCADA设备", tags=["SCADA设备"])
|
||||
@router.get("/scada-devices", summary="获取所有SCADA设备", tags=["SCADA设备"])
|
||||
async def fastapi_get_all_scada_devices(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)")
|
||||
) -> list[dict[str, Any]]:
|
||||
@@ -220,7 +218,7 @@ async def fastapi_get_all_scada_devices(
|
||||
# scada_device_data 设备数据管理
|
||||
############################################################
|
||||
|
||||
@router.get("/getscadadevicedataschema/", summary="获取SCADA设备数据架构", tags=["SCADA设备数据"])
|
||||
@router.get("/network-schemas/scada-device-data", summary="获取SCADA设备数据架构", tags=["SCADA设备数据"])
|
||||
async def fastapi_get_scada_device_data_schema(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
@@ -237,7 +235,7 @@ async def fastapi_get_scada_device_data_schema(
|
||||
"""
|
||||
return get_scada_device_data_schema(network)
|
||||
|
||||
@router.get("/getscadadevicedata/", summary="获取SCADA设备数据", tags=["SCADA设备数据"])
|
||||
@router.get("/scada-device-datas/detail", summary="获取SCADA设备数据", tags=["SCADA设备数据"])
|
||||
async def fastapi_get_scada_device_data(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
device_id: str = Query(..., description="SCADA设备ID")
|
||||
@@ -256,7 +254,7 @@ async def fastapi_get_scada_device_data(
|
||||
"""
|
||||
return get_scada_device_data(network, device_id)
|
||||
|
||||
@router.post("/setscadadevicedata/", response_model=None, summary="更新SCADA设备数据", tags=["SCADA设备数据"])
|
||||
@router.patch("/scada-device-datas", response_model=None, summary="更新SCADA设备数据", tags=["SCADA设备数据"])
|
||||
async def fastapi_set_scada_device_data(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
req: Request = None
|
||||
@@ -276,7 +274,7 @@ async def fastapi_set_scada_device_data(
|
||||
props = await req.json()
|
||||
return set_scada_device_data(network, ChangeSet(props))
|
||||
|
||||
@router.post("/addscadadevicedata/", response_model=None, summary="添加SCADA设备数据", tags=["SCADA设备数据"])
|
||||
@router.post("/scada-device-datas", response_model=None, summary="添加SCADA设备数据", tags=["SCADA设备数据"])
|
||||
async def fastapi_add_scada_device_data(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
req: Request = None
|
||||
@@ -296,7 +294,7 @@ async def fastapi_add_scada_device_data(
|
||||
props = await req.json()
|
||||
return add_scada_device_data(network, ChangeSet(props))
|
||||
|
||||
@router.post("/deletescadadevicedata/", response_model=None, summary="删除SCADA设备数据", tags=["SCADA设备数据"])
|
||||
@router.delete("/scada-device-datas", response_model=None, summary="删除SCADA设备数据", tags=["SCADA设备数据"])
|
||||
async def fastapi_delete_scada_device_data(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
req: Request = None
|
||||
@@ -316,7 +314,7 @@ async def fastapi_delete_scada_device_data(
|
||||
props = await req.json()
|
||||
return delete_scada_device_data(network, ChangeSet(props))
|
||||
|
||||
@router.post("/cleanscadadevicedata/", response_model=None, summary="清空SCADA设备数据表", tags=["SCADA设备数据"])
|
||||
@router.post("/scada-device-data-cleaning-runs", response_model=None, summary="清空SCADA设备数据表", tags=["SCADA设备数据"])
|
||||
async def fastapi_clean_scada_device_data(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)")
|
||||
) -> ChangeSet:
|
||||
@@ -338,7 +336,7 @@ async def fastapi_clean_scada_device_data(
|
||||
# scada_element SCADA元素映射
|
||||
############################################################
|
||||
|
||||
@router.get("/getscadaelementschema/", summary="获取SCADA元素架构", tags=["SCADA元素映射"])
|
||||
@router.get("/network-schemas/scada-element", summary="获取SCADA元素架构", tags=["SCADA元素映射"])
|
||||
async def fastapi_get_scada_element_schema(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
@@ -355,7 +353,7 @@ async def fastapi_get_scada_element_schema(
|
||||
"""
|
||||
return get_scada_element_schema(network)
|
||||
|
||||
@router.get("/getscadaelements/", summary="获取所有SCADA元素映射", tags=["SCADA元素映射"])
|
||||
@router.get("/scada-elements", summary="获取所有SCADA元素映射", tags=["SCADA元素映射"])
|
||||
async def fastapi_get_scada_elements(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)")
|
||||
) -> list[dict[str, Any]]:
|
||||
@@ -372,7 +370,7 @@ async def fastapi_get_scada_elements(
|
||||
"""
|
||||
return get_all_scada_elements(network)
|
||||
|
||||
@router.get("/getscadaelement/", summary="获取单个SCADA元素映射", tags=["SCADA元素映射"])
|
||||
@router.get("/scada-elements/detail", summary="获取单个SCADA元素映射", tags=["SCADA元素映射"])
|
||||
async def fastapi_get_scada_element(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
id: str = Query(..., description="SCADA元素映射ID")
|
||||
@@ -391,7 +389,7 @@ async def fastapi_get_scada_element(
|
||||
"""
|
||||
return get_scada_element(network, id)
|
||||
|
||||
@router.post("/setscadaelement/", response_model=None, summary="更新SCADA元素映射", tags=["SCADA元素映射"])
|
||||
@router.patch("/scada-elements", response_model=None, summary="更新SCADA元素映射", tags=["SCADA元素映射"])
|
||||
async def fastapi_set_scada_element(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
req: Request = None
|
||||
@@ -411,7 +409,7 @@ async def fastapi_set_scada_element(
|
||||
props = await req.json()
|
||||
return set_scada_element(network, ChangeSet(props))
|
||||
|
||||
@router.post("/addscadaelement/", response_model=None, summary="添加SCADA元素映射", tags=["SCADA元素映射"])
|
||||
@router.post("/scada-elements", response_model=None, summary="添加SCADA元素映射", tags=["SCADA元素映射"])
|
||||
async def fastapi_add_scada_element(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
req: Request = None
|
||||
@@ -431,7 +429,7 @@ async def fastapi_add_scada_element(
|
||||
props = await req.json()
|
||||
return add_scada_element(network, ChangeSet(props))
|
||||
|
||||
@router.post("/deletescadaelement/", response_model=None, summary="删除SCADA元素映射", tags=["SCADA元素映射"])
|
||||
@router.delete("/scada-elements", response_model=None, summary="删除SCADA元素映射", tags=["SCADA元素映射"])
|
||||
async def fastapi_delete_scada_element(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
req: Request = None
|
||||
@@ -451,7 +449,7 @@ async def fastapi_delete_scada_element(
|
||||
props = await req.json()
|
||||
return delete_scada_element(network, ChangeSet(props))
|
||||
|
||||
@router.post("/cleanscadaelement/", response_model=None, summary="清空SCADA元素映射表", tags=["SCADA元素映射"])
|
||||
@router.post("/scada-element-cleaning-runs", response_model=None, summary="清空SCADA元素映射表", tags=["SCADA元素映射"])
|
||||
async def fastapi_clean_scada_element(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)")
|
||||
) -> ChangeSet:
|
||||
@@ -473,7 +471,7 @@ async def fastapi_clean_scada_element(
|
||||
# scada_info SCADA信息
|
||||
############################################################
|
||||
|
||||
@router.get("/getscadainfoschema/", summary="获取SCADA信息架构", tags=["SCADA信息"])
|
||||
@router.get("/scada-info-schemas", summary="获取SCADA信息架构", tags=["SCADA信息"])
|
||||
async def fastapi_get_scada_info_schema(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)")
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
@@ -490,7 +488,7 @@ async def fastapi_get_scada_info_schema(
|
||||
"""
|
||||
return get_scada_info_schema(network)
|
||||
|
||||
@router.get("/getscadainfo/", summary="获取SCADA信息", tags=["SCADA信息"])
|
||||
@router.get("/scada-info/detail", summary="获取SCADA信息", tags=["SCADA信息"])
|
||||
async def fastapi_get_scada_info(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
id: str = Query(..., description="SCADA信息ID")
|
||||
@@ -509,7 +507,7 @@ async def fastapi_get_scada_info(
|
||||
"""
|
||||
return get_scada_info(network, id)
|
||||
|
||||
@router.get("/getallscadainfo/", summary="获取所有SCADA信息", tags=["SCADA信息"])
|
||||
@router.get("/scada-info", summary="获取所有SCADA信息", tags=["SCADA信息"])
|
||||
async def fastapi_get_all_scada_info(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)")
|
||||
) -> list[dict[str, Any]]:
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
from fastapi import APIRouter, Query
|
||||
from typing import Any, List, Dict
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, HTTPException, Path, Query
|
||||
from typing import Any
|
||||
from app.services.tjnetwork import get_scheme_schema, get_scheme, get_all_schemes
|
||||
from app.services.scheme_management import query_scheme_detail
|
||||
from app.services.time_api import extract_date
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/getschemeschema/", summary="获取方案模式", description="获取指定网络的方案模式定义")
|
||||
@router.get("/network-schemas/scheme", summary="获取方案模式", description="获取指定网络的方案模式定义")
|
||||
async def fastapi_get_scheme_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[Any, Any]]:
|
||||
"""
|
||||
获取方案模式定义
|
||||
@@ -13,7 +16,7 @@ async def fastapi_get_scheme_schema(network: str = Query(..., description="管
|
||||
"""
|
||||
return get_scheme_schema(network)
|
||||
|
||||
@router.get("/getscheme/", summary="获取单个方案", description="根据名称获取指定的方案信息")
|
||||
@router.get("/schemes/detail", summary="获取单个方案", description="根据名称获取指定的方案信息")
|
||||
async def fastapi_get_scheme(network: str = Query(..., description="管网名称(或数据库名称)"), schema_name: str = Query(..., description="方案名称")) -> dict[Any, Any]:
|
||||
"""
|
||||
获取单个方案详情
|
||||
@@ -22,11 +25,36 @@ async def fastapi_get_scheme(network: str = Query(..., description="管网名称
|
||||
"""
|
||||
return get_scheme(network, schema_name)
|
||||
|
||||
@router.get("/getallschemes/", summary="获取所有方案", description="获取指定网络的所有方案信息")
|
||||
async def fastapi_get_all_schemes(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[dict[Any, Any]]:
|
||||
@router.get("/schemes", summary="获取所有方案", description="获取指定网络的所有方案信息")
|
||||
async def fastapi_get_all_schemes(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
scheme_type: str | None = Query(None, description="方案类型;为空时返回全部类型"),
|
||||
query_date: datetime | None = Query(None, description="查询日期(可选)"),
|
||||
) -> list[dict[Any, Any]]:
|
||||
"""
|
||||
获取所有方案列表
|
||||
|
||||
返回指定网络中所有可用的方案
|
||||
"""
|
||||
return get_all_schemes(network)
|
||||
parsed_date = (
|
||||
extract_date(query_date, field_name="query_date")
|
||||
if query_date is not None
|
||||
else None
|
||||
)
|
||||
return get_all_schemes(network, scheme_type=scheme_type, query_date=parsed_date)
|
||||
|
||||
|
||||
@router.get("/schemes/{scheme_name}", summary="获取方案详情", description="按方案类型获取指定方案详情")
|
||||
async def fastapi_get_scheme_detail(
|
||||
scheme_name: str = Path(..., description="方案名称"),
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
scheme_type: str | None = Query(None, description="方案类型;为空时返回通用方案详情"),
|
||||
) -> dict[Any, Any]:
|
||||
result = query_scheme_detail(
|
||||
name=network,
|
||||
scheme_name=scheme_name,
|
||||
scheme_type=scheme_type,
|
||||
)
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail=f"Scheme {scheme_name} not found")
|
||||
return result
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from app.algorithms.sensor import (
|
||||
pressure_sensor_placement_kmeans,
|
||||
pressure_sensor_placement_sensitivity,
|
||||
)
|
||||
from app.auth.metadata_dependencies import get_current_metadata_user
|
||||
from app.auth.project_dependencies import ProjectContext, get_project_context
|
||||
from app.domain.schemas.sensor_placement import (
|
||||
SensorPlacementExportRequest,
|
||||
SensorPlacementOptimizeRequest,
|
||||
SensorPlacementSchemeResponse,
|
||||
SensorPlacementUpdateRequest,
|
||||
)
|
||||
from app.services.sensor_placement import (
|
||||
SensorPlacementConflictError,
|
||||
SensorPlacementNotFoundError,
|
||||
SensorPlacementValidationError,
|
||||
build_sensor_placement_workbook,
|
||||
can_edit_sensor_placement,
|
||||
get_sensor_placement_scheme,
|
||||
update_sensor_placement_scheme,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _project_network(network: str, project_context: ProjectContext) -> str:
|
||||
if network != project_context.project_code:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="请求的管网不属于当前项目",
|
||||
)
|
||||
return project_context.project_code
|
||||
|
||||
|
||||
def _can_modify_project(project_context: ProjectContext) -> bool:
|
||||
return project_context.project_role == "member"
|
||||
|
||||
|
||||
def _require_project_write(
|
||||
project_context: ProjectContext,
|
||||
) -> None:
|
||||
if not _can_modify_project(project_context):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="当前项目角色为只读,不能修改监测点方案",
|
||||
)
|
||||
|
||||
|
||||
def _service_http_error(exc: Exception) -> HTTPException:
|
||||
if isinstance(exc, SensorPlacementNotFoundError):
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(exc),
|
||||
)
|
||||
if isinstance(exc, SensorPlacementConflictError):
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=str(exc),
|
||||
)
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=str(exc),
|
||||
)
|
||||
|
||||
|
||||
def _get_scheme_response(
|
||||
network: str,
|
||||
scheme_id: int,
|
||||
current_user: Any,
|
||||
project_context: ProjectContext,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
scheme = get_sensor_placement_scheme(network, scheme_id)
|
||||
return {
|
||||
**scheme,
|
||||
"can_edit": (
|
||||
_can_modify_project(project_context)
|
||||
and can_edit_sensor_placement(current_user, scheme)
|
||||
),
|
||||
}
|
||||
except (
|
||||
SensorPlacementNotFoundError,
|
||||
SensorPlacementValidationError,
|
||||
) as exc:
|
||||
raise _service_http_error(exc) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sensor-placement-optimization-runs",
|
||||
response_model=SensorPlacementSchemeResponse,
|
||||
summary="创建并返回监测点优化方案",
|
||||
)
|
||||
async def optimize_sensor_placement_scheme(
|
||||
payload: SensorPlacementOptimizeRequest,
|
||||
project_context: ProjectContext = Depends(get_project_context),
|
||||
current_user=Depends(get_current_metadata_user),
|
||||
) -> dict[str, Any]:
|
||||
network = _project_network(payload.network, project_context)
|
||||
_require_project_write(project_context)
|
||||
optimizer = (
|
||||
pressure_sensor_placement_sensitivity
|
||||
if payload.method == "sensitivity"
|
||||
else pressure_sensor_placement_kmeans
|
||||
)
|
||||
try:
|
||||
created = await run_in_threadpool(
|
||||
optimizer,
|
||||
name=network,
|
||||
scheme_name=payload.scheme_name,
|
||||
sensor_number=payload.sensor_count,
|
||||
min_diameter=payload.min_diameter,
|
||||
username=current_user.username,
|
||||
)
|
||||
scheme = get_sensor_placement_scheme(network, int(created["id"]))
|
||||
return {**scheme, "can_edit": True}
|
||||
except (
|
||||
SensorPlacementConflictError,
|
||||
SensorPlacementValidationError,
|
||||
ValueError,
|
||||
) as exc:
|
||||
raise _service_http_error(exc) from exc
|
||||
except Exception as exc:
|
||||
logger.exception("Sensor placement optimization failed")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="监测点优化失败,请稍后重试",
|
||||
) from exc
|
||||
|
||||
|
||||
@router.get(
|
||||
"/sensor-placement-schemes/{scheme_id}",
|
||||
response_model=SensorPlacementSchemeResponse,
|
||||
summary="获取监测点方案详情",
|
||||
)
|
||||
async def get_sensor_placement_scheme_detail(
|
||||
scheme_id: int,
|
||||
network: str = Query(..., min_length=1),
|
||||
project_context: ProjectContext = Depends(get_project_context),
|
||||
current_user=Depends(get_current_metadata_user),
|
||||
) -> dict[str, Any]:
|
||||
return _get_scheme_response(
|
||||
_project_network(network, project_context),
|
||||
scheme_id,
|
||||
current_user,
|
||||
project_context,
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/sensor-placement-schemes/{scheme_id}",
|
||||
response_model=SensorPlacementSchemeResponse,
|
||||
summary="覆盖保存监测点方案",
|
||||
)
|
||||
async def overwrite_sensor_placement_scheme(
|
||||
scheme_id: int,
|
||||
payload: SensorPlacementUpdateRequest,
|
||||
network: str = Query(..., min_length=1),
|
||||
project_context: ProjectContext = Depends(get_project_context),
|
||||
current_user=Depends(get_current_metadata_user),
|
||||
) -> dict[str, Any]:
|
||||
network = _project_network(network, project_context)
|
||||
_require_project_write(project_context)
|
||||
scheme = _get_scheme_response(
|
||||
network,
|
||||
scheme_id,
|
||||
current_user,
|
||||
project_context,
|
||||
)
|
||||
if not scheme["can_edit"]:
|
||||
raise HTTPException(status_code=403, detail="无权修改该监测点方案")
|
||||
|
||||
try:
|
||||
updated = update_sensor_placement_scheme(
|
||||
network,
|
||||
scheme_id,
|
||||
expected_sensor_location=payload.expected_sensor_location,
|
||||
sensor_location=payload.sensor_location,
|
||||
)
|
||||
return {**updated, "can_edit": True}
|
||||
except (
|
||||
SensorPlacementConflictError,
|
||||
SensorPlacementNotFoundError,
|
||||
SensorPlacementValidationError,
|
||||
) as exc:
|
||||
raise _service_http_error(exc) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sensor-placement-schemes/{scheme_id}/exports/excel",
|
||||
summary="导出监测点工程清单",
|
||||
)
|
||||
async def export_sensor_placement_excel(
|
||||
scheme_id: int,
|
||||
payload: SensorPlacementExportRequest,
|
||||
network: str = Query(..., min_length=1),
|
||||
project_context: ProjectContext = Depends(get_project_context),
|
||||
current_user=Depends(get_current_metadata_user),
|
||||
) -> StreamingResponse:
|
||||
network = _project_network(network, project_context)
|
||||
scheme = _get_scheme_response(
|
||||
network,
|
||||
scheme_id,
|
||||
current_user,
|
||||
project_context,
|
||||
)
|
||||
if (
|
||||
payload.sensor_location != scheme["sensor_location"]
|
||||
and not scheme["can_edit"]
|
||||
):
|
||||
raise HTTPException(status_code=403, detail="无权导出该方案的未保存草稿")
|
||||
|
||||
try:
|
||||
workbook = await run_in_threadpool(
|
||||
build_sensor_placement_workbook,
|
||||
network=network,
|
||||
scheme=scheme,
|
||||
sensor_location=payload.sensor_location,
|
||||
adjustment_status=payload.adjustment_status,
|
||||
)
|
||||
except SensorPlacementValidationError as exc:
|
||||
raise _service_http_error(exc) from exc
|
||||
|
||||
filename = f"{scheme['scheme_name']}_监测点清单.xlsx"
|
||||
encoded_filename = quote(filename)
|
||||
return StreamingResponse(
|
||||
workbook,
|
||||
media_type=(
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
),
|
||||
headers={
|
||||
"Content-Disposition": (
|
||||
f"attachment; filename*=UTF-8''{encoded_filename}"
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -1,11 +1,10 @@
|
||||
from typing import Any, List, Optional
|
||||
from datetime import datetime, timedelta
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import threading
|
||||
from fastapi import APIRouter, HTTPException, File, UploadFile, Query, Path, Body
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Path, Body
|
||||
from fastapi.responses import PlainTextResponse
|
||||
from app.auth.keycloak_dependencies import get_current_keycloak_username
|
||||
import app.services.simulation as simulation
|
||||
import app.services.globals as globals
|
||||
from app.services.tjnetwork import (
|
||||
@@ -28,14 +27,17 @@ from app.algorithms.sensor import (
|
||||
pressure_sensor_placement_kmeans,
|
||||
)
|
||||
|
||||
from app.services.network_import import network_update
|
||||
from app.services.simulation_ops import (
|
||||
project_management,
|
||||
scheduling_simulation,
|
||||
daily_scheduling_simulation,
|
||||
)
|
||||
from app.services.valve_isolation import analyze_valve_isolation
|
||||
from app.services.time_api import parse_aware_time, parse_utc_time
|
||||
from app.services.time_api import (
|
||||
parse_aware_time,
|
||||
parse_clock_duration_seconds,
|
||||
parse_utc_time,
|
||||
)
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
router = APIRouter()
|
||||
@@ -118,6 +120,14 @@ def run_simulation_manually_by_date(
|
||||
network_name: str, start_time: datetime, duration: int
|
||||
) -> None:
|
||||
end_datetime = start_time + timedelta(minutes=duration)
|
||||
time_properties = simulation.get_time(network_name)
|
||||
hydraulic_step_seconds = parse_clock_duration_seconds(
|
||||
time_properties["HYDRAULIC TIMESTEP"],
|
||||
field_name="HYDRAULIC TIMESTEP",
|
||||
)
|
||||
if hydraulic_step_seconds <= 0:
|
||||
raise ValueError("HYDRAULIC TIMESTEP must be greater than 0.")
|
||||
hydraulic_step = timedelta(seconds=hydraulic_step_seconds)
|
||||
current_time = start_time
|
||||
while current_time < end_datetime:
|
||||
simulation.run_simulation(
|
||||
@@ -125,11 +135,11 @@ def run_simulation_manually_by_date(
|
||||
simulation_type="realtime",
|
||||
modify_pattern_start_time=current_time.isoformat(timespec="seconds"),
|
||||
)
|
||||
current_time += timedelta(minutes=15)
|
||||
current_time += hydraulic_step
|
||||
|
||||
|
||||
# 必须用这个PlainTextResponse,不然每个key都有引号
|
||||
@router.get("/runproject/", response_class=PlainTextResponse, summary="运行项目模拟", description="基于指定的管网项目运行标准水力模拟,返回纯文本格式的模拟报告。")
|
||||
@router.post("/project-runs", response_class=PlainTextResponse, summary="运行项目模拟", description="基于指定的管网项目运行标准水力模拟,返回纯文本格式的模拟报告。")
|
||||
async def run_project_endpoint(network: str = Query(..., description="管网名称(或数据库名称)")) -> str:
|
||||
"""
|
||||
运行项目模拟
|
||||
@@ -145,7 +155,7 @@ async def run_project_endpoint(network: str = Query(..., description="管网名
|
||||
# output 和 report
|
||||
# output 是 json
|
||||
# report 是 text
|
||||
@router.get("/runprojectreturndict/", summary="运行项目模拟(返回字典)", description="基于指定的管网项目运行标准水力模拟,返回JSON格式的字典,包含输出数据和报告文本。")
|
||||
@router.post("/project-return-dict-runs", summary="运行项目模拟(返回字典)", description="基于指定的管网项目运行标准水力模拟,返回JSON格式的字典,包含输出数据和报告文本。")
|
||||
async def run_project_return_dict_endpoint(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, Any]:
|
||||
"""
|
||||
运行项目模拟(返回字典)
|
||||
@@ -162,7 +172,7 @@ async def run_project_return_dict_endpoint(network: str = Query(..., description
|
||||
|
||||
|
||||
# put in inp folder, name without extension
|
||||
@router.get("/runinp/", summary="运行INP文件", description="运行指定INP文件格式的管网模型进行水力模拟。INP文件应该放在inp文件夹中,参数为文件名不含扩展名。")
|
||||
@router.post("/inp-runs", summary="运行INP文件", description="运行指定INP文件格式的管网模型进行水力模拟。INP文件应该放在inp文件夹中,参数为文件名不含扩展名。")
|
||||
async def run_inp_endpoint(network: str = Query(..., description="inp文件名(不含扩展名)")) -> str:
|
||||
"""
|
||||
运行INP文件
|
||||
@@ -175,7 +185,7 @@ async def run_inp_endpoint(network: str = Query(..., description="inp文件名
|
||||
|
||||
|
||||
# path is absolute path
|
||||
@router.get("/dumpoutput/", summary="导出模拟输出", description="导出指定路径的模拟输出文件内容。参数应为绝对路径。")
|
||||
@router.get("/outputs", summary="导出模拟输出", description="导出指定路径的模拟输出文件内容。参数应为绝对路径。")
|
||||
async def dump_output_endpoint(output: str = Query(..., description="模拟输出文件的绝对路径")) -> str:
|
||||
"""
|
||||
导出模拟输出
|
||||
@@ -188,7 +198,7 @@ async def dump_output_endpoint(output: str = Query(..., description="模拟输
|
||||
|
||||
|
||||
# Analysis Endpoints
|
||||
@router.get("/burst_analysis/", summary="爆管分析(高级)", description="高级版本的爆管分析,支持在指定时间点修改泵控制模式和阀门开度,以分析这些改变对爆管影响的作用。支持固定泵和变速泵的独立控制。")
|
||||
@router.post("/burst-analyses", summary="爆管分析(高级)", description="高级版本的爆管分析,支持在指定时间点修改泵控制模式和阀门开度,以分析这些改变对爆管影响的作用。支持固定泵和变速泵的独立控制。")
|
||||
async def fastapi_burst_analysis(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
modify_pattern_start_time: str = Query(..., description="模式修改开始时间(ISO 8601格式)"),
|
||||
@@ -196,6 +206,7 @@ async def fastapi_burst_analysis(
|
||||
burst_size: list[float] = Query(..., description="对应各爆管点的爆管流量大小列表(L/s)"),
|
||||
modify_total_duration: int = Query(..., description="模拟总时长(秒)"),
|
||||
scheme_name: str = Query(..., description="分析方案名称"),
|
||||
username: str = Depends(get_current_keycloak_username),
|
||||
) -> str:
|
||||
"""
|
||||
爆管分析(高级版本)
|
||||
@@ -216,11 +227,12 @@ async def fastapi_burst_analysis(
|
||||
burst_size=burst_size,
|
||||
modify_total_duration=modify_total_duration,
|
||||
scheme_name=scheme_name,
|
||||
username=username,
|
||||
)
|
||||
return "success"
|
||||
|
||||
|
||||
@router.get("/valve_close_analysis/", response_class=PlainTextResponse, summary="阀门关闭分析(高级)", description="高级版本的阀门关闭分析,支持同时关闭多个阀门,并在指定持续时间内进行模拟。返回纯文本格式的分析结果。")
|
||||
@router.post("/valve-closure-analyses", response_class=PlainTextResponse, summary="阀门关闭分析(高级)", description="高级版本的阀门关闭分析,支持同时关闭多个阀门,并在指定持续时间内进行模拟。返回纯文本格式的分析结果。")
|
||||
async def fastapi_valve_close_analysis(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
start_time: str = Query(..., description="阀门关闭开始时间(ISO 8601格式)"),
|
||||
@@ -249,7 +261,7 @@ async def fastapi_valve_close_analysis(
|
||||
return result or "success"
|
||||
|
||||
|
||||
@router.get("/valve_isolation_analysis/", summary="阀门隔离分析", description="分析当发生突发事件时,通过关闭指定阀门进行隔离,确定哪些阀门必须关闭、哪些可选关闭,以及隔离的可行性。")
|
||||
@router.post("/valve-isolation-analyses", summary="阀门隔离分析", description="分析当发生突发事件时,通过关闭指定阀门进行隔离,确定哪些阀门必须关闭、哪些可选关闭,以及隔离的可行性。")
|
||||
async def valve_isolation_endpoint(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
accident_element: List[str] = Query(..., description="发生事故的管段/节点ID列表"),
|
||||
@@ -265,7 +277,8 @@ async def valve_isolation_endpoint(
|
||||
返回隔离方案,包括:
|
||||
- must_close_valves: 必须关闭的阀门列表
|
||||
- optional_valves: 可选关闭的阀门列表
|
||||
- affected_nodes: 受影响的节点列表
|
||||
- affected_nodes: 受影响的节点列表;不可隔离时为空列表
|
||||
- affected_node_count: 受影响的节点总数
|
||||
- isolatable: 是否可以有效隔离
|
||||
"""
|
||||
# result = {
|
||||
@@ -289,7 +302,7 @@ async def valve_isolation_endpoint(
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/flushing_analysis/", response_class=PlainTextResponse, summary="冲洗分析(高级)", description="高级版本的冲洗分析,支持同时开启多个阀门进行冲洗,指定排污节点,并设置固定的冲洗流量。返回纯文本格式的分析结果。")
|
||||
@router.post("/flushing-analyses", response_class=PlainTextResponse, summary="冲洗分析(高级)", description="高级版本的冲洗分析,支持同时开启多个阀门进行冲洗,指定排污节点,并设置固定的冲洗流量。返回纯文本格式的分析结果。")
|
||||
async def fastapi_flushing_analysis(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
start_time: str = Query(..., description="冲洗开始时间(ISO 8601格式)"),
|
||||
@@ -299,6 +312,7 @@ async def fastapi_flushing_analysis(
|
||||
flush_flow: float = Query(0, description="冲洗流量(L/s),0表示自动计算"),
|
||||
duration: int | None = Query(None, description="模拟持续时间(秒),默认900秒"),
|
||||
scheme_name: str = Query(..., description="冲洗方案名称"),
|
||||
username: str = Depends(get_current_keycloak_username),
|
||||
) -> str:
|
||||
"""
|
||||
冲洗分析(高级版本)
|
||||
@@ -325,11 +339,12 @@ async def fastapi_flushing_analysis(
|
||||
drainage_node_ID=drainage_node_ID,
|
||||
flushing_flow=flush_flow,
|
||||
scheme_name=scheme_name,
|
||||
username=username,
|
||||
)
|
||||
return result or "success"
|
||||
|
||||
|
||||
@router.get("/contaminant_simulation/", response_class=PlainTextResponse, summary="污染物模拟", description="对管网中的污染物扩散进行模拟,评估污染源对管网的影响范围和浓度分布。支持指定污染源位置、污染浓度和扩散模式。")
|
||||
@router.post("/contaminant-simulations", response_class=PlainTextResponse, summary="污染物模拟", description="对管网中的污染物扩散进行模拟,评估污染源对管网的影响范围和浓度分布。支持指定污染源位置、污染浓度和扩散模式。")
|
||||
async def fastapi_contaminant_simulation(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
start_time: str = Query(..., description="污染开始时间(ISO 8601格式)"),
|
||||
@@ -338,6 +353,7 @@ async def fastapi_contaminant_simulation(
|
||||
duration: int = Query(..., description="模拟持续时间(秒)"),
|
||||
scheme_name: str = Query(..., description="模拟方案名称"),
|
||||
pattern: str | None = Query(None, description="污染源模式ID(可选)"),
|
||||
username: str = Depends(get_current_keycloak_username),
|
||||
) -> str:
|
||||
"""
|
||||
污染物模拟
|
||||
@@ -360,11 +376,12 @@ async def fastapi_contaminant_simulation(
|
||||
source=source,
|
||||
concentration=concentration,
|
||||
source_pattern=pattern,
|
||||
username=username,
|
||||
)
|
||||
return result or "success"
|
||||
|
||||
|
||||
@router.get("/age_analysis/", response_class=PlainTextResponse, summary="水龄分析(高级)", description="高级版本的水龄分析,在指定时间点进行分析,支持自定义模拟持续时间。返回纯文本格式的分析结果。")
|
||||
@router.post("/water-age-analyses", response_class=PlainTextResponse, summary="水龄分析(高级)", description="高级版本的水龄分析,在指定时间点进行分析,支持自定义模拟持续时间。返回纯文本格式的分析结果。")
|
||||
async def fastapi_age_analysis(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
start_time: str = Query(..., description="分析开始时间(ISO 8601格式)"),
|
||||
@@ -388,7 +405,7 @@ async def fastapi_age_analysis(
|
||||
# return scheduling_analysis(network)
|
||||
|
||||
|
||||
@router.get("/pressureregulation/", summary="压力调节(基础)", description="对管网的压力进行调节分析,通过控制泵的运行来维持目标节点的目标压力。此为基础版本。")
|
||||
@router.post("/pressure-regulation-calculations", summary="压力调节(基础)", description="对管网的压力进行调节分析,通过控制泵的运行来维持目标节点的目标压力。此为基础版本。")
|
||||
async def pressure_regulation_endpoint(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
target_node: str = Query(..., description="目标节点ID"),
|
||||
@@ -406,7 +423,7 @@ async def pressure_regulation_endpoint(
|
||||
return pressure_regulation(network, target_node, target_pressure)
|
||||
|
||||
|
||||
@router.post("/pressure_regulation/", summary="压力调节(高级)", description="高级版本的压力调节分析,通过JSON请求体提供详细的控制参数,包括固定泵和变速泵的独立控制、水箱初始水位等。")
|
||||
@router.post("/pressure-regulation-analyses", summary="压力调节(高级)", description="高级版本的压力调节分析,通过JSON请求体提供详细的控制参数,包括固定泵和变速泵的独立控制、水箱初始水位等。")
|
||||
async def fastapi_pressure_regulation(data: PressureRegulation = Body(..., description="压力调节控制参数")) -> str:
|
||||
"""
|
||||
压力调节(高级版本)
|
||||
@@ -444,7 +461,7 @@ async def fastapi_pressure_regulation(data: PressureRegulation = Body(..., descr
|
||||
return "success"
|
||||
|
||||
|
||||
@router.post("/project_management/", summary="项目管理(高级)", description="高级版本的项目管理,通过JSON请求体提供详细的控制参数,包括泵控制策略、水箱初始水位和区域需水量控制。")
|
||||
@router.post("/project-managements", summary="项目管理(高级)", description="高级版本的项目管理,通过JSON请求体提供详细的控制参数,包括泵控制策略、水箱初始水位和区域需水量控制。")
|
||||
async def fastapi_project_management(data: ProjectManagement = Body(..., description="项目管理控制参数")) -> str:
|
||||
"""
|
||||
项目管理(高级版本)
|
||||
@@ -473,7 +490,7 @@ async def fastapi_project_management(data: ProjectManagement = Body(..., descrip
|
||||
# return daily_scheduling_analysis(network)
|
||||
|
||||
|
||||
@router.post("/scheduling_analysis/", summary="排程分析", description="对管网的供水排程进行分析,优化泵的运行时间和出水流量,平衡水厂出水、水箱进出水,满足用户需求。")
|
||||
@router.post("/scheduling-analyses", summary="排程分析", description="对管网的供水排程进行分析,优化泵的运行时间和出水流量,平衡水厂出水、水箱进出水,满足用户需求。")
|
||||
async def fastapi_scheduling_analysis(data: SchedulingAnalysis = Body(..., description="排程分析参数")) -> str:
|
||||
"""
|
||||
排程分析
|
||||
@@ -499,7 +516,7 @@ async def fastapi_scheduling_analysis(data: SchedulingAnalysis = Body(..., descr
|
||||
)
|
||||
|
||||
|
||||
@router.post("/daily_scheduling_analysis/", summary="日排程分析", description="对管网的每日供水排程进行分析,优化水库、水厂、水箱和用户需求的协调,制定合理的每日排程方案。")
|
||||
@router.post("/daily-scheduling-analyses", summary="日排程分析", description="对管网的每日供水排程进行分析,优化水库、水厂、水箱和用户需求的协调,制定合理的每日排程方案。")
|
||||
async def fastapi_daily_scheduling_analysis(data: DailySchedulingAnalysis = Body(..., description="日排程分析参数")) -> str:
|
||||
"""
|
||||
日排程分析
|
||||
@@ -526,52 +543,12 @@ async def fastapi_daily_scheduling_analysis(data: DailySchedulingAnalysis = Body
|
||||
)
|
||||
|
||||
|
||||
@router.post("/network_project/", summary="导入网络项目", description="通过上传INP格式的管网文件导入新的网络项目。系统将自动处理文件并执行模拟。")
|
||||
async def fastapi_network_project(file: UploadFile = File(..., description="INP格式的管网文件")) -> str:
|
||||
"""
|
||||
导入网络项目
|
||||
|
||||
- **file**: 上传的INP格式管网文件
|
||||
|
||||
系统将上传的文件保存到inp文件夹并执行模拟。
|
||||
"""
|
||||
temp_file_dir = "./inp/"
|
||||
if not os.path.exists(temp_file_dir):
|
||||
os.mkdir(temp_file_dir)
|
||||
temp_file_name = f'network_project_{datetime.now().strftime("%Y%m%d")}'
|
||||
temp_file_path = f"{temp_file_dir}{temp_file_name}.inp"
|
||||
with open(temp_file_path, "wb") as buffer:
|
||||
shutil.copyfileobj(file.file, buffer)
|
||||
return run_inp(temp_file_name)
|
||||
|
||||
|
||||
@router.post("/network_update/", summary="管网更新(高级)", description="通过上传更新文件对管网进行高级的更新操作。系统将处理更新文件并应用到数据库。")
|
||||
async def fastapi_network_update(file: UploadFile = File(..., description="包含管网更新信息的文件")) -> str:
|
||||
"""
|
||||
管网更新(高级版本)
|
||||
|
||||
- **file**: 包含管网更新信息的文件
|
||||
|
||||
系统将处理上传的文件并应用管网更新。
|
||||
"""
|
||||
default_folder = "./"
|
||||
temp_file_name = f'network_update_{datetime.now().strftime("%Y%m%d")}'
|
||||
temp_file_path = os.path.join(default_folder, temp_file_name)
|
||||
try:
|
||||
with open(temp_file_path, "wb") as buffer:
|
||||
shutil.copyfileobj(file.file, buffer)
|
||||
network_update(temp_file_path)
|
||||
return json.dumps({"message": "管网更新成功"})
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"数据库操作失败: {exc}")
|
||||
|
||||
|
||||
# @router.get("/pumpfailure/")
|
||||
# async def pump_failure_endpoint(network: str, pump_id: str, time: str):
|
||||
# return pump_failure(network, pump_id, time)
|
||||
|
||||
|
||||
@router.post("/pump_failure/", summary="泵故障管理", description="记录和管理泵的故障状态,包括故障发生时间和受影响的泵列表。系统将记录故障日志并更新泵状态。")
|
||||
@router.post("/pump-failure-events", summary="泵故障管理", description="记录和管理泵的故障状态,包括故障发生时间和受影响的泵列表。系统将记录故障日志并更新泵状态。")
|
||||
async def fastapi_pump_failure(data: PumpFailureState = Body(..., description="泵故障状态信息")) -> str:
|
||||
"""
|
||||
泵故障管理
|
||||
@@ -615,7 +592,7 @@ async def fastapi_pump_failure(data: PumpFailureState = Body(..., description="
|
||||
return json.dumps("SUCCESS")
|
||||
|
||||
|
||||
@router.get("/pressuresensorplacementsensitivity/", summary="压力传感器放置-灵敏度分析(基础)", description="基于灵敏度分析方法,为指定管网项目确定最优的压力传感器放置位置。此为基础版本。")
|
||||
@router.post("/pressure-sensor-placement-sensitivity-calculations", summary="压力传感器放置-灵敏度分析(基础)", description="基于灵敏度分析方法,为指定管网项目确定最优的压力传感器放置位置。此为基础版本。")
|
||||
async def pressure_sensor_placement_sensitivity_endpoint(
|
||||
name: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
scheme_name: str = Query(..., description="放置方案名称"),
|
||||
@@ -639,7 +616,7 @@ async def pressure_sensor_placement_sensitivity_endpoint(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/pressure_sensor_placement_sensitivity/", summary="压力传感器放置-灵敏度分析(高级)", description="高级版本的压力传感器放置分析,通过JSON请求体提供详细参数。基于灵敏度分析方法确定最优放置位置。")
|
||||
@router.post("/pressure-sensor-placement-sensitivities", summary="压力传感器放置-灵敏度分析(高级)", description="高级版本的压力传感器放置分析,通过JSON请求体提供详细参数。基于灵敏度分析方法确定最优放置位置。")
|
||||
async def fastapi_pressure_sensor_placement_sensitivity(
|
||||
data: PressureSensorPlacement = Body(..., description="传感器放置分析参数"),
|
||||
) -> None:
|
||||
@@ -665,7 +642,7 @@ async def fastapi_pressure_sensor_placement_sensitivity(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/pressuresensorplacementkmeans/", summary="压力传感器放置-KMeans聚类分析(基础)", description="基于KMeans聚类算法,为指定管网项目确定压力传感器的最优放置位置。此为基础版本。")
|
||||
@router.post("/pressure-sensor-placement-kmeans-calculations", summary="压力传感器放置-KMeans聚类分析(基础)", description="基于KMeans聚类算法,为指定管网项目确定压力传感器的最优放置位置。此为基础版本。")
|
||||
async def pressure_sensor_placement_kmeans_endpoint(
|
||||
name: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
scheme_name: str = Query(..., description="放置方案名称"),
|
||||
@@ -689,7 +666,7 @@ async def pressure_sensor_placement_kmeans_endpoint(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/pressure_sensor_placement_kmeans/", summary="压力传感器放置-KMeans聚类分析(高级)", description="高级版本的压力传感器放置分析,通过JSON请求体提供详细参数。基于KMeans聚类算法确定最优放置位置。")
|
||||
@router.post("/pressure-sensor-placement-kmeans", summary="压力传感器放置-KMeans聚类分析(高级)", description="高级版本的压力传感器放置分析,通过JSON请求体提供详细参数。基于KMeans聚类算法确定最优放置位置。")
|
||||
async def fastapi_pressure_sensor_placement_kmeans(
|
||||
data: PressureSensorPlacement = Body(..., description="传感器放置分析参数"),
|
||||
) -> None:
|
||||
@@ -715,7 +692,7 @@ async def fastapi_pressure_sensor_placement_kmeans(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/sensorplacementscheme/create", summary="传感器放置方案创建", description="创建新的传感器放置方案,支持灵敏度分析和KMeans聚类两种方法。根据指定的方法自动计算最优的传感器放置位置。")
|
||||
@router.post("/sensor-placement-schemes", summary="传感器放置方案创建", description="创建新的传感器放置方案,支持灵敏度分析和KMeans聚类两种方法。根据指定的方法自动计算最优的传感器放置位置。")
|
||||
async def fastapi_pressure_sensor_placement(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
scheme_name: str = Query(..., description="放置方案名称"),
|
||||
@@ -763,7 +740,7 @@ async def fastapi_pressure_sensor_placement(
|
||||
return "success"
|
||||
|
||||
|
||||
@router.post("/runsimulationmanuallybydate/", summary="手动运行日期指定模拟", description="根据指定的开始时间和持续时间,手动运行水力模拟。开始时间必须是显式带时区的 ISO 8601 / RFC3339 时间。")
|
||||
@router.post("/simulation-runs", summary="手动运行日期指定模拟", description="根据指定的开始时间和持续时间,手动运行水力模拟。开始时间必须是显式带时区的 ISO 8601 / RFC3339 时间。")
|
||||
async def fastapi_run_simulation_manually_by_date(
|
||||
data: RunSimulationManuallyByDate = Body(..., description="模拟运行参数"),
|
||||
) -> dict[str, str]:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from fastapi import APIRouter, Request, Query
|
||||
from fastapi import APIRouter, Depends, Request, Query
|
||||
from app.auth.permissions import SIMULATION_RUN, require_permission
|
||||
from app.services.tjnetwork import (
|
||||
ChangeSet,
|
||||
get_current_operation,
|
||||
@@ -22,7 +23,7 @@ from app.services.tjnetwork import (
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/getcurrentoperationid/", summary="获取当前操作ID", description="获取网络当前的操作ID")
|
||||
@router.get("/current-operation-ids", summary="获取当前操作ID", description="获取网络当前的操作ID")
|
||||
async def get_current_operation_id_endpoint(network: str = Query(..., description="管网名称(或数据库名称)")) -> int:
|
||||
"""
|
||||
获取当前操作ID
|
||||
@@ -31,7 +32,7 @@ async def get_current_operation_id_endpoint(network: str = Query(..., descriptio
|
||||
"""
|
||||
return get_current_operation(network)
|
||||
|
||||
@router.post("/undo/", summary="撤销操作", description="撤销网络上最后的一个操作")
|
||||
@router.post("/undos", summary="撤销操作", description="撤销网络上最后的一个操作")
|
||||
async def undo_endpoint(network: str = Query(..., description="管网名称(或数据库名称)")):
|
||||
"""
|
||||
撤销操作
|
||||
@@ -40,7 +41,7 @@ async def undo_endpoint(network: str = Query(..., description="管网名称(
|
||||
"""
|
||||
return execute_undo(network)
|
||||
|
||||
@router.post("/redo/", summary="重做操作", description="重做网络上被撤销的操作")
|
||||
@router.post("/redos", summary="重做操作", description="重做网络上被撤销的操作")
|
||||
async def redo_endpoint(network: str = Query(..., description="管网名称(或数据库名称)")):
|
||||
"""
|
||||
重做操作
|
||||
@@ -49,7 +50,7 @@ async def redo_endpoint(network: str = Query(..., description="管网名称(
|
||||
"""
|
||||
return execute_redo(network)
|
||||
|
||||
@router.get("/getsnapshots/", summary="获取快照列表", description="获取网络中的所有快照")
|
||||
@router.get("/snapshots", summary="获取快照列表", description="获取网络中的所有快照")
|
||||
async def list_snapshot_endpoint(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[tuple[int, str]]:
|
||||
"""
|
||||
获取快照列表
|
||||
@@ -58,7 +59,7 @@ async def list_snapshot_endpoint(network: str = Query(..., description="管网
|
||||
"""
|
||||
return list_snapshot(network)
|
||||
|
||||
@router.get("/havesnapshot/", summary="检查快照是否存在", description="检查指定标签的快照是否存在")
|
||||
@router.get("/snapshots/existence", summary="检查快照是否存在", description="检查指定标签的快照是否存在")
|
||||
async def have_snapshot_endpoint(network: str = Query(..., description="管网名称(或数据库名称)"), tag: str = Query(..., description="快照标签")) -> bool:
|
||||
"""
|
||||
检查快照是否存在
|
||||
@@ -67,7 +68,7 @@ async def have_snapshot_endpoint(network: str = Query(..., description="管网
|
||||
"""
|
||||
return have_snapshot(network, tag)
|
||||
|
||||
@router.get("/havesnapshotforoperation/", summary="检查操作快照是否存在", description="检查指定操作ID的快照是否存在")
|
||||
@router.get("/snapshot-for-operations", summary="检查操作快照是否存在", description="检查指定操作ID的快照是否存在")
|
||||
async def have_snapshot_for_operation_endpoint(network: str = Query(..., description="管网名称(或数据库名称)"), operation: int = Query(..., description="操作ID")) -> bool:
|
||||
"""
|
||||
检查操作快照是否存在
|
||||
@@ -76,7 +77,7 @@ async def have_snapshot_for_operation_endpoint(network: str = Query(..., descrip
|
||||
"""
|
||||
return have_snapshot_for_operation(network, operation)
|
||||
|
||||
@router.get("/havesnapshotforcurrentoperation/", summary="检查当前操作快照是否存在", description="检查当前操作的快照是否存在")
|
||||
@router.get("/snapshot-for-current-operations", summary="检查当前操作快照是否存在", description="检查当前操作的快照是否存在")
|
||||
async def have_snapshot_for_current_operation_endpoint(network: str = Query(..., description="管网名称(或数据库名称)")) -> bool:
|
||||
"""
|
||||
检查当前操作快照是否存在
|
||||
@@ -85,7 +86,7 @@ async def have_snapshot_for_current_operation_endpoint(network: str = Query(...,
|
||||
"""
|
||||
return have_snapshot_for_current_operation(network)
|
||||
|
||||
@router.post("/takesnapshotforoperation/", summary="为操作创建快照", description="为指定的操作创建快照")
|
||||
@router.post("/snapshot-for-operations", summary="为操作创建快照", description="为指定的操作创建快照")
|
||||
async def take_snapshot_for_operation_endpoint(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
operation: int = Query(..., description="操作ID"),
|
||||
@@ -98,7 +99,7 @@ async def take_snapshot_for_operation_endpoint(
|
||||
"""
|
||||
return take_snapshot_for_operation(network, operation, tag)
|
||||
|
||||
@router.post("/takesnapshotforcurrentoperation", summary="为当前操作创建快照", description="为当前操作创建快照")
|
||||
@router.post("/snapshot-for-current-operations", summary="为当前操作创建快照", description="为当前操作创建快照")
|
||||
async def take_snapshot_for_current_operation_endpoint(network: str = Query(..., description="管网名称(或数据库名称)"), tag: str = Query(..., description="快照标签")) -> None:
|
||||
"""
|
||||
为当前操作创建快照
|
||||
@@ -107,17 +108,7 @@ async def take_snapshot_for_current_operation_endpoint(network: str = Query(...,
|
||||
"""
|
||||
return take_snapshot_for_current_operation(network, tag)
|
||||
|
||||
# 兼容旧拼写: takenapshotforcurrentoperation
|
||||
@router.post("/takenapshotforcurrentoperation", summary="为当前操作创建快照(兼容模式)", description="为当前操作创建快照(兼容旧的API路径)")
|
||||
async def take_snapshot_for_current_operation_legacy_endpoint(network: str = Query(..., description="管网名称(或数据库名称)"), tag: str = Query(..., description="快照标签")) -> None:
|
||||
"""
|
||||
为当前操作创建快照(兼容模式)
|
||||
|
||||
兼容旧的API路径,为网络当前操作创建一个快照
|
||||
"""
|
||||
return take_snapshot_for_current_operation(network, tag)
|
||||
|
||||
@router.post("/takesnapshot/", summary="创建快照", description="为网络创建一个快照")
|
||||
@router.post("/snapshots", summary="创建快照", description="为网络创建一个快照")
|
||||
async def take_snapshot_endpoint(network: str = Query(..., description="管网名称(或数据库名称)"), tag: str = Query(..., description="快照标签")) -> None:
|
||||
"""
|
||||
创建快照
|
||||
@@ -126,7 +117,7 @@ async def take_snapshot_endpoint(network: str = Query(..., description="管网
|
||||
"""
|
||||
return take_snapshot(network, tag)
|
||||
|
||||
@router.post("/picksnapshot/", summary="选择快照", description="选择并恢复到指定的快照", response_model=None)
|
||||
@router.patch("/snapshots", summary="选择快照", description="选择并恢复到指定的快照", response_model=None)
|
||||
async def pick_snapshot_endpoint(network: str = Query(..., description="管网名称(或数据库名称)"), tag: str = Query(..., description="快照标签"), discard: bool = Query(False, description="是否丢弃当前更改")) -> ChangeSet:
|
||||
"""
|
||||
选择快照
|
||||
@@ -135,7 +126,7 @@ async def pick_snapshot_endpoint(network: str = Query(..., description="管网
|
||||
"""
|
||||
return pick_snapshot(network, tag, discard)
|
||||
|
||||
@router.post("/pickoperation/", summary="选择操作", description="选择并恢复到指定的操作", response_model=None)
|
||||
@router.patch("/operations", summary="选择操作", description="选择并恢复到指定的操作", response_model=None)
|
||||
async def pick_operation_endpoint(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
operation: int = Query(..., description="操作ID"),
|
||||
@@ -148,8 +139,12 @@ async def pick_operation_endpoint(
|
||||
"""
|
||||
return pick_operation(network, operation, discard)
|
||||
|
||||
@router.get("/syncwithserver/", summary="与服务器同步", description="将网络与服务器同步到指定操作", response_model=None)
|
||||
async def sync_with_server_endpoint(network: str = Query(..., description="管网名称(或数据库名称)"), operation: int = Query(..., description="目标操作ID")) -> ChangeSet:
|
||||
@router.post("/with-servers", summary="与服务器同步", description="将网络与服务器同步到指定操作", response_model=None)
|
||||
async def sync_with_server_endpoint(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
operation: int = Query(..., description="目标操作ID"),
|
||||
_=Depends(require_permission(SIMULATION_RUN)),
|
||||
) -> ChangeSet:
|
||||
"""
|
||||
与服务器同步
|
||||
|
||||
@@ -157,7 +152,7 @@ async def sync_with_server_endpoint(network: str = Query(..., description="管
|
||||
"""
|
||||
return sync_with_server(network, operation)
|
||||
|
||||
@router.post("/batch/", summary="执行批量命令", description="执行多个网络操作命令", response_model=None)
|
||||
@router.post("/network-command-batches", summary="执行批量命令", description="执行多个网络操作命令", response_model=None)
|
||||
async def execute_batch_commands_endpoint(network: str = Query(..., description="管网名称(或数据库名称)"), req: Request = None) -> ChangeSet:
|
||||
"""
|
||||
执行批量命令
|
||||
@@ -170,7 +165,7 @@ async def execute_batch_commands_endpoint(network: str = Query(..., description=
|
||||
rcs = execute_batch_commands(network, cs)
|
||||
return rcs
|
||||
|
||||
@router.post("/compressedbatch/", summary="执行压缩批量命令", description="执行压缩的批量命令", response_model=None)
|
||||
@router.post("/network-command-batches/compressed", summary="执行压缩批量命令", description="执行压缩的批量命令", response_model=None)
|
||||
async def execute_compressed_batch_commands_endpoint(
|
||||
network: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
req: Request = None
|
||||
@@ -185,7 +180,7 @@ async def execute_compressed_batch_commands_endpoint(
|
||||
cs.operations = jo_root["operations"]
|
||||
return execute_batch_command(network, cs)
|
||||
|
||||
@router.get("/getrestoreoperation/", summary="获取恢复操作ID", description="获取网络的恢复操作ID")
|
||||
@router.get("/restore-operations", summary="获取恢复操作ID", description="获取网络的恢复操作ID")
|
||||
async def get_restore_operation_endpoint(network: str = Query(..., description="管网名称(或数据库名称)")) -> int:
|
||||
"""
|
||||
获取恢复操作ID
|
||||
@@ -194,7 +189,7 @@ async def get_restore_operation_endpoint(network: str = Query(..., description="
|
||||
"""
|
||||
return get_restore_operation(network)
|
||||
|
||||
@router.post("/setrestoreoperation/", summary="设置恢复操作ID", description="设置网络的恢复操作ID")
|
||||
@router.patch("/restore-operations", summary="设置恢复操作ID", description="设置网络的恢复操作ID")
|
||||
async def set_restore_operation_endpoint(network: str = Query(..., description="管网名称(或数据库名称)"), operation: int = Query(..., description="操作ID")) -> None:
|
||||
"""
|
||||
设置恢复操作ID
|
||||
|
||||
@@ -8,7 +8,7 @@ from .dependencies import get_timescale_connection, get_postgres_connection
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/composite/scada-simulation", summary="获取SCADA关联的模拟数据")
|
||||
@router.get("/timeseries/views/scada-simulations", summary="获取SCADA关联的模拟数据")
|
||||
async def get_scada_associated_simulation_data(
|
||||
start_time: datetime = Query(..., description="查询开始时间"),
|
||||
end_time: datetime = Query(..., description="查询结束时间"),
|
||||
@@ -73,7 +73,7 @@ async def get_scada_associated_simulation_data(
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/composite/element-simulation", summary="获取管网元素的模拟数据")
|
||||
@router.get("/timeseries/views/element-simulations", summary="获取管网元素的模拟数据")
|
||||
async def get_feature_simulation_data(
|
||||
start_time: datetime = Query(..., description="查询开始时间"),
|
||||
end_time: datetime = Query(..., description="查询结束时间"),
|
||||
@@ -143,7 +143,7 @@ async def get_feature_simulation_data(
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/composite/element-scada", summary="获取管网元素关联的SCADA监测数据")
|
||||
@router.get("/timeseries/views/element-scada-readings", summary="获取管网元素关联的SCADA监测数据")
|
||||
async def get_element_associated_scada_data(
|
||||
element_id: str = Query(..., description="管网元素ID(管道或节点)"),
|
||||
start_time: datetime = Query(..., description="查询开始时间"),
|
||||
@@ -185,7 +185,7 @@ async def get_element_associated_scada_data(
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/composite/clean-scada", summary="清洗SCADA监测数据")
|
||||
@router.post("/timeseries/scada-cleaning-runs", summary="清洗SCADA监测数据")
|
||||
async def clean_scada_data(
|
||||
device_ids: str = Query(..., description="设备ID列表或 'all' 表示清洗所有设备"),
|
||||
start_time: datetime = Query(..., description="清洗数据的开始时间"),
|
||||
@@ -228,7 +228,7 @@ async def clean_scada_data(
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/composite/pipeline-health-prediction", summary="预测管道健康状况")
|
||||
@router.get("/pipeline-health-predictions", summary="预测管道健康状况")
|
||||
async def predict_pipeline_health(
|
||||
query_time: datetime = Query(..., description="查询时间"),
|
||||
network_name: str = Query(..., description="管网名称(或数据库名称)"),
|
||||
|
||||
@@ -13,7 +13,7 @@ TIME_RANGE_START_DESC = f"时间范围开始时间。{TIME_WITH_TZ_DESC}"
|
||||
TIME_RANGE_END_DESC = f"时间范围结束时间。{TIME_WITH_TZ_DESC}"
|
||||
|
||||
|
||||
@router.post("/realtime/links/batch", status_code=201, summary="批量插入实时管道数据")
|
||||
@router.post("/timeseries/realtime/links/batches", status_code=201, summary="批量插入实时管道数据")
|
||||
async def insert_realtime_links(
|
||||
data: List[dict] = Body(..., description="管道数据列表,每项包含管道ID、时间戳等信息"),
|
||||
conn: AsyncConnection = Depends(get_timescale_connection)
|
||||
@@ -34,7 +34,7 @@ async def insert_realtime_links(
|
||||
|
||||
|
||||
@router.get(
|
||||
"/realtime/links",
|
||||
"/timeseries/realtime/links",
|
||||
summary="查询实时管道数据",
|
||||
description="按时间范围查询实时管道数据。start_time 和 end_time 必须显式带时区;允许传 UTC+8,服务端会先归一化为 UTC 再执行查询。",
|
||||
)
|
||||
@@ -60,7 +60,7 @@ async def get_realtime_links(
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/realtime/links",
|
||||
"/timeseries/realtime/links",
|
||||
summary="删除实时管道数据",
|
||||
description="按时间范围删除实时管道数据。start_time 和 end_time 必须显式带时区;允许传 UTC+8,服务端按请求中的绝对时间删除对应 UTC 数据。",
|
||||
)
|
||||
@@ -85,7 +85,7 @@ async def delete_realtime_links(
|
||||
return {"message": "Deleted successfully"}
|
||||
|
||||
|
||||
@router.patch("/realtime/links/{link_id}/field", summary="更新实时管道字段")
|
||||
@router.patch("/timeseries/realtime/links/{link_id}/field", summary="更新实时管道字段")
|
||||
async def update_realtime_link_field(
|
||||
link_id: str = Path(..., description="管道ID"),
|
||||
time: datetime = Query(..., description=f"要更新记录的时间戳。{TIME_WITH_TZ_DESC}"),
|
||||
@@ -117,7 +117,7 @@ async def update_realtime_link_field(
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/realtime/nodes/batch", status_code=201, summary="批量插入实时节点数据")
|
||||
@router.post("/timeseries/realtime/nodes/batches", status_code=201, summary="批量插入实时节点数据")
|
||||
async def insert_realtime_nodes(
|
||||
data: List[dict] = Body(..., description="节点数据列表,每项包含节点ID、时间戳等信息"),
|
||||
conn: AsyncConnection = Depends(get_timescale_connection)
|
||||
@@ -138,7 +138,7 @@ async def insert_realtime_nodes(
|
||||
|
||||
|
||||
@router.get(
|
||||
"/realtime/nodes",
|
||||
"/timeseries/realtime/nodes",
|
||||
summary="查询实时节点数据",
|
||||
description="按时间范围查询实时节点数据。start_time 和 end_time 必须显式带时区;允许传 UTC+8,服务端会先归一化为 UTC 再执行查询。",
|
||||
)
|
||||
@@ -164,7 +164,7 @@ async def get_realtime_nodes(
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/realtime/nodes",
|
||||
"/timeseries/realtime/nodes",
|
||||
summary="删除实时节点数据",
|
||||
description="按时间范围删除实时节点数据。start_time 和 end_time 必须显式带时区;允许传 UTC+8,服务端按请求中的绝对时间删除对应 UTC 数据。",
|
||||
)
|
||||
@@ -191,7 +191,7 @@ async def delete_realtime_nodes(
|
||||
|
||||
|
||||
|
||||
@router.post("/realtime/simulation/store", status_code=201, summary="存储实时模拟结果")
|
||||
@router.post("/timeseries/realtime/simulation-results", status_code=201, summary="存储实时模拟结果")
|
||||
async def store_realtime_simulation_result(
|
||||
node_result_list: List[dict] = Body(..., description="节点模拟结果列表"),
|
||||
link_result_list: List[dict] = Body(..., description="管道模拟结果列表"),
|
||||
@@ -218,7 +218,7 @@ async def store_realtime_simulation_result(
|
||||
|
||||
|
||||
@router.get(
|
||||
"/realtime/query/by-time-property",
|
||||
"/timeseries/realtime/records",
|
||||
summary="按时间和属性查询实时数据",
|
||||
description="查询指定时间点的实时属性值。query_time 必须显式带时区;允许传 UTC+8,服务端会先归一化为 UTC 再执行查询。",
|
||||
)
|
||||
@@ -254,7 +254,7 @@ async def query_realtime_records_by_time_property(
|
||||
|
||||
|
||||
@router.get(
|
||||
"/realtime/query/by-id-time",
|
||||
"/timeseries/realtime/simulation-results",
|
||||
summary="按ID和时间查询实时模拟数据",
|
||||
description="查询指定元素在某一时间点的实时模拟结果。query_time 必须显式带时区;允许传 UTC+8,服务端会先归一化为 UTC 再执行查询。",
|
||||
)
|
||||
|
||||
@@ -9,7 +9,7 @@ from .dependencies import get_timescale_connection
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/scada/batch", status_code=201, summary="批量插入SCADA监测数据")
|
||||
@router.post("/timeseries/scada-readings/batches", status_code=201, summary="批量插入SCADA监测数据")
|
||||
async def insert_scada_data(
|
||||
data: List[dict] = Body(..., description="SCADA设备监测数据列表"),
|
||||
conn: AsyncConnection = Depends(get_timescale_connection),
|
||||
@@ -29,7 +29,7 @@ async def insert_scada_data(
|
||||
return {"message": f"Inserted {len(data)} records"}
|
||||
|
||||
|
||||
@router.get("/scada/by-ids-time-range", summary="按设备ID和时间范围查询SCADA数据")
|
||||
@router.get("/timeseries/scada-readings", summary="按设备ID和时间范围查询SCADA数据")
|
||||
async def get_scada_by_ids_time_range(
|
||||
start_time: datetime = Query(..., description="查询开始时间"),
|
||||
end_time: datetime = Query(..., description="查询结束时间"),
|
||||
@@ -60,7 +60,7 @@ async def get_scada_by_ids_time_range(
|
||||
|
||||
|
||||
@router.get(
|
||||
"/scada/by-ids-field-time-range", summary="按设备ID、字段和时间范围查询SCADA数据"
|
||||
"/timeseries/scada-readings/fields", summary="按设备ID、字段和时间范围查询SCADA数据"
|
||||
)
|
||||
async def get_scada_field_by_ids_time_range(
|
||||
start_time: datetime = Query(..., description="查询开始时间"),
|
||||
@@ -101,7 +101,7 @@ async def get_scada_field_by_ids_time_range(
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.patch("/scada/{device_id}/field", summary="更新SCADA设备字段")
|
||||
@router.patch("/timeseries/scada-readings/{device_id}/field", summary="更新SCADA设备字段")
|
||||
async def update_scada_field(
|
||||
device_id: str = Path(..., description="设备ID"),
|
||||
time: datetime = Query(..., description="更新数据的时间戳"),
|
||||
@@ -133,7 +133,7 @@ async def update_scada_field(
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/scada/by-id-time-range", summary="按设备ID和时间范围删除SCADA数据")
|
||||
@router.delete("/timeseries/scada-readings", summary="按设备ID和时间范围删除SCADA数据")
|
||||
async def delete_scada_data(
|
||||
device_id: str = Query(..., description="设备ID"),
|
||||
start_time: datetime = Query(..., description="删除开始时间"),
|
||||
|
||||
@@ -9,7 +9,7 @@ from .dependencies import get_timescale_connection
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/scheme/links/batch", status_code=201, summary="批量插入方案管道数据")
|
||||
@router.post("/timeseries/schemes/links/batches", status_code=201, summary="批量插入方案管道数据")
|
||||
async def insert_scheme_links(
|
||||
data: List[dict] = Body(..., description="方案管道数据列表"),
|
||||
conn: AsyncConnection = Depends(get_timescale_connection),
|
||||
@@ -29,7 +29,7 @@ async def insert_scheme_links(
|
||||
return {"message": f"Inserted {len(data)} records"}
|
||||
|
||||
|
||||
@router.get("/scheme/links", summary="查询方案管道数据")
|
||||
@router.get("/timeseries/schemes/links", summary="查询方案管道数据")
|
||||
async def get_scheme_links(
|
||||
scheme_type: str = Query(..., description="方案类型"),
|
||||
scheme_name: str = Query(..., description="方案名称"),
|
||||
@@ -56,7 +56,7 @@ async def get_scheme_links(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/scheme/links/{link_id}/field", summary="查询方案管道字段数据")
|
||||
@router.get("/timeseries/schemes/links/{link_id}/field", summary="查询方案管道字段数据")
|
||||
async def get_scheme_link_field(
|
||||
link_id: str = Path(..., description="管道ID"),
|
||||
scheme_type: str = Query(..., description="方案类型"),
|
||||
@@ -93,7 +93,7 @@ async def get_scheme_link_field(
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.patch("/scheme/links/{link_id}/field", summary="更新方案管道字段")
|
||||
@router.patch("/timeseries/schemes/links/{link_id}/field", summary="更新方案管道字段")
|
||||
async def update_scheme_link_field(
|
||||
link_id: str = Path(..., description="管道ID"),
|
||||
scheme_type: str = Query(..., description="方案类型"),
|
||||
@@ -131,7 +131,7 @@ async def update_scheme_link_field(
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/scheme/links", summary="删除方案管道数据")
|
||||
@router.delete("/timeseries/schemes/links", summary="删除方案管道数据")
|
||||
async def delete_scheme_links(
|
||||
scheme_type: str = Query(..., description="方案类型"),
|
||||
scheme_name: str = Query(..., description="方案名称"),
|
||||
@@ -159,7 +159,7 @@ async def delete_scheme_links(
|
||||
return {"message": "Deleted successfully"}
|
||||
|
||||
|
||||
@router.post("/scheme/nodes/batch", status_code=201, summary="批量插入方案节点数据")
|
||||
@router.post("/timeseries/schemes/nodes/batches", status_code=201, summary="批量插入方案节点数据")
|
||||
async def insert_scheme_nodes(
|
||||
data: List[dict] = Body(..., description="方案节点数据列表"),
|
||||
conn: AsyncConnection = Depends(get_timescale_connection),
|
||||
@@ -179,7 +179,7 @@ async def insert_scheme_nodes(
|
||||
return {"message": f"Inserted {len(data)} records"}
|
||||
|
||||
|
||||
@router.get("/scheme/nodes/{node_id}/field", summary="查询方案节点字段数据")
|
||||
@router.get("/timeseries/schemes/nodes/{node_id}/field", summary="查询方案节点字段数据")
|
||||
async def get_scheme_node_field(
|
||||
node_id: str = Path(..., description="节点ID"),
|
||||
scheme_type: str = Query(..., description="方案类型"),
|
||||
@@ -216,7 +216,7 @@ async def get_scheme_node_field(
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.patch("/scheme/nodes/{node_id}/field", summary="更新方案节点字段")
|
||||
@router.patch("/timeseries/schemes/nodes/{node_id}/field", summary="更新方案节点字段")
|
||||
async def update_scheme_node_field(
|
||||
node_id: str = Path(..., description="节点ID"),
|
||||
scheme_type: str = Query(..., description="方案类型"),
|
||||
@@ -254,7 +254,7 @@ async def update_scheme_node_field(
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/scheme/nodes", summary="删除方案节点数据")
|
||||
@router.delete("/timeseries/schemes/nodes", summary="删除方案节点数据")
|
||||
async def delete_scheme_nodes(
|
||||
scheme_type: str = Query(..., description="方案类型"),
|
||||
scheme_name: str = Query(..., description="方案名称"),
|
||||
@@ -282,7 +282,7 @@ async def delete_scheme_nodes(
|
||||
return {"message": "Deleted successfully"}
|
||||
|
||||
|
||||
@router.post("/scheme/simulation/store", status_code=201, summary="存储方案模拟结果")
|
||||
@router.post("/timeseries/schemes/simulation-results", status_code=201, summary="存储方案模拟结果")
|
||||
async def store_scheme_simulation_result(
|
||||
scheme_type: str = Query(..., description="方案类型"),
|
||||
scheme_name: str = Query(..., description="方案名称"),
|
||||
@@ -318,7 +318,7 @@ async def store_scheme_simulation_result(
|
||||
|
||||
|
||||
@router.get(
|
||||
"/scheme/query/by-scheme-time-property", summary="按方案、时间和属性查询数据"
|
||||
"/timeseries/schemes/records", summary="按方案、时间和属性查询数据"
|
||||
)
|
||||
async def query_scheme_records_by_scheme_time_property(
|
||||
scheme_type: str = Query(..., description="方案类型"),
|
||||
@@ -355,7 +355,7 @@ async def query_scheme_records_by_scheme_time_property(
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/scheme/query/by-id-time", summary="按ID和时间查询方案模拟数据")
|
||||
@router.get("/timeseries/schemes/simulation-results", summary="按ID和时间查询方案模拟数据")
|
||||
async def query_scheme_simulation_by_id_time(
|
||||
scheme_type: str = Query(..., description="方案类型"),
|
||||
scheme_name: str = Query(..., description="方案名称"),
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
"""
|
||||
用户管理 API 接口
|
||||
|
||||
演示权限控制的使用
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Path, Query
|
||||
from app.domain.schemas.user import UserResponse, UserUpdate, UserCreate
|
||||
from app.domain.models.role import UserRole
|
||||
from app.domain.schemas.user import UserInDB
|
||||
from app.infra.db.metadb.repositories.user_repository import UserRepository
|
||||
from app.auth.dependencies import get_user_repository, get_current_active_user
|
||||
from app.auth.permissions import get_current_admin, require_role, check_resource_owner
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/",
|
||||
summary="列出所有用户",
|
||||
description="获取用户列表(仅管理员)",
|
||||
response_model=List[UserResponse],
|
||||
)
|
||||
async def list_users(
|
||||
skip: int = Query(0, ge=0, description="跳过的用户数"),
|
||||
limit: int = Query(100, ge=1, le=1000, description="返回的最大用户数"),
|
||||
current_user: UserInDB = Depends(require_role(UserRole.ADMIN)),
|
||||
user_repo: UserRepository = Depends(get_user_repository),
|
||||
) -> List[UserResponse]:
|
||||
"""
|
||||
获取用户列表
|
||||
|
||||
获取系统中所有的用户信息(需要管理员权限)
|
||||
"""
|
||||
users = await user_repo.get_all_users(skip=skip, limit=limit)
|
||||
return [UserResponse.model_validate(user) for user in users]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{user_id}",
|
||||
summary="获取用户详情",
|
||||
description="获取指定用户的详细信息",
|
||||
response_model=UserResponse,
|
||||
)
|
||||
async def get_user(
|
||||
user_id: int = Path(..., gt=0, description="用户ID"),
|
||||
current_user: UserInDB = Depends(get_current_active_user),
|
||||
user_repo: UserRepository = Depends(get_user_repository),
|
||||
) -> UserResponse:
|
||||
"""
|
||||
获取用户详情
|
||||
|
||||
管理员可查看所有用户,普通用户只能查看自己
|
||||
"""
|
||||
# 检查权限
|
||||
if not check_resource_owner(user_id, current_user):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You don't have permission to view this user",
|
||||
)
|
||||
|
||||
user = await user_repo.get_user_by_id(user_id)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
|
||||
)
|
||||
|
||||
return UserResponse.model_validate(user)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{user_id}",
|
||||
summary="更新用户信息",
|
||||
description="更新指定用户的信息",
|
||||
response_model=UserResponse,
|
||||
)
|
||||
async def update_user(
|
||||
user_id: int = Path(..., gt=0, description="用户ID"),
|
||||
user_update: UserUpdate = None,
|
||||
current_user: UserInDB = Depends(get_current_active_user),
|
||||
user_repo: UserRepository = Depends(get_user_repository),
|
||||
) -> UserResponse:
|
||||
"""
|
||||
更新用户信息
|
||||
|
||||
管理员可更新所有用户,普通用户只能更新自己(且不能修改角色)
|
||||
"""
|
||||
# 检查用户是否存在
|
||||
target_user = await user_repo.get_user_by_id(user_id)
|
||||
if not target_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
|
||||
)
|
||||
|
||||
# 权限检查
|
||||
is_owner = current_user.id == user_id
|
||||
is_admin = UserRole(current_user.role).has_permission(UserRole.ADMIN)
|
||||
|
||||
if not is_owner and not is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You don't have permission to update this user",
|
||||
)
|
||||
|
||||
# 非管理员不能修改角色和激活状态
|
||||
if not is_admin:
|
||||
if user_update.role is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only admins can change user roles",
|
||||
)
|
||||
if user_update.is_active is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only admins can change user active status",
|
||||
)
|
||||
|
||||
# 更新用户
|
||||
updated_user = await user_repo.update_user(user_id, user_update)
|
||||
if not updated_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to update user",
|
||||
)
|
||||
|
||||
return UserResponse.model_validate(updated_user)
|
||||
|
||||
|
||||
@router.delete("/{user_id}", summary="删除用户", description="删除指定用户(仅管理员)")
|
||||
async def delete_user(
|
||||
user_id: int = Path(..., gt=0, description="用户ID"),
|
||||
current_user: UserInDB = Depends(get_current_admin),
|
||||
user_repo: UserRepository = Depends(get_user_repository),
|
||||
) -> dict:
|
||||
"""
|
||||
删除用户
|
||||
|
||||
删除指定用户(需要管理员权限,不能删除自己)
|
||||
"""
|
||||
# 不能删除自己
|
||||
if current_user.id == user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="You cannot delete your own account",
|
||||
)
|
||||
|
||||
success = await user_repo.delete_user(user_id)
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
|
||||
)
|
||||
|
||||
return {"message": "User deleted successfully"}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{user_id}/activate",
|
||||
summary="激活用户",
|
||||
description="激活指定用户账户(仅管理员)",
|
||||
response_model=UserResponse,
|
||||
)
|
||||
async def activate_user(
|
||||
user_id: int = Path(..., gt=0, description="用户ID"),
|
||||
current_user: UserInDB = Depends(get_current_admin),
|
||||
user_repo: UserRepository = Depends(get_user_repository),
|
||||
) -> UserResponse:
|
||||
"""
|
||||
激活用户
|
||||
|
||||
激活指定用户的账户(需要管理员权限)
|
||||
"""
|
||||
user_update = UserUpdate(is_active=True)
|
||||
updated_user = await user_repo.update_user(user_id, user_update)
|
||||
|
||||
if not updated_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
|
||||
)
|
||||
|
||||
return UserResponse.model_validate(updated_user)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{user_id}/deactivate",
|
||||
summary="停用用户",
|
||||
description="停用指定用户账户(仅管理员)",
|
||||
response_model=UserResponse,
|
||||
)
|
||||
async def deactivate_user(
|
||||
user_id: int = Path(..., gt=0, description="用户ID"),
|
||||
current_user: UserInDB = Depends(get_current_admin),
|
||||
user_repo: UserRepository = Depends(get_user_repository),
|
||||
) -> UserResponse:
|
||||
"""
|
||||
停用用户
|
||||
|
||||
停用指定用户的账户(需要管理员权限,不能停用自己)
|
||||
"""
|
||||
# 不能停用自己
|
||||
if current_user.id == user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="You cannot deactivate your own account",
|
||||
)
|
||||
|
||||
user_update = UserUpdate(is_active=False)
|
||||
updated_user = await user_repo.update_user(user_id, user_update)
|
||||
|
||||
if not updated_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
|
||||
)
|
||||
|
||||
return UserResponse.model_validate(updated_user)
|
||||
@@ -8,7 +8,7 @@ router = APIRouter()
|
||||
# user 39
|
||||
###########################################################
|
||||
|
||||
@router.get("/getuserschema/", summary="获取用户模式", description="获取指定网络的用户模式定义")
|
||||
@router.get("/network-schemas/user", summary="获取用户模式", description="获取指定网络的用户模式定义")
|
||||
async def fastapi_get_user_schema(network: str = Query(..., description="管网名称(或数据库名称)")) -> dict[str, dict[Any, Any]]:
|
||||
"""
|
||||
获取用户模式定义
|
||||
@@ -17,7 +17,7 @@ async def fastapi_get_user_schema(network: str = Query(..., description="管网
|
||||
"""
|
||||
return get_user_schema(network)
|
||||
|
||||
@router.get("/getuser/", summary="获取单个用户", description="获取指定网络中的单个用户信息")
|
||||
@router.get("/users/detail", summary="获取单个用户", description="获取指定网络中的单个用户信息")
|
||||
async def fastapi_get_user(network: str = Query(..., description="管网名称(或数据库名称)"), user_name: str = Query(..., description="用户名")) -> dict[Any, Any]:
|
||||
"""
|
||||
获取用户信息
|
||||
@@ -26,7 +26,7 @@ async def fastapi_get_user(network: str = Query(..., description="管网名称
|
||||
"""
|
||||
return get_user(network, user_name)
|
||||
|
||||
@router.get("/getallusers/", summary="获取所有用户", description="获取指定网络的所有用户列表")
|
||||
@router.get("/users", summary="获取所有用户", description="获取指定网络的所有用户列表")
|
||||
async def fastapi_get_all_users(network: str = Query(..., description="管网名称(或数据库名称)")) -> list[dict[Any, Any]]:
|
||||
"""
|
||||
获取所有用户列表
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
from app.services.web_search import (
|
||||
BochaSearchAPIError,
|
||||
BochaSearchConfigError,
|
||||
WebSearchRequest,
|
||||
search_bocha_web,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/web-searches",
|
||||
summary="Web Search",
|
||||
description="调用 Bocha Web Search API 获取实时网页搜索结果",
|
||||
)
|
||||
async def web_search(request: WebSearchRequest) -> dict[str, Any]:
|
||||
try:
|
||||
return await search_bocha_web(request)
|
||||
except BochaSearchConfigError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
except BochaSearchAPIError as exc:
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
|
||||
@@ -0,0 +1,371 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import re
|
||||
from collections.abc import Iterable
|
||||
from copy import copy
|
||||
from functools import wraps
|
||||
from typing import Any, Generic, TypeVar, get_args, get_origin
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.routing import APIRoute
|
||||
from pydantic import BaseModel, JsonValue, create_model
|
||||
|
||||
from app.api.problem_details import ProblemDetails
|
||||
from app.api.v1.router import api_router as handler_api_router
|
||||
from app.auth.metadata_dependencies import get_current_metadata_user
|
||||
from app.auth.project_dependencies import ProjectContext, get_project_context
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class Page(BaseModel, Generic[T]):
|
||||
items: list[T]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
_NAME_IS_NETWORK = {
|
||||
"pressure_sensor_placement_sensitivity_endpoint",
|
||||
"pressure_sensor_placement_kmeans_endpoint",
|
||||
}
|
||||
_DERIVE_USERNAME = {
|
||||
"pressure_sensor_placement_sensitivity_endpoint": "username",
|
||||
"pressure_sensor_placement_kmeans_endpoint": "username",
|
||||
"fastapi_pressure_sensor_placement": "user_name",
|
||||
}
|
||||
_PUBLIC_PARAMETER_RENAMES = {
|
||||
"burst_ID": "burst_id",
|
||||
"drainage_node_ID": "drainage_node_id",
|
||||
}
|
||||
_MODEL_NAME_IS_NETWORK = {"RunSimulationManuallyByDate"}
|
||||
_MODEL_USERNAME_FROM_AUTH: set[str] = set()
|
||||
|
||||
|
||||
def _clean_name(name: str) -> str:
|
||||
for prefix in ("fastapi_", "fast_"):
|
||||
if name.startswith(prefix):
|
||||
name = name[len(prefix) :]
|
||||
break
|
||||
if name.endswith("_endpoint"):
|
||||
name = name[: -len("_endpoint")]
|
||||
return name
|
||||
|
||||
|
||||
def _rest_body_model(annotation):
|
||||
if not inspect.isclass(annotation) or not issubclass(annotation, BaseModel):
|
||||
return None
|
||||
project_fields = {
|
||||
name
|
||||
for name in ("network", "network_name")
|
||||
if name in annotation.model_fields
|
||||
}
|
||||
if annotation.__name__ in _MODEL_NAME_IS_NETWORK and "name" in annotation.model_fields:
|
||||
project_fields.add("name")
|
||||
username_fields = (
|
||||
{
|
||||
name
|
||||
for name in ("username", "user_name")
|
||||
if name in annotation.model_fields
|
||||
}
|
||||
if annotation.__name__ in _MODEL_USERNAME_FROM_AUTH
|
||||
else set()
|
||||
)
|
||||
excluded_fields = project_fields | username_fields
|
||||
if not excluded_fields:
|
||||
return None
|
||||
|
||||
public_fields = {
|
||||
name: (field.annotation, copy(field))
|
||||
for name, field in annotation.model_fields.items()
|
||||
if name not in excluded_fields
|
||||
}
|
||||
public_model = create_model(
|
||||
f"{annotation.__name__}Rest",
|
||||
__module__=annotation.__module__,
|
||||
**public_fields,
|
||||
)
|
||||
return annotation, public_model, project_fields, username_fields
|
||||
|
||||
|
||||
def _with_header_project_context(endpoint, route_name: str):
|
||||
signature = inspect.signature(endpoint)
|
||||
network_parameters = [
|
||||
name for name in ("network", "network_name") if name in signature.parameters
|
||||
]
|
||||
if route_name in _NAME_IS_NETWORK and "name" in signature.parameters:
|
||||
network_parameters.append("name")
|
||||
username_parameter = _DERIVE_USERNAME.get(route_name)
|
||||
parameter_renames = {
|
||||
internal: public
|
||||
for internal, public in _PUBLIC_PARAMETER_RENAMES.items()
|
||||
if internal in signature.parameters
|
||||
}
|
||||
body_models = {
|
||||
name: body_model
|
||||
for name, parameter in signature.parameters.items()
|
||||
if (body_model := _rest_body_model(parameter.annotation)) is not None
|
||||
}
|
||||
model_has_username = any(model[3] for model in body_models.values())
|
||||
if (
|
||||
not network_parameters
|
||||
and not username_parameter
|
||||
and not parameter_renames
|
||||
and not body_models
|
||||
):
|
||||
return endpoint
|
||||
|
||||
existing_context_parameter = next(
|
||||
(
|
||||
name
|
||||
for name, parameter in signature.parameters.items()
|
||||
if parameter.annotation is ProjectContext
|
||||
),
|
||||
None,
|
||||
)
|
||||
injected_context_name = existing_context_parameter or "_rest_project_context"
|
||||
injected_user_name = "_rest_current_user"
|
||||
|
||||
@wraps(endpoint)
|
||||
async def wrapper(*args, **kwargs):
|
||||
project_context = kwargs.get(injected_context_name)
|
||||
if not isinstance(project_context, ProjectContext):
|
||||
raise RuntimeError("REST project context was not resolved")
|
||||
if not existing_context_parameter:
|
||||
kwargs.pop(injected_context_name, None)
|
||||
for parameter_name in network_parameters:
|
||||
kwargs[parameter_name] = project_context.project_code
|
||||
if username_parameter:
|
||||
kwargs[username_parameter] = kwargs[injected_user_name].username
|
||||
kwargs.pop(injected_user_name, None)
|
||||
for internal_name, public_name in parameter_renames.items():
|
||||
kwargs[internal_name] = kwargs.pop(public_name)
|
||||
for parameter_name, (
|
||||
original_model,
|
||||
_public_model,
|
||||
project_fields,
|
||||
username_fields,
|
||||
) in body_models.items():
|
||||
data = kwargs[parameter_name].model_dump()
|
||||
data.update(
|
||||
{field_name: project_context.project_code for field_name in project_fields}
|
||||
)
|
||||
if username_fields:
|
||||
current_user = kwargs[injected_user_name]
|
||||
data.update(
|
||||
{field_name: current_user.username for field_name in username_fields}
|
||||
)
|
||||
kwargs[parameter_name] = original_model.model_validate(data)
|
||||
if model_has_username:
|
||||
kwargs.pop(injected_user_name, None)
|
||||
result = endpoint(*args, **kwargs)
|
||||
if inspect.isawaitable(result):
|
||||
return await result
|
||||
return result
|
||||
|
||||
parameters = []
|
||||
for name, parameter in signature.parameters.items():
|
||||
if name in network_parameters or name == username_parameter:
|
||||
continue
|
||||
public_name = parameter_renames.get(name, name)
|
||||
if public_name != name:
|
||||
default = copy(parameter.default)
|
||||
default.alias = public_name
|
||||
default.validation_alias = public_name
|
||||
default.serialization_alias = public_name
|
||||
parameter = parameter.replace(name=public_name, default=default)
|
||||
if name in body_models:
|
||||
parameter = parameter.replace(annotation=body_models[name][1])
|
||||
parameters.append(parameter)
|
||||
if not existing_context_parameter:
|
||||
parameters.append(
|
||||
inspect.Parameter(
|
||||
injected_context_name,
|
||||
kind=inspect.Parameter.KEYWORD_ONLY,
|
||||
annotation=ProjectContext,
|
||||
default=Depends(get_project_context),
|
||||
)
|
||||
)
|
||||
if username_parameter or model_has_username:
|
||||
parameters.append(
|
||||
inspect.Parameter(
|
||||
injected_user_name,
|
||||
kind=inspect.Parameter.KEYWORD_ONLY,
|
||||
default=Depends(get_current_metadata_user),
|
||||
)
|
||||
)
|
||||
wrapper.__signature__ = signature.replace(parameters=parameters)
|
||||
return wrapper
|
||||
|
||||
|
||||
def _with_pagination(endpoint):
|
||||
signature = inspect.signature(endpoint)
|
||||
handler_limit_parameter = "limit" if "limit" in signature.parameters else None
|
||||
handler_offset_parameter = next(
|
||||
(
|
||||
parameter_name
|
||||
for parameter_name in ("offset", "skip")
|
||||
if parameter_name in signature.parameters
|
||||
),
|
||||
None,
|
||||
)
|
||||
handler_handles_pagination = bool(
|
||||
handler_limit_parameter or handler_offset_parameter
|
||||
)
|
||||
|
||||
@wraps(endpoint)
|
||||
async def wrapper(*args, **kwargs):
|
||||
if handler_handles_pagination:
|
||||
limit = kwargs.get(handler_limit_parameter, 0)
|
||||
offset = kwargs.get(handler_offset_parameter, 0)
|
||||
else:
|
||||
limit = kwargs.pop("_rest_limit")
|
||||
offset = kwargs.pop("_rest_offset")
|
||||
result = endpoint(*args, **kwargs)
|
||||
if inspect.isawaitable(result):
|
||||
result = await result
|
||||
if not isinstance(result, list):
|
||||
return result
|
||||
if handler_handles_pagination:
|
||||
return Page(
|
||||
items=result,
|
||||
total=offset + len(result),
|
||||
limit=limit or len(result),
|
||||
offset=offset,
|
||||
)
|
||||
return Page(
|
||||
items=result[offset : offset + limit],
|
||||
total=len(result),
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
parameters = list(signature.parameters.values())
|
||||
if not handler_handles_pagination:
|
||||
parameters.extend(
|
||||
[
|
||||
inspect.Parameter(
|
||||
"_rest_limit",
|
||||
kind=inspect.Parameter.KEYWORD_ONLY,
|
||||
annotation=int,
|
||||
default=Query(100, ge=1, le=1000, alias="limit"),
|
||||
),
|
||||
inspect.Parameter(
|
||||
"_rest_offset",
|
||||
kind=inspect.Parameter.KEYWORD_ONLY,
|
||||
annotation=int,
|
||||
default=Query(0, ge=0, alias="offset"),
|
||||
),
|
||||
]
|
||||
)
|
||||
wrapper.__signature__ = signature.replace(parameters=parameters)
|
||||
return wrapper
|
||||
|
||||
|
||||
def _adapt_route(route: APIRoute) -> APIRoute:
|
||||
methods = route.methods or set()
|
||||
if len(methods) != 1:
|
||||
raise RuntimeError(
|
||||
f"REST route {route.name!r} must declare exactly one HTTP method"
|
||||
)
|
||||
method = next(iter(methods))
|
||||
responses = dict(route.responses or {})
|
||||
for status_code, description in (
|
||||
(401, "Authentication required"),
|
||||
(403, "Insufficient permission"),
|
||||
(404, "Resource not found"),
|
||||
(409, "Resource conflict"),
|
||||
(422, "Validation error"),
|
||||
(503, "Dependency unavailable"),
|
||||
):
|
||||
responses.setdefault(
|
||||
status_code,
|
||||
{"model": ProblemDetails, "description": description},
|
||||
)
|
||||
|
||||
endpoint = _with_header_project_context(route.endpoint, route.name)
|
||||
response_model = route.response_model
|
||||
if get_origin(response_model) is list:
|
||||
item_type = get_args(response_model)[0] if get_args(response_model) else JsonValue
|
||||
response_model = Page[item_type]
|
||||
endpoint = _with_pagination(endpoint)
|
||||
|
||||
clean_name = _clean_name(route.name)
|
||||
creates_resource = clean_name.startswith(
|
||||
("add_", "create_", "copy_", "import_", "insert_", "store_", "take_", "upload_")
|
||||
) or route.name == "fastapi_pressure_sensor_placement"
|
||||
status_code = (
|
||||
204
|
||||
if method == "DELETE"
|
||||
else 201
|
||||
if method == "POST" and creates_resource
|
||||
else route.status_code
|
||||
)
|
||||
if status_code == 204:
|
||||
response_model = None
|
||||
elif response_model is None:
|
||||
response_model = JsonValue
|
||||
|
||||
return APIRoute(
|
||||
path=route.path,
|
||||
endpoint=endpoint,
|
||||
response_model=response_model,
|
||||
status_code=status_code,
|
||||
tags=route.tags,
|
||||
dependencies=route.dependencies,
|
||||
summary=route.summary,
|
||||
description=route.description,
|
||||
response_description=route.response_description,
|
||||
responses=responses,
|
||||
deprecated=False,
|
||||
name=route.name,
|
||||
methods={method},
|
||||
operation_id=f"{method.lower()}_{re.sub(r'[^a-z0-9]+', '_', route.path).strip('_')}",
|
||||
response_model_include=route.response_model_include,
|
||||
response_model_exclude=route.response_model_exclude,
|
||||
response_model_by_alias=route.response_model_by_alias,
|
||||
response_model_exclude_unset=route.response_model_exclude_unset,
|
||||
response_model_exclude_defaults=route.response_model_exclude_defaults,
|
||||
response_model_exclude_none=route.response_model_exclude_none,
|
||||
include_in_schema=route.include_in_schema,
|
||||
response_class=route.response_class,
|
||||
callbacks=route.callbacks,
|
||||
openapi_extra=route.openapi_extra,
|
||||
)
|
||||
|
||||
|
||||
def build_rest_router(routes: Iterable[Any]) -> APIRouter:
|
||||
router = APIRouter()
|
||||
seen: dict[tuple[str, str], APIRoute] = {}
|
||||
operation_ids: set[str] = set()
|
||||
|
||||
for route in routes:
|
||||
if not isinstance(route, APIRoute):
|
||||
continue
|
||||
|
||||
methods = route.methods or set()
|
||||
if len(methods) != 1:
|
||||
raise RuntimeError(
|
||||
f"REST route {route.name!r} must declare exactly one HTTP method"
|
||||
)
|
||||
method = next(iter(methods))
|
||||
key = (method, route.path)
|
||||
if key in seen:
|
||||
previous = seen[key]
|
||||
raise RuntimeError(
|
||||
"REST route collision for "
|
||||
f"{method} {route.path}: {previous.name!r} and {route.name!r}."
|
||||
)
|
||||
|
||||
adapted = _adapt_route(route)
|
||||
if adapted.operation_id in operation_ids:
|
||||
adapted.operation_id = f"{adapted.operation_id}_{route.name}"
|
||||
seen[key] = route
|
||||
operation_ids.add(adapted.operation_id or "")
|
||||
router.routes.append(adapted)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
api_router = build_rest_router(handler_api_router.routes)
|
||||
+212
-90
@@ -1,114 +1,236 @@
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.api.v1.endpoints import (
|
||||
auth,
|
||||
project,
|
||||
simulation,
|
||||
scada,
|
||||
extension,
|
||||
snapshots,
|
||||
# data_query,
|
||||
users,
|
||||
schemes,
|
||||
misc,
|
||||
risk,
|
||||
cache,
|
||||
leakage,
|
||||
access,
|
||||
admin_metadata,
|
||||
agent_auth,
|
||||
audit,
|
||||
burst_detection,
|
||||
burst_location,
|
||||
user_management, # 新增:用户管理
|
||||
audit, # 新增:审计日志
|
||||
cache,
|
||||
extension,
|
||||
geocoding,
|
||||
leakage,
|
||||
meta,
|
||||
)
|
||||
from app.api.v1.endpoints.network import (
|
||||
general,
|
||||
junctions,
|
||||
reservoirs,
|
||||
tanks,
|
||||
pipes,
|
||||
pumps,
|
||||
valves,
|
||||
tags,
|
||||
demands,
|
||||
geometry,
|
||||
regions,
|
||||
misc,
|
||||
model_import,
|
||||
project,
|
||||
project_data,
|
||||
risk,
|
||||
scada,
|
||||
schemes,
|
||||
sensor_placement,
|
||||
simulation,
|
||||
snapshots,
|
||||
users,
|
||||
web_search,
|
||||
)
|
||||
from app.api.v1.endpoints.components import (
|
||||
curves,
|
||||
patterns,
|
||||
controls,
|
||||
curves,
|
||||
options,
|
||||
patterns,
|
||||
quality,
|
||||
visuals,
|
||||
)
|
||||
|
||||
from app.api.v1.endpoints import project_data
|
||||
from app.api.v1.endpoints.network import (
|
||||
demands,
|
||||
general,
|
||||
geometry,
|
||||
junctions,
|
||||
pipes,
|
||||
pumps,
|
||||
regions,
|
||||
reservoirs,
|
||||
tags,
|
||||
tanks,
|
||||
valves,
|
||||
)
|
||||
from app.api.v1.endpoints.timeseries import (
|
||||
realtime as ts_realtime,
|
||||
scheme as ts_scheme,
|
||||
scada as ts_scada,
|
||||
composite as ts_composite,
|
||||
realtime as ts_realtime,
|
||||
scada as ts_scada,
|
||||
scheme as ts_scheme,
|
||||
)
|
||||
from app.auth.permissions import (
|
||||
BURST_RUN,
|
||||
OPTIMIZATION_RUN,
|
||||
RISK_RUN,
|
||||
SCADA_CLEAN,
|
||||
SCADA_VIEW,
|
||||
SIMULATION_RUN,
|
||||
SIMULATION_VIEW,
|
||||
WEBGIS_EDIT,
|
||||
WEBGIS_VIEW,
|
||||
require_method_permission,
|
||||
require_permission,
|
||||
)
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
# Core Services
|
||||
api_router.include_router(auth.router, prefix="/auth", tags=["Auth"])
|
||||
webgis_access = Depends(
|
||||
require_method_permission(
|
||||
read_permission=WEBGIS_VIEW,
|
||||
write_permission=WEBGIS_EDIT,
|
||||
)
|
||||
)
|
||||
scada_access = Depends(
|
||||
require_method_permission(
|
||||
read_permission=SCADA_VIEW,
|
||||
write_permission=SCADA_CLEAN,
|
||||
)
|
||||
)
|
||||
simulation_access = Depends(
|
||||
require_method_permission(
|
||||
read_permission=SIMULATION_VIEW,
|
||||
write_permission=SIMULATION_RUN,
|
||||
)
|
||||
)
|
||||
|
||||
webgis_view_access = Depends(require_permission(WEBGIS_VIEW))
|
||||
simulation_run_access = Depends(require_permission(SIMULATION_RUN))
|
||||
burst_run_access = Depends(require_permission(BURST_RUN))
|
||||
risk_run_access = Depends(require_permission(RISK_RUN))
|
||||
optimization_run_access = Depends(require_permission(OPTIMIZATION_RUN))
|
||||
|
||||
# Core services
|
||||
api_router.include_router(access.router, tags=["Access Control"])
|
||||
api_router.include_router(agent_auth.router, tags=["Agent Auth"])
|
||||
api_router.include_router(
|
||||
user_management.router, prefix="/users", tags=["User Management"]
|
||||
) # 新增
|
||||
api_router.include_router(audit.router, prefix="/audit", tags=["Audit Logs"]) # 新增
|
||||
admin_metadata.router,
|
||||
tags=["Metadata Admin"],
|
||||
)
|
||||
api_router.include_router(model_import.router, tags=["Model Administration"])
|
||||
api_router.include_router(audit.router, tags=["Audit Logs"])
|
||||
api_router.include_router(meta.router, tags=["Metadata"])
|
||||
api_router.include_router(project.router, tags=["Project"])
|
||||
|
||||
# Network Elements (Node/Link Types)
|
||||
api_router.include_router(general.router, tags=["Network General"])
|
||||
api_router.include_router(junctions.router, tags=["Junctions"])
|
||||
api_router.include_router(reservoirs.router, tags=["Reservoirs"])
|
||||
api_router.include_router(tanks.router, tags=["Tanks"])
|
||||
api_router.include_router(pipes.router, tags=["Pipes"])
|
||||
api_router.include_router(pumps.router, tags=["Pumps"])
|
||||
api_router.include_router(valves.router, tags=["Valves"])
|
||||
|
||||
# Network Features
|
||||
api_router.include_router(tags.router, tags=["Tags"])
|
||||
api_router.include_router(demands.router, tags=["Demands"])
|
||||
api_router.include_router(geometry.router, tags=["Geometry & Coordinates"])
|
||||
api_router.include_router(regions.router, tags=["Regions & DMAs"])
|
||||
|
||||
# Components & Controls
|
||||
api_router.include_router(curves.router, tags=["Curves"])
|
||||
api_router.include_router(patterns.router, tags=["Patterns"])
|
||||
api_router.include_router(controls.router, tags=["Controls & Rules"])
|
||||
api_router.include_router(options.router, tags=["Options"])
|
||||
api_router.include_router(quality.router, tags=["Quality"])
|
||||
api_router.include_router(visuals.router, tags=["Visuals"])
|
||||
|
||||
# Simulation & Data
|
||||
api_router.include_router(simulation.router, tags=["Simulation Control"])
|
||||
# api_router.include_router(data_query.router, tags=["Data Query & InfluxDB"])
|
||||
api_router.include_router(scada.router)
|
||||
api_router.include_router(snapshots.router, tags=["Snapshots"])
|
||||
api_router.include_router(users.router, tags=["Users"])
|
||||
api_router.include_router(schemes.router, tags=["Schemes"])
|
||||
api_router.include_router(misc.router, tags=["Misc"])
|
||||
api_router.include_router(risk.router, tags=["Risk"])
|
||||
api_router.include_router(cache.router, tags=["Cache"])
|
||||
api_router.include_router(leakage.router, prefix="/leakage", tags=["Leakage"])
|
||||
api_router.include_router(
|
||||
burst_detection.router, prefix="/burst-detection", tags=["Burst Detection"]
|
||||
)
|
||||
api_router.include_router(
|
||||
burst_location.router, prefix="/burst-location", tags=["Burst Location"]
|
||||
project.router,
|
||||
tags=["Project"],
|
||||
dependencies=[webgis_access],
|
||||
)
|
||||
|
||||
# TimescaleDB Data Access
|
||||
api_router.include_router(ts_realtime.router, tags=["TimescaleDB - Realtime"])
|
||||
api_router.include_router(ts_scheme.router, tags=["TimescaleDB - Scheme"])
|
||||
api_router.include_router(ts_scada.router, tags=["TimescaleDB - SCADA"])
|
||||
api_router.include_router(ts_composite.router, tags=["TimescaleDB - Composite"])
|
||||
# WebGIS data
|
||||
for endpoint_router, tag in (
|
||||
(general.router, "Network General"),
|
||||
(junctions.router, "Junctions"),
|
||||
(reservoirs.router, "Reservoirs"),
|
||||
(tanks.router, "Tanks"),
|
||||
(pipes.router, "Pipes"),
|
||||
(pumps.router, "Pumps"),
|
||||
(valves.router, "Valves"),
|
||||
(tags.router, "Tags"),
|
||||
(demands.router, "Demands"),
|
||||
(geometry.router, "Geometry & Coordinates"),
|
||||
(regions.router, "Regions & DMAs"),
|
||||
(curves.router, "Curves"),
|
||||
(patterns.router, "Patterns"),
|
||||
(controls.router, "Controls & Rules"),
|
||||
(options.router, "Options"),
|
||||
(quality.router, "Quality"),
|
||||
(visuals.router, "Visuals"),
|
||||
):
|
||||
api_router.include_router(
|
||||
endpoint_router,
|
||||
tags=[tag],
|
||||
dependencies=[webgis_access],
|
||||
)
|
||||
|
||||
# Project Data (PostgreSQL)
|
||||
api_router.include_router(project_data.router, tags=["Project Data"])
|
||||
# Simulation and analysis
|
||||
api_router.include_router(
|
||||
simulation.router,
|
||||
tags=["Simulation Control"],
|
||||
dependencies=[simulation_run_access],
|
||||
)
|
||||
api_router.include_router(scada.router, dependencies=[scada_access])
|
||||
api_router.include_router(
|
||||
sensor_placement.router,
|
||||
tags=["Sensor Placement"],
|
||||
dependencies=[optimization_run_access],
|
||||
)
|
||||
api_router.include_router(
|
||||
snapshots.router,
|
||||
tags=["Snapshots"],
|
||||
dependencies=[simulation_access],
|
||||
)
|
||||
api_router.include_router(
|
||||
users.router,
|
||||
tags=["Users"],
|
||||
dependencies=[webgis_view_access],
|
||||
)
|
||||
api_router.include_router(
|
||||
schemes.router,
|
||||
tags=["Schemes"],
|
||||
dependencies=[simulation_access],
|
||||
)
|
||||
api_router.include_router(
|
||||
misc.router,
|
||||
tags=["Misc"],
|
||||
dependencies=[webgis_view_access],
|
||||
)
|
||||
api_router.include_router(
|
||||
risk.router,
|
||||
tags=["Risk"],
|
||||
dependencies=[risk_run_access],
|
||||
)
|
||||
api_router.include_router(
|
||||
cache.router,
|
||||
tags=["Cache"],
|
||||
dependencies=[simulation_run_access],
|
||||
)
|
||||
api_router.include_router(
|
||||
web_search.router,
|
||||
tags=["Web Search"],
|
||||
dependencies=[webgis_view_access],
|
||||
)
|
||||
api_router.include_router(
|
||||
geocoding.router,
|
||||
tags=["Geocoding"],
|
||||
dependencies=[webgis_view_access],
|
||||
)
|
||||
api_router.include_router(
|
||||
leakage.router,
|
||||
tags=["Leakage"],
|
||||
dependencies=[burst_run_access],
|
||||
)
|
||||
api_router.include_router(
|
||||
burst_detection.router,
|
||||
tags=["Burst Detection"],
|
||||
dependencies=[burst_run_access],
|
||||
)
|
||||
api_router.include_router(
|
||||
burst_location.router,
|
||||
tags=["Burst Location"],
|
||||
dependencies=[burst_run_access],
|
||||
)
|
||||
|
||||
# Extension
|
||||
api_router.include_router(extension.router, tags=["Extension"])
|
||||
# TimescaleDB data
|
||||
for endpoint_router, tag in (
|
||||
(ts_realtime.router, "TimescaleDB - Realtime"),
|
||||
(ts_scheme.router, "TimescaleDB - Scheme"),
|
||||
):
|
||||
api_router.include_router(
|
||||
endpoint_router,
|
||||
tags=[tag],
|
||||
dependencies=[simulation_access],
|
||||
)
|
||||
|
||||
for endpoint_router, tag in (
|
||||
(ts_scada.router, "TimescaleDB - SCADA"),
|
||||
(ts_composite.router, "TimescaleDB - Composite"),
|
||||
):
|
||||
api_router.include_router(
|
||||
endpoint_router,
|
||||
tags=[tag],
|
||||
dependencies=[scada_access],
|
||||
)
|
||||
|
||||
api_router.include_router(
|
||||
project_data.router,
|
||||
tags=["Project Data"],
|
||||
dependencies=[webgis_view_access],
|
||||
)
|
||||
api_router.include_router(
|
||||
extension.router,
|
||||
tags=["Extension"],
|
||||
dependencies=[webgis_access],
|
||||
)
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
from typing import Annotated, Optional
|
||||
from fastapi import Depends, HTTPException, status, Request
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from jose import jwt, JWTError
|
||||
from app.core.config import settings
|
||||
from app.domain.schemas.user import UserInDB, TokenPayload
|
||||
from app.infra.db.metadb.repositories.user_repository import UserRepository
|
||||
from app.infra.db.postgresql.database import Database
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"{settings.API_V1_STR}/auth/login")
|
||||
|
||||
|
||||
# 数据库依赖
|
||||
async def get_db(request: Request) -> Database:
|
||||
"""
|
||||
获取数据库实例
|
||||
|
||||
从 FastAPI app.state 中获取在启动时初始化的数据库连接
|
||||
"""
|
||||
if not hasattr(request.app.state, "db"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Database not initialized",
|
||||
)
|
||||
return request.app.state.db
|
||||
|
||||
|
||||
async def get_user_repository(db: Database = Depends(get_db)) -> UserRepository:
|
||||
"""获取用户仓储实例"""
|
||||
return UserRepository(db)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
token: str = Depends(oauth2_scheme),
|
||||
user_repo: UserRepository = Depends(get_user_repository),
|
||||
) -> UserInDB:
|
||||
"""
|
||||
获取当前登录用户
|
||||
|
||||
从 JWT Token 中解析用户信息,并从数据库验证
|
||||
"""
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
|
||||
)
|
||||
username: str = payload.get("sub")
|
||||
token_type: str = payload.get("type", "access")
|
||||
|
||||
if username is None:
|
||||
raise credentials_exception
|
||||
|
||||
if token_type != "access":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token type. Access token required.",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
# 从数据库获取用户
|
||||
user = await user_repo.get_user_by_username(username)
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_active_user(
|
||||
current_user: UserInDB = Depends(get_current_user),
|
||||
) -> UserInDB:
|
||||
"""
|
||||
获取当前活跃用户(必须是激活状态)
|
||||
"""
|
||||
if not current_user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user"
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
async def get_current_superuser(
|
||||
current_user: UserInDB = Depends(get_current_user),
|
||||
) -> UserInDB:
|
||||
"""
|
||||
获取当前超级管理员用户
|
||||
"""
|
||||
if not current_user.is_superuser:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough privileges. Superuser access required.",
|
||||
)
|
||||
return current_user
|
||||
@@ -8,35 +8,41 @@ from jose import JWTError, jwt
|
||||
from app.core.config import settings
|
||||
|
||||
oauth2_optional = OAuth2PasswordBearer(
|
||||
tokenUrl=f"{settings.API_V1_STR}/auth/login", auto_error=False
|
||||
tokenUrl="keycloak", auto_error=False
|
||||
)
|
||||
|
||||
# logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_current_keycloak_sub(
|
||||
def _decode_keycloak_token(token: str) -> dict:
|
||||
if not settings.KEYCLOAK_PUBLIC_KEY:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Keycloak public key is not configured",
|
||||
)
|
||||
|
||||
key = settings.KEYCLOAK_PUBLIC_KEY.replace("\\n", "\n")
|
||||
|
||||
return jwt.decode(
|
||||
token,
|
||||
key,
|
||||
algorithms=[settings.KEYCLOAK_ALGORITHM],
|
||||
audience=settings.KEYCLOAK_AUDIENCE or None,
|
||||
)
|
||||
|
||||
|
||||
async def get_current_keycloak_payload(
|
||||
token: str | None = Depends(oauth2_optional),
|
||||
) -> UUID:
|
||||
) -> dict:
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Not authenticated",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
if settings.KEYCLOAK_PUBLIC_KEY:
|
||||
key = settings.KEYCLOAK_PUBLIC_KEY.replace("\\n", "\n")
|
||||
algorithms = [settings.KEYCLOAK_ALGORITHM]
|
||||
else:
|
||||
key = settings.SECRET_KEY
|
||||
algorithms = [settings.ALGORITHM]
|
||||
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
key,
|
||||
algorithms=algorithms,
|
||||
audience=settings.KEYCLOAK_AUDIENCE or None,
|
||||
)
|
||||
return _decode_keycloak_token(token)
|
||||
except JWTError as exc:
|
||||
# logger.warning("Keycloak token validation failed: %s", exc)
|
||||
raise HTTPException(
|
||||
@@ -45,6 +51,10 @@ async def get_current_keycloak_sub(
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
) from exc
|
||||
|
||||
|
||||
async def get_current_keycloak_sub(
|
||||
payload: dict = Depends(get_current_keycloak_payload),
|
||||
) -> UUID:
|
||||
sub = payload.get("sub")
|
||||
if not sub:
|
||||
raise HTTPException(
|
||||
@@ -63,41 +73,18 @@ async def get_current_keycloak_sub(
|
||||
) from exc
|
||||
|
||||
|
||||
async def get_current_keycloak_username(
|
||||
token: str | None = Depends(oauth2_optional),
|
||||
) -> str:
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Not authenticated",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
if settings.KEYCLOAK_PUBLIC_KEY:
|
||||
key = settings.KEYCLOAK_PUBLIC_KEY.replace("\\n", "\n")
|
||||
algorithms = [settings.KEYCLOAK_ALGORITHM]
|
||||
else:
|
||||
key = settings.SECRET_KEY
|
||||
algorithms = [settings.ALGORITHM]
|
||||
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
key,
|
||||
algorithms=algorithms,
|
||||
audience=settings.KEYCLOAK_AUDIENCE or None,
|
||||
)
|
||||
except JWTError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
) from exc
|
||||
|
||||
username = payload.get("preferred_username") or payload.get("username")
|
||||
def get_keycloak_preferred_username(payload: dict) -> str:
|
||||
username = payload.get("preferred_username")
|
||||
if not username:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Missing username claim",
|
||||
detail="Missing preferred_username claim",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return str(username)
|
||||
|
||||
|
||||
async def get_current_keycloak_username(
|
||||
payload: dict = Depends(get_current_keycloak_payload),
|
||||
) -> str:
|
||||
return get_keycloak_preferred_username(payload)
|
||||
|
||||
@@ -6,8 +6,10 @@ from fastapi import Depends, HTTPException, status
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.keycloak_dependencies import get_current_keycloak_sub
|
||||
from app.core.config import settings
|
||||
from app.auth.keycloak_dependencies import (
|
||||
get_current_keycloak_payload,
|
||||
get_keycloak_preferred_username,
|
||||
)
|
||||
from app.infra.db.metadb.database import get_metadata_session
|
||||
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
|
||||
|
||||
@@ -20,10 +22,36 @@ async def get_metadata_repository(
|
||||
return MetadataRepository(session)
|
||||
|
||||
|
||||
def _keycloak_sub_from_payload(payload: dict) -> UUID:
|
||||
sub = payload.get("sub")
|
||||
if not sub:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Missing subject claim",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
try:
|
||||
return UUID(str(sub))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid subject claim",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
) from exc
|
||||
|
||||
|
||||
def _email_from_payload(payload: dict) -> str | None:
|
||||
email = payload.get("email")
|
||||
return str(email) if email else None
|
||||
|
||||
|
||||
async def get_current_metadata_user(
|
||||
keycloak_sub: UUID = Depends(get_current_keycloak_sub),
|
||||
keycloak_payload: dict = Depends(get_current_keycloak_payload),
|
||||
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||
):
|
||||
keycloak_sub = _keycloak_sub_from_payload(keycloak_payload)
|
||||
username = get_keycloak_preferred_username(keycloak_payload)
|
||||
try:
|
||||
user = await metadata_repo.get_user_by_keycloak_id(keycloak_sub)
|
||||
except SQLAlchemyError as exc:
|
||||
@@ -33,12 +61,27 @@ async def get_current_metadata_user(
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=f"Metadata database error: {exc}",
|
||||
detail="Metadata database is unavailable",
|
||||
) from exc
|
||||
if not user or not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user"
|
||||
)
|
||||
try:
|
||||
user = await metadata_repo.refresh_user_keycloak_snapshot(
|
||||
user,
|
||||
username=username,
|
||||
email=_email_from_payload(keycloak_payload),
|
||||
)
|
||||
except SQLAlchemyError as exc:
|
||||
logger.error(
|
||||
"Metadata DB error while refreshing current user snapshot",
|
||||
exc_info=True,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Metadata database is unavailable",
|
||||
) from exc
|
||||
return user
|
||||
|
||||
|
||||
|
||||
+155
-99
@@ -1,106 +1,162 @@
|
||||
"""
|
||||
权限控制依赖项和装饰器
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
基于角色的访问控制(RBAC)
|
||||
"""
|
||||
from typing import Callable
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from app.domain.models.role import UserRole
|
||||
from app.domain.schemas.user import UserInDB
|
||||
from app.auth.dependencies import get_current_active_user
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
|
||||
def require_role(required_role: UserRole):
|
||||
"""
|
||||
要求特定角色或更高权限
|
||||
|
||||
用法:
|
||||
@router.get("/admin-only")
|
||||
async def admin_endpoint(user: UserInDB = Depends(require_role(UserRole.ADMIN))):
|
||||
...
|
||||
|
||||
Args:
|
||||
required_role: 需要的最低角色
|
||||
|
||||
Returns:
|
||||
依赖函数
|
||||
"""
|
||||
async def role_checker(
|
||||
current_user: UserInDB = Depends(get_current_active_user)
|
||||
) -> UserInDB:
|
||||
user_role = UserRole(current_user.role)
|
||||
|
||||
if not user_role.has_permission(required_role):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Insufficient permissions. Required role: {required_role.value}, "
|
||||
f"Your role: {user_role.value}"
|
||||
)
|
||||
|
||||
return current_user
|
||||
|
||||
return role_checker
|
||||
from app.auth.project_dependencies import ProjectContext, get_project_context
|
||||
|
||||
# 预定义的权限检查依赖
|
||||
require_admin = require_role(UserRole.ADMIN)
|
||||
require_operator = require_role(UserRole.OPERATOR)
|
||||
require_user = require_role(UserRole.USER)
|
||||
WEBGIS_VIEW = "webgis.view"
|
||||
WEBGIS_EDIT = "webgis.edit"
|
||||
SCADA_VIEW = "scada.view"
|
||||
SCADA_CLEAN = "scada.clean"
|
||||
SIMULATION_VIEW = "simulation.view"
|
||||
SIMULATION_RUN = "simulation.run"
|
||||
BURST_VIEW = "burst.view"
|
||||
BURST_RUN = "burst.run"
|
||||
RISK_VIEW = "risk.view"
|
||||
RISK_RUN = "risk.run"
|
||||
OPTIMIZATION_VIEW = "optimization.view"
|
||||
OPTIMIZATION_RUN = "optimization.run"
|
||||
MODEL_IMPORT = "model.import"
|
||||
AUDIT_VIEW = "audit.view"
|
||||
ENVIRONMENT_MANAGE = "environment.manage"
|
||||
MEMBERSHIP_MANAGE = "membership.manage"
|
||||
|
||||
def get_current_admin(
|
||||
current_user: UserInDB = Depends(require_admin)
|
||||
) -> UserInDB:
|
||||
"""
|
||||
获取当前管理员用户
|
||||
|
||||
等同于 Depends(require_role(UserRole.ADMIN))
|
||||
"""
|
||||
return current_user
|
||||
PROJECT_MEMBER_PERMISSIONS = frozenset(
|
||||
{
|
||||
WEBGIS_VIEW,
|
||||
WEBGIS_EDIT,
|
||||
SCADA_VIEW,
|
||||
SCADA_CLEAN,
|
||||
SIMULATION_VIEW,
|
||||
SIMULATION_RUN,
|
||||
BURST_VIEW,
|
||||
BURST_RUN,
|
||||
RISK_VIEW,
|
||||
RISK_RUN,
|
||||
OPTIMIZATION_VIEW,
|
||||
OPTIMIZATION_RUN,
|
||||
}
|
||||
)
|
||||
|
||||
def get_current_operator(
|
||||
current_user: UserInDB = Depends(require_operator)
|
||||
) -> UserInDB:
|
||||
"""
|
||||
获取当前操作员用户(或更高权限)
|
||||
|
||||
等同于 Depends(require_role(UserRole.OPERATOR))
|
||||
"""
|
||||
return current_user
|
||||
PROJECT_VIEWER_PERMISSIONS = frozenset(
|
||||
{
|
||||
WEBGIS_VIEW,
|
||||
SCADA_VIEW,
|
||||
SIMULATION_VIEW,
|
||||
}
|
||||
)
|
||||
|
||||
def check_resource_owner(user_id: int, current_user: UserInDB) -> bool:
|
||||
"""
|
||||
检查是否是资源拥有者或管理员
|
||||
|
||||
Args:
|
||||
user_id: 资源拥有者ID
|
||||
current_user: 当前用户
|
||||
|
||||
Returns:
|
||||
是否有权限
|
||||
"""
|
||||
# 管理员可以访问所有资源
|
||||
if UserRole(current_user.role).has_permission(UserRole.ADMIN):
|
||||
return True
|
||||
|
||||
# 检查是否是资源拥有者
|
||||
return current_user.id == user_id
|
||||
SYSTEM_ADMIN_PERMISSIONS = frozenset(
|
||||
{
|
||||
MODEL_IMPORT,
|
||||
AUDIT_VIEW,
|
||||
ENVIRONMENT_MANAGE,
|
||||
MEMBERSHIP_MANAGE,
|
||||
}
|
||||
)
|
||||
|
||||
def require_owner_or_admin(user_id: int):
|
||||
"""
|
||||
要求是资源拥有者或管理员
|
||||
|
||||
Args:
|
||||
user_id: 资源拥有者ID
|
||||
|
||||
Returns:
|
||||
依赖函数
|
||||
"""
|
||||
async def owner_or_admin_checker(
|
||||
current_user: UserInDB = Depends(get_current_active_user)
|
||||
) -> UserInDB:
|
||||
if not check_resource_owner(user_id, current_user):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You don't have permission to access this resource"
|
||||
)
|
||||
return current_user
|
||||
|
||||
return owner_or_admin_checker
|
||||
PROJECT_ROLE_PERMISSIONS: dict[str, frozenset[str]] = {
|
||||
"member": PROJECT_MEMBER_PERMISSIONS,
|
||||
"viewer": PROJECT_VIEWER_PERMISSIONS,
|
||||
}
|
||||
|
||||
|
||||
def resolve_permissions(
|
||||
*,
|
||||
project_role: str | None,
|
||||
system_role: str,
|
||||
is_superuser: bool,
|
||||
) -> frozenset[str]:
|
||||
permissions = set(PROJECT_ROLE_PERMISSIONS.get(project_role or "", frozenset()))
|
||||
if is_superuser or system_role == "admin":
|
||||
permissions.update(SYSTEM_ADMIN_PERMISSIONS)
|
||||
return frozenset(permissions)
|
||||
|
||||
|
||||
def permissions_for_context(ctx: ProjectContext) -> frozenset[str]:
|
||||
return resolve_permissions(
|
||||
project_role=ctx.project_role,
|
||||
system_role=ctx.system_role,
|
||||
is_superuser=ctx.is_superuser,
|
||||
)
|
||||
|
||||
|
||||
def _permission_denied(permission: str) -> HTTPException:
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={
|
||||
"code": "permission_denied",
|
||||
"permission": permission,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _enforce_project_scope(request: Request, ctx: ProjectContext) -> None:
|
||||
requested_network = (
|
||||
request.path_params.get("network")
|
||||
or request.query_params.get("network")
|
||||
)
|
||||
if not requested_network:
|
||||
content_type = request.headers.get("content-type", "")
|
||||
if content_type.startswith("application/json"):
|
||||
try:
|
||||
payload = await request.json()
|
||||
except (ValueError, RuntimeError):
|
||||
payload = None
|
||||
if isinstance(payload, dict):
|
||||
requested_network = payload.get("network")
|
||||
|
||||
if requested_network and str(requested_network) != ctx.project_code:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={
|
||||
"code": "project_scope_denied",
|
||||
"project_id": str(ctx.project_id),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def require_permission(
|
||||
permission: str,
|
||||
) -> Callable[..., Awaitable[ProjectContext]]:
|
||||
async def dependency(
|
||||
request: Request,
|
||||
ctx: ProjectContext = Depends(get_project_context),
|
||||
) -> ProjectContext:
|
||||
if permission not in permissions_for_context(ctx):
|
||||
raise _permission_denied(permission)
|
||||
await _enforce_project_scope(request, ctx)
|
||||
return ctx
|
||||
|
||||
return dependency
|
||||
|
||||
|
||||
def require_method_permission(
|
||||
*,
|
||||
read_permission: str,
|
||||
write_permission: str,
|
||||
) -> Callable[..., Awaitable[ProjectContext]]:
|
||||
async def dependency(
|
||||
request: Request,
|
||||
ctx: ProjectContext = Depends(get_project_context),
|
||||
) -> ProjectContext:
|
||||
permission = (
|
||||
read_permission
|
||||
if request.method.upper() in {"GET", "HEAD", "OPTIONS"}
|
||||
else write_permission
|
||||
)
|
||||
if permission not in permissions_for_context(ctx):
|
||||
raise _permission_denied(permission)
|
||||
await _enforce_project_scope(request, ctx)
|
||||
return ctx
|
||||
|
||||
return dependency
|
||||
|
||||
|
||||
def has_permission(user: Any, project_role: str | None, permission: str) -> bool:
|
||||
return permission in resolve_permissions(
|
||||
project_role=project_role,
|
||||
system_role=str(getattr(user, "role", "user")),
|
||||
is_superuser=bool(getattr(user, "is_superuser", False)),
|
||||
)
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import logging
|
||||
from collections.abc import AsyncGenerator
|
||||
from dataclasses import dataclass
|
||||
from typing import AsyncGenerator
|
||||
from uuid import UUID
|
||||
|
||||
import logging
|
||||
from fastapi import Depends, Header, HTTPException, status
|
||||
from psycopg import AsyncConnection
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.keycloak_dependencies import get_current_keycloak_sub
|
||||
from app.auth.metadata_dependencies import get_current_metadata_user
|
||||
from app.core.config import settings
|
||||
from app.infra.db.dynamic_manager import project_connection_manager
|
||||
from app.infra.db.metadb.database import get_metadata_session
|
||||
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
|
||||
from app.infra.db.metadb.repositories.metadata_repository import (
|
||||
MetadataRepository,
|
||||
ProjectDbRouting,
|
||||
)
|
||||
|
||||
DB_ROLE_BIZ_DATA = "biz_data"
|
||||
DB_ROLE_IOT_DATA = "iot_data"
|
||||
@@ -25,8 +28,11 @@ logger = logging.getLogger(__name__)
|
||||
@dataclass(frozen=True)
|
||||
class ProjectContext:
|
||||
project_id: UUID
|
||||
project_code: str
|
||||
user_id: UUID
|
||||
project_role: str
|
||||
system_role: str = "user"
|
||||
is_superuser: bool = False
|
||||
|
||||
|
||||
async def get_metadata_repository(
|
||||
@@ -35,10 +41,10 @@ async def get_metadata_repository(
|
||||
return MetadataRepository(session)
|
||||
|
||||
|
||||
async def get_project_context(
|
||||
x_project_id: str = Header(..., alias="X-Project-Id"),
|
||||
keycloak_sub: UUID = Depends(get_current_keycloak_sub),
|
||||
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||
async def resolve_project_context(
|
||||
x_project_id: str,
|
||||
current_user,
|
||||
metadata_repo: MetadataRepository,
|
||||
) -> ProjectContext:
|
||||
try:
|
||||
project_uuid = UUID(x_project_id)
|
||||
@@ -58,17 +64,9 @@ async def get_project_context(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="Project is not active"
|
||||
)
|
||||
|
||||
user = await metadata_repo.get_user_by_keycloak_id(keycloak_sub)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="User not registered"
|
||||
)
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user"
|
||||
)
|
||||
|
||||
membership_role = await metadata_repo.get_membership_role(project_uuid, user.id)
|
||||
membership_role = await metadata_repo.get_membership_role(
|
||||
project_uuid, current_user.id
|
||||
)
|
||||
if not membership_role:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="No access to project"
|
||||
@@ -80,43 +78,71 @@ async def get_project_context(
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=f"Metadata database error: {exc}",
|
||||
detail="Metadata database is unavailable",
|
||||
) from exc
|
||||
|
||||
return ProjectContext(
|
||||
project_id=project.id,
|
||||
user_id=user.id,
|
||||
project_code=project.code,
|
||||
user_id=current_user.id,
|
||||
project_role=membership_role,
|
||||
system_role=current_user.role,
|
||||
is_superuser=current_user.is_superuser,
|
||||
)
|
||||
|
||||
|
||||
async def get_project_context(
|
||||
x_project_id: str = Header(..., alias="X-Project-Id"),
|
||||
current_user=Depends(get_current_metadata_user),
|
||||
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||
) -> ProjectContext:
|
||||
return await resolve_project_context(x_project_id, current_user, metadata_repo)
|
||||
|
||||
|
||||
async def _get_project_routing(
|
||||
metadata_repo: MetadataRepository,
|
||||
project_id: UUID,
|
||||
db_role: str,
|
||||
expected_db_type: str,
|
||||
database_label: str,
|
||||
) -> ProjectDbRouting:
|
||||
try:
|
||||
routing = await metadata_repo.get_project_db_routing(project_id, db_role)
|
||||
except ValueError as exc:
|
||||
logger.error(
|
||||
"Invalid project %s routing DSN configuration",
|
||||
database_label,
|
||||
exc_info=True,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=f"Project {database_label} routing DSN is invalid: {exc}",
|
||||
) from exc
|
||||
|
||||
if not routing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=f"Project {database_label} not configured",
|
||||
)
|
||||
if routing.db_type != expected_db_type:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=f"Project {database_label} type mismatch",
|
||||
)
|
||||
return routing
|
||||
|
||||
|
||||
async def get_project_pg_session(
|
||||
ctx: ProjectContext = Depends(get_project_context),
|
||||
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||
) -> AsyncGenerator[AsyncSession, None]:
|
||||
try:
|
||||
routing = await metadata_repo.get_project_db_routing(
|
||||
ctx.project_id, DB_ROLE_BIZ_DATA
|
||||
)
|
||||
except ValueError as exc:
|
||||
logger.error(
|
||||
"Invalid project PostgreSQL routing DSN configuration",
|
||||
exc_info=True,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=f"Project PostgreSQL routing DSN is invalid: {exc}",
|
||||
) from exc
|
||||
if not routing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Project PostgreSQL not configured",
|
||||
)
|
||||
if routing.db_type != DB_TYPE_POSTGRES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Project PostgreSQL type mismatch",
|
||||
)
|
||||
routing = await _get_project_routing(
|
||||
metadata_repo,
|
||||
ctx.project_id,
|
||||
DB_ROLE_BIZ_DATA,
|
||||
DB_TYPE_POSTGRES,
|
||||
"PostgreSQL",
|
||||
)
|
||||
|
||||
pool_min_size = routing.pool_min_size or settings.PROJECT_PG_POOL_SIZE
|
||||
pool_max_size = routing.pool_max_size or settings.PROJECT_PG_POOL_SIZE
|
||||
@@ -135,29 +161,13 @@ async def get_project_pg_connection(
|
||||
ctx: ProjectContext = Depends(get_project_context),
|
||||
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||
) -> AsyncGenerator[AsyncConnection, None]:
|
||||
try:
|
||||
routing = await metadata_repo.get_project_db_routing(
|
||||
ctx.project_id, DB_ROLE_BIZ_DATA
|
||||
)
|
||||
except ValueError as exc:
|
||||
logger.error(
|
||||
"Invalid project PostgreSQL routing DSN configuration",
|
||||
exc_info=True,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=f"Project PostgreSQL routing DSN is invalid: {exc}",
|
||||
) from exc
|
||||
if not routing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Project PostgreSQL not configured",
|
||||
)
|
||||
if routing.db_type != DB_TYPE_POSTGRES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Project PostgreSQL type mismatch",
|
||||
)
|
||||
routing = await _get_project_routing(
|
||||
metadata_repo,
|
||||
ctx.project_id,
|
||||
DB_ROLE_BIZ_DATA,
|
||||
DB_TYPE_POSTGRES,
|
||||
"PostgreSQL",
|
||||
)
|
||||
|
||||
pool_min_size = routing.pool_min_size or settings.PROJECT_PG_POOL_SIZE
|
||||
pool_max_size = routing.pool_max_size or settings.PROJECT_PG_POOL_SIZE
|
||||
@@ -176,29 +186,13 @@ async def get_project_timescale_connection(
|
||||
ctx: ProjectContext = Depends(get_project_context),
|
||||
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||
) -> AsyncGenerator[AsyncConnection, None]:
|
||||
try:
|
||||
routing = await metadata_repo.get_project_db_routing(
|
||||
ctx.project_id, DB_ROLE_IOT_DATA
|
||||
)
|
||||
except ValueError as exc:
|
||||
logger.error(
|
||||
"Invalid project TimescaleDB routing DSN configuration",
|
||||
exc_info=True,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=f"Project TimescaleDB routing DSN is invalid: {exc}",
|
||||
) from exc
|
||||
if not routing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Project TimescaleDB not configured",
|
||||
)
|
||||
if routing.db_type != DB_TYPE_TIMESCALE:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Project TimescaleDB type mismatch",
|
||||
)
|
||||
routing = await _get_project_routing(
|
||||
metadata_repo,
|
||||
ctx.project_id,
|
||||
DB_ROLE_IOT_DATA,
|
||||
DB_TYPE_TIMESCALE,
|
||||
"TimescaleDB",
|
||||
)
|
||||
|
||||
pool_min_size = routing.pool_min_size or settings.PROJECT_TS_POOL_MIN_SIZE
|
||||
pool_max_size = routing.pool_max_size or settings.PROJECT_TS_POOL_MAX_SIZE
|
||||
|
||||
+13
-13
@@ -8,20 +8,10 @@ class Settings(BaseSettings):
|
||||
PROJECT_NAME: str = "TJWater Server"
|
||||
ENVIRONMENT: str = "production"
|
||||
API_V1_STR: str = "/api/v1"
|
||||
|
||||
NETWORK_NAME: str = "default_network"
|
||||
|
||||
# JWT 配置
|
||||
SECRET_KEY: str = (
|
||||
"your-secret-key-here-change-in-production-use-openssl-rand-hex-32"
|
||||
)
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
||||
|
||||
# 数据加密密钥 (使用 Fernet)
|
||||
ENCRYPTION_KEY: str = "" # 必须从环境变量设置
|
||||
DATABASE_ENCRYPTION_KEY: str = "" # project_databases.dsn_encrypted 专用
|
||||
# 敏感配置加密密钥 (Fernet)
|
||||
DATABASE_ENCRYPTION_KEY: str = ""
|
||||
|
||||
# Database Config (PostgreSQL)
|
||||
DB_NAME: str = "tjwater"
|
||||
@@ -59,11 +49,21 @@ class Settings(BaseSettings):
|
||||
PROJECT_TS_POOL_MIN_SIZE: int = 1
|
||||
PROJECT_TS_POOL_MAX_SIZE: int = 10
|
||||
|
||||
# Keycloak JWT (optional override)
|
||||
# Keycloak access token verification
|
||||
KEYCLOAK_PUBLIC_KEY: str = ""
|
||||
KEYCLOAK_ALGORITHM: str = "RS256"
|
||||
KEYCLOAK_AUDIENCE: str = ""
|
||||
|
||||
# Bocha Web Search API
|
||||
BOCHA_API_KEY: str = ""
|
||||
BOCHA_WEB_SEARCH_URL: str = "https://api.bochaai.com/v1/web-search"
|
||||
BOCHA_WEB_SEARCH_TIMEOUT_SECONDS: float = 30.0
|
||||
|
||||
# Tianditu Geocoding API
|
||||
TIANDITU_GEOCODER_TOKEN: str = ""
|
||||
TIANDITU_GEOCODER_URL: str = "https://api.tianditu.gov.cn/geocoder"
|
||||
TIANDITU_GEOCODER_TIMEOUT_SECONDS: float = 30.0
|
||||
|
||||
@property
|
||||
def SQLALCHEMY_DATABASE_URI(self) -> str:
|
||||
db_password = quote_plus(self.DB_PASSWORD)
|
||||
|
||||
@@ -20,10 +20,10 @@ class Encryptor:
|
||||
key: 加密密钥,如果为 None 则从环境变量读取
|
||||
"""
|
||||
if key is None:
|
||||
key_str = os.getenv("ENCRYPTION_KEY") or settings.ENCRYPTION_KEY
|
||||
key_str = os.getenv("DATABASE_ENCRYPTION_KEY") or settings.DATABASE_ENCRYPTION_KEY
|
||||
if not key_str:
|
||||
raise ValueError(
|
||||
"ENCRYPTION_KEY not found in environment variables or .env. "
|
||||
"DATABASE_ENCRYPTION_KEY not found in environment variables or .env. "
|
||||
"Generate one using: Encryptor.generate_key()"
|
||||
)
|
||||
key = key_str.encode()
|
||||
@@ -80,15 +80,13 @@ _database_encryptor: Optional[Encryptor] = None
|
||||
|
||||
|
||||
def is_encryption_configured() -> bool:
|
||||
return bool(os.getenv("ENCRYPTION_KEY") or settings.ENCRYPTION_KEY)
|
||||
return is_database_encryption_configured()
|
||||
|
||||
|
||||
def is_database_encryption_configured() -> bool:
|
||||
return bool(
|
||||
os.getenv("DATABASE_ENCRYPTION_KEY")
|
||||
or settings.DATABASE_ENCRYPTION_KEY
|
||||
or os.getenv("ENCRYPTION_KEY")
|
||||
or settings.ENCRYPTION_KEY
|
||||
)
|
||||
|
||||
|
||||
@@ -107,8 +105,6 @@ def get_database_encryptor() -> Encryptor:
|
||||
key_str = (
|
||||
os.getenv("DATABASE_ENCRYPTION_KEY")
|
||||
or settings.DATABASE_ENCRYPTION_KEY
|
||||
or os.getenv("ENCRYPTION_KEY")
|
||||
or settings.ENCRYPTION_KEY
|
||||
)
|
||||
if not key_str:
|
||||
raise ValueError(
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional, Union, Any
|
||||
|
||||
from jose import jwt
|
||||
from passlib.context import CryptContext
|
||||
from app.core.config import settings
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
def _utc_now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def create_access_token(
|
||||
subject: Union[str, Any], expires_delta: Optional[timedelta] = None
|
||||
) -> str:
|
||||
"""
|
||||
创建 JWT Access Token
|
||||
|
||||
Args:
|
||||
subject: 用户标识(通常是用户名或用户ID)
|
||||
expires_delta: 过期时间增量
|
||||
|
||||
Returns:
|
||||
JWT token 字符串
|
||||
"""
|
||||
if expires_delta:
|
||||
expire = _utc_now() + expires_delta
|
||||
else:
|
||||
expire = _utc_now() + timedelta(
|
||||
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
|
||||
)
|
||||
|
||||
to_encode = {
|
||||
"exp": expire,
|
||||
"sub": str(subject),
|
||||
"type": "access",
|
||||
"iat": _utc_now(),
|
||||
}
|
||||
encoded_jwt = jwt.encode(
|
||||
to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM
|
||||
)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def create_refresh_token(subject: Union[str, Any]) -> str:
|
||||
"""
|
||||
创建 JWT Refresh Token(长期有效)
|
||||
|
||||
Args:
|
||||
subject: 用户标识
|
||||
|
||||
Returns:
|
||||
JWT refresh token 字符串
|
||||
"""
|
||||
expire = _utc_now() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
|
||||
to_encode = {
|
||||
"exp": expire,
|
||||
"sub": str(subject),
|
||||
"type": "refresh",
|
||||
"iat": _utc_now(),
|
||||
}
|
||||
encoded_jwt = jwt.encode(
|
||||
to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM
|
||||
)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""
|
||||
验证密码
|
||||
|
||||
Args:
|
||||
plain_password: 明文密码
|
||||
hashed_password: 密码哈希
|
||||
|
||||
Returns:
|
||||
是否匹配
|
||||
"""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
"""
|
||||
生成密码哈希
|
||||
|
||||
Args:
|
||||
password: 明文密码
|
||||
|
||||
Returns:
|
||||
bcrypt 哈希字符串
|
||||
"""
|
||||
return pwd_context.hash(password)
|
||||
@@ -1,36 +0,0 @@
|
||||
from enum import Enum
|
||||
|
||||
class UserRole(str, Enum):
|
||||
"""用户角色枚举"""
|
||||
ADMIN = "ADMIN" # 管理员 - 完全权限
|
||||
OPERATOR = "OPERATOR" # 操作员 - 可修改数据
|
||||
USER = "USER" # 普通用户 - 读写权限
|
||||
VIEWER = "VIEWER" # 观察者 - 仅查询权限
|
||||
|
||||
def __str__(self):
|
||||
return self.value
|
||||
|
||||
@classmethod
|
||||
def get_hierarchy(cls) -> dict:
|
||||
"""
|
||||
获取角色层级(数字越大权限越高)
|
||||
"""
|
||||
return {
|
||||
cls.VIEWER: 1,
|
||||
cls.USER: 2,
|
||||
cls.OPERATOR: 3,
|
||||
cls.ADMIN: 4,
|
||||
}
|
||||
|
||||
def has_permission(self, required_role: 'UserRole') -> bool:
|
||||
"""
|
||||
检查当前角色是否有足够权限
|
||||
|
||||
Args:
|
||||
required_role: 需要的最低角色
|
||||
|
||||
Returns:
|
||||
True if has permission
|
||||
"""
|
||||
hierarchy = self.get_hierarchy()
|
||||
return hierarchy[self] >= hierarchy[required_role]
|
||||
@@ -0,0 +1,13 @@
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class AccessContextResponse(BaseModel):
|
||||
user_id: UUID
|
||||
username: str
|
||||
system_role: str
|
||||
is_system_admin: bool
|
||||
project_id: UUID | None = None
|
||||
project_role: str | None = None
|
||||
permissions: list[str]
|
||||
@@ -0,0 +1,134 @@
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
|
||||
BusinessRole = Literal["admin", "user"]
|
||||
ProjectRole = Literal["member", "viewer"]
|
||||
ProjectStatus = Literal["active", "inactive", "archived"]
|
||||
ProjectDbRole = Literal["biz_data", "iot_data"]
|
||||
|
||||
|
||||
class MetadataUserSyncRequest(BaseModel):
|
||||
keycloak_id: UUID
|
||||
username: str = Field(..., min_length=1, max_length=50)
|
||||
email: str = Field(..., min_length=1, max_length=100)
|
||||
role: BusinessRole = "user"
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class MetadataUsersBatchSyncRequest(BaseModel):
|
||||
users: list[MetadataUserSyncRequest] = Field(..., min_length=1, max_length=500)
|
||||
|
||||
|
||||
class MetadataUserUpdateRequest(BaseModel):
|
||||
role: BusinessRole | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class MetadataUserResponse(BaseModel):
|
||||
id: UUID
|
||||
keycloak_id: UUID
|
||||
username: str
|
||||
email: str
|
||||
role: str
|
||||
is_active: bool
|
||||
is_superuser: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
last_login_at: datetime | None = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class MetadataUserSyncResult(BaseModel):
|
||||
keycloak_id: UUID
|
||||
user: MetadataUserResponse | None = None
|
||||
success: bool
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class ProjectMemberCreateRequest(BaseModel):
|
||||
user_id: UUID
|
||||
project_role: ProjectRole = "viewer"
|
||||
|
||||
|
||||
class ProjectMemberUpdateRequest(BaseModel):
|
||||
project_role: ProjectRole
|
||||
|
||||
|
||||
class ProjectMemberResponse(BaseModel):
|
||||
id: UUID
|
||||
user_id: UUID
|
||||
project_id: UUID
|
||||
project_role: str
|
||||
username: str
|
||||
email: str
|
||||
is_active: bool
|
||||
|
||||
|
||||
class AdminProjectCreateRequest(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
code: str = Field(..., min_length=1, max_length=50)
|
||||
description: str | None = None
|
||||
gs_workspace: str = Field(..., min_length=1, max_length=100)
|
||||
map_extent: dict | None = None
|
||||
status: ProjectStatus = "active"
|
||||
|
||||
|
||||
class AdminProjectUpdateRequest(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=100)
|
||||
code: str | None = Field(default=None, min_length=1, max_length=50)
|
||||
description: str | None = None
|
||||
gs_workspace: str | None = Field(default=None, min_length=1, max_length=100)
|
||||
map_extent: dict | None = None
|
||||
status: ProjectStatus | None = None
|
||||
|
||||
|
||||
class AdminProjectResponse(BaseModel):
|
||||
project_id: UUID
|
||||
name: str
|
||||
code: str
|
||||
description: str | None = None
|
||||
gs_workspace: str
|
||||
map_extent: dict | None = None
|
||||
status: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ProjectDatabaseUpsertRequest(BaseModel):
|
||||
db_role: ProjectDbRole
|
||||
dsn: str | None = Field(default=None, min_length=1)
|
||||
pool_min_size: int = Field(default=2, ge=1)
|
||||
pool_max_size: int = Field(default=10, ge=1)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_pool_bounds(self):
|
||||
if self.pool_max_size < self.pool_min_size:
|
||||
raise ValueError("pool_max_size must be greater than or equal to pool_min_size")
|
||||
return self
|
||||
|
||||
|
||||
class ProjectDatabaseResponse(BaseModel):
|
||||
id: UUID
|
||||
project_id: UUID
|
||||
db_role: str
|
||||
db_type: str
|
||||
pool_min_size: int
|
||||
pool_max_size: int
|
||||
has_dsn: bool
|
||||
|
||||
|
||||
class ProjectDatabaseHealthRequest(BaseModel):
|
||||
dsn: str | None = Field(default=None, min_length=1)
|
||||
|
||||
|
||||
class ProjectDatabaseHealthResponse(BaseModel):
|
||||
project_id: UUID
|
||||
db_role: str
|
||||
db_type: str
|
||||
ok: bool
|
||||
detail: str
|
||||
@@ -4,14 +4,6 @@ from uuid import UUID
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class GeoServerConfigResponse(BaseModel):
|
||||
gs_base_url: Optional[str] = None
|
||||
gs_admin_user: Optional[str] = None
|
||||
gs_datastore_name: str
|
||||
default_extent: Optional[dict] = None
|
||||
srid: int
|
||||
|
||||
|
||||
class ProjectMetaResponse(BaseModel):
|
||||
project_id: UUID
|
||||
name: str
|
||||
@@ -21,7 +13,6 @@ class ProjectMetaResponse(BaseModel):
|
||||
map_extent: Optional[dict] = None
|
||||
status: str
|
||||
project_role: str
|
||||
geoserver: Optional[GeoServerConfigResponse] = None
|
||||
|
||||
|
||||
class ProjectSummaryResponse(BaseModel):
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
AdjustmentStatus = Literal["current", "original", "added", "replaced"]
|
||||
|
||||
|
||||
def _normalize_location_ids(value: list[str]) -> list[str]:
|
||||
normalized = [str(item).strip() for item in value]
|
||||
if any(not item for item in normalized):
|
||||
raise ValueError("sensor locations cannot contain blank node IDs")
|
||||
if len(set(normalized)) != len(normalized):
|
||||
raise ValueError("sensor locations cannot contain duplicate node IDs")
|
||||
return normalized
|
||||
|
||||
|
||||
class SensorPlacementOptimizeRequest(BaseModel):
|
||||
network: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
max_length=63,
|
||||
pattern=r"^[^/\\\x00]+$",
|
||||
)
|
||||
scheme_name: str = Field(..., min_length=1, max_length=32)
|
||||
sensor_type: Literal["pressure"]
|
||||
method: Literal["sensitivity", "kmeans"]
|
||||
sensor_count: int = Field(..., gt=0, le=200)
|
||||
min_diameter: int = Field(default=0, ge=0)
|
||||
|
||||
@field_validator("network")
|
||||
@classmethod
|
||||
def validate_network(cls, value: str) -> str:
|
||||
normalized = value.strip()
|
||||
if normalized in {".", ".."}:
|
||||
raise ValueError("network must be a project identifier")
|
||||
return normalized
|
||||
|
||||
|
||||
class SensorPlacementUpdateRequest(BaseModel):
|
||||
expected_sensor_location: list[str] = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
max_length=200,
|
||||
)
|
||||
sensor_location: list[str] = Field(..., min_length=1, max_length=200)
|
||||
|
||||
@field_validator("expected_sensor_location", "sensor_location")
|
||||
@classmethod
|
||||
def validate_locations(cls, value: list[str]) -> list[str]:
|
||||
return _normalize_location_ids(value)
|
||||
|
||||
|
||||
class SensorPlacementExportRequest(BaseModel):
|
||||
sensor_location: list[str] = Field(..., min_length=1, max_length=200)
|
||||
adjustment_status: dict[str, AdjustmentStatus] = Field(
|
||||
default_factory=dict,
|
||||
max_length=200,
|
||||
)
|
||||
|
||||
@field_validator("sensor_location")
|
||||
@classmethod
|
||||
def validate_locations(cls, value: list[str]) -> list[str]:
|
||||
return _normalize_location_ids(value)
|
||||
|
||||
|
||||
class SensorPointResponse(BaseModel):
|
||||
node_id: str
|
||||
project_x: float
|
||||
project_y: float
|
||||
map_x: float
|
||||
map_y: float
|
||||
longitude: float
|
||||
latitude: float
|
||||
elevation: float
|
||||
|
||||
|
||||
class SensorPlacementSchemeResponse(BaseModel):
|
||||
id: int
|
||||
scheme_name: str
|
||||
sensor_number: int
|
||||
min_diameter: int
|
||||
username: str
|
||||
create_time: datetime
|
||||
sensor_location: list[str]
|
||||
sensor_points: list[SensorPointResponse]
|
||||
can_edit: bool = False
|
||||
@@ -1,68 +0,0 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, EmailStr, Field, ConfigDict
|
||||
from app.domain.models.role import UserRole
|
||||
|
||||
# ============================================
|
||||
# Request Schemas (输入)
|
||||
# ============================================
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
"""用户注册"""
|
||||
username: str = Field(..., min_length=3, max_length=50,
|
||||
description="用户名,3-50个字符")
|
||||
email: EmailStr = Field(..., description="邮箱地址")
|
||||
password: str = Field(..., min_length=6, max_length=100,
|
||||
description="密码,至少6个字符")
|
||||
role: UserRole = Field(default=UserRole.USER, description="用户角色")
|
||||
|
||||
class UserLogin(BaseModel):
|
||||
"""用户登录"""
|
||||
username: str = Field(..., description="用户名或邮箱")
|
||||
password: str = Field(..., description="密码")
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
"""用户信息更新"""
|
||||
email: Optional[EmailStr] = None
|
||||
password: Optional[str] = Field(None, min_length=6, max_length=100)
|
||||
role: Optional[UserRole] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
# ============================================
|
||||
# Response Schemas (输出)
|
||||
# ============================================
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
"""用户信息响应(不含密码)"""
|
||||
id: int
|
||||
username: str
|
||||
email: str
|
||||
role: UserRole
|
||||
is_active: bool
|
||||
is_superuser: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
class UserInDB(UserResponse):
|
||||
"""数据库中的用户(含密码哈希)"""
|
||||
hashed_password: str
|
||||
|
||||
# ============================================
|
||||
# Token Schemas
|
||||
# ============================================
|
||||
|
||||
class Token(BaseModel):
|
||||
"""JWT Token 响应"""
|
||||
access_token: str
|
||||
refresh_token: Optional[str] = None
|
||||
token_type: str = "bearer"
|
||||
expires_in: int = Field(..., description="过期时间(秒)")
|
||||
|
||||
class TokenPayload(BaseModel):
|
||||
"""JWT Token Payload"""
|
||||
sub: str = Field(..., description="用户ID或用户名")
|
||||
exp: Optional[int] = None
|
||||
iat: Optional[int] = None
|
||||
type: str = Field(default="access", description="token类型: access 或 refresh")
|
||||
@@ -33,8 +33,6 @@ class AuditMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
# 需要审计的路径前缀
|
||||
AUDIT_PATHS = [
|
||||
# "/api/v1/auth/",
|
||||
# "/api/v1/users/",
|
||||
# "/api/v1/projects/",
|
||||
# "/api/v1/networks/",
|
||||
]
|
||||
@@ -60,6 +58,8 @@ class AuditMiddleware(BaseHTTPMiddleware):
|
||||
"/meta/projects",
|
||||
"/api/v1/openproject/",
|
||||
"/openproject/",
|
||||
"/api/v1/audit/session-events",
|
||||
"/audit/session-events",
|
||||
}
|
||||
EXCLUDED_PATH_PREFIXES = (
|
||||
)
|
||||
@@ -82,27 +82,9 @@ class AuditMiddleware(BaseHTTPMiddleware):
|
||||
request_data = None
|
||||
if should_capture_body:
|
||||
try:
|
||||
# 注意:读取 body 后需要重新设置,避免影响后续处理
|
||||
original_receive = request._receive
|
||||
body = await request.body()
|
||||
if body:
|
||||
request_data = json.loads(body.decode())
|
||||
|
||||
# 重新构造请求以供后续使用:仅回放一次,后续回落原始 receive
|
||||
body_sent = False
|
||||
|
||||
async def receive():
|
||||
nonlocal body_sent
|
||||
if not body_sent:
|
||||
body_sent = True
|
||||
return {
|
||||
"type": "http.request",
|
||||
"body": body,
|
||||
"more_body": False,
|
||||
}
|
||||
return await original_receive()
|
||||
|
||||
request._receive = receive
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to read request body for audit: {e}")
|
||||
|
||||
@@ -193,20 +175,14 @@ class AuditMiddleware(BaseHTTPMiddleware):
|
||||
return None
|
||||
sub = None
|
||||
try:
|
||||
key = (
|
||||
settings.KEYCLOAK_PUBLIC_KEY.replace("\\n", "\n")
|
||||
if settings.KEYCLOAK_PUBLIC_KEY
|
||||
else settings.SECRET_KEY
|
||||
)
|
||||
algorithms = (
|
||||
[settings.KEYCLOAK_ALGORITHM]
|
||||
if settings.KEYCLOAK_PUBLIC_KEY
|
||||
else [settings.ALGORITHM]
|
||||
)
|
||||
if not settings.KEYCLOAK_PUBLIC_KEY:
|
||||
return None
|
||||
|
||||
key = settings.KEYCLOAK_PUBLIC_KEY.replace("\\n", "\n")
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
key,
|
||||
algorithms=algorithms,
|
||||
algorithms=[settings.KEYCLOAK_ALGORITHM],
|
||||
audience=settings.KEYCLOAK_AUDIENCE or None,
|
||||
)
|
||||
sub = payload.get("sub")
|
||||
@@ -221,7 +197,7 @@ class AuditMiddleware(BaseHTTPMiddleware):
|
||||
keycloak_id = UUID(sub)
|
||||
user = await repo.get_user_by_keycloak_id(keycloak_id)
|
||||
except ValueError:
|
||||
user = await repo.get_user_by_username(sub)
|
||||
return None
|
||||
if user and user.is_active:
|
||||
return user.id
|
||||
return None
|
||||
|
||||
@@ -54,9 +54,9 @@ class ProjectConnectionManager:
|
||||
|
||||
def _normalize_pg_url(self, url: str) -> str:
|
||||
parsed = make_url(url)
|
||||
if parsed.drivername == "postgresql":
|
||||
if parsed.drivername in {"postgresql", "postgres"}:
|
||||
parsed = parsed.set(drivername="postgresql+psycopg")
|
||||
return str(parsed)
|
||||
return parsed.render_as_string(hide_password=False)
|
||||
|
||||
async def get_pg_sessionmaker(
|
||||
self,
|
||||
|
||||
@@ -64,26 +64,6 @@ class ProjectDatabase(Base):
|
||||
pool_max_size: Mapped[int] = mapped_column(Integer, default=10)
|
||||
|
||||
|
||||
class ProjectGeoServerConfig(Base):
|
||||
__tablename__ = "project_geoserver_configs"
|
||||
|
||||
id: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True)
|
||||
project_id: Mapped[UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), unique=True, index=True
|
||||
)
|
||||
gs_base_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
gs_admin_user: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
gs_admin_password_encrypted: Mapped[str | None] = mapped_column(
|
||||
Text, nullable=True
|
||||
)
|
||||
gs_datastore_name: Mapped[str] = mapped_column(String(100), default="ds_postgis")
|
||||
default_extent: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||
srid: Mapped[int] = mapped_column(Integer, default=4326)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=datetime.utcnow
|
||||
)
|
||||
|
||||
|
||||
class UserProjectMembership(Base):
|
||||
__tablename__ = "user_project_membership"
|
||||
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, List
|
||||
from uuid import UUID
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from cryptography.fernet import InvalidToken
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.encryption import (
|
||||
get_database_encryptor,
|
||||
get_encryptor,
|
||||
is_database_encryption_configured,
|
||||
is_encryption_configured,
|
||||
)
|
||||
from app.infra.db.metadb import models
|
||||
|
||||
@@ -43,17 +42,6 @@ class ProjectDbRouting:
|
||||
pool_max_size: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProjectGeoServerInfo:
|
||||
project_id: UUID
|
||||
gs_base_url: Optional[str]
|
||||
gs_admin_user: Optional[str]
|
||||
gs_admin_password: Optional[str]
|
||||
gs_datastore_name: str
|
||||
default_extent: Optional[dict]
|
||||
srid: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProjectSummary:
|
||||
project_id: UUID
|
||||
@@ -75,7 +63,27 @@ class ProjectDetail:
|
||||
gs_workspace: str
|
||||
map_extent: Optional[dict]
|
||||
status: str
|
||||
geoserver: Optional[ProjectGeoServerInfo]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProjectMemberSummary:
|
||||
id: UUID
|
||||
user_id: UUID
|
||||
project_id: UUID
|
||||
project_role: str
|
||||
username: str
|
||||
email: str
|
||||
is_active: bool
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _encrypt_database_secret(value: str) -> str:
|
||||
if not is_database_encryption_configured():
|
||||
raise ValueError("DATABASE_ENCRYPTION_KEY is not configured")
|
||||
return get_database_encryptor().encrypt(value)
|
||||
|
||||
|
||||
class MetadataRepository:
|
||||
@@ -96,6 +104,86 @@ class MetadataRepository:
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_user_by_id(self, user_id: UUID) -> Optional[models.User]:
|
||||
result = await self.session.execute(
|
||||
select(models.User).where(models.User.id == user_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def list_users(self, skip: int = 0, limit: int = 100) -> List[models.User]:
|
||||
result = await self.session.execute(
|
||||
select(models.User)
|
||||
.order_by(models.User.created_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def upsert_user_from_keycloak(
|
||||
self,
|
||||
*,
|
||||
keycloak_id: UUID,
|
||||
username: str,
|
||||
email: str,
|
||||
role: str,
|
||||
is_active: bool,
|
||||
) -> models.User:
|
||||
user = await self.get_user_by_keycloak_id(keycloak_id)
|
||||
if user is None:
|
||||
user = models.User(
|
||||
id=uuid4(),
|
||||
keycloak_id=keycloak_id,
|
||||
username=username,
|
||||
email=email,
|
||||
role=role,
|
||||
is_active=is_active,
|
||||
is_superuser=False,
|
||||
)
|
||||
self.session.add(user)
|
||||
else:
|
||||
user.username = username
|
||||
user.email = email
|
||||
user.role = role
|
||||
user.is_active = is_active
|
||||
await self.session.commit()
|
||||
await self.session.refresh(user)
|
||||
return user
|
||||
|
||||
async def refresh_user_keycloak_snapshot(
|
||||
self,
|
||||
user: models.User,
|
||||
*,
|
||||
username: str | None,
|
||||
email: str | None,
|
||||
last_login_at: datetime | None = None,
|
||||
) -> models.User:
|
||||
if username:
|
||||
user.username = username
|
||||
if email:
|
||||
user.email = email
|
||||
user.last_login_at = last_login_at or _utcnow()
|
||||
user.updated_at = _utcnow()
|
||||
await self.session.commit()
|
||||
await self.session.refresh(user)
|
||||
return user
|
||||
|
||||
async def update_user_admin(
|
||||
self,
|
||||
user_id: UUID,
|
||||
*,
|
||||
updates: dict,
|
||||
) -> Optional[models.User]:
|
||||
user = await self.get_user_by_id(user_id)
|
||||
if user is None:
|
||||
return None
|
||||
if "role" in updates:
|
||||
user.role = updates["role"]
|
||||
if "is_active" in updates:
|
||||
user.is_active = updates["is_active"]
|
||||
await self.session.commit()
|
||||
await self.session.refresh(user)
|
||||
return user
|
||||
|
||||
async def get_project_by_id(self, project_id: UUID) -> Optional[models.Project]:
|
||||
result = await self.session.execute(
|
||||
select(models.Project).where(models.Project.id == project_id)
|
||||
@@ -108,13 +196,76 @@ class MetadataRepository:
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def list_project_records(self) -> List[models.Project]:
|
||||
result = await self.session.execute(
|
||||
select(models.Project).order_by(models.Project.name)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def create_project(
|
||||
self,
|
||||
*,
|
||||
name: str,
|
||||
code: str,
|
||||
description: str | None,
|
||||
gs_workspace: str,
|
||||
map_extent: dict | None,
|
||||
status: str,
|
||||
creator_user_id: UUID | None = None,
|
||||
) -> models.Project:
|
||||
project = models.Project(
|
||||
id=uuid4(),
|
||||
name=name,
|
||||
code=code,
|
||||
description=description,
|
||||
gs_workspace=gs_workspace,
|
||||
map_extent=map_extent,
|
||||
status=status,
|
||||
created_at=_utcnow(),
|
||||
updated_at=_utcnow(),
|
||||
)
|
||||
self.session.add(project)
|
||||
if creator_user_id is not None:
|
||||
self.session.add(
|
||||
models.UserProjectMembership(
|
||||
id=uuid4(),
|
||||
user_id=creator_user_id,
|
||||
project_id=project.id,
|
||||
project_role="member",
|
||||
)
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(project)
|
||||
return project
|
||||
|
||||
async def update_project(
|
||||
self,
|
||||
project_id: UUID,
|
||||
*,
|
||||
updates: dict,
|
||||
) -> Optional[models.Project]:
|
||||
project = await self.get_project_by_id(project_id)
|
||||
if project is None:
|
||||
return None
|
||||
for field in (
|
||||
"name",
|
||||
"code",
|
||||
"description",
|
||||
"gs_workspace",
|
||||
"map_extent",
|
||||
"status",
|
||||
):
|
||||
if field in updates:
|
||||
setattr(project, field, updates[field])
|
||||
project.updated_at = _utcnow()
|
||||
await self.session.commit()
|
||||
await self.session.refresh(project)
|
||||
return project
|
||||
|
||||
async def get_project_detail_by_code(self, code: str) -> Optional[ProjectDetail]:
|
||||
project = await self.get_project_by_code(code)
|
||||
if not project:
|
||||
return None
|
||||
|
||||
geoserver = await self.get_geoserver_config(project.id)
|
||||
|
||||
return ProjectDetail(
|
||||
project_id=project.id,
|
||||
name=project.name,
|
||||
@@ -123,7 +274,6 @@ class MetadataRepository:
|
||||
gs_workspace=project.gs_workspace,
|
||||
map_extent=project.map_extent,
|
||||
status=project.status,
|
||||
geoserver=geoserver
|
||||
)
|
||||
|
||||
async def get_membership_role(
|
||||
@@ -137,6 +287,142 @@ class MetadataRepository:
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def list_project_members(
|
||||
self, project_id: UUID
|
||||
) -> List[ProjectMemberSummary]:
|
||||
stmt = (
|
||||
select(models.UserProjectMembership, models.User)
|
||||
.join(models.User, models.User.id == models.UserProjectMembership.user_id)
|
||||
.where(models.UserProjectMembership.project_id == project_id)
|
||||
.order_by(models.User.username)
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return [
|
||||
ProjectMemberSummary(
|
||||
id=membership.id,
|
||||
user_id=membership.user_id,
|
||||
project_id=membership.project_id,
|
||||
project_role=membership.project_role,
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
is_active=user.is_active,
|
||||
)
|
||||
for membership, user in result.all()
|
||||
]
|
||||
|
||||
async def get_project_membership(
|
||||
self, project_id: UUID, user_id: UUID
|
||||
) -> Optional[models.UserProjectMembership]:
|
||||
result = await self.session.execute(
|
||||
select(models.UserProjectMembership).where(
|
||||
models.UserProjectMembership.project_id == project_id,
|
||||
models.UserProjectMembership.user_id == user_id,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def add_project_member(
|
||||
self, project_id: UUID, user_id: UUID, project_role: str
|
||||
) -> models.UserProjectMembership:
|
||||
membership = models.UserProjectMembership(
|
||||
id=uuid4(),
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
project_role=project_role,
|
||||
)
|
||||
self.session.add(membership)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(membership)
|
||||
return membership
|
||||
|
||||
async def update_project_member_role(
|
||||
self, project_id: UUID, user_id: UUID, project_role: str
|
||||
) -> Optional[models.UserProjectMembership]:
|
||||
membership = await self.get_project_membership(project_id, user_id)
|
||||
if membership is None:
|
||||
return None
|
||||
membership.project_role = project_role
|
||||
await self.session.commit()
|
||||
await self.session.refresh(membership)
|
||||
return membership
|
||||
|
||||
async def remove_project_member(self, project_id: UUID, user_id: UUID) -> bool:
|
||||
result = await self.session.execute(
|
||||
delete(models.UserProjectMembership).where(
|
||||
models.UserProjectMembership.project_id == project_id,
|
||||
models.UserProjectMembership.user_id == user_id,
|
||||
)
|
||||
)
|
||||
await self.session.commit()
|
||||
return bool(result.rowcount)
|
||||
|
||||
async def list_project_databases(
|
||||
self, project_id: UUID
|
||||
) -> List[models.ProjectDatabase]:
|
||||
result = await self.session.execute(
|
||||
select(models.ProjectDatabase)
|
||||
.where(models.ProjectDatabase.project_id == project_id)
|
||||
.order_by(models.ProjectDatabase.db_role)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_project_database_config(
|
||||
self, project_id: UUID, db_role: str
|
||||
) -> Optional[models.ProjectDatabase]:
|
||||
result = await self.session.execute(
|
||||
select(models.ProjectDatabase).where(
|
||||
models.ProjectDatabase.project_id == project_id,
|
||||
models.ProjectDatabase.db_role == db_role,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def upsert_project_database_config(
|
||||
self,
|
||||
project_id: UUID,
|
||||
*,
|
||||
db_role: str,
|
||||
db_type: str,
|
||||
dsn: str | None,
|
||||
pool_min_size: int,
|
||||
pool_max_size: int,
|
||||
) -> models.ProjectDatabase:
|
||||
record = await self.get_project_database_config(project_id, db_role)
|
||||
if record is None:
|
||||
if dsn is None:
|
||||
raise ValueError("dsn is required when creating project database config")
|
||||
record = models.ProjectDatabase(
|
||||
id=uuid4(),
|
||||
project_id=project_id,
|
||||
db_role=db_role,
|
||||
db_type=db_type,
|
||||
dsn_encrypted=_encrypt_database_secret(dsn),
|
||||
pool_min_size=pool_min_size,
|
||||
pool_max_size=pool_max_size,
|
||||
)
|
||||
self.session.add(record)
|
||||
else:
|
||||
record.db_type = db_type
|
||||
if dsn is not None:
|
||||
record.dsn_encrypted = _encrypt_database_secret(dsn)
|
||||
record.pool_min_size = pool_min_size
|
||||
record.pool_max_size = pool_max_size
|
||||
await self.session.commit()
|
||||
await self.session.refresh(record)
|
||||
return record
|
||||
|
||||
async def delete_project_database_config(
|
||||
self, project_id: UUID, db_role: str
|
||||
) -> bool:
|
||||
result = await self.session.execute(
|
||||
delete(models.ProjectDatabase).where(
|
||||
models.ProjectDatabase.project_id == project_id,
|
||||
models.ProjectDatabase.db_role == db_role,
|
||||
)
|
||||
)
|
||||
await self.session.commit()
|
||||
return bool(result.rowcount)
|
||||
|
||||
async def get_project_db_routing(
|
||||
self, project_id: UUID, db_role: str
|
||||
) -> Optional[ProjectDbRouting]:
|
||||
@@ -169,35 +455,6 @@ class MetadataRepository:
|
||||
pool_max_size=record.pool_max_size,
|
||||
)
|
||||
|
||||
async def get_geoserver_config(
|
||||
self, project_id: UUID
|
||||
) -> Optional[ProjectGeoServerInfo]:
|
||||
result = await self.session.execute(
|
||||
select(models.ProjectGeoServerConfig).where(
|
||||
models.ProjectGeoServerConfig.project_id == project_id
|
||||
)
|
||||
)
|
||||
record = result.scalar_one_or_none()
|
||||
if not record:
|
||||
return None
|
||||
if record.gs_admin_password_encrypted:
|
||||
if is_encryption_configured():
|
||||
encryptor = get_encryptor()
|
||||
password = encryptor.decrypt(record.gs_admin_password_encrypted)
|
||||
else:
|
||||
password = record.gs_admin_password_encrypted
|
||||
else:
|
||||
password = None
|
||||
return ProjectGeoServerInfo(
|
||||
project_id=record.project_id,
|
||||
gs_base_url=record.gs_base_url,
|
||||
gs_admin_user=record.gs_admin_user,
|
||||
gs_admin_password=password,
|
||||
gs_datastore_name=record.gs_datastore_name,
|
||||
default_extent=record.default_extent,
|
||||
srid=record.srid,
|
||||
)
|
||||
|
||||
async def list_projects_for_user(self, user_id: UUID) -> List[ProjectSummary]:
|
||||
stmt = (
|
||||
select(models.Project, models.UserProjectMembership.project_role)
|
||||
@@ -236,7 +493,7 @@ class MetadataRepository:
|
||||
gs_workspace=project.gs_workspace,
|
||||
map_extent=project.map_extent,
|
||||
status=project.status,
|
||||
project_role="owner",
|
||||
project_role="member",
|
||||
)
|
||||
for project in result.scalars().all()
|
||||
]
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from app.infra.db.postgresql.database import Database
|
||||
from app.domain.schemas.user import UserCreate, UserUpdate, UserInDB
|
||||
from app.domain.models.role import UserRole
|
||||
from app.core.security import get_password_hash
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class UserRepository:
|
||||
"""用户数据访问层"""
|
||||
|
||||
def __init__(self, db: Database):
|
||||
self.db = db
|
||||
|
||||
async def create_user(self, user: UserCreate) -> Optional[UserInDB]:
|
||||
"""
|
||||
创建新用户
|
||||
|
||||
Args:
|
||||
user: 用户创建数据
|
||||
|
||||
Returns:
|
||||
创建的用户对象
|
||||
"""
|
||||
hashed_password = get_password_hash(user.password)
|
||||
|
||||
query = """
|
||||
INSERT INTO users (username, email, hashed_password, role, is_active, is_superuser)
|
||||
VALUES (%(username)s, %(email)s, %(hashed_password)s, %(role)s, TRUE, FALSE)
|
||||
RETURNING id, username, email, hashed_password, role, is_active, is_superuser,
|
||||
created_at, updated_at
|
||||
"""
|
||||
|
||||
try:
|
||||
async with self.db.get_connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(query, {
|
||||
'username': user.username,
|
||||
'email': user.email,
|
||||
'hashed_password': hashed_password,
|
||||
'role': user.role.value
|
||||
})
|
||||
row = await cur.fetchone()
|
||||
if row:
|
||||
return UserInDB(**row)
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating user: {e}")
|
||||
raise
|
||||
|
||||
return None
|
||||
|
||||
async def get_user_by_id(self, user_id: int) -> Optional[UserInDB]:
|
||||
"""根据ID获取用户"""
|
||||
query = """
|
||||
SELECT id, username, email, hashed_password, role, is_active, is_superuser,
|
||||
created_at, updated_at
|
||||
FROM users
|
||||
WHERE id = %(user_id)s
|
||||
"""
|
||||
|
||||
async with self.db.get_connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(query, {'user_id': user_id})
|
||||
row = await cur.fetchone()
|
||||
if row:
|
||||
return UserInDB(**row)
|
||||
|
||||
return None
|
||||
|
||||
async def get_user_by_username(self, username: str) -> Optional[UserInDB]:
|
||||
"""根据用户名获取用户"""
|
||||
query = """
|
||||
SELECT id, username, email, hashed_password, role, is_active, is_superuser,
|
||||
created_at, updated_at
|
||||
FROM users
|
||||
WHERE username = %(username)s
|
||||
"""
|
||||
|
||||
async with self.db.get_connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(query, {'username': username})
|
||||
row = await cur.fetchone()
|
||||
if row:
|
||||
return UserInDB(**row)
|
||||
|
||||
return None
|
||||
|
||||
async def get_user_by_email(self, email: str) -> Optional[UserInDB]:
|
||||
"""根据邮箱获取用户"""
|
||||
query = """
|
||||
SELECT id, username, email, hashed_password, role, is_active, is_superuser,
|
||||
created_at, updated_at
|
||||
FROM users
|
||||
WHERE email = %(email)s
|
||||
"""
|
||||
|
||||
async with self.db.get_connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(query, {'email': email})
|
||||
row = await cur.fetchone()
|
||||
if row:
|
||||
return UserInDB(**row)
|
||||
|
||||
return None
|
||||
|
||||
async def get_all_users(self, skip: int = 0, limit: int = 100) -> List[UserInDB]:
|
||||
"""获取所有用户(分页)"""
|
||||
query = """
|
||||
SELECT id, username, email, hashed_password, role, is_active, is_superuser,
|
||||
created_at, updated_at
|
||||
FROM users
|
||||
ORDER BY created_at DESC
|
||||
LIMIT %(limit)s OFFSET %(skip)s
|
||||
"""
|
||||
|
||||
async with self.db.get_connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(query, {'skip': skip, 'limit': limit})
|
||||
rows = await cur.fetchall()
|
||||
return [UserInDB(**row) for row in rows]
|
||||
|
||||
async def update_user(self, user_id: int, user_update: UserUpdate) -> Optional[UserInDB]:
|
||||
"""
|
||||
更新用户信息
|
||||
|
||||
Args:
|
||||
user_id: 用户ID
|
||||
user_update: 更新数据
|
||||
|
||||
Returns:
|
||||
更新后的用户对象
|
||||
"""
|
||||
# 构建动态更新语句
|
||||
update_fields = []
|
||||
params = {'user_id': user_id}
|
||||
|
||||
if user_update.email is not None:
|
||||
update_fields.append("email = %(email)s")
|
||||
params['email'] = user_update.email
|
||||
|
||||
if user_update.password is not None:
|
||||
update_fields.append("hashed_password = %(hashed_password)s")
|
||||
params['hashed_password'] = get_password_hash(user_update.password)
|
||||
|
||||
if user_update.role is not None:
|
||||
update_fields.append("role = %(role)s")
|
||||
params['role'] = user_update.role.value
|
||||
|
||||
if user_update.is_active is not None:
|
||||
update_fields.append("is_active = %(is_active)s")
|
||||
params['is_active'] = user_update.is_active
|
||||
|
||||
if not update_fields:
|
||||
return await self.get_user_by_id(user_id)
|
||||
|
||||
query = f"""
|
||||
UPDATE users
|
||||
SET {', '.join(update_fields)}, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = %(user_id)s
|
||||
RETURNING id, username, email, hashed_password, role, is_active, is_superuser,
|
||||
created_at, updated_at
|
||||
"""
|
||||
|
||||
try:
|
||||
async with self.db.get_connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(query, params)
|
||||
row = await cur.fetchone()
|
||||
if row:
|
||||
return UserInDB(**row)
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating user {user_id}: {e}")
|
||||
raise
|
||||
|
||||
return None
|
||||
|
||||
async def delete_user(self, user_id: int) -> bool:
|
||||
"""
|
||||
删除用户
|
||||
|
||||
Args:
|
||||
user_id: 用户ID
|
||||
|
||||
Returns:
|
||||
是否成功删除
|
||||
"""
|
||||
query = "DELETE FROM users WHERE id = %(user_id)s"
|
||||
|
||||
try:
|
||||
async with self.db.get_connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(query, {'user_id': user_id})
|
||||
return cur.rowcount > 0
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting user {user_id}: {e}")
|
||||
return False
|
||||
|
||||
async def user_exists(self, username: str = None, email: str = None) -> bool:
|
||||
"""
|
||||
检查用户是否存在
|
||||
|
||||
Args:
|
||||
username: 用户名
|
||||
email: 邮箱
|
||||
|
||||
Returns:
|
||||
是否存在
|
||||
"""
|
||||
conditions = []
|
||||
params = {}
|
||||
|
||||
if username:
|
||||
conditions.append("username = %(username)s")
|
||||
params['username'] = username
|
||||
|
||||
if email:
|
||||
conditions.append("email = %(email)s")
|
||||
params['email'] = email
|
||||
|
||||
if not conditions:
|
||||
return False
|
||||
|
||||
query = f"""
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM users WHERE {' OR '.join(conditions)}
|
||||
)
|
||||
"""
|
||||
|
||||
async with self.db.get_connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(query, params)
|
||||
result = await cur.fetchone()
|
||||
return result['exists'] if result else False
|
||||
@@ -0,0 +1,51 @@
|
||||
from typing import Any
|
||||
|
||||
from psycopg import AsyncConnection
|
||||
|
||||
|
||||
def _optional_text(value: Any) -> str | None:
|
||||
return str(value).strip() if value is not None else None
|
||||
|
||||
|
||||
def _optional_float(value: Any) -> float | None:
|
||||
return float(value) if value is not None else None
|
||||
|
||||
|
||||
class ScadaInfoRepository:
|
||||
"""Read SCADA metadata from the current project's business database."""
|
||||
|
||||
@staticmethod
|
||||
async def get_scadas(conn: AsyncConnection) -> list[dict[str, Any]]:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"""
|
||||
SELECT id,
|
||||
type,
|
||||
associated_element_id,
|
||||
api_query_id,
|
||||
transmission_mode,
|
||||
transmission_frequency,
|
||||
reliability,
|
||||
x_coor,
|
||||
y_coor
|
||||
FROM public.scada_info
|
||||
"""
|
||||
)
|
||||
records = await cur.fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": str(record["id"]).strip(),
|
||||
"type": str(record["type"]).strip().lower(),
|
||||
"associated_element_id": _optional_text(
|
||||
record["associated_element_id"]
|
||||
),
|
||||
"api_query_id": record["api_query_id"],
|
||||
"transmission_mode": record["transmission_mode"],
|
||||
"transmission_frequency": record["transmission_frequency"],
|
||||
"reliability": _optional_float(record["reliability"]),
|
||||
"x": _optional_float(record["x_coor"]),
|
||||
"y": _optional_float(record["y_coor"]),
|
||||
}
|
||||
for record in records
|
||||
]
|
||||
@@ -1,18 +1,19 @@
|
||||
import time
|
||||
from typing import List, Optional, Any, Dict, Tuple
|
||||
from datetime import datetime, timedelta
|
||||
from psycopg import AsyncConnection
|
||||
import pandas as pd
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from psycopg import AsyncConnection
|
||||
|
||||
import app.native.wndb as wndb
|
||||
from app.algorithms.cleaning.flow import clean_flow_data_df_kf
|
||||
from app.algorithms.cleaning.pressure import clean_pressure_data_df_km
|
||||
from app.algorithms.health.analyzer import PipelineHealthAnalyzer
|
||||
import app.native.wndb as wndb
|
||||
|
||||
from app.infra.db.postgresql.scada import ScadaInfoRepository
|
||||
from app.infra.db.timescaledb.repositories.realtime import RealtimeRepository
|
||||
from app.infra.db.timescaledb.repositories.scheme import SchemeRepository
|
||||
from app.infra.db.timescaledb.repositories.scada import ScadaRepository
|
||||
from app.services import project_info
|
||||
|
||||
|
||||
class CompositeQueries:
|
||||
@@ -20,6 +21,13 @@ class CompositeQueries:
|
||||
复合查询类,提供跨表查询功能
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
async def _get_project_scada_index(
|
||||
postgres_conn: AsyncConnection,
|
||||
) -> Dict[str, Dict[str, Any]]:
|
||||
scadas = await ScadaInfoRepository.get_scadas(postgres_conn)
|
||||
return {scada["id"]: scada for scada in scadas}
|
||||
|
||||
@staticmethod
|
||||
async def get_scada_associated_realtime_simulation_data(
|
||||
timescale_conn: AsyncConnection,
|
||||
@@ -48,31 +56,22 @@ class CompositeQueries:
|
||||
ValueError: 当 SCADA 设备未找到或字段无效时
|
||||
"""
|
||||
result = {}
|
||||
# 1. 查询所有 SCADA 信息
|
||||
network_name = project_info.name
|
||||
scada_infos = wndb.get_all_scada_info(network_name) if network_name else []
|
||||
scada_by_id = await CompositeQueries._get_project_scada_index(postgres_conn)
|
||||
|
||||
for device_id in device_ids:
|
||||
# 2. 根据 device_id 找到对应的 SCADA 信息
|
||||
target_scada = None
|
||||
for scada in scada_infos:
|
||||
if scada["id"] == device_id:
|
||||
target_scada = scada
|
||||
break
|
||||
|
||||
target_scada = scada_by_id.get(device_id)
|
||||
if not target_scada:
|
||||
raise ValueError(f"SCADA device {device_id} not found")
|
||||
|
||||
# 3. 根据 type 和 associated_element_id 查询对应的模拟数据
|
||||
element_id = target_scada["associated_element_id"]
|
||||
scada_type = target_scada["type"]
|
||||
|
||||
if scada_type.lower() == "pipe_flow":
|
||||
if scada_type == "pipe_flow":
|
||||
# 查询 link 模拟数据
|
||||
res = await RealtimeRepository.get_link_field_by_time_range(
|
||||
timescale_conn, start_time, end_time, element_id, "flow"
|
||||
)
|
||||
elif scada_type.lower() == "pressure":
|
||||
elif scada_type == "pressure":
|
||||
# 查询 node 模拟数据
|
||||
res = await RealtimeRepository.get_node_field_by_time_range(
|
||||
timescale_conn, start_time, end_time, element_id, "pressure"
|
||||
@@ -115,26 +114,17 @@ class CompositeQueries:
|
||||
ValueError: 当 SCADA 设备未找到或字段无效时
|
||||
"""
|
||||
result = {}
|
||||
# 1. 查询所有 SCADA 信息
|
||||
network_name = project_info.name
|
||||
scada_infos = wndb.get_all_scada_info(network_name) if network_name else []
|
||||
scada_by_id = await CompositeQueries._get_project_scada_index(postgres_conn)
|
||||
|
||||
for device_id in device_ids:
|
||||
# 2. 根据 device_id 找到对应的 SCADA 信息
|
||||
target_scada = None
|
||||
for scada in scada_infos:
|
||||
if scada["id"] == device_id:
|
||||
target_scada = scada
|
||||
break
|
||||
|
||||
target_scada = scada_by_id.get(device_id)
|
||||
if not target_scada:
|
||||
raise ValueError(f"SCADA device {device_id} not found")
|
||||
|
||||
# 3. 根据 type 和 associated_element_id 查询对应的模拟数据
|
||||
element_id = target_scada["associated_element_id"]
|
||||
scada_type = target_scada["type"]
|
||||
|
||||
if scada_type.lower() == "pipe_flow":
|
||||
if scada_type == "pipe_flow":
|
||||
# 查询 link 模拟数据
|
||||
res = await SchemeRepository.get_link_field_by_scheme_and_time_range(
|
||||
timescale_conn,
|
||||
@@ -145,7 +135,7 @@ class CompositeQueries:
|
||||
element_id,
|
||||
"flow",
|
||||
)
|
||||
elif scada_type.lower() == "pressure":
|
||||
elif scada_type == "pressure":
|
||||
# 查询 node 模拟数据
|
||||
res = await SchemeRepository.get_node_field_by_scheme_and_time_range(
|
||||
timescale_conn,
|
||||
@@ -167,19 +157,19 @@ class CompositeQueries:
|
||||
@staticmethod
|
||||
async def get_realtime_simulation_data(
|
||||
timescale_conn: AsyncConnection,
|
||||
featureInfos: List[Tuple[str, str]],
|
||||
feature_infos: List[Tuple[str, str]],
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
) -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""
|
||||
获取 link/node 模拟值
|
||||
|
||||
根据传入的 featureInfos,找到关联的 link/node,
|
||||
根据传入的 feature_infos,找到关联的 link/node,
|
||||
并根据对应的 type,查询对应的模拟数据
|
||||
|
||||
Args:
|
||||
timescale_conn: TimescaleDB 异步连接
|
||||
featureInfos: 传入的 feature 信息列表,包含 (element_id, type)
|
||||
feature_infos: 传入的 feature 信息列表,包含 (element_id, type)
|
||||
start_time: 开始时间
|
||||
end_time: 结束时间
|
||||
|
||||
@@ -190,20 +180,20 @@ class CompositeQueries:
|
||||
ValueError: 当 SCADA 设备未找到或字段无效时
|
||||
"""
|
||||
result = {}
|
||||
for feature_id, type in featureInfos:
|
||||
for feature_id, feature_type in feature_infos:
|
||||
|
||||
if type.lower() == "pipe":
|
||||
if feature_type.lower() == "pipe":
|
||||
# 查询 link 模拟数据
|
||||
res = await RealtimeRepository.get_link_field_by_time_range(
|
||||
timescale_conn, start_time, end_time, feature_id, "flow"
|
||||
)
|
||||
elif type.lower() == "junction":
|
||||
elif feature_type.lower() == "junction":
|
||||
# 查询 node 模拟数据
|
||||
res = await RealtimeRepository.get_node_field_by_time_range(
|
||||
timescale_conn, start_time, end_time, feature_id, "pressure"
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unknown type: {type}")
|
||||
raise ValueError(f"Unknown type: {feature_type}")
|
||||
# 添加 scada_id 到每个数据项
|
||||
for item in res:
|
||||
item["feature_id"] = feature_id
|
||||
@@ -213,7 +203,7 @@ class CompositeQueries:
|
||||
@staticmethod
|
||||
async def get_scheme_simulation_data(
|
||||
timescale_conn: AsyncConnection,
|
||||
featureInfos: List[Tuple[str, str]],
|
||||
feature_infos: List[Tuple[str, str]],
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
scheme_type: str,
|
||||
@@ -222,12 +212,12 @@ class CompositeQueries:
|
||||
"""
|
||||
获取 link/node scheme 模拟值
|
||||
|
||||
根据传入的 featureInfos,找到关联的 link/node,
|
||||
根据传入的 feature_infos,找到关联的 link/node,
|
||||
并根据对应的 type,查询对应的模拟数据
|
||||
|
||||
Args:
|
||||
timescale_conn: TimescaleDB 异步连接
|
||||
featureInfos: 传入的 feature 信息列表,包含 (element_id, type)
|
||||
feature_infos: 传入的 feature 信息列表,包含 (element_id, type)
|
||||
start_time: 开始时间
|
||||
end_time: 结束时间
|
||||
scheme_type: 工况类型
|
||||
@@ -240,8 +230,8 @@ class CompositeQueries:
|
||||
ValueError: 当类型无效时
|
||||
"""
|
||||
result = {}
|
||||
for feature_id, type in featureInfos:
|
||||
if type.lower() == "pipe":
|
||||
for feature_id, feature_type in feature_infos:
|
||||
if feature_type.lower() == "pipe":
|
||||
# 查询 link 模拟数据
|
||||
res = await SchemeRepository.get_link_field_by_scheme_and_time_range(
|
||||
timescale_conn,
|
||||
@@ -252,7 +242,7 @@ class CompositeQueries:
|
||||
feature_id,
|
||||
"flow",
|
||||
)
|
||||
elif type.lower() == "junction":
|
||||
elif feature_type.lower() == "junction":
|
||||
# 查询 node 模拟数据
|
||||
res = await SchemeRepository.get_node_field_by_scheme_and_time_range(
|
||||
timescale_conn,
|
||||
@@ -264,7 +254,7 @@ class CompositeQueries:
|
||||
"pressure",
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unknown type: {type}")
|
||||
raise ValueError(f"Unknown type: {feature_type}")
|
||||
# 添加 feature_id 到每个数据项
|
||||
for item in res:
|
||||
item["feature_id"] = feature_id
|
||||
@@ -301,33 +291,27 @@ class CompositeQueries:
|
||||
ValueError: 当元素类型无效时
|
||||
"""
|
||||
|
||||
# 1. 查询所有 SCADA 信息
|
||||
network_name = project_info.name
|
||||
scada_infos = wndb.get_all_scada_info(network_name) if network_name else []
|
||||
|
||||
# 2. 根据 element_type 和 element_id 找到关联的 SCADA 设备
|
||||
associated_scada = None
|
||||
for scada in scada_infos:
|
||||
if scada["associated_element_id"] == element_id:
|
||||
associated_scada = scada
|
||||
break
|
||||
scada_by_id = await CompositeQueries._get_project_scada_index(postgres_conn)
|
||||
associated_scada = next(
|
||||
(
|
||||
scada
|
||||
for scada in scada_by_id.values()
|
||||
if scada["associated_element_id"] == element_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
if not associated_scada:
|
||||
# 没有找到关联的 SCADA 设备
|
||||
return None
|
||||
|
||||
# 3. 通过 SCADA device_id 获取监测数据
|
||||
device_id = associated_scada["id"]
|
||||
|
||||
# 根据 use_cleaned 参数选择字段
|
||||
data_field = "cleaned_value" if use_cleaned else "monitored_value"
|
||||
|
||||
# 保证 device_id 以列表形式传递
|
||||
res = await ScadaRepository.get_scada_field_by_id_time_range(
|
||||
timescale_conn, [device_id], start_time, end_time, data_field
|
||||
)
|
||||
|
||||
# 将 device_id 替换为 element_id 返回
|
||||
return {element_id: res.get(device_id, [])}
|
||||
|
||||
@staticmethod
|
||||
@@ -351,108 +335,124 @@ class CompositeQueries:
|
||||
end_time: 结束时间
|
||||
|
||||
Returns:
|
||||
"success" 或错误信息
|
||||
"success"
|
||||
|
||||
Raises:
|
||||
ValueError: 当前项目没有可清洗设备或指定时间范围内没有监测数据
|
||||
"""
|
||||
try:
|
||||
# 获取所有 SCADA 信息
|
||||
network_name = project_info.name
|
||||
scada_infos = wndb.get_all_scada_info(network_name) if network_name else []
|
||||
# 将列表转换为字典,以 device_id 为键
|
||||
scada_device_info_dict = {info["id"]: info for info in scada_infos}
|
||||
scada_by_id = await CompositeQueries._get_project_scada_index(postgres_conn)
|
||||
supported_types = {"pressure", "pipe_flow", "flow"}
|
||||
|
||||
# 如果 device_ids 为空,则处理所有 SCADA 设备
|
||||
if not device_ids:
|
||||
device_ids = list(scada_device_info_dict.keys())
|
||||
if device_ids:
|
||||
device_ids = [str(device_id).strip() for device_id in device_ids]
|
||||
missing_metadata_ids = [
|
||||
device_id
|
||||
for device_id in device_ids
|
||||
if device_id not in scada_by_id
|
||||
]
|
||||
if missing_metadata_ids:
|
||||
raise ValueError(
|
||||
f"当前项目中有 {len(missing_metadata_ids)} 个 SCADA 设备缺少元数据"
|
||||
)
|
||||
|
||||
# 批量查询所有设备的数据
|
||||
data = await ScadaRepository.get_scada_field_by_id_time_range(
|
||||
timescale_conn, device_ids, start_time, end_time, "monitored_value"
|
||||
unsupported_ids = [
|
||||
device_id
|
||||
for device_id in device_ids
|
||||
if scada_by_id[device_id]["type"] not in supported_types
|
||||
]
|
||||
if unsupported_ids:
|
||||
raise ValueError(
|
||||
f"当前项目中有 {len(unsupported_ids)} 个 SCADA 设备类型不支持清洗"
|
||||
)
|
||||
else:
|
||||
device_ids = [
|
||||
device_id
|
||||
for device_id, info in scada_by_id.items()
|
||||
if info["type"] in supported_types
|
||||
]
|
||||
|
||||
if not device_ids:
|
||||
raise ValueError("当前项目没有可清洗的 SCADA 设备")
|
||||
|
||||
data = await ScadaRepository.get_scada_field_by_id_time_range(
|
||||
timescale_conn, device_ids, start_time, end_time, "monitored_value"
|
||||
)
|
||||
if not data:
|
||||
raise ValueError("指定时间范围内没有 SCADA 监测数据")
|
||||
|
||||
normalized_data = {
|
||||
str(device_id): records for device_id, records in data.items()
|
||||
}
|
||||
missing_data_ids = [
|
||||
device_id for device_id in device_ids if not normalized_data.get(device_id)
|
||||
]
|
||||
if missing_data_ids:
|
||||
raise ValueError(
|
||||
f"指定时间范围内有 {len(missing_data_ids)} 个 SCADA 设备没有监测数据"
|
||||
)
|
||||
|
||||
if not data:
|
||||
return "error: fetch none scada data" # 没有数据,直接返回
|
||||
all_records = [
|
||||
{
|
||||
"time": record["time"],
|
||||
"device_id": device_id,
|
||||
"value": record["value"],
|
||||
}
|
||||
for device_id, records in normalized_data.items()
|
||||
for record in records
|
||||
]
|
||||
if not all_records:
|
||||
raise ValueError("指定时间范围内没有 SCADA 监测数据")
|
||||
|
||||
# 将嵌套字典转换为 DataFrame,使用 time 作为索引
|
||||
# data 格式: {device_id: [{"time": "...", "value": ...}, ...]}
|
||||
all_records = []
|
||||
for device_id, records in data.items():
|
||||
for record in records:
|
||||
all_records.append(
|
||||
{
|
||||
"time": record["time"],
|
||||
"device_id": device_id,
|
||||
"value": record["value"],
|
||||
}
|
||||
df_long = pd.DataFrame(all_records)
|
||||
df = df_long.pivot(index="time", columns="device_id", values="value")
|
||||
|
||||
pressure_ids = [
|
||||
device_id
|
||||
for device_id in df.columns
|
||||
if scada_by_id[device_id]["type"] == "pressure"
|
||||
]
|
||||
flow_ids = [
|
||||
device_id
|
||||
for device_id in df.columns
|
||||
if scada_by_id[device_id]["type"] in {"pipe_flow", "flow"}
|
||||
]
|
||||
|
||||
updated_rows = 0
|
||||
for grouped_ids, cleaning_function in (
|
||||
(pressure_ids, clean_pressure_data_df_km),
|
||||
(flow_ids, clean_flow_data_df_kf),
|
||||
):
|
||||
if not grouped_ids:
|
||||
continue
|
||||
|
||||
source_df = df[grouped_ids].reset_index()
|
||||
cleaned_df = cleaning_function(source_df)
|
||||
time_values = cleaned_df["time"].tolist()
|
||||
|
||||
for device_id in grouped_ids:
|
||||
if device_id not in cleaned_df.columns:
|
||||
raise ValueError(f"设备 {device_id} 的清洗结果缺少数据列")
|
||||
|
||||
cleaned_values = cleaned_df[device_id].tolist()
|
||||
for time_value, value in zip(time_values, cleaned_values):
|
||||
time_dt = (
|
||||
time_value
|
||||
if isinstance(time_value, datetime)
|
||||
else datetime.fromisoformat(str(time_value))
|
||||
)
|
||||
await ScadaRepository.update_scada_field(
|
||||
timescale_conn,
|
||||
time_dt,
|
||||
device_id,
|
||||
"cleaned_value",
|
||||
value,
|
||||
)
|
||||
updated_rows += 1
|
||||
|
||||
if not all_records:
|
||||
return "error: fetch none scada data" # 没有数据,直接返回
|
||||
if updated_rows == 0:
|
||||
raise ValueError("SCADA 数据清洗未产生任何数据库更新")
|
||||
|
||||
# 创建 DataFrame 并透视,使 device_id 成为列
|
||||
df_long = pd.DataFrame(all_records)
|
||||
df = df_long.pivot(index="time", columns="device_id", values="value")
|
||||
|
||||
# 根据type分类设备
|
||||
pressure_ids = [
|
||||
id
|
||||
for id in df.columns
|
||||
if scada_device_info_dict.get(id, {}).get("type") == "pressure"
|
||||
]
|
||||
flow_ids = [
|
||||
id
|
||||
for id in df.columns
|
||||
if scada_device_info_dict.get(id, {}).get("type") == "pipe_flow"
|
||||
]
|
||||
|
||||
# 处理pressure数据
|
||||
if pressure_ids:
|
||||
pressure_df = df[pressure_ids]
|
||||
# 重置索引,将 time 变为普通列
|
||||
pressure_df = pressure_df.reset_index()
|
||||
# 调用清洗方法
|
||||
cleaned_df = clean_pressure_data_df_km(pressure_df)
|
||||
# 将清洗后的数据写回数据库
|
||||
for device_id in pressure_ids:
|
||||
if device_id in cleaned_df.columns:
|
||||
cleaned_values = cleaned_df[device_id].tolist()
|
||||
time_values = cleaned_df["time"].tolist()
|
||||
for i, time_str in enumerate(time_values):
|
||||
time_dt = datetime.fromisoformat(time_str)
|
||||
value = cleaned_values[i]
|
||||
await ScadaRepository.update_scada_field(
|
||||
timescale_conn,
|
||||
time_dt,
|
||||
device_id,
|
||||
"cleaned_value",
|
||||
value,
|
||||
)
|
||||
|
||||
# 处理flow数据
|
||||
if flow_ids:
|
||||
flow_df = df[flow_ids]
|
||||
# 重置索引,将 time 变为普通列
|
||||
flow_df = flow_df.reset_index()
|
||||
# 调用清洗方法
|
||||
cleaned_df = clean_flow_data_df_kf(flow_df)
|
||||
# 将清洗后的数据写回数据库
|
||||
for device_id in flow_ids:
|
||||
if device_id in cleaned_df.columns:
|
||||
cleaned_values = cleaned_df[device_id].tolist()
|
||||
time_values = cleaned_df["time"].tolist()
|
||||
for i, time_str in enumerate(time_values):
|
||||
time_dt = datetime.fromisoformat(time_str)
|
||||
value = cleaned_values[i]
|
||||
await ScadaRepository.update_scada_field(
|
||||
timescale_conn,
|
||||
time_dt,
|
||||
device_id,
|
||||
"cleaned_value",
|
||||
value,
|
||||
)
|
||||
|
||||
return "success"
|
||||
except Exception as e:
|
||||
return f"error: {str(e)}"
|
||||
return "success"
|
||||
|
||||
@staticmethod
|
||||
async def predict_pipeline_health(
|
||||
|
||||
@@ -50,6 +50,7 @@ class InternalStorage:
|
||||
link_result_list: List[dict],
|
||||
result_start_time: str,
|
||||
num_periods: int = 1,
|
||||
result_timestep_seconds: int | None = None,
|
||||
db_name: str = None,
|
||||
max_retries: int = 3,
|
||||
):
|
||||
@@ -70,6 +71,7 @@ class InternalStorage:
|
||||
link_result_list,
|
||||
result_start_time,
|
||||
num_periods,
|
||||
result_timestep_seconds,
|
||||
)
|
||||
break # 成功
|
||||
except Exception as e:
|
||||
@@ -167,6 +169,38 @@ class InternalQueries:
|
||||
else:
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def query_latest_scada_time(
|
||||
device_ids: List[str],
|
||||
before_time: str | datetime | None = None,
|
||||
db_name: str = None,
|
||||
max_retries: int = 3,
|
||||
) -> datetime | None:
|
||||
"""Return the latest SCADA timestamp for the selected devices."""
|
||||
before_dt = (
|
||||
parse_utc_time(before_time, field_name="before_time")
|
||||
if before_time is not None
|
||||
else None
|
||||
)
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
conn_string = (
|
||||
get_timescaledb_pgconn_string(db_name=db_name)
|
||||
if db_name
|
||||
else get_timescaledb_pgconn_string()
|
||||
)
|
||||
with psycopg.Connection.connect(conn_string) as conn:
|
||||
return ScadaRepository.get_latest_scada_time_sync(
|
||||
conn,
|
||||
device_ids,
|
||||
before_dt,
|
||||
)
|
||||
except Exception:
|
||||
if attempt < max_retries - 1:
|
||||
time.sleep(1)
|
||||
else:
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def query_realtime_simulation_by_ids_timerange(
|
||||
element_ids: List[str],
|
||||
@@ -229,7 +263,14 @@ class InternalQueries:
|
||||
scheme_type: str | None = None,
|
||||
scheme_name: str | None = None,
|
||||
) -> dict[str, list[dict]]:
|
||||
if not element_ids:
|
||||
normalized_element_ids = list(
|
||||
dict.fromkeys(
|
||||
normalized
|
||||
for normalized in (str(element_id).strip() for element_id in element_ids)
|
||||
if normalized
|
||||
)
|
||||
)
|
||||
if not normalized_element_ids:
|
||||
return {}
|
||||
|
||||
start_dt = parse_utc_time(start_time, field_name="start_time")
|
||||
@@ -253,9 +294,9 @@ class InternalQueries:
|
||||
with conn.cursor(row_factory=dict_row) as cur:
|
||||
if schema_name == "scheme":
|
||||
query = sql.SQL(
|
||||
"SELECT id, time, {} FROM {}.{} "
|
||||
"SELECT btrim(id::text) AS id, time, {} FROM {}.{} "
|
||||
"WHERE scheme_type = %s AND scheme_name = %s "
|
||||
"AND time >= %s AND time <= %s AND id = ANY(%s)"
|
||||
"AND time >= %s AND time <= %s AND btrim(id::text) = ANY(%s)"
|
||||
).format(
|
||||
sql.Identifier(field),
|
||||
sql.Identifier(schema_name),
|
||||
@@ -268,25 +309,26 @@ class InternalQueries:
|
||||
scheme_name,
|
||||
start_dt,
|
||||
end_dt,
|
||||
element_ids,
|
||||
normalized_element_ids,
|
||||
),
|
||||
)
|
||||
else:
|
||||
query = sql.SQL(
|
||||
"SELECT id, time, {} FROM {}.{} "
|
||||
"WHERE time >= %s AND time <= %s AND id = ANY(%s)"
|
||||
"SELECT btrim(id::text) AS id, time, {} FROM {}.{} "
|
||||
"WHERE time >= %s AND time <= %s AND btrim(id::text) = ANY(%s)"
|
||||
).format(
|
||||
sql.Identifier(field),
|
||||
sql.Identifier(schema_name),
|
||||
sql.Identifier(table_name),
|
||||
)
|
||||
cur.execute(query, (start_dt, end_dt, element_ids))
|
||||
cur.execute(query, (start_dt, end_dt, normalized_element_ids))
|
||||
rows = cur.fetchall()
|
||||
result: dict[str, list[dict]] = {
|
||||
element_id: [] for element_id in element_ids
|
||||
element_id: [] for element_id in normalized_element_ids
|
||||
}
|
||||
for row in rows:
|
||||
result.setdefault(row["id"], []).append(
|
||||
element_id = str(row["id"]).strip()
|
||||
result.setdefault(element_id, []).append(
|
||||
{"time": row["time"].isoformat(), "value": row[field]}
|
||||
)
|
||||
for element_id in result:
|
||||
|
||||
@@ -54,6 +54,27 @@ class ScadaRepository:
|
||||
)
|
||||
return cur.fetchall()
|
||||
|
||||
@staticmethod
|
||||
def get_latest_scada_time_sync(
|
||||
conn: Connection,
|
||||
device_ids: List[str],
|
||||
before_time: datetime | None = None,
|
||||
) -> datetime | None:
|
||||
with conn.cursor(row_factory=dict_row) as cur:
|
||||
if before_time is None:
|
||||
cur.execute(
|
||||
"SELECT max(time) AS time FROM scada.scada_data WHERE device_id = ANY(%s)",
|
||||
(device_ids,),
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"SELECT max(time) AS time FROM scada.scada_data "
|
||||
"WHERE device_id = ANY(%s) AND time <= %s",
|
||||
(device_ids, before_time),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return row["time"] if row else None
|
||||
|
||||
@staticmethod
|
||||
async def get_scada_field_by_id_time_range(
|
||||
conn: AsyncConnection,
|
||||
|
||||
@@ -3,10 +3,24 @@ from datetime import datetime, timedelta
|
||||
from collections import defaultdict
|
||||
from psycopg import AsyncConnection, Connection, sql
|
||||
import app.services.globals as globals
|
||||
from app.services.time_api import parse_utc_time
|
||||
from app.services.time_api import parse_clock_duration_seconds, parse_utc_time
|
||||
|
||||
|
||||
class SchemeRepository:
|
||||
@staticmethod
|
||||
def _get_result_timestep(result_timestep_seconds: int | None) -> timedelta:
|
||||
if result_timestep_seconds is not None:
|
||||
if result_timestep_seconds <= 0:
|
||||
raise ValueError("result_timestep_seconds must be greater than 0.")
|
||||
return timedelta(seconds=result_timestep_seconds)
|
||||
|
||||
timestep_seconds = parse_clock_duration_seconds(
|
||||
globals.hydraulic_timestep,
|
||||
field_name="HYDRAULIC TIMESTEP",
|
||||
)
|
||||
if timestep_seconds <= 0:
|
||||
raise ValueError("HYDRAULIC TIMESTEP must be greater than 0.")
|
||||
return timedelta(seconds=timestep_seconds)
|
||||
|
||||
# --- Link Simulation ---
|
||||
|
||||
@@ -452,6 +466,7 @@ class SchemeRepository:
|
||||
link_result_list: List[Dict[str, any]],
|
||||
result_start_time: str,
|
||||
num_periods: int = 1,
|
||||
result_timestep_seconds: int | None = None,
|
||||
):
|
||||
"""
|
||||
Store scheme simulation results to TimescaleDB.
|
||||
@@ -468,20 +483,16 @@ class SchemeRepository:
|
||||
result_start_time, field_name="result_start_time"
|
||||
)
|
||||
|
||||
timestep_parts = globals.hydraulic_timestep.split(":")
|
||||
timestep = timedelta(
|
||||
hours=int(timestep_parts[0]),
|
||||
minutes=int(timestep_parts[1]),
|
||||
seconds=int(timestep_parts[2]),
|
||||
)
|
||||
timestep = SchemeRepository._get_result_timestep(result_timestep_seconds)
|
||||
|
||||
# Prepare node data for batch insert
|
||||
node_data = []
|
||||
for node_result in node_result_list:
|
||||
node_id = node_result.get("node")
|
||||
for period_index in range(num_periods):
|
||||
result_rows = node_result.get("result", [])
|
||||
for period_index in range(min(num_periods, len(result_rows))):
|
||||
current_time = simulation_time + (timestep * period_index)
|
||||
data = node_result.get("result", [])[period_index]
|
||||
data = result_rows[period_index]
|
||||
node_data.append(
|
||||
{
|
||||
"time": current_time,
|
||||
@@ -499,9 +510,10 @@ class SchemeRepository:
|
||||
link_data = []
|
||||
for link_result in link_result_list:
|
||||
link_id = link_result.get("link")
|
||||
for period_index in range(num_periods):
|
||||
result_rows = link_result.get("result", [])
|
||||
for period_index in range(min(num_periods, len(result_rows))):
|
||||
current_time = simulation_time + (timestep * period_index)
|
||||
data = link_result.get("result", [])[period_index]
|
||||
data = result_rows[period_index]
|
||||
link_data.append(
|
||||
{
|
||||
"time": current_time,
|
||||
@@ -535,6 +547,7 @@ class SchemeRepository:
|
||||
link_result_list: List[Dict[str, any]],
|
||||
result_start_time: str,
|
||||
num_periods: int = 1,
|
||||
result_timestep_seconds: int | None = None,
|
||||
):
|
||||
"""
|
||||
Store scheme simulation results to TimescaleDB (sync version).
|
||||
@@ -551,20 +564,16 @@ class SchemeRepository:
|
||||
result_start_time, field_name="result_start_time"
|
||||
)
|
||||
|
||||
timestep_parts = globals.hydraulic_timestep.split(":")
|
||||
timestep = timedelta(
|
||||
hours=int(timestep_parts[0]),
|
||||
minutes=int(timestep_parts[1]),
|
||||
seconds=int(timestep_parts[2]),
|
||||
)
|
||||
timestep = SchemeRepository._get_result_timestep(result_timestep_seconds)
|
||||
|
||||
# Prepare node data for batch insert
|
||||
node_data = []
|
||||
for node_result in node_result_list:
|
||||
node_id = node_result.get("node")
|
||||
for period_index in range(num_periods):
|
||||
result_rows = node_result.get("result", [])
|
||||
for period_index in range(min(num_periods, len(result_rows))):
|
||||
current_time = simulation_time + (timestep * period_index)
|
||||
data = node_result.get("result", [])[period_index]
|
||||
data = result_rows[period_index]
|
||||
node_data.append(
|
||||
{
|
||||
"time": current_time,
|
||||
@@ -582,9 +591,10 @@ class SchemeRepository:
|
||||
link_data = []
|
||||
for link_result in link_result_list:
|
||||
link_id = link_result.get("link")
|
||||
for period_index in range(num_periods):
|
||||
result_rows = link_result.get("result", [])
|
||||
for period_index in range(min(num_periods, len(result_rows))):
|
||||
current_time = simulation_time + (timestep * period_index)
|
||||
data = link_result.get("result", [])[period_index]
|
||||
data = result_rows[period_index]
|
||||
link_data.append(
|
||||
{
|
||||
"time": current_time,
|
||||
|
||||
+4
-1
@@ -6,7 +6,8 @@ import logging
|
||||
from datetime import datetime
|
||||
|
||||
import app.services.project_info as project_info
|
||||
from app.api.v1.router import api_router
|
||||
from app.api.problem_details import install_problem_details_handlers
|
||||
from app.api.v1.rest_router import api_router
|
||||
from app.infra.db.timescaledb.database import db as tsdb
|
||||
from app.infra.db.postgresql.database import db as pgdb
|
||||
from app.infra.db.dynamic_manager import project_connection_manager
|
||||
@@ -64,11 +65,13 @@ app = FastAPI(
|
||||
docs_url=None if is_production else "/docs",
|
||||
redoc_url=None if is_production else "/redoc",
|
||||
openapi_url=None if is_production else "/openapi.json",
|
||||
redirect_slashes=False,
|
||||
)
|
||||
|
||||
|
||||
# Include Routers
|
||||
app.include_router(api_router, prefix="/api/v1")
|
||||
install_problem_details_handlers(app)
|
||||
# Legcy Routers without version prefix
|
||||
# app.include_router(api_router)
|
||||
|
||||
|
||||
@@ -320,7 +320,11 @@ from .s23_options_util import (
|
||||
from .s23_options_util import get_option_v3_schema, get_option_v3
|
||||
from .batch_api import set_option_v3_ex
|
||||
|
||||
from .s24_coordinates import get_node_coord, get_nodes_in_extent, get_links_in_extent
|
||||
from .s24_coordinates import (
|
||||
get_links_in_extent,
|
||||
get_node_coord,
|
||||
get_nodes_in_extent,
|
||||
)
|
||||
|
||||
from .s25_vertices import (
|
||||
get_vertex_schema,
|
||||
@@ -468,6 +472,11 @@ from .s41_pipe_risk_probability import (
|
||||
get_pipe_risk_probability_geometries,
|
||||
)
|
||||
|
||||
from .s42_sensor_placement import get_all_sensor_placements
|
||||
from .s42_sensor_placement import (
|
||||
get_all_sensor_placements,
|
||||
get_sensor_placement,
|
||||
get_sensor_placement_nodes,
|
||||
update_sensor_placement,
|
||||
)
|
||||
|
||||
from .s43_burst_locate_result import get_all_burst_locate_results
|
||||
|
||||
@@ -1,3 +1,78 @@
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from threading import RLock
|
||||
|
||||
import psycopg as pg
|
||||
|
||||
g_conn_dict : dict[str, pg.Connection] = {}
|
||||
from app.core.config import get_pgconn_string
|
||||
|
||||
g_conn_dict: dict[str, pg.Connection] = {}
|
||||
_registry_lock = RLock()
|
||||
_project_locks: dict[str, RLock] = {}
|
||||
|
||||
|
||||
def _is_closed(connection: pg.Connection) -> bool:
|
||||
return bool(getattr(connection, "closed", False))
|
||||
|
||||
|
||||
def _close_connection(connection: pg.Connection) -> None:
|
||||
if not _is_closed(connection):
|
||||
connection.close()
|
||||
|
||||
|
||||
def _is_healthy(connection: pg.Connection) -> bool:
|
||||
if _is_closed(connection):
|
||||
return False
|
||||
try:
|
||||
with connection.cursor() as cur:
|
||||
cur.execute("SELECT 1")
|
||||
except pg.Error:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _get_project_lock(name: str) -> RLock:
|
||||
with _registry_lock:
|
||||
lock = _project_locks.get(name)
|
||||
if lock is None:
|
||||
lock = RLock()
|
||||
_project_locks[name] = lock
|
||||
return lock
|
||||
|
||||
|
||||
def open_connection(name: str) -> pg.Connection:
|
||||
with _get_project_lock(name):
|
||||
connection = g_conn_dict.get(name)
|
||||
if connection is None or not _is_healthy(connection):
|
||||
if connection is not None:
|
||||
_close_connection(connection)
|
||||
connection = pg.connect(
|
||||
conninfo=get_pgconn_string(db_name=name), autocommit=True
|
||||
)
|
||||
g_conn_dict[name] = connection
|
||||
return connection
|
||||
|
||||
|
||||
def is_connection_open(name: str) -> bool:
|
||||
with _get_project_lock(name):
|
||||
connection = g_conn_dict.get(name)
|
||||
if connection is None:
|
||||
return False
|
||||
if not _is_healthy(connection):
|
||||
del g_conn_dict[name]
|
||||
_close_connection(connection)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def close_connection(name: str) -> None:
|
||||
with _get_project_lock(name):
|
||||
connection = g_conn_dict.pop(name, None)
|
||||
if connection is not None:
|
||||
_close_connection(connection)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def project_connection(name: str) -> Iterator[pg.Connection]:
|
||||
with _get_project_lock(name):
|
||||
yield open_connection(name)
|
||||
|
||||
+19
-15
@@ -1,6 +1,6 @@
|
||||
from typing import Any
|
||||
from psycopg.rows import dict_row, Row
|
||||
from .connection import g_conn_dict as conn
|
||||
from .connection import project_connection
|
||||
|
||||
API_ADD = 'add'
|
||||
API_UPDATE = 'update'
|
||||
@@ -83,29 +83,33 @@ class DbChangeSet:
|
||||
|
||||
|
||||
def read(name: str, sql: str) -> Row:
|
||||
with conn[name].cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(sql)
|
||||
row = cur.fetchone()
|
||||
if row == None:
|
||||
raise Exception(sql)
|
||||
return row
|
||||
with project_connection(name) as conn:
|
||||
with conn.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(sql)
|
||||
row = cur.fetchone()
|
||||
if row == None:
|
||||
raise Exception(sql)
|
||||
return row
|
||||
|
||||
|
||||
def read_all(name: str, sql: str) -> list[Row]:
|
||||
with conn[name].cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(sql)
|
||||
return cur.fetchall()
|
||||
with project_connection(name) as conn:
|
||||
with conn.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(sql)
|
||||
return cur.fetchall()
|
||||
|
||||
|
||||
def try_read(name: str, sql: str) -> Row | None:
|
||||
with conn[name].cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(sql)
|
||||
return cur.fetchone()
|
||||
with project_connection(name) as conn:
|
||||
with conn.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(sql)
|
||||
return cur.fetchone()
|
||||
|
||||
|
||||
def write(name: str, sql: str) -> None:
|
||||
with conn[name].cursor() as cur:
|
||||
cur.execute(sql)
|
||||
with project_connection(name) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(sql)
|
||||
|
||||
|
||||
def get_current_operation(name: str) -> int:
|
||||
|
||||
@@ -2,7 +2,11 @@ import os
|
||||
import psycopg as pg
|
||||
from psycopg import sql
|
||||
from psycopg.rows import dict_row
|
||||
from .connection import g_conn_dict as conn
|
||||
from .connection import (
|
||||
close_connection,
|
||||
is_connection_open,
|
||||
open_connection,
|
||||
)
|
||||
from app.core.config import get_pgconn_string, get_pg_config, get_pg_password
|
||||
|
||||
# no undo/redo
|
||||
@@ -31,9 +35,7 @@ def have_project(name: str) -> bool:
|
||||
|
||||
|
||||
def copy_project(source: str, new: str) -> None:
|
||||
if source in conn:
|
||||
conn[source].close()
|
||||
del conn[source]
|
||||
close_connection(source)
|
||||
|
||||
with pg.connect(
|
||||
conninfo=get_pgconn_string(db_name="postgres"), autocommit=True
|
||||
@@ -176,17 +178,12 @@ def clean_project(excluded: list[str] = []) -> None:
|
||||
|
||||
|
||||
def open_project(name: str) -> None:
|
||||
if name not in conn:
|
||||
conn[name] = pg.connect(
|
||||
conninfo=get_pgconn_string(db_name=name), autocommit=True
|
||||
)
|
||||
open_connection(name)
|
||||
|
||||
|
||||
def is_project_open(name: str) -> bool:
|
||||
return name in conn
|
||||
return is_connection_open(name)
|
||||
|
||||
|
||||
def close_project(name: str) -> None:
|
||||
if name in conn:
|
||||
conn[name].close()
|
||||
del conn[name]
|
||||
close_connection(name)
|
||||
|
||||
+51
-43
@@ -1,5 +1,5 @@
|
||||
from psycopg.rows import dict_row, Row
|
||||
from .connection import g_conn_dict as conn
|
||||
from .connection import project_connection
|
||||
from .database import read
|
||||
from typing import Any
|
||||
|
||||
@@ -47,9 +47,10 @@ ELEMENT_TYPES : dict[str, int] = {
|
||||
}
|
||||
|
||||
def _get_from(name: str, id: str, base_type: str) -> Row | None:
|
||||
with conn[name].cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(f"select * from {base_type} where id = '{id}'")
|
||||
return cur.fetchone()
|
||||
with project_connection(name) as conn:
|
||||
with conn.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(f"select * from {base_type} where id = '{id}'")
|
||||
return cur.fetchone()
|
||||
|
||||
|
||||
def is_node(name: str, id: str) -> bool:
|
||||
@@ -125,10 +126,11 @@ def is_region(name: str, id: str) -> bool:
|
||||
|
||||
def _get_all(name: str, base_type: str) -> list[str]:
|
||||
ids : list[str] = []
|
||||
with conn[name].cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(f"select id from {base_type} order by id")
|
||||
for record in cur:
|
||||
ids.append(record['id'])
|
||||
with project_connection(name) as conn:
|
||||
with conn.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(f"select id from {base_type} order by id")
|
||||
for record in cur:
|
||||
ids.append(record['id'])
|
||||
return ids
|
||||
|
||||
|
||||
@@ -138,29 +140,32 @@ def get_nodes(name: str) -> list[str]:
|
||||
# DingZQ
|
||||
def _get_nodes_by_type(name: str, type: str) -> list[str]:
|
||||
ids : list[str] = []
|
||||
with conn[name].cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(f"select id from {_NODE} where type = '{type}' order by id")
|
||||
for record in cur:
|
||||
ids.append(record['id'])
|
||||
with project_connection(name) as conn:
|
||||
with conn.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(f"select id from {_NODE} where type = '{type}' order by id")
|
||||
for record in cur:
|
||||
ids.append(record['id'])
|
||||
return ids
|
||||
|
||||
# DingZQ
|
||||
def get_nodes_id_and_type(name: str) -> dict[str, str]:
|
||||
nodes_id_and_type: dict[str, str] = {}
|
||||
with conn[name].cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(f"select id, type from {_NODE} order by id")
|
||||
for record in cur:
|
||||
nodes_id_and_type[record['id']] = record['type']
|
||||
with project_connection(name) as conn:
|
||||
with conn.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(f"select id, type from {_NODE} order by id")
|
||||
for record in cur:
|
||||
nodes_id_and_type[record['id']] = record['type']
|
||||
return nodes_id_and_type
|
||||
|
||||
# DingZQ 2024-12-31
|
||||
def get_major_nodes(name: str, diameter: int) -> list[str]:
|
||||
major_nodes_set = set()
|
||||
with conn[name].cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(f"select node1, node2 from pipes where diameter > {diameter}")
|
||||
for record in cur:
|
||||
major_nodes_set.add(record['node1'])
|
||||
major_nodes_set.add(record['node2'])
|
||||
with project_connection(name) as conn:
|
||||
with conn.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(f"select node1, node2 from pipes where diameter > {diameter}")
|
||||
for record in cur:
|
||||
major_nodes_set.add(record['node1'])
|
||||
major_nodes_set.add(record['node2'])
|
||||
|
||||
return list(major_nodes_set)
|
||||
|
||||
@@ -183,29 +188,32 @@ def get_links(name: str) -> list[str]:
|
||||
# DingZQ
|
||||
def _get_links_by_type(name: str, type: str) -> list[str]:
|
||||
ids : list[str] = []
|
||||
with conn[name].cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(f"select id from {_LINK} where type = '{type}' order by id")
|
||||
for record in cur:
|
||||
ids.append(record['id'])
|
||||
with project_connection(name) as conn:
|
||||
with conn.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(f"select id from {_LINK} where type = '{type}' order by id")
|
||||
for record in cur:
|
||||
ids.append(record['id'])
|
||||
return ids
|
||||
|
||||
# DingZQ
|
||||
def get_links_id_and_type(name: str) -> dict[str, str]:
|
||||
links_id_and_type: dict[str, str] = {}
|
||||
with conn[name].cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(f"select id, type from {_LINK} order by id")
|
||||
for record in cur:
|
||||
links_id_and_type[record['id']] = record['type']
|
||||
with project_connection(name) as conn:
|
||||
with conn.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(f"select id, type from {_LINK} order by id")
|
||||
for record in cur:
|
||||
links_id_and_type[record['id']] = record['type']
|
||||
return links_id_and_type
|
||||
|
||||
# DingZQ 2024-12-31
|
||||
# 获取直径大于800的管道
|
||||
def get_major_pipes(name: str, diameter: int) -> list[str]:
|
||||
major_pipe_ids: list[str] = []
|
||||
with conn[name].cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(f"select id from pipes where diameter > {diameter} order by id")
|
||||
for record in cur:
|
||||
major_pipe_ids.append(record['id'])
|
||||
with project_connection(name) as conn:
|
||||
with conn.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(f"select id from pipes where diameter > {diameter} order by id")
|
||||
for record in cur:
|
||||
major_pipe_ids.append(record['id'])
|
||||
return major_pipe_ids
|
||||
|
||||
# DingZQ
|
||||
@@ -232,15 +240,16 @@ def get_regions(name: str) -> list[str]:
|
||||
return _get_all(name, _REGION)
|
||||
|
||||
def get_node_links(name: str, id: str) -> list[str]:
|
||||
with conn[name].cursor(row_factory=dict_row) as cur:
|
||||
links: list[str] = []
|
||||
for p in cur.execute(f"select id from pipes where node1 = '{id}' or node2 = '{id}'").fetchall():
|
||||
links.append(p['id'])
|
||||
for p in cur.execute(f"select id from pumps where node1 = '{id}' or node2 = '{id}'").fetchall():
|
||||
links.append(p['id'])
|
||||
for p in cur.execute(f"select id from valves where node1 = '{id}' or node2 = '{id}'").fetchall():
|
||||
links.append(p['id'])
|
||||
return links
|
||||
with project_connection(name) as conn:
|
||||
with conn.cursor(row_factory=dict_row) as cur:
|
||||
links: list[str] = []
|
||||
for p in cur.execute(f"select id from pipes where node1 = '{id}' or node2 = '{id}'").fetchall():
|
||||
links.append(p['id'])
|
||||
for p in cur.execute(f"select id from pumps where node1 = '{id}' or node2 = '{id}'").fetchall():
|
||||
links.append(p['id'])
|
||||
for p in cur.execute(f"select id from valves where node1 = '{id}' or node2 = '{id}'").fetchall():
|
||||
links.append(p['id'])
|
||||
return links
|
||||
|
||||
|
||||
def get_link_nodes(name: str, id: str) -> list[str]:
|
||||
@@ -259,4 +268,3 @@ def get_region_type(name: str, id: str)->str:
|
||||
return type
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from .database import *
|
||||
from .connection import project_connection
|
||||
from .s0_base import get_link_nodes
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
def sql_update_coord(node: str, x: float, y: float) -> str:
|
||||
coord = f"st_geomfromtext('point({x} {y})')"
|
||||
@@ -49,10 +51,11 @@ def get_links_in_extent(name: str, x1: float, y1: float, x2: float, y2: float) -
|
||||
node_ids = set([s.split(':')[0] for s in get_nodes_in_extent(name, x1, y1, x2, y2)])
|
||||
|
||||
all_link_ids = []
|
||||
with conn[name].cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(f"select id from pipes")
|
||||
for record in cur:
|
||||
all_link_ids.append(record['id'])
|
||||
with project_connection(name) as conn:
|
||||
with conn.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(f"select id from pipes")
|
||||
for record in cur:
|
||||
all_link_ids.append(record['id'])
|
||||
|
||||
links = []
|
||||
for link_id in all_link_ids:
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from .database import *
|
||||
from .connection import project_connection
|
||||
from .s0_base import *
|
||||
from psycopg.rows import dict_row
|
||||
import json
|
||||
|
||||
def get_pipe_risk_probability_now(name: str, pipe_id: str) -> dict[str, Any]:
|
||||
@@ -28,29 +30,31 @@ def get_pipe_risk_probability(name: str, pipe_id: str) -> dict[str, Any]:
|
||||
|
||||
def get_network_pipe_risk_probability_now(name: str) -> list[dict[str, Any]]:
|
||||
pipe_risk_probability_list = []
|
||||
with conn[name].cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(f"select * from pipe_risk_probability")
|
||||
for record in cur:
|
||||
#pipe_risk_probability_list.append(record)
|
||||
t = {}
|
||||
t['pipeid'] = record['pipeid']
|
||||
t['pipeage'] = record['pipeage']
|
||||
t['risk_probability_now'] = record['risk_probability_now']
|
||||
pipe_risk_probability_list.append(t)
|
||||
with project_connection(name) as conn:
|
||||
with conn.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(f"select * from pipe_risk_probability")
|
||||
for record in cur:
|
||||
#pipe_risk_probability_list.append(record)
|
||||
t = {}
|
||||
t['pipeid'] = record['pipeid']
|
||||
t['pipeage'] = record['pipeage']
|
||||
t['risk_probability_now'] = record['risk_probability_now']
|
||||
pipe_risk_probability_list.append(t)
|
||||
|
||||
return pipe_risk_probability_list
|
||||
|
||||
def get_pipes_risk_probability(name: str, pipe_ids: list[str]) -> list[dict[str, Any]]:
|
||||
pipe_risk_probability_list = []
|
||||
with conn[name].cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(f"select * from pipe_risk_probability")
|
||||
for record in cur:
|
||||
if record['pipeid'] in pipe_ids:
|
||||
t = {}
|
||||
t['pipeid'] = record['pipeid']
|
||||
t['x'] = record['x']
|
||||
t['y'] = record['y']
|
||||
pipe_risk_probability_list.append(t)
|
||||
with project_connection(name) as conn:
|
||||
with conn.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(f"select * from pipe_risk_probability")
|
||||
for record in cur:
|
||||
if record['pipeid'] in pipe_ids:
|
||||
t = {}
|
||||
t['pipeid'] = record['pipeid']
|
||||
t['x'] = record['x']
|
||||
t['y'] = record['y']
|
||||
pipe_risk_probability_list.append(t)
|
||||
|
||||
return pipe_risk_probability_list
|
||||
|
||||
@@ -67,21 +71,22 @@ def get_pipe_risk_probability_geometries(name: str) -> dict[str, Any]:
|
||||
# key_endnode = '下游节点'
|
||||
key_geometry = 'geometry'
|
||||
|
||||
with conn[name].cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(f"select *, ST_AsGeoJSON(geometry) AS {key_geometry} from gis_pipe")
|
||||
with project_connection(name) as conn:
|
||||
with conn.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(f"select *, ST_AsGeoJSON(geometry) AS {key_geometry} from gis_pipe")
|
||||
|
||||
for record in cur:
|
||||
id = record[key_pipeId]
|
||||
geom = json.loads(record[key_geometry])
|
||||
for record in cur:
|
||||
id = record[key_pipeId]
|
||||
geom = json.loads(record[key_geometry])
|
||||
|
||||
pipe_risk_probability_geometries[id] = {
|
||||
'points': geom['coordinates']
|
||||
}
|
||||
pipe_risk_probability_geometries[id] = {
|
||||
'points': geom['coordinates']
|
||||
}
|
||||
|
||||
for col in record:
|
||||
if col != key_geometry:
|
||||
pipe_risk_probability_geometries[id][col] = record[col]
|
||||
for col in record:
|
||||
if col != key_geometry:
|
||||
pipe_risk_probability_geometries[id][col] = record[col]
|
||||
|
||||
# print(len(pipe_risk_probability_geometries))
|
||||
|
||||
return pipe_risk_probability_geometries
|
||||
return pipe_risk_probability_geometries
|
||||
|
||||
@@ -1,7 +1,109 @@
|
||||
from .database import *
|
||||
from .s0_base import *
|
||||
from .s42_sensor_placement import *
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
def get_all_sensor_placements(name: str) -> list[dict[Any, Any]]:
|
||||
return read_all(name, "select * from sensor_placement")
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
from .connection import project_connection
|
||||
from .database import read_all
|
||||
|
||||
|
||||
def get_all_sensor_placements(name: str) -> list[dict[str, Any]]:
|
||||
return read_all(name, "select * from sensor_placement")
|
||||
|
||||
|
||||
def create_sensor_placement(
|
||||
name: str,
|
||||
*,
|
||||
scheme_name: str,
|
||||
min_diameter: int,
|
||||
username: str,
|
||||
sensor_location: list[str],
|
||||
) -> dict[str, Any]:
|
||||
with project_connection(name) as conn:
|
||||
with conn.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO sensor_placement (
|
||||
scheme_name,
|
||||
sensor_number,
|
||||
min_diameter,
|
||||
username,
|
||||
sensor_location
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
scheme_name,
|
||||
len(sensor_location),
|
||||
min_diameter,
|
||||
username,
|
||||
sensor_location,
|
||||
),
|
||||
)
|
||||
created = cur.fetchone()
|
||||
if created is None:
|
||||
raise RuntimeError("监测点方案写入失败")
|
||||
return dict(created)
|
||||
|
||||
|
||||
def get_sensor_placement(name: str, scheme_id: int) -> dict[str, Any] | None:
|
||||
with project_connection(name) as conn:
|
||||
with conn.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"SELECT * FROM sensor_placement WHERE id = %s",
|
||||
(scheme_id,),
|
||||
)
|
||||
return cur.fetchone()
|
||||
|
||||
|
||||
def get_sensor_placement_nodes(
|
||||
name: str,
|
||||
node_ids: list[str],
|
||||
) -> list[dict[str, Any]]:
|
||||
if not node_ids:
|
||||
return []
|
||||
with project_connection(name) as conn:
|
||||
with conn.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT DISTINCT ON (gj.id)
|
||||
gj.id AS node_id,
|
||||
gj.elevation,
|
||||
ST_X(c.coord) AS project_x,
|
||||
ST_Y(c.coord) AS project_y,
|
||||
ST_X(gj.geom) AS map_x,
|
||||
ST_Y(gj.geom) AS map_y
|
||||
FROM geo_junctions_mat AS gj
|
||||
JOIN coordinates AS c ON c.node = gj.id
|
||||
WHERE gj.id = ANY(%s)
|
||||
ORDER BY gj.id
|
||||
""",
|
||||
(node_ids,),
|
||||
)
|
||||
return list(cur.fetchall())
|
||||
|
||||
|
||||
def update_sensor_placement(
|
||||
name: str,
|
||||
scheme_id: int,
|
||||
*,
|
||||
expected_sensor_location: list[str],
|
||||
sensor_location: list[str],
|
||||
) -> dict[str, Any] | None:
|
||||
with project_connection(name) as conn:
|
||||
with conn.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE sensor_placement
|
||||
SET sensor_location = %s, sensor_number = %s
|
||||
WHERE id = %s AND sensor_location = %s
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
sensor_location,
|
||||
len(sensor_location),
|
||||
scheme_id,
|
||||
expected_sensor_location,
|
||||
),
|
||||
)
|
||||
return cur.fetchone()
|
||||
|
||||
@@ -1,36 +1,5 @@
|
||||
from app.services.network_import import network_update, submit_scada_info
|
||||
from app.services.scheme_management import (
|
||||
create_user,
|
||||
delete_user,
|
||||
scheme_name_exists,
|
||||
store_scheme_info,
|
||||
delete_scheme_info,
|
||||
query_scheme_list,
|
||||
upload_shp_to_pg,
|
||||
submit_risk_probability_result,
|
||||
)
|
||||
from app.services.valve_isolation import analyze_valve_isolation
|
||||
from app.services.simulation_ops import (
|
||||
project_management,
|
||||
scheduling_simulation,
|
||||
daily_scheduling_simulation,
|
||||
)
|
||||
from app.services.leakage_identifier import run_leakage_identification
|
||||
"""Service package.
|
||||
|
||||
__all__ = [
|
||||
"network_update",
|
||||
"submit_scada_info",
|
||||
"create_user",
|
||||
"delete_user",
|
||||
"scheme_name_exists",
|
||||
"store_scheme_info",
|
||||
"delete_scheme_info",
|
||||
"query_scheme_list",
|
||||
"upload_shp_to_pg",
|
||||
"submit_risk_probability_result",
|
||||
"project_management",
|
||||
"scheduling_simulation",
|
||||
"daily_scheduling_simulation",
|
||||
"analyze_valve_isolation",
|
||||
"run_leakage_identification",
|
||||
]
|
||||
Keep package initialization lightweight. Import concrete service modules directly,
|
||||
for example: `from app.services.tjnetwork import open_project`.
|
||||
"""
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user