commit 967bb8d936e9b614b4dbd406d0f1b396f5821c46 Author: Miwory Date: Wed Sep 24 04:11:55 2025 +0300 first commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a1cd5c7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,22 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv + +# uv +uv.lock + +# Ruff +.ruff_cache + +# env +*.env + +# Container +container diff --git a/.gitea/workflows/latest.yaml b/.gitea/workflows/latest.yaml new file mode 100644 index 0000000..259a5a0 --- /dev/null +++ b/.gitea/workflows/latest.yaml @@ -0,0 +1,75 @@ +name: Build And Push +run-name: ${{ github.actor }} builds and pushes production-ready image +on: + push: + branches: + - latest + +jobs: + publish: + runs-on: ubuntu-latest + env: + RUNNER_TOOL_CACHE: /${{ github.workspace }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + registry: git.miwory.dev + username: ${{ secrets.CI_USERNAME }} + password: ${{ secrets.CI_TOKEN }} + + - name: Cache uv binary + uses: actions/cache@v4 + with: + path: ${{ github.workspace }}/uv + key: uv-${{ runner.os }} + restore-keys: uv-${{ runner.os }} + + - name: Cache uv dependencies + uses: actions/cache@v4 + with: + path: ${{ github.workspace }}/.cache/uv + key: uv-${{ runner.os }} + restore-keys: uv-${{ runner.os }} + + - name: Cache pre-commit + uses: actions/cache@v4 + with: + path: ~/.cache/pre-commit + key: pre-commit-cache-${{ runner.os }}-${{ hashFiles('.pre-commit-config.yaml') }} + restore-keys: pre-commit-cache-${{ runner.os }}- + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + version: "0.7.8" + enable-cache: true + cache-local-path: ${{ github.workspace }}/.cache/uv + tool-dir: ${{ github.workspace }}/.cache/uv + tool-bin-dir: ${{ github.workspace }}/.cache/uv + cache-dependency-glob: "" + + - name: Set up Python + run: uv python install + + - name: Install the project + run: uv sync --no-install-project --cache-dir ${{ github.workspace }}/.cache/uv + + - name: Linter & Formatter + run: uv run pre-commit run --all-files + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + target: production + push: true + tags: "git.miwory.dev/SmartSolutions/HospitalAssistantBackend:latest" + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a1cd5c7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv + +# uv +uv.lock + +# Ruff +.ruff_cache + +# env +*.env + +# Container +container diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..ef5e6ec --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,34 @@ +repos: + - repo: https://github.com/crate-ci/typos + rev: v1.31.1 + hooks: + - id: typos + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.11.2 + hooks: + - id: ruff + args: [ --fix ] + - id: ruff-format + + - repo: https://github.com/RobertCraigie/pyright-python + rev: v1.1.398 + hooks: + - id: pyright + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: check-docstring-first + - id: check-added-large-files + - id: check-yaml + - id: debug-statements + - id: check-merge-conflict + - id: double-quote-string-fixer + - id: end-of-file-fixer + + - repo: meta + hooks: + - id: check-hooks-apply + - id: check-useless-excludes diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4c57601 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,82 @@ +################################################# +FROM debian:bookworm-slim AS builder-base + +RUN apt-get update && \ + apt-get install --no-install-recommends -y \ + libpq-dev \ + ca-certificates \ + libc6 \ + libstdc++6 \ + sudo \ + && groupadd --gid 1001 appuser \ + && useradd --uid 1001 --gid appuser --shell /bin/bash --create-home appuser + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + UV_VERSION="0.7.6" \ + UV_PYTHON="3.13.3" \ + UV_PYTHON_INSTALL_DIR="/app/.python" \ + UV_PYTHON_PREFERENCE="only-managed" \ + UV_COMPILE_BYTECODE=1 \ + UV_NO_INSTALLER_METADATA=1 \ + UV_LINK_MODE=copy \ + PATH="$PATH:/root/.local/bin/:/app/.venv/bin:/opt/cprocsp/bin/amd64:/opt/cprocsp/sbin/amd64" + +# Install CryptoPro CSP 5 +WORKDIR /tmp/cryptopro +COPY packages/linux-amd64_deb.tgz /tmp/cryptopro/ +RUN tar -xzf linux-amd64_deb.tgz && \ + cd linux-amd64_deb && \ + ./install.sh && \ + dpkg -i cprocsp-cptools-*.deb lsb-cprocsp-base_*.deb lsb-cprocsp-kc1_*.deb lsb-cprocsp-capilite_*.deb || apt-get install -f -y && \ + # Create symbolic links for CryptoPro tools + ln -s /opt/cprocsp/bin/amd64/certmgr /bin/certmgr && \ + ln -s /opt/cprocsp/bin/amd64/cpverify /bin/cpverify && \ + ln -s /opt/cprocsp/bin/amd64/cryptcp /bin/cryptcp && \ + ln -s /opt/cprocsp/bin/amd64/csptest /bin/csptest && \ + ln -s /opt/cprocsp/sbin/amd64/cpconfig /bin/cpconfig && \ + # Set permissions for CryptoPro directories + mkdir -p /etc/opt/cprocsp /var/opt/cprocsp && \ + chown -R appuser:appuser /etc/opt/cprocsp /var/opt/cprocsp && \ + # Clean up + rm -rf /tmp/cryptopro && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +################################################# +FROM builder-base AS python-base + +WORKDIR /app + +RUN apt-get update && \ + apt-get install --no-install-recommends -y \ + curl \ + clang \ + && curl -LsSf https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-installer.sh | sh && \ + uv python install && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +COPY pyproject.toml ./ + +RUN uv sync --no-dev -n +RUN uv version --short > .version + +################################################# +FROM builder-base AS production + +WORKDIR /app + +RUN chown -R appuser:appuser /app + +COPY --from=python-base /app/.python /app/.python +COPY --from=python-base /app/.venv /app/.venv +COPY --from=python-base /app/.version /app/ +COPY /src/ /app/ +COPY /scripts/ /app/scripts +RUN chmod -R 755 /app/scripts + +USER appuser + +CMD ["sh", "./scripts/boot.sh"] +################################################# diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..cc9be18 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,34 @@ +name: HospitalAssistantAPI + +x-app-common: &app-common + build: + context: . + target: production + tty: true + restart: unless-stopped + stop_signal: SIGINT + env_file: + - .test.env + environment: + DEBUG: false + DATABASE_URL: "postgresql://postgres:example@db:5432/postgres" + REDIS_URL: "redis://valkey:6379/0" + volumes: + - "./container:/var/opt/cprocsp/keys" + +services: + valkey: + image: valkey/valkey:alpine + restart: unless-stopped + ports: + - ${VALKEY_PORT:-6380}:6379 + healthcheck: + test: [ "CMD", "redis-cli", "ping" ] + interval: 5s + timeout: 10s + retries: 5 + + web: + <<: *app-common + ports: + - "${APP_PORT:-6767}:${APP_PORT:-6767}" diff --git a/packages/linux-amd64_deb.tgz b/packages/linux-amd64_deb.tgz new file mode 100644 index 0000000..a48052b Binary files /dev/null and b/packages/linux-amd64_deb.tgz differ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4b9fc3b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,121 @@ +[project] +name = "HospitalAssistantBackend" +version = "1.0.0" +description = "Backend for Hospital Assistant" +readme = "README.md" +requires-python = ">=3.13" +dependencies = [ + # Server + "fastapi==0.116.1", + "gunicorn==23.0.0", + "orjson==3.11.3", + "redis[hiredis]==6.4.0", + "uvicorn-worker==0.3.0", + "uvicorn[standard]==0.35.0", + # Logging + "python-logging-loki==0.3.1", + # Requests + "httpx==0.28.1", + # Database + "alembic==1.16.4", + "psycopg==3.2.9", + "psycopg-c==3.2.9; sys_platform != 'win32'", + "asyncpg==0.30.0", + "sqlmodel==0.0.24", + # Types + "pydantic==2.11.7", + "pydantic-settings==2.10.1", + "pydantic-extra-types==2.10.5", + "semver==3.0.4", + "pyjwt==2.10.1", + # CLI + "typer-slim==0.16.1", +] + +[dependency-groups] +dev = [ + "celery-types==0.23.0", + "poethepoet==0.34.0", + "pre-commit==4.2.0", + "psycopg[binary]==3.2.9", + "pyright==1.1.401", + "ruff==0.11.12", + "types-pyjwt==1.7.1", + "types-redis==4.6.0.20241004", +] + +[tool.poe.tasks] +_git = "git add ." +_lint = "pre-commit run --all-files" + +lint = ["_git", "_lint"] +check = "uv pip ls --outdated" +run = "uv run --directory ./src/ server.py" +manage = "uv run --directory ./src/ manage.py" +migrate = "uv run --directory ./src/ alembic revision --autogenerate" + +[tool.uv] +required-version = ">=0.7.0" +dependency-metadata = [ + { name = "psycopg-c", version = "3.2.9", requires-python = ">=3.8", requires-dist = [ + "psycopg==3.2.9", + ] }, +] +[tool.typos.files] +extend-exclude = ["**/migrations/versions"] + +[tool.pyright] +venvPath = "." +venv = ".venv" +exclude = ["**/migrations/versions"] +strictListInference = true +strictDictionaryInference = true +strictSetInference = true +deprecateTypingAliases = true +typeCheckingMode = "strict" +pythonPlatform = "All" + +[tool.ruff] +target-version = "py313" +line-length = 79 +fix = true + +[tool.ruff.lint] +preview = true +select = [ + "E", + "W", + "F", + "UP", + "A", + "B", + "C4", + "SIM", + "I", + "S", + "G", + "FAST", + "ASYNC", + "BLE", + "INT", + "ISC", + "ICN", + "PYI", + "INP", + "RSE", + "PIE", + "SLOT", + "TID", + "LOG", + "FBT", + "DTZ", + "EM", + "PERF", + "RUF", +] +ignore = ["RUF029", "S104", "RUF001"] + +[tool.ruff.format] +quote-style = "single" +indent-style = "space" +docstring-code-format = true diff --git a/scripts/boot.sh b/scripts/boot.sh new file mode 100644 index 0000000..821f7e2 --- /dev/null +++ b/scripts/boot.sh @@ -0,0 +1,9 @@ +#!/bin/bash +set -e + +cpconfig -license -set "$CRYPTOPRO_LICENSE" +# certmgr -inst -file /var/opt/cprocsp/keys/cert.cer -cont "$CRYPTOPRO_CONTAINER" +certmgr -inst -file /var/opt/cprocsp/keys/cert.p7b -cont "$CRYPTOPRO_CONTAINER" + +# python -m alembic upgrade head +python server.py diff --git a/src/alembic.ini b/src/alembic.ini new file mode 100644 index 0000000..633496d --- /dev/null +++ b/src/alembic.ini @@ -0,0 +1,6 @@ +[alembic] +file_template = %%(year)d.%%(month).2d.%%(day).2d_%%(hour).2d-%%(minute).2d-%%(second).2d_%%(rev)s +script_location = migrations +prepend_sys_path = . +version_path_separator = os +output_encoding = utf-8 diff --git a/src/apps/esia/__init__.py b/src/apps/esia/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/apps/esia/scopes.py b/src/apps/esia/scopes.py new file mode 100644 index 0000000..f8408fc --- /dev/null +++ b/src/apps/esia/scopes.py @@ -0,0 +1,10 @@ +SCOPES = [ + 'openid', + 'fullname', + # 'email', + # 'birthdate', + # 'gender', + # 'snils', + # 'id_doc', + # 'mobile', +] diff --git a/src/apps/esia/sign.py b/src/apps/esia/sign.py new file mode 100644 index 0000000..46daf1b --- /dev/null +++ b/src/apps/esia/sign.py @@ -0,0 +1,82 @@ +import base64 +import secrets +import subprocess # noqa: S404 +import tempfile +import uuid +from datetime import UTC, datetime +from pathlib import Path +from typing import Any +from urllib.parse import urlencode + +from apps.esia.scopes import SCOPES +from core.config import settings + +ACCESS_TYPE = 'online' +RESPONSE_CODE = 'code' + + +def csp_sign(data: str): + with tempfile.TemporaryDirectory() as tmp_dir: + tmp_file_name = secrets.token_hex(8) + source_path = Path(tmp_dir) / f'{tmp_file_name}.txt' + destination_path = source_path.with_suffix('.txt.sgn') + + with open(source_path, 'w', encoding='utf-8') as f: + f.write(data) + + print(data) + + cmd = [ + 'cryptcp', + '-signf', + '-norev', + '-nochain', + '-der', + '-strict', + '-cert', + '-detached', + '-thumbprint', + settings.ESIA_CONTAINER_THUMBPRINT, + '-pin', + settings.ESIA_CONTAINER_PASSWORD, + '-dir', + tmp_dir, + str(source_path), + ] + + subprocess.run( # noqa: S603 + cmd, input=b'y\n', capture_output=True, check=True, text=False + ) + signed_message = destination_path.read_bytes() + + return signed_message + + +def sign_params(params: dict[str, Any]): + plaintext = ( + params.get('scope', '') + + params.get('timestamp', '') + + params.get('client_id', '') + + params.get('state', '') + ) + + client_secret = csp_sign(plaintext) + return base64.urlsafe_b64encode(client_secret).decode('utf-8') + + +def get_url(): + timestamp = datetime.now(UTC).strftime('%Y.%m.%d %H:%M:%S %z').strip() + state = str(uuid.uuid4()) + params = { + 'client_id': settings.ESIA_CLIENT_ID, + 'client_secret': '', + 'redirect_uri': settings.ESIA_REDIRECT_URI, + 'response_type': RESPONSE_CODE, + 'state': state, + 'timestamp': timestamp, + 'access_type': ACCESS_TYPE, + 'scope': ' '.join(SCOPES), + } + params['client_secret'] = sign_params(params) + + return f'{settings.ESIA_BASE_URL}/aas/oauth2/ac?{urlencode(params)}' diff --git a/src/apps/esia/v1/__init__.py b/src/apps/esia/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/apps/esia/v1/router.py b/src/apps/esia/v1/router.py new file mode 100644 index 0000000..eca32f1 --- /dev/null +++ b/src/apps/esia/v1/router.py @@ -0,0 +1,50 @@ +import secrets +from logging import getLogger + +from fastapi import APIRouter + +from apps.esia.sign import get_url +from clients import clients as c +from shared import exceptions as e +from shared.redis import client as cache + +from . import schema as s + +logger = getLogger(__name__) +router = APIRouter( + prefix='/esia', + tags=[ + 'ESIA', + ], +) + + +@router.get('/login', response_model=s.LoginURL) +async def login(): + url = get_url() + return s.LoginURL(url=url) + + +@router.post('/callback') +async def callback(code: str): + token = None + for i in range(3): + try: + token = await c.esia_api.access_token(code) + break + except Exception: + logger.warning( + 'Error occurred while accessing ESI API. Retrying...' + ) + if i == 2: + raise + + if token is None: + raise e.BadRequestException + + await c.esia_api.get_user_info(token.access_token, token.id_token) + + access_token = secrets.token_urlsafe(32) + cache.set(access_token, access_token) + + return s.Token(access_token=access_token) diff --git a/src/apps/esia/v1/schema.py b/src/apps/esia/v1/schema.py new file mode 100644 index 0000000..f20409b --- /dev/null +++ b/src/apps/esia/v1/schema.py @@ -0,0 +1,9 @@ +from typing import TypedDict + + +class LoginURL(TypedDict): + url: str + + +class Token(TypedDict): + access_token: str diff --git a/src/apps/users/__init__.py b/src/apps/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/apps/users/auth.py b/src/apps/users/auth.py new file mode 100644 index 0000000..69b3d8d --- /dev/null +++ b/src/apps/users/auth.py @@ -0,0 +1,20 @@ +from typing import Annotated + +from fastapi import Depends +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +from shared import exceptions as e +from shared.redis import client as cache + +BEARER = HTTPBearer() + + +async def login( + credentials: Annotated[HTTPAuthorizationCredentials, Depends(BEARER)], +): + is_exist = cache.get(credentials.credentials) + + if is_exist is None: + raise e.UnauthorizedException + + return True diff --git a/src/apps/users/v1/__init__.py b/src/apps/users/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/apps/users/v1/mock.py b/src/apps/users/v1/mock.py new file mode 100644 index 0000000..47c0e1a --- /dev/null +++ b/src/apps/users/v1/mock.py @@ -0,0 +1,1113 @@ +specs = { + 'Specialities': [ + {'SpecialityID': '24', 'SpecialityName': 'Инфекционные болезни'}, + {'SpecialityID': '53', 'SpecialityName': 'Психиатрия-наркология'}, + {'SpecialityID': '92', 'SpecialityName': 'Эндокринология'}, + {'SpecialityID': '49', 'SpecialityName': 'Педиатрия'}, + {'SpecialityID': '81', 'SpecialityName': 'Ультразвуковая диагностика'}, + {'SpecialityID': '68', 'SpecialityName': 'Стоматология детская'}, + { + 'SpecialityID': '74', + 'SpecialityName': 'Судебно-психиатрическая экспертиза', + }, + { + 'SpecialityID': '221', + 'SpecialityName': 'Сестринское дело в педиатрии', + }, + {'SpecialityID': '215', 'SpecialityName': 'Лабораторная диагностика'}, + { + 'SpecialityID': '281', + 'SpecialityName': 'Реабилитационное сестринское дело', + }, + {'SpecialityID': '6', 'SpecialityName': 'Вирусология'}, + { + 'SpecialityID': '104', + 'SpecialityName': 'Физическая и реабилитационная медицина', + }, + {'SpecialityID': '64', 'SpecialityName': 'Сексология'}, + { + 'SpecialityID': '234', + 'SpecialityName': 'Судебно-медицинская экспертиза', + }, + { + 'SpecialityID': '75', + 'SpecialityName': 'Сурдология-оториноларингология', + }, + {'SpecialityID': '284', 'SpecialityName': 'Бактериология'}, + {'SpecialityID': '51', 'SpecialityName': 'Профпатология'}, + {'SpecialityID': '58', 'SpecialityName': 'Радиотерапия'}, + {'SpecialityID': '29', 'SpecialityName': 'Коммунальная гигиена'}, + { + 'SpecialityID': '34', + 'SpecialityName': 'Медико-социальная экспертиза', + }, + {'SpecialityID': '16', 'SpecialityName': 'Дезинфектология'}, + {'SpecialityID': '228', 'SpecialityName': 'Медицинский массаж'}, + {'SpecialityID': '47', 'SpecialityName': 'Паразитология'}, + {'SpecialityID': '78', 'SpecialityName': 'Торакальная хирургия'}, + {'SpecialityID': '40', 'SpecialityName': 'Общая гигиена'}, + {'SpecialityID': '38', 'SpecialityName': 'Нефрология'}, + {'SpecialityID': '28', 'SpecialityName': 'Колопроктология'}, + { + 'SpecialityID': '210', + 'SpecialityName': 'Эпидемиология (паразитология)', + }, + { + 'SpecialityID': '1', + 'SpecialityName': 'Авиационная и космическая медицина', + }, + {'SpecialityID': '18', 'SpecialityName': 'Детская кардиология'}, + { + 'SpecialityID': '96', + 'SpecialityName': 'Медико-профилактическое дело', + }, + {'SpecialityID': '8', 'SpecialityName': 'Гастроэнтерология'}, + {'SpecialityID': '37', 'SpecialityName': 'Неонатология'}, + {'SpecialityID': '94', 'SpecialityName': 'Эпидемиология'}, + {'SpecialityID': '79', 'SpecialityName': 'Травматология и ортопедия'}, + {'SpecialityID': '35', 'SpecialityName': 'Неврология'}, + {'SpecialityID': '48', 'SpecialityName': 'Патологическая анатомия'}, + {'SpecialityID': '45', 'SpecialityName': 'Оториноларингология'}, + {'SpecialityID': '19', 'SpecialityName': 'Детская онкология'}, + {'SpecialityID': '84', 'SpecialityName': 'Урология'}, + {'SpecialityID': '280', 'SpecialityName': 'Наркология'}, + {'SpecialityID': '55', 'SpecialityName': 'Пульмонология'}, + {'SpecialityID': '46', 'SpecialityName': 'Офтальмология'}, + {'SpecialityID': '43', 'SpecialityName': 'Ортодонтия'}, + {'SpecialityID': '62', 'SpecialityName': 'Рефлексотерапия'}, + {'SpecialityID': '44', 'SpecialityName': 'Остеопатия'}, + {'SpecialityID': '52', 'SpecialityName': 'Психиатрия'}, + { + 'SpecialityID': '39', + 'SpecialityName': 'Общая врачебная практика (семейная медицина)', + }, + {'SpecialityID': '80', 'SpecialityName': 'Трансфузиология'}, + {'SpecialityID': '22', 'SpecialityName': 'Детская эндокринология'}, + {'SpecialityID': '88', 'SpecialityName': 'Фтизиатрия'}, + {'SpecialityID': '87', 'SpecialityName': 'Физиотерапия'}, + {'SpecialityID': '36', 'SpecialityName': 'Нейрохирургия'}, + {'SpecialityID': '30', 'SpecialityName': 'Косметология'}, + {'SpecialityID': '23', 'SpecialityName': 'Диетология'}, + {'SpecialityID': '31', 'SpecialityName': 'Лабораторная генетика'}, + {'SpecialityID': '224', 'SpecialityName': 'Общая практика'}, + { + 'SpecialityID': '283', + 'SpecialityName': 'Скорая и неотложная помощь', + }, + { + 'SpecialityID': '70', + 'SpecialityName': 'Стоматология ортопедическая', + }, + { + 'SpecialityID': '26', + 'SpecialityName': 'Клиническая лабораторная диагностика', + }, + { + 'SpecialityID': '223', + 'SpecialityName': 'Анестезиология и реаниматология', + }, + {'SpecialityID': '72', 'SpecialityName': 'Стоматология хирургическая'}, + {'SpecialityID': '5', 'SpecialityName': 'Бактериология'}, + {'SpecialityID': '76', 'SpecialityName': 'Терапия'}, + {'SpecialityID': '95', 'SpecialityName': 'Лечебное дело'}, + {'SpecialityID': '91', 'SpecialityName': 'Челюстно-лицевая хирургия'}, + { + 'SpecialityID': '32', + 'SpecialityName': 'Лечебная физкультура и спортивная медицина', + }, + {'SpecialityID': '21', 'SpecialityName': 'Детская хирургия'}, + {'SpecialityID': '41', 'SpecialityName': 'Онкология'}, + { + 'SpecialityID': '65', + 'SpecialityName': 'Сердечно-сосудистая хирургия', + }, + {'SpecialityID': '50', 'SpecialityName': 'Пластическая хирургия'}, + {'SpecialityID': '59', 'SpecialityName': 'Ревматология'}, + {'SpecialityID': '231', 'SpecialityName': 'Диетология'}, + {'SpecialityID': '90', 'SpecialityName': 'Хирургия'}, + {'SpecialityID': '101', 'SpecialityName': 'Фармация'}, + { + 'SpecialityID': '63', + 'SpecialityName': 'Санитарно-гигиенические лабораторные исследования', + }, + {'SpecialityID': '17', 'SpecialityName': 'Дерматовенерология'}, + { + 'SpecialityID': '208', + 'SpecialityName': 'Стоматология (средний медперсонал)', + }, + {'SpecialityID': '11', 'SpecialityName': 'Гериатрия'}, + { + 'SpecialityID': '226', + 'SpecialityName': 'Функциональная диагностика', + }, + {'SpecialityID': '227', 'SpecialityName': 'Физиотерапия'}, + { + 'SpecialityID': '73', + 'SpecialityName': 'Судебно-медицинская экспертиза', + }, + {'SpecialityID': '93', 'SpecialityName': 'Эндоскопия'}, + { + 'SpecialityID': '69', + 'SpecialityName': 'Стоматология общей практики', + }, + {'SpecialityID': '213', 'SpecialityName': 'Гигиеническое воспитание'}, + {'SpecialityID': '14', 'SpecialityName': 'Гигиена труда'}, + {'SpecialityID': '60', 'SpecialityName': 'Рентгенология'}, + {'SpecialityID': '89', 'SpecialityName': 'Функциональная диагностика'}, + {'SpecialityID': '66', 'SpecialityName': 'Скорая медицинская помощь'}, + { + 'SpecialityID': '209', + 'SpecialityName': 'Стоматология ортопедическая', + }, + { + 'SpecialityID': '20', + 'SpecialityName': 'Детская урология-андрология', + }, + {'SpecialityID': '7', 'SpecialityName': 'Водолазная медицина'}, + {'SpecialityID': '13', 'SpecialityName': 'Гигиена питания'}, + {'SpecialityID': '3', 'SpecialityName': 'Аллергология и иммунология'}, + { + 'SpecialityID': '71', + 'SpecialityName': 'Стоматология терапевтическая', + }, + {'SpecialityID': '25', 'SpecialityName': 'Кардиология'}, + {'SpecialityID': '54', 'SpecialityName': 'Психотерапия'}, + { + 'SpecialityID': '102', + 'SpecialityName': 'Детская онкология-гематология', + }, + {'SpecialityID': '56', 'SpecialityName': 'Радиационная гигиена'}, + {'SpecialityID': '77', 'SpecialityName': 'Токсикология'}, + {'SpecialityID': '219', 'SpecialityName': 'Сестринское дело'}, + {'SpecialityID': '12', 'SpecialityName': 'Гигиена детей и подростков'}, + { + 'SpecialityID': '207', + 'SpecialityName': 'Акушерское дело (средний медперсонал)', + }, + {'SpecialityID': '9', 'SpecialityName': 'Гематология'}, + { + 'SpecialityID': '206', + 'SpecialityName': 'Лечебное дело (средний медперсонал)', + }, + {'SpecialityID': '27', 'SpecialityName': 'Клиническая фармакология'}, + {'SpecialityID': '15', 'SpecialityName': 'Гигиеническое воспитание'}, + { + 'SpecialityID': '61', + 'SpecialityName': 'Рентгенэндоваскулярные диагностика и лечение', + }, + {'SpecialityID': '217', 'SpecialityName': 'Лабораторное дело'}, + {'SpecialityID': '33', 'SpecialityName': 'Мануальная терапия'}, + {'SpecialityID': '222', 'SpecialityName': 'Операционное дело'}, + {'SpecialityID': '10', 'SpecialityName': 'Генетика'}, + { + 'SpecialityID': '4', + 'SpecialityName': 'Анестезиология-реаниматология', + }, + {'SpecialityID': '57', 'SpecialityName': 'Радиология'}, + { + 'SpecialityID': '233', + 'SpecialityName': 'Стоматология профилактическая', + }, + {'SpecialityID': '230', 'SpecialityName': 'Лечебная физкультура'}, + {'SpecialityID': '2', 'SpecialityName': 'Акушерство и гинекология'}, + ] +} +findpat = [ + { + 'patients': [ + { + 'id': '3c963d68-14e5-4a43-ab15-1d2d19b76398', + 'SNILS': '191-815-870 98', + 'lastName': 'Махарадзе', + 'firstName': 'Ильназ', + 'middleName': 'Рустэмович', + 'birthDate': '1956-03-26', + 'gender': 'М', + 'docType': 'Свид. о рожд. РФ', + 'docSer': 'III-КБ', + 'docNum': '863865', + 'polNum': '1688489725000170', + 'address1': 'Татарстан, Лаишевский р-н, с Среднее Девятово, ул.Новостройка, д.9', + } + ] + }, + { + 'patients': [ + { + 'id': 'e7b8456e-73c2-4723-8e4e-4f275546ea97', + 'SNILS': '056-547-820 87', + 'lastName': 'Ахатов', + 'firstName': 'Радик', + 'middleName': 'Иванович', + 'birthDate': '1948-12-11', + 'gender': 'М', + 'docType': 'Паспорт РФ', + 'docSer': '92 10', + 'docNum': '190402', + 'polNum': '1650230848000380', + 'address1': '92228000010 ул. д.0 кв.0', + } + ] + }, +] +profile = [ + { + 'id': '3c963d68-14e5-4a43-ab15-1d2d19b76398', + 'SNILS': '191-815-870 98', + 'lastName': 'Махарадзе', + 'firstName': 'Ильназ', + 'middleName': 'Рустэмович', + 'birthDate': '1956-03-26', + 'gender': 'М', + 'docType': 'Свидетельство о рождении, выданное в РФ', + 'docSer': 'III-КБ', + 'docNum': '863865', + 'addressReal': 'Татарстан, Лаишевский р-н, с Среднее Девятово, ул.Новостройка, д.9', + }, + { + 'id': 'e7b8456e-73c2-4723-8e4e-4f275546ea97', + 'SNILS': '056-547-820 87', + 'lastName': 'Ахатов', + 'firstName': 'Радик', + 'middleName': 'Иванович', + 'birthDate': '1948-12-11', + 'gender': 'М', + 'docType': 'Паспорт гражданина РФ', + 'docSer': '92 10', + 'docNum': '190402', + 'addressReal': '92228000010 ул. д.0 кв.0', + }, +] +vacs = [ + { + 'error': 'a268e6d7-618c-4b83-97ca-c9dc8b79b55b', + 'Routes': [ + { + 'Type': 'ROUTE_TO_DOCTOR_INSPECTION', + 'CreationDateTime': '2025-03-2414:49', + 'Name': 'Кардиолог [1259]', + 'Place': 'Каб.№211 Шайдуллина Г.И. (Кардиолог)', + 'ResultExits': '1', + 'LpuName': 'ГАУЗ "ГКБ №7"', + }, + { + 'Type': 'ROUTE_TO_DIAGNOSTICS', + 'CreationDateTime': '2025-03-1716:06', + 'Name': 'Общий анализ крови [B03.016.002]', + 'Place': None, + 'ResultExits': '0', + 'LpuName': 'ГАУЗ "ГКБ №7"', + }, + { + 'Type': 'ROUTE_TO_DIAGNOSTICS', + 'CreationDateTime': '2025-03-1711:41', + 'Name': 'Ультразвуковое исследование лимфатических узлов (одна анатомическая зона) [A04.06.002]', + 'Place': None, + 'ResultExits': '0', + 'LpuName': 'ГАУЗ "ГКБ №7"', + }, + { + 'Type': 'ROUTE_TO_DIAGNOSTICS', + 'CreationDateTime': '2025-03-1711:41', + 'Name': 'Компьютерная томография головного мозга [A06.23.004]', + 'Place': None, + 'ResultExits': '0', + 'LpuName': 'ГАУЗ "ГКБ №7"', + }, + { + 'Type': 'ROUTE_TO_DIAGNOSTICS', + 'CreationDateTime': '2025-03-1711:41', + 'Name': 'Общий (клинический) анализ крови [B03.016.002]', + 'Place': None, + 'ResultExits': '0', + 'LpuName': 'ГАУЗ "ГКБ №7"', + }, + { + 'Type': 'ROUTE_TO_DIAGNOSTICS', + 'CreationDateTime': '2025-03-1711:41', + 'Name': 'Компьютерная томография головного мозга [A06.23.004]', + 'Place': None, + 'ResultExits': '0', + 'LpuName': 'ГАУЗ "ГКБ №7"', + }, + { + 'Type': 'ROUTE_TO_DOCTOR_INSPECTION', + 'CreationDateTime': '2025-03-1711:41', + 'Name': 'Терапевт [1231]', + 'Place': None, + 'ResultExits': '1', + 'LpuName': 'ГАУЗ "ГКБ №7"', + }, + { + 'Type': 'ROUTE_TO_DOCTOR_INSPECTION', + 'CreationDateTime': '2025-03-3114:50', + 'Name': 'Кардиолог [1260]', + 'Place': None, + 'ResultExits': '0', + 'LpuName': 'ГАУЗ "ГКБ №7"', + }, + ], + }, + { + 'error': 'a268e6d7-618c-4b83-97ca-c9dc8b79b55b', + 'Routes': [ + { + 'Type': 'ROUTE_TO_DOCTOR_INSPECTION', + 'CreationDateTime': '2025-03-2414:49', + 'Name': 'Кардиолог [1259]', + 'Place': 'Каб.№211 Шайдуллина Г.И. (Кардиолог)', + 'ResultExits': '1', + 'LpuName': 'ГАУЗ "ГКБ №7"', + }, + { + 'Type': 'ROUTE_TO_DIAGNOSTICS', + 'CreationDateTime': '2025-03-1716:06', + 'Name': 'Общий анализ крови [B03.016.002]', + 'Place': None, + 'ResultExits': '0', + 'LpuName': 'ГАУЗ "ГКБ №7"', + }, + { + 'Type': 'ROUTE_TO_DIAGNOSTICS', + 'CreationDateTime': '2025-03-1711:41', + 'Name': 'Ультразвуковое исследование лимфатических узлов (одна анатомическая зона) [A04.06.002]', + 'Place': None, + 'ResultExits': '0', + 'LpuName': 'ГАУЗ "ГКБ №7"', + }, + { + 'Type': 'ROUTE_TO_DIAGNOSTICS', + 'CreationDateTime': '2025-03-1711:41', + 'Name': 'Компьютерная томография головного мозга [A06.23.004]', + 'Place': None, + 'ResultExits': '0', + 'LpuName': 'ГАУЗ "ГКБ №7"', + }, + { + 'Type': 'ROUTE_TO_DIAGNOSTICS', + 'CreationDateTime': '2025-03-1711:41', + 'Name': 'Общий (клинический) анализ крови [B03.016.002]', + 'Place': None, + 'ResultExits': '0', + 'LpuName': 'ГАУЗ "ГКБ №7"', + }, + { + 'Type': 'ROUTE_TO_DIAGNOSTICS', + 'CreationDateTime': '2025-03-1711:41', + 'Name': 'Компьютерная томография головного мозга [A06.23.004]', + 'Place': None, + 'ResultExits': '0', + 'LpuName': 'ГАУЗ "ГКБ №7"', + }, + { + 'Type': 'ROUTE_TO_DOCTOR_INSPECTION', + 'CreationDateTime': '2025-03-1711:41', + 'Name': 'Терапевт [1231]', + 'Place': None, + 'ResultExits': '1', + 'LpuName': 'ГАУЗ "ГКБ №7"', + }, + { + 'Type': 'ROUTE_TO_DOCTOR_INSPECTION', + 'CreationDateTime': '2025-03-3114:50', + 'Name': 'Кардиолог [1260]', + 'Place': None, + 'ResultExits': '0', + 'LpuName': 'ГАУЗ "ГКБ №7"', + }, + ], + }, +] +hosps = { + 'Hospitalizations': [ + { + 'EventID': 'ce742cd4-d803-4cfc-afc8-99070f0af792', + 'CreationDateTime': '2025-04-04 10:16', + 'CloseDate': '', + 'ReceptionDiagnosis': None, + 'HospitalizationType': None, + 'HospitalizationReason': None, + 'Division': None, + 'LpuName': 'ГАУЗ Азнакаевская ЦРБ', + }, + { + 'EventID': 'b79a37b4-e99b-43c9-9948-a89a8251b712', + 'CreationDateTime': '2023-06-15 12:13', + 'CloseDate': '', + 'ReceptionDiagnosis': None, + 'HospitalizationType': None, + 'HospitalizationReason': None, + 'Division': None, + 'LpuName': 'ГАУЗ "ГКБ №7"', + }, + ] +} +elns = [ + { + 'error': '191-815-870 98', + 'ELNs': [ + { + 'Number': '910009279388', + 'OpenDate': '2019-07-23', + 'ProlongationDate': '2019-08-02', + 'DateClose': '2019-08-02', + 'DaysCount': 11, + 'Cause': 'Уход за больным членом семьи', + 'Prolongations': [ + { + 'StartDate': '2019-07-23', + 'ProlongationDate': '2019-07-26', + 'Post': 'Алукаева А.Ф. (Педиатр участковый)', + }, + { + 'StartDate': '2019-07-27', + 'ProlongationDate': '2019-07-30', + 'Post': 'Алукаева А.Ф. (Педиатр участковый)', + }, + { + 'StartDate': '2019-07-31', + 'ProlongationDate': '2019-08-02', + 'Post': 'Алукаева А.Ф. (Педиатр участковый)', + }, + ], + 'LpuName': 'ГАУЗ Новошешминская ЦРБ', + 'BranchAddress': 'Республика Татарстан,с.Новошешминск, ул.Майская, д.8', + 'FssLnStatus': '30', + 'SentSNILS': '10690452241', + }, + { + 'Number': '910009673922', + 'OpenDate': '2019-08-02', + 'ProlongationDate': '2019-08-05', + 'DateClose': '', + 'DaysCount': 4, + 'Cause': 'Уход за больным членом семьи', + 'Prolongations': [ + { + 'StartDate': '2019-08-03', + 'ProlongationDate': '2019-08-05', + 'Post': 'Алукаева А.Ф. (Педиатр участковый)', + } + ], + 'LpuName': 'ГАУЗ Новошешминская ЦРБ', + 'BranchAddress': 'Республика Татарстан,с.Новошешминск, ул.Майская, д.8', + 'FssLnStatus': '10', + 'SentSNILS': '10690452241', + }, + { + 'Number': '910008411090', + 'OpenDate': '2019-06-26', + 'ProlongationDate': '2019-06-29', + 'DateClose': '2019-06-29', + 'DaysCount': 4, + 'Cause': 'Уход за больным членом семьи', + 'Prolongations': [ + { + 'StartDate': '2019-06-26', + 'ProlongationDate': '2019-06-29', + 'Post': 'Алукаева А.Ф. (Педиатр участковый)', + } + ], + 'LpuName': 'ГАУЗ Новошешминская ЦРБ', + 'BranchAddress': 'Республика Татарстан,с.Новошешминск, ул.Майская, д.8', + 'FssLnStatus': '30', + 'SentSNILS': '10690452241', + }, + ], + }, + {'error': '056-547-820 87'}, +] +diagnosticResults = { + 'DainosticsResults': [ + { + 'DiagResultID': '1407910a-1901-4b21-be2d-0ef89041f4fe', + 'ContainsFile': 0, + 'PostingDate': '2025-05-06', + 'MedServiceCode': 'A12.05.004.002', + 'MedServiceName': 'Проба на совместимость перед переливанием эритроцитов по неполным антителам (IgG)', + 'PostName': 'Сиразиева Г.Р.', + 'PostSpec': 'Терапевт', + 'LpuName': 'ГАУЗ "ГКБ №7"', + 'EventID': '36cf2c90-fdad-4961-899c-652c5e0817a9', + }, + { + 'DiagResultID': 'd157947d-a4fc-4b04-844a-a0bf7d4b831b', + 'ContainsFile': 0, + 'PostingDate': '2025-05-06', + 'MedServiceCode': 'A12.05.005', + 'MedServiceName': 'Определение основных групп по системе AB0', + 'PostName': 'Сиразиева Г.Р.', + 'PostSpec': 'Терапевт', + 'LpuName': 'ГАУЗ "ГКБ №7"', + 'EventID': '36cf2c90-fdad-4961-899c-652c5e0817a9', + }, + ] +} +currHosp = [ + { + 'Hospitalizations': [ + { + 'EventID': 'b8227793-0f40-40f0-b8aa-9fc00cc13b96', + 'CreationDateTime': '2025-07-21 17:49', + 'ReceptionDiagnosis': 'Z00.0 | Общий медицинский осмотр', + 'Diagnosis': 'Z00.0 | Общий медицинский осмотр', + 'HospitalizationType': 'плановая', + 'HospitalizationReason': 'заболевание', + 'Division': 'Кардиология №1', + 'LpuName': 'ГАУЗ "ГКБ №7"', + 'Exams': [ + { + 'ExaminationDate': '22.07.2025', + 'ExaminationTime': '08:50', + 'Status': '', + 'Post': 'Изотова Г.М. (Сердечно-сосудистый хирург)', + 'MedicalExaminationType': 'Дневниковая запись', + }, + { + 'ExaminationDate': '22.07.2025', + 'ExaminationTime': '08:40', + 'Status': '', + 'Post': 'Изотова Г.М. (Сердечно-сосудистый хирург)', + 'MedicalExaminationType': 'Дневниковая запись', + 'HospDestinations': [ + { + 'Signa': 'Массаж Длительность: 25 мин.Область воздействия: Шея. \r\nПосле еды 1 раз через день в 11:00. Повторять 3 дня. Назначил:Изотова Г.М. (Сердечно-сосудистый хирург)' + } + ], + }, + { + 'ExaminationDate': '22.07.2025', + 'ExaminationTime': '08:39', + 'Status': 'Удовлетворительное', + 'Post': 'Шайдуллина Г.И. (Кардиолог)', + 'MedicalExaminationType': 'Осмотр врача-консультанта', + }, + { + 'ExaminationDate': '21.07.2025', + 'ExaminationTime': '17:55', + 'Status': 'Удовлетворительное', + 'Post': 'Изотова Г.М. (Сердечно-сосудистый хирург)', + 'MedicalExaminationType': 'Осмотр врача в отделении', + 'RoutesToDiagnostic': [ + { + 'RouteDate': '21.07.2025', + 'ResearchCode': 'И10', + 'ResearchName': 'Билирубин общий', + }, + { + 'RouteDate': '21.07.2025', + 'ResearchCode': 'A04.10.002', + 'ResearchName': 'Узи сердца', + }, + { + 'RouteDate': '21.07.2025', + 'ResearchCode': '14.1.A8.900', + 'ResearchName': '*Посев на гемофильную палочку (Haemophylus influenzae) с определением чувствительности к антибиотикам', + }, + { + 'RouteDate': '21.07.2025', + 'ResearchCode': 'A04.20.002', + 'ResearchName': 'УЗИ молочных желез', + }, + { + 'RouteDate': '21.07.2025', + 'ResearchCode': 'B03.016.010', + 'ResearchName': 'Общий анализ кала ', + }, + ], + 'RoutesToDoctor': [ + { + 'RouteDate': '21.07.2025', + 'SpecialityCode': '013', + 'SpecialityName': 'Кардиолог', + } + ], + 'HospDestinations': [ + { + 'Signa': 'Ацетилсалициловая кислота табл. шип. 500 мг x 1 доза по 1 дозе по 1 дозе. \r\nВнутрь (перорально) (до еды) 1 раз в 08:00. Повторять 2 дня. Назначил:Изотова Г.М. (Сердечно-сосудистый хирург)' + } + ], + }, + ], + 'Operations': [ + { + 'RouteDate': '22.07.2025', + 'OperName': 'Альбуминовый диализ', + 'OperStatus': 'В плане', + }, + { + 'RouteDate': '22.07.2025', + 'OperName': 'Остеопластика', + 'OperStatus': 'Завершена', + }, + ], + } + ] + }, + {'error': 'Пациент не госпитализирован!'}, +] +patFLG = [ + { + 'id': '3c963d68-14e5-4a43-ab15-1d2d19b76398', + 'SNILS': '191-815-870 98', + 'LastFgDate': '', + 'NextPrgDate': '', + 'PrgContingent': None, + }, + { + 'id': '0bf2e271-e565-42a8-924e-0017bcdedecd', + 'SNILS': '127-192-834 66', + 'LastFgDate': '2020-09-24', + 'NextPrgDate': '2021-09-24', + 'PrgContingent': 'Неорганизованное население', + }, +] +entries = [ + {'error': 'Не найдены записи по указанному patId'}, + {'error': 'Не найдены записи по указанному patId'}, +] +routesList = [ + { + 'error': 'a268e6d7-618c-4b83-97ca-c9dc8b79b55b', + 'Routes': [ + { + 'Type': 'ROUTE_TO_DOCTOR_INSPECTION', + 'CreationDateTime': '2025-03-2414:49', + 'Name': 'Кардиолог [1259]', + 'Place': 'Каб.№211 Шайдуллина Г.И. (Кардиолог)', + 'ResultExits': '1', + 'LpuName': 'ГАУЗ "ГКБ №7"', + }, + { + 'Type': 'ROUTE_TO_DIAGNOSTICS', + 'CreationDateTime': '2025-03-1716:06', + 'Name': 'Общий анализ крови [B03.016.002]', + 'Place': None, + 'ResultExits': '0', + 'LpuName': 'ГАУЗ "ГКБ №7"', + }, + { + 'Type': 'ROUTE_TO_DIAGNOSTICS', + 'CreationDateTime': '2025-03-1711:41', + 'Name': 'Ультразвуковое исследование лимфатических узлов (одна анатомическая зона) [A04.06.002]', + 'Place': None, + 'ResultExits': '0', + 'LpuName': 'ГАУЗ "ГКБ №7"', + }, + { + 'Type': 'ROUTE_TO_DIAGNOSTICS', + 'CreationDateTime': '2025-03-1711:41', + 'Name': 'Компьютерная томография головного мозга [A06.23.004]', + 'Place': None, + 'ResultExits': '0', + 'LpuName': 'ГАУЗ "ГКБ №7"', + }, + { + 'Type': 'ROUTE_TO_DIAGNOSTICS', + 'CreationDateTime': '2025-03-1711:41', + 'Name': 'Общий (клинический) анализ крови [B03.016.002]', + 'Place': None, + 'ResultExits': '0', + 'LpuName': 'ГАУЗ "ГКБ №7"', + }, + { + 'Type': 'ROUTE_TO_DIAGNOSTICS', + 'CreationDateTime': '2025-03-1711:41', + 'Name': 'Компьютерная томография головного мозга [A06.23.004]', + 'Place': None, + 'ResultExits': '0', + 'LpuName': 'ГАУЗ "ГКБ №7"', + }, + { + 'Type': 'ROUTE_TO_DOCTOR_INSPECTION', + 'CreationDateTime': '2025-03-1711:41', + 'Name': 'Терапевт [1231]', + 'Place': None, + 'ResultExits': '1', + 'LpuName': 'ГАУЗ "ГКБ №7"', + }, + { + 'Type': 'ROUTE_TO_DOCTOR_INSPECTION', + 'CreationDateTime': '2025-03-3114:50', + 'Name': 'Кардиолог [1260]', + 'Place': None, + 'ResultExits': '0', + 'LpuName': 'ГАУЗ "ГКБ №7"', + }, + ], + }, + { + 'error': 'a268e6d7-618c-4b83-97ca-c9dc8b79b55b', + 'Routes': [ + { + 'Type': 'ROUTE_TO_DOCTOR_INSPECTION', + 'CreationDateTime': '2025-03-2414:49', + 'Name': 'Кардиолог [1259]', + 'Place': 'Каб.№211 Шайдуллина Г.И. (Кардиолог)', + 'ResultExits': '1', + 'LpuName': 'ГАУЗ "ГКБ №7"', + }, + { + 'Type': 'ROUTE_TO_DIAGNOSTICS', + 'CreationDateTime': '2025-03-1716:06', + 'Name': 'Общий анализ крови [B03.016.002]', + 'Place': None, + 'ResultExits': '0', + 'LpuName': 'ГАУЗ "ГКБ №7"', + }, + { + 'Type': 'ROUTE_TO_DIAGNOSTICS', + 'CreationDateTime': '2025-03-1711:41', + 'Name': 'Ультразвуковое исследование лимфатических узлов (одна анатомическая зона) [A04.06.002]', + 'Place': None, + 'ResultExits': '0', + 'LpuName': 'ГАУЗ "ГКБ №7"', + }, + { + 'Type': 'ROUTE_TO_DIAGNOSTICS', + 'CreationDateTime': '2025-03-1711:41', + 'Name': 'Компьютерная томография головного мозга [A06.23.004]', + 'Place': None, + 'ResultExits': '0', + 'LpuName': 'ГАУЗ "ГКБ №7"', + }, + { + 'Type': 'ROUTE_TO_DIAGNOSTICS', + 'CreationDateTime': '2025-03-1711:41', + 'Name': 'Общий (клинический) анализ крови [B03.016.002]', + 'Place': None, + 'ResultExits': '0', + 'LpuName': 'ГАУЗ "ГКБ №7"', + }, + { + 'Type': 'ROUTE_TO_DIAGNOSTICS', + 'CreationDateTime': '2025-03-1711:41', + 'Name': 'Компьютерная томография головного мозга [A06.23.004]', + 'Place': None, + 'ResultExits': '0', + 'LpuName': 'ГАУЗ "ГКБ №7"', + }, + { + 'Type': 'ROUTE_TO_DOCTOR_INSPECTION', + 'CreationDateTime': '2025-03-1711:41', + 'Name': 'Терапевт [1231]', + 'Place': None, + 'ResultExits': '1', + 'LpuName': 'ГАУЗ "ГКБ №7"', + }, + { + 'Type': 'ROUTE_TO_DOCTOR_INSPECTION', + 'CreationDateTime': '2025-03-3114:50', + 'Name': 'Кардиолог [1260]', + 'Place': None, + 'ResultExits': '0', + 'LpuName': 'ГАУЗ "ГКБ №7"', + }, + ], + }, +] +medexamDict = { + 'MedExamTypes': [ + { + 'Name': 'ДВН 1 этап 404н', + 'Code': '024', + 'MedExamItems': [ + { + 'AgeGroupName': 'Взрослые(18,21,24,27,30,33,36,39,40,41,42,43-99)', + 'AgeGroupCriteria': ' 18 21 24 27 30 33 36 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99\r\n', + 'Required': 'Обязательный', + 'Type': 'Услуга', + 'MedicalServiceCode': 'A02.07.004', + 'MedicalServiceName': 'Д04 Антропометрия (измерение роста стоя, массы тела, окружности талии), расчет индекса массы тела 1 этап', + }, + { + 'AgeGroupName': 'Взрослые(18,21,24,27,30,33,36,39,40,41,42,43-99)', + 'AgeGroupCriteria': ' 18 21 24 27 30 33 36 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99\r\n', + 'Required': 'Обязательный', + 'Type': 'Услуга', + 'MedicalServiceCode': 'A09.05.026', + 'MedicalServiceName': 'Д12 Определение уровня общего холестерина в крови (допускается экспресс-метод) 1 этап', + }, + { + 'AgeGroupName': 'От 18 лет', + 'AgeGroupCriteria': ' 18 \r\n', + 'Required': 'Обязательный', + 'Type': 'Услуга', + 'MedicalServiceCode': 'A09.19.001', + 'MedicalServiceName': 'Д30 Исследование кала на скрытую кровь 1 этап', + }, + { + 'AgeGroupName': 'Взрослые 40-99', + 'AgeGroupCriteria': ' 404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899 \r\n', + 'Required': 'Обязательный', + 'Type': 'Услуга', + 'MedicalServiceCode': 'B03.016.002', + 'MedicalServiceName': 'Д10 Клинический анализ крови (в объеме не менее определения концентрации гемоглобина в эритроцитах, количества лейкоцитов и скорости оседания эритроцитов) 1 этап', + }, + { + 'AgeGroupName': 'Взрослые(18,21,24,27,30,33,36,39,40,41,42,43-99)', + 'AgeGroupCriteria': ' 18 21 24 27 30 33 36 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99\r\n', + 'Required': 'Обязательный', + 'Type': 'Услуга', + 'MedicalServiceCode': 'A01.30.026', + 'MedicalServiceName': 'Д01 Опрос (анкетирование) на выявление неинфекционных заболеваний и факторов риска их развития', + }, + { + 'AgeGroupName': 'Жен 40,42,44,46,48,50,52,54,56,58,60,62,64,66,68,70,72,74', + 'AgeGroupCriteria': ' 404244464850525456586062646668707274 \r\n', + 'Required': 'Обязательный', + 'Type': 'Услуга', + 'MedicalServiceCode': 'A06.30.00299', + 'MedicalServiceName': 'Д35 Расшифровка маммограммы врачом (описание и интерпретация рентгенографических изображений) 1 этап', + }, + { + 'AgeGroupName': 'Взрослые(18,21,24,27,30,33,36,39,40,41,42,43-99)', + 'AgeGroupCriteria': ' 18 21 24 27 30 33 36 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99\r\n', + 'Required': 'Обязательный', + 'Type': 'Услуга', + 'MedicalServiceCode': 'A09.05.023.003', + 'MedicalServiceName': 'Д11 Определение уровня глюкозы в крови экспресс-методом (допускается лабораторный метод) 1 этап', + }, + { + 'AgeGroupName': '45', + 'AgeGroupCriteria': ' 45\r\n', + 'Required': 'Обязательный', + 'Type': 'Услуга', + 'MedicalServiceCode': 'A03.16.001', + 'MedicalServiceName': 'Эзофагогастродуоденоскопия', + }, + { + 'AgeGroupName': 'Женщины(18,21,24,27,30,33,36,39,40,41,42,43,44-99)', + 'AgeGroupCriteria': ' 1821242730333639404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899 \r\n', + 'Required': 'Обязательный', + 'Type': 'Специальность', + 'MedicalServiceCode': 'B04.001.002', + 'MedicalServiceName': 'Осмотр фельдшером (акушеркой) или врачом акушером-гинекологом (ПОСЕЩЕНИЕ)', + 'SpecialityName': 'Акушер-гинеколог', + }, + { + 'AgeGroupName': '35-99', + 'AgeGroupCriteria': ' 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99\r\n', + 'Required': 'Обязательный', + 'Type': 'Услуга', + 'MedicalServiceCode': 'A05.10.0066', + 'MedicalServiceName': 'Д20 Электрокардиография (в покое) 1 этап', + }, + { + 'AgeGroupName': 'Мужчины (45,50,55,60,64)', + 'AgeGroupCriteria': ' 4550556064 \r\n', + 'Required': 'Обязательный', + 'Type': 'Услуга', + 'MedicalServiceCode': 'A09.05.130', + 'MedicalServiceName': 'Д31 Анализ крови на уровень содержания простатспецифического антигена 1 этап', + }, + { + 'AgeGroupName': 'женщины(40,42,44,46,48,50,52,54,56,58,60,62,64,66,68,70,72,74)', + 'AgeGroupCriteria': ' 10302 404244464850525456586062646668707274 \r\n', + 'Required': 'Обязательный', + 'Type': 'Услуга', + 'MedicalServiceCode': 'A06.20.004', + 'MedicalServiceName': 'Д34 Маммография обеих молочных желез без расшифровки маммограммы врачом (включает стоимость проведения процедуры рентгеновской или цифровой маммографии на рентгеновском аппарате-маммографе) 1 этап', + }, + { + 'AgeGroupName': 'жен(18,21,24,27,30,33,36,39,42,45,48,51,54,57,60,63)', + 'AgeGroupCriteria': ' 10302 18212427303336394245485154576063 \r\n', + 'Required': 'Обязательный', + 'Type': 'Услуга', + 'MedicalServiceCode': 'A11.20.025', + 'MedicalServiceName': 'П37 Взятие мазка (соскоба) с поверхности шейки матки (наружного маточного зева) и цервикального канала на цитологическое исследование (без учёта стоимости цитологического исследования мазка с шейки матки) 1 этап', + }, + { + 'AgeGroupName': 'Взрослые(18,21,24,27,30,33,36,39)', + 'AgeGroupCriteria': ' 18 21 24 27 30 33 36 39\r\n', + 'Required': 'Обязательный', + 'Type': 'Услуга', + 'MedicalServiceCode': 'A23.30.055/1', + 'MedicalServiceName': 'П42 Определение относительного сердечно-сосудистого риска', + }, + { + 'AgeGroupName': 'Взрослые 40-99', + 'AgeGroupCriteria': ' 404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899 \r\n', + 'Required': 'Обязательный', + 'Type': 'Услуга', + 'MedicalServiceCode': 'A02.26.015', + 'MedicalServiceName': 'Д03 Измерение внутриглазного давления 1 этап', + }, + { + 'AgeGroupName': 'Взрослые(18,21,24,27,30,33,36,39,40,41,42,43-99)', + 'AgeGroupCriteria': ' 18 21 24 27 30 33 36 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99\r\n', + 'Required': 'Обязательный', + 'Type': 'Услуга', + 'MedicalServiceCode': 'A02.12.002', + 'MedicalServiceName': 'Д02 Измерение артериального давления 1 этап', + }, + { + 'AgeGroupName': '40-64', + 'AgeGroupCriteria': ' 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64\r\n', + 'Required': 'Обязательный', + 'Type': 'Услуга', + 'MedicalServiceCode': 'A23.30.055/2', + 'MedicalServiceName': 'П40 Определение абсолютного сердечно-сосудистого риска', + }, + { + 'AgeGroupName': 'Мужчины (45,50,55,60,64)', + 'AgeGroupCriteria': ' 4550556064 \r\n', + 'Required': 'Обязательный', + 'Type': 'Услуга', + 'MedicalServiceCode': 'A11.12.009', + 'MedicalServiceName': 'Д32 Взятие крови из периферической вены', + }, + { + 'AgeGroupName': 'Женщины от 18 лет', + 'AgeGroupCriteria': ' 18 \r\n', + 'Required': 'Обязательный', + 'Type': 'Услуга', + 'MedicalServiceCode': 'A08.20.017.0021', + 'MedicalServiceName': 'П38 Цитологическое исследование мазка с шейки матки (Жидкостной метод) 1 этап', + }, + { + 'AgeGroupName': 'От 18 лет', + 'AgeGroupCriteria': ' 18 \r\n', + 'Required': 'Обязательный', + 'Type': 'Услуга', + 'MedicalServiceCode': 'A08.20.013/3', + 'MedicalServiceName': 'П39 Цитологическое исследование мазка с шейки матки Папаниколау 1 этап', + }, + { + 'AgeGroupName': 'Взрослые (18,21,24,27,30,33,36,39,42,45,48,51,54,57,60,63,63,65,68,71,74,77,80,83,86,89,92,95,98)', + 'AgeGroupCriteria': ' 18 21 24 27 30 33 36 39 42 45 48 51 54 57 60 63 65 68 71 74 77 80 83 86 89 92 95 98\r\n', + 'Required': 'Обязательный', + 'Type': 'Услуга', + 'MedicalServiceCode': 'B04.070.002', + 'MedicalServiceName': 'П42 Индивидуальное краткое профилактическое консультирование по коррекции факторов риска развития неинфекционных заболеваний', + }, + { + 'AgeGroupName': 'Взрослые(18,24,30,36,40,42,44,46,48,50,52-98)', + 'AgeGroupCriteria': ' 18 20 22 24 26 28 30 32 34 36 38 40 42 44 45 46 48 50 52 54 56 58 60 62 64 66 68 70 72 74 76 78 80 82 84 86 88 90 92 94 96 98\r\n', + 'Required': 'Обязательный', + 'Type': 'Услуга', + 'MedicalServiceCode': 'A06.09.006', + 'MedicalServiceName': 'Д21 Флюорография легких 1этап', + }, + { + 'AgeGroupName': 'От 18 лет', + 'AgeGroupCriteria': '18', + 'Required': 'Обязательный', + 'Type': 'Специальность', + 'MedicalServiceCode': 'B01.047.005', + 'MedicalServiceName': 'Прием (осмотр) врача-терапевта, включающий установление диагноза, определение группы состояния здоровья, группы диспансерного наблюдения, проведение краткого профилактического консультирования, включая рекомендации по здоровому питанию, уровню физической а', + 'SpecialityName': 'Терапевт', + }, + { + 'AgeGroupName': 'Жен 40,42,44,46,48,50,52,54,56,58,60,62,64,65-75', + 'AgeGroupCriteria': ' 404244464850525456586062646566676869707172737475 \r\n', + 'Required': 'Обязательный', + 'Type': 'Услуга', + 'MedicalServiceCode': 'A09.19.001', + 'MedicalServiceName': 'Д30 Исследование кала на скрытую кровь 1 этап', + }, + { + 'Required': 'Дополнительный', + 'Type': 'Услуга', + 'MedicalServiceCode': 'A06.09.006', + 'MedicalServiceName': 'Д21 Флюорография легких 1этап', + }, + { + 'Required': 'Дополнительный', + 'Type': 'Услуга', + 'MedicalServiceCode': 'A06.09.006', + 'MedicalServiceName': 'Флюорография легких', + }, + { + 'AgeGroupName': 'жен(18,21,24,27,30,33,36,39,42,45,48,51,54,57,60,63)', + 'AgeGroupCriteria': ' 10302 18212427303336394245485154576063 \r\n', + 'Required': 'Дополнительный', + 'Type': 'Специальность', + 'MedicalServiceCode': 'B04.001.002', + 'MedicalServiceName': 'Д36 Осмотр фельдшером (акушеркой) или врачом акушером-гинекологом', + 'SpecialityName': 'Акушер-гинеколог', + }, + { + 'Required': 'Дополнительный', + 'Type': 'Услуга', + 'MedicalServiceCode': 'A11.18.003и', + 'MedicalServiceName': 'Бужирование колостомы2', + }, + ], + } + ] +} +hospRecommendations = { + 'EventID': 'ddfa23ea-b0de-4d88-8abe-7d6a7a241df1', + 'EventDate': '2025-07-10', + 'Recommendations': [ + { + 'Type': 'Осмотр', + 'DateTime': '18.07.2025 8:30:43', + 'Recommendation': 'рекомендации тест', + }, + { + 'Type': 'Осмотр', + 'DateTime': '10.07.2025 17:29:25', + 'Recommendation': 'РЕКОМЕНДАЦИИ', + }, + ], +} +hospRoutes = { + 'EventID': 'ddfa23ea-b0de-4d88-8abe-7d6a7a241df1', + 'EventDate': '2025-07-10', + 'RoutesToDiagnostic': [ + { + 'RouteDate': '10.07.2025', + 'ResearchCode': 'A06.04.011', + 'ResearchName': 'Рентгенография бедренного сустава', + }, + { + 'RouteDate': '10.07.2025', + 'ResearchCode': 'А09,05,026', + 'ResearchName': 'Холестерин общий', + }, + { + 'RouteDate': '10.07.2025', + 'ResearchCode': '30.02', + 'ResearchName': 'Общий анализ мочи', + }, + { + 'RouteDate': '10.07.2025', + 'ResearchCode': 'B03.016.002.004', + 'ResearchName': 'Определение антител IgM и IgG к Coronavirus (SARS-CoV-2)', + }, + { + 'RouteDate': '10.07.2025', + 'ResearchCode': 'A26.06.072', + 'ResearchName': 'АСТ', + }, + { + 'RouteDate': '10.07.2025', + 'ResearchCode': 'B03.016.010', + 'ResearchName': 'Общий анализ кала ', + }, + { + 'RouteDate': '10.07.2025', + 'ResearchCode': 'B03.016.002', + 'ResearchName': 'Общий анализ крови ', + }, + { + 'RouteDate': '06.08.2025', + 'ResearchCode': 'B03.016.003 пров', + 'ResearchName': 'Общий (клинический) анализ крови развернутый', + }, + ], + 'RoutesToDoctor': [ + { + 'RouteDate': '10.07.2025', + 'SpecialityCode': '016', + 'SpecialityName': 'Эндокринолог', + }, + { + 'RouteDate': '17.07.2025', + 'SpecialityCode': '013', + 'SpecialityName': 'Кардиолог', + }, + ], +} diff --git a/src/apps/users/v1/router.py b/src/apps/users/v1/router.py new file mode 100644 index 0000000..6d2de09 --- /dev/null +++ b/src/apps/users/v1/router.py @@ -0,0 +1,152 @@ +from datetime import datetime +from json import dumps +from logging import getLogger +from typing import Annotated, Any + +from fastapi import APIRouter, Body, Depends, status + +from apps.users.auth import login +from shared.redis import client as cache + +from . import mock + +logger = getLogger(__name__) +router = APIRouter( + prefix='/user', + tags=[ + 'User', + ], +) + + +@router.post('/measurement', status_code=status.HTTP_202_ACCEPTED) +async def measurement( + user: Annotated[str, Depends(login)], + ad: Annotated[int, Body()], + sd: Annotated[int, Body()], + pulse: Annotated[int, Body()], + created_at: Annotated[datetime, Body()], + comment: Annotated[str, Body()], + status: Annotated[str, Body()], +): + created = created_at.strftime('%Y-%m-%d %H:%M:%S') + data = { + 'ad': ad, + 'sd': sd, + 'pulse': pulse, + 'created_at': created, + 'comment': comment, + 'status': status, + } + cache_key = f'tdn:measurement:{user}:{created}' + cache.set(cache_key, dumps(data)) + return + + +@router.get('/measurements') +async def measurements(user: Annotated[str, Depends(login)],): + data = [cache.get(key) for key in cache.keys(f'tdn:measurement:{user}:*')] + return data + + +@router.get('/queue') +async def queue(user: Annotated[bool, Depends(login)]): + return { + 'id': 60, + 'guid': '92b3343d-1cb2-47b2-8497-a37e38b6ba24', + 'tmk_date': None, + 'created_at': '2025-04-02 15:21:19.890343', + 'code_mo': '166502', + 'mo_name': 'ГАУЗ "ГКБ№7 ИМ. М.Н.САДЫКОВА"', + 'doctor_spec': '109', + 'doctor_snils': None, + 'patient_name': 'Иванов Петр Федорович', + 'patient_birthday': '1997-03-01', + 'patient_snils': '099-678-666 12', + 'patient_policy': None, + 'patient_phone': '+79123456789', + 'patient_email': None, + 'tmk_status': 1, + 'tmk_status_name': 'Создана', + 'tmk_cancel_reason': None, + 'tmk_cancel_reason_name': None, + 'vks_doctor_link': None, + 'vks_patient_link': None, + 'doctor_spec_name': 'врач-терапевт', + } + + +@router.get('/getDepartments') +async def get_departments(): + data: dict[Any, Any] = {} + return data + + +@router.get('/getSpecs') +async def get_specs(): + return mock.specs + + +@router.get('/findPat') +async def find_pat(user: Annotated[str, Depends(login)]): + return mock.findpat[0] + + +@router.get('/getProfile') +async def get_profile(user: Annotated[str, Depends(login)]): + return mock.profile[0] + + +@router.get('/getHosps') +async def get_hosps(): + return mock.hosps + + +@router.get('/getELNS') +async def get_elns(user: Annotated[str, Depends(login)]): + return mock.elns[0] + + +@router.get('/getVaccsReport') +async def get_vaccs_report(user: Annotated[str, Depends(login)]): + return mock.vacs[0] + + +@router.get('/getDiagnosticResults') +async def get_diagnostic_results(user: Annotated[str, Depends(login)]): + return mock.diagnosticResults[0] + + +@router.get('/getCurrHosp') +async def get_curr_hosp(user: Annotated[str, Depends(login)]): + return mock.currHosp[0] + + +@router.get('/getPatFLG') +async def get_pat_flg(user: Annotated[str, Depends(login)]): + return mock.patFLG[0] + + +@router.get('/getEntries') +async def get_entries(user: Annotated[str, Depends(login)]): + return mock.entries[0] + + +@router.get('/getRoutesList') +async def get_routes_list(user: Annotated[str, Depends(login)]): + return mock.routesList[0] + + +@router.get('/getMedExamDict') +async def get_med_exam_dict(user: Annotated[str, Depends(login)]): + return mock.medexamDict + + +@router.get('/getHospRecommendations') +async def get_hosp_recommendations(user: Annotated[str, Depends(login)]): + return mock.hospRecommendations + + +@router.get('/getHospRoutes') +async def get_hosp_routes(user: Annotated[str, Depends(login)]): + return mock.hospRoutes diff --git a/src/clients/__init__.py b/src/clients/__init__.py new file mode 100644 index 0000000..feff345 --- /dev/null +++ b/src/clients/__init__.py @@ -0,0 +1,15 @@ +from .esia.api import ESIA_API + + +class ClientsObject: + _esia_api = None + + @property + def esia_api(self): + if not self._esia_api: + self._esia_api = ESIA_API() + + return self._esia_api + + +clients = ClientsObject() diff --git a/src/clients/esia/__init__.py b/src/clients/esia/__init__.py new file mode 100644 index 0000000..c5fca36 --- /dev/null +++ b/src/clients/esia/__init__.py @@ -0,0 +1,4 @@ +from httpx import AsyncClient + + +class TMKClient(AsyncClient): ... diff --git a/src/clients/esia/api.py b/src/clients/esia/api.py new file mode 100644 index 0000000..c413332 --- /dev/null +++ b/src/clients/esia/api.py @@ -0,0 +1,71 @@ +import uuid +from datetime import UTC, datetime +from logging import getLogger +from typing import Any + +import jwt +from fastapi import status as st +from httpx import AsyncClient + +from apps.esia.scopes import SCOPES +from apps.esia.sign import sign_params +from core.config import settings +from shared import exceptions as e + +from . import schema as s + + +class ESIA_API(AsyncClient): + def __init__(self): + self.logger = getLogger(__name__) + super().__init__(base_url=settings.ESIA_BASE_URL) + + async def sign_request(self, data: dict[str, Any]): + timestamp = datetime.now(UTC).strftime('%Y.%m.%d %H:%M:%S %z').strip() + state = str(uuid.uuid4()) + params = { + 'client_id': settings.ESIA_CLIENT_ID, + 'timestamp': timestamp, + 'state': state, + 'scope': ' '.join(SCOPES), + } + params.update(data) + params['client_secret'] = sign_params(params) + + return params + + async def access_token(self, code: str): + params = { + 'grant_type': 'authorization_code', + 'redirect_uri': settings.ESIA_REDIRECT_URI, + 'code': code, + } + signed_params = await self.sign_request(params) + res = await self.post('/aas/oauth2/te', data=signed_params) + + match res.status_code: + case st.HTTP_200_OK: + return s.AccessTokenModel.model_validate(res.json()) + case st.HTTP_400_BAD_REQUEST: + return None + + case _: + self.logger.error(res.json()) + raise e.UnknownException + + async def get_user_info(self, access_token: str, id_token: str): + IDToken = s.IDTokenModel.model_validate( + jwt.decode(id_token, options={'verify_signature': False}) + ) + res = await self.get( + f'/rs/prns/{IDToken.urn_esia_sbj.oid}', + headers={'Authorization': f'Bearer {access_token}'}, + ) + + match res.status_code: + case st.HTTP_200_OK: + return s.UserInfoModel.model_validate(res.json()) + + case _: + self.logger.error(res.json()) + raise e.UnknownException diff --git a/src/clients/esia/schema.py b/src/clients/esia/schema.py new file mode 100644 index 0000000..596bdce --- /dev/null +++ b/src/clients/esia/schema.py @@ -0,0 +1,60 @@ +from typing import Literal + +from pydantic import BaseModel, Field, PositiveInt + + +class AccessTokenModel(BaseModel): + access_token: str + refresh_token: str + state: str + id_token: str + token_type: Literal['Bearer'] + expires_in: PositiveInt + + +class IDTokenACRModel(BaseModel): + twoAF: str = Field(alias='2fa') + + +class IDTokenSBJModel(BaseModel): + lvl: str = Field(alias='urn:esia:sbj:lvl') + typ: str = Field(alias='urn:esia:sbj:typ') + is_tru: bool = Field(alias='urn:esia:sbj:is_tru') + oid: int = Field(alias='urn:esia:sbj:oid') + name: str = Field(alias='urn:esia:sbj:nam') + + +class IDTokenModel(BaseModel): + aud: str + sub: int + nbf: int + amr: str + auth_time: int + exp: int + iat: int + iss: str + # acr: IDTokenACRModel + urn_esia_amd: str = Field(alias='urn:esia:amd') + urn_esia_sid: str = Field(alias='urn:esia:sid') + urn_esia_sbj: IDTokenSBJModel = Field(alias='urn:esia:sbj') + + +class UserInfoModel(BaseModel): + stateFacts: list[str] + firstName: str + lastName: str + # middleName: str + # birthDate: str + # gender: str + trusted: bool + # citizenship: str + snils: str + inn: int + updatedOn: int + rfgUOperatorCheck: bool + status: str + verifying: bool + rIdDoc: int + containsUpCfmCode: bool + kidAccCreatedByParent: bool + eTag: str diff --git a/src/core/__init__.py b/src/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/core/config.py b/src/core/config.py new file mode 100644 index 0000000..ebce073 --- /dev/null +++ b/src/core/config.py @@ -0,0 +1,62 @@ +from os import environ +from os.path import exists + +from pydantic import Field, model_validator +from pydantic_extra_types.semantic_version import SemanticVersion +from pydantic_settings import BaseSettings, SettingsConfigDict + + +def get_version(): + if exists('.version'): + with open('.version', encoding='utf-8') as f: + return SemanticVersion.parse(f.read().strip()) + + return SemanticVersion.parse('0.0.0') + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file='.env', + validate_default=False, + extra='ignore', + ) + + # App info + APP_NAME: str = 'Hospital Assistant API' + APP_DESCRIPTION: str = 'API for the Hospital Assistant' + APP_PORT: int = Field(default=6767) + VERSION: SemanticVersion = Field(default_factory=get_version) + DEBUG: bool = Field(default=False) + + # Security + SECRET_KEY: str = Field(default='secret') + ALGORITHM: str = 'HS256' + + # Database + DATABASE_URL: str = Field(default='sqlite:///sql.db') + + # Redis + REDIS_URL: str = Field(default='redis://localhost:6379/0') + + # Loki Logging + LOKI_URL: str | None = Field(default=None) + + # Environment + TMK_BASE_URL: str = Field(default='https://tmk-api.tatar.ru/api') + + # ESIA + ESIA_BASE_URL: str = Field(default='https://esia.gosuslugi.ru') + ESIA_CLIENT_ID: str = Field(default='') + ESIA_REDIRECT_URI: str = Field(default='') + ESIA_CONTAINER_PASSWORD: str = Field(default='') + ESIA_CONTAINER_THUMBPRINT: str = Field(default='') + + @model_validator(mode='after') + def celery_env(self): + environ['CELERY_BROKER_URL'] = self.REDIS_URL + environ['CELERY_RESULT_BACKEND'] = self.REDIS_URL + + return self + + +settings = Settings() diff --git a/src/core/exceptions.py b/src/core/exceptions.py new file mode 100644 index 0000000..9e378ad --- /dev/null +++ b/src/core/exceptions.py @@ -0,0 +1,54 @@ +from logging import getLogger + +from fastapi import FastAPI, Request, Response, status +from fastapi.encoders import jsonable_encoder +from fastapi.exceptions import ( + RequestValidationError, + WebSocketRequestValidationError, +) +from fastapi.responses import ORJSONResponse +from fastapi.utils import is_body_allowed_for_status_code +from fastapi.websockets import WebSocket +from starlette.exceptions import HTTPException + +logger = getLogger(__name__) +logger_format = '%s: %s' + + +def register_exceptions(app: FastAPI): + @app.exception_handler(HTTPException) + async def http_exception_handler(request: Request, exc: HTTPException): # type: ignore + headers = getattr(exc, 'headers', None) + if not is_body_allowed_for_status_code(exc.status_code): + return Response(status_code=exc.status_code, headers=headers) + return ORJSONResponse( + status_code=exc.status_code, + content={'detail': exc.detail}, + headers=headers, + ) + + @app.exception_handler(RequestValidationError) + async def validation_exception_handler( # type: ignore + request: Request, + exc: RequestValidationError, + ): + logger.warning(logger_format, 'Validation Error', exc.body) + return ORJSONResponse( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + content=jsonable_encoder({'detail': exc.errors()}), + ) + + @app.exception_handler(WebSocketRequestValidationError) + async def websocket_validation_exception_handler( # type: ignore + websocket: WebSocket, + exc: WebSocketRequestValidationError, + ): + logger.warning( + logger_format, 'WebSocket Validation Error', exc.errors() + ) + return await websocket.close( + code=status.WS_1008_POLICY_VIOLATION, + reason=jsonable_encoder(exc.errors()), + ) + + return app diff --git a/src/core/log.py b/src/core/log.py new file mode 100644 index 0000000..4d80edb --- /dev/null +++ b/src/core/log.py @@ -0,0 +1,67 @@ +import logging +from typing import Any + +from logging_loki import LokiHandler as Loki # type: ignore + +from core.config import settings + + +class LokiHandler(Loki): + def __init__(self): + if not settings.LOKI_URL: + msg = 'LOKI_URL is not set' + raise ValueError(msg) + + super().__init__( # type: ignore + settings.LOKI_URL, + tags={ + 'application': settings.APP_NAME, + 'version': str(settings.VERSION), + }, + version='1', + ) + + +class Config: + def __init__(self): + self.version = 1 + self.disable_existing_loggers = False + self.handlers = self._get_handlers() + self.loggers = self._get_loggers() + + @staticmethod + def _get_handlers(): + handlers: dict[str, Any] = { + 'console': { + 'class': 'logging.StreamHandler', + 'level': logging.INFO, + 'stream': 'ext://sys.stderr', + } + } + + if settings.LOKI_URL: + handlers['loki'] = {'class': LokiHandler} + + return handlers + + def _get_loggers(self): + loggers = { + '': { + 'level': logging.INFO, + 'handlers': list(self.handlers.keys()), + 'propagate': False, + }, + } + + return loggers + + def render(self): + return { + 'version': self.version, + 'disable_existing_loggers': self.disable_existing_loggers, + 'handlers': self.handlers, + 'loggers': self.loggers, + } + + +config = Config().render() diff --git a/src/core/main.py b/src/core/main.py new file mode 100644 index 0000000..2b0dd49 --- /dev/null +++ b/src/core/main.py @@ -0,0 +1,29 @@ +from logging import getLogger + +from fastapi import FastAPI +from fastapi.responses import ORJSONResponse + +# from database import lifespan +from middlewares import register_middlewares + +from .config import settings +from .exceptions import register_exceptions +from .routers.v1 import router as v1_router + +logger = getLogger(__name__) + +app = FastAPI( + debug=settings.DEBUG, + title=settings.APP_NAME, + description=settings.APP_DESCRIPTION, + version=str(settings.VERSION), + openapi_url=None, + default_response_class=ORJSONResponse, + # lifespan=lifespan, + docs_url=None, + redoc_url=None, +) + +app = register_middlewares(app) +app = register_exceptions(app) +app.include_router(v1_router) diff --git a/src/core/routers/__init__.py b/src/core/routers/__init__.py new file mode 100644 index 0000000..97cdb81 --- /dev/null +++ b/src/core/routers/__init__.py @@ -0,0 +1,40 @@ +from fastapi import APIRouter +from fastapi.openapi.docs import get_swagger_ui_html +from fastapi.openapi.utils import get_openapi +from fastapi.responses import ORJSONResponse + +from core.config import settings + + +def get_openapi_schema(router: APIRouter): + # if not settings.DEBUG: + # return None + + return ORJSONResponse( + get_openapi( + title=settings.APP_NAME, + version=str(settings.VERSION), + description=settings.APP_DESCRIPTION, + routes=router.routes, + servers=[ + { + 'url': '/', + 'description': 'Development environment', + }, + { + 'url': 'https://med-assistant-api.tatar.ru/', + 'description': 'Production environment', + }, + ], + ) + ) + + +def get_swagger_html(router: APIRouter): + # if not settings.DEBUG: + # return None + + return get_swagger_ui_html( + openapi_url=f'{router.prefix}/openapi.json', + title='Docs', + ) diff --git a/src/core/routers/v1.py b/src/core/routers/v1.py new file mode 100644 index 0000000..330f0df --- /dev/null +++ b/src/core/routers/v1.py @@ -0,0 +1,30 @@ +from fastapi import APIRouter, HTTPException + +from apps.esia.v1.router import router as esia_router +from apps.users.v1.router import router as users_router + +from . import get_openapi_schema, get_swagger_html + +router = APIRouter(prefix='/v1') + +router.include_router(esia_router) +router.include_router(users_router) + +openapi_schema = get_openapi_schema(router) +swagger_ui_html = get_swagger_html(router) + + +@router.get('/openapi.json', include_in_schema=False) +async def openapi(): + if openapi_schema is None: + raise HTTPException(status_code=404) + + return openapi_schema + + +@router.get('/docs', include_in_schema=False) +async def docs(): + if swagger_ui_html is None: + raise HTTPException(status_code=404) + + return swagger_ui_html diff --git a/src/database/__init__.py b/src/database/__init__.py new file mode 100644 index 0000000..0659bc6 --- /dev/null +++ b/src/database/__init__.py @@ -0,0 +1,34 @@ +from contextlib import asynccontextmanager +from logging import getLogger +from typing import Annotated + +from alembic.command import upgrade +from alembic.config import Config +from fastapi import Depends, FastAPI +from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import Session + +from core.config import settings +from database.manager import DBManager + +logger = getLogger(__name__) +db_manager = DBManager(settings.DATABASE_URL) + +SyncSessionDep = Annotated[Session, Depends(db_manager.sync_session)] +AsyncSessionDep = Annotated[ + AsyncSession, + Depends(db_manager.async_session), +] + + +@asynccontextmanager +async def lifespan(app: FastAPI): + log_format = '%s: %s' + logger.info(log_format, 'App Name', settings.APP_NAME) + logger.info(log_format, 'App Description', settings.APP_DESCRIPTION) + logger.info(log_format, 'App Version', settings.VERSION) + + config = Config('alembic.ini') + upgrade(config, 'head') + + yield diff --git a/src/database/manager.py b/src/database/manager.py new file mode 100644 index 0000000..4633314 --- /dev/null +++ b/src/database/manager.py @@ -0,0 +1,41 @@ +from contextlib import asynccontextmanager, contextmanager + +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from sqlmodel import Session, create_engine + + +class DBManager: + sync_url = '' + async_url = '' + + def __init__(self, database_url: str): + self.sync_url, self.async_url = self._initialize_urls(database_url) + + self.sync_engine = create_engine(self.sync_url) + self.async_engine = create_async_engine(self.async_url) + + def _initialize_urls(self, database_url: str): + url_parts = database_url.split('://') + + return ( + f'postgresql+psycopg://{url_parts[1]}', + f'postgresql+asyncpg://{url_parts[1]}', + ) + + def sync_session(self): + with Session(self.sync_engine) as session: + yield session + + async def async_session(self): + async with AsyncSession(self.async_engine) as session: + yield session + + @contextmanager + def sync_context_session(self): + with Session(self.sync_engine) as session: + yield session + + @asynccontextmanager + async def async_context_session(self): + async with AsyncSession(self.async_engine) as session: + yield session diff --git a/src/middlewares/__init__.py b/src/middlewares/__init__.py new file mode 100644 index 0000000..4bbcce1 --- /dev/null +++ b/src/middlewares/__init__.py @@ -0,0 +1,20 @@ +from logging import getLogger + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from .access_log_middleware import AccessLogMiddleware + +logger = getLogger(__name__) + + +def register_middlewares(app: FastAPI): + app.add_middleware(AccessLogMiddleware) + app.add_middleware( + CORSMiddleware, + allow_origins=['*'], + allow_methods=['*'], + allow_headers=['*'], + ) + + return app diff --git a/src/middlewares/access_log_middleware.py b/src/middlewares/access_log_middleware.py new file mode 100644 index 0000000..c0c757a --- /dev/null +++ b/src/middlewares/access_log_middleware.py @@ -0,0 +1,108 @@ +from logging import getLogger +from re import findall +from time import perf_counter + +from starlette.types import ASGIApp, Message, Receive, Scope, Send + +from core.config import settings + +LOCALHOST = '127.0.0.1' +BROWSERS = { + 'firefox': 'Firefox', + 'yabrowser': 'Yandex', + 'samsungbrowser': 'Samsung Internet', + 'trident': 'Internet Explorer', + 'opera': 'Opera', + 'vivaldi': 'Vivaldi', + 'brave': 'Brave', + 'edg': 'Edge', + 'chrome': 'Chrome', + 'safari': 'Safari', + 'chromium': 'Chromium', + 'msie': 'Internet Explorer', +} + + +class AccessLogMiddleware: + def __init__(self, app: ASGIApp): + self.app = app + self.logger = getLogger(__name__) + + self.version = ( + b'Version', + f'{settings.VERSION}'.encode(), + ) + + async def detect_browser(self, headers: dict[bytes, bytes]): + if b'user-agent' not in headers: + return 'unknown' + + user_agent = headers[b'user-agent'].decode().lower() + + for k, v in BROWSERS.items(): + if findall(k, user_agent): + return v + + return 'unknown' + + @staticmethod + async def get_client_ip( + headers: dict[bytes, bytes], + default_ip: str = LOCALHOST, + ): + if b'x-forwarded-for' not in headers: + return default_ip + + ips = headers[b'x-forwarded-for'].decode().split(',') + + if len(ips) > 1: + return ips[-1].strip() + + return ips[0] + + async def __call__(self, scope: Scope, receive: Receive, send: Send): + if scope['type'] != 'http': + return await self.app(scope, receive, send) + + start_time = perf_counter() + + async def send_wrapper(message: Message) -> None: + if message['type'] != 'http.response.start': + return await send(message) + + headers = dict(scope.get('headers', [])) + + client_ip = await self.get_client_ip(headers, scope['client'][0]) + browser = await self.detect_browser(headers) + + response_time = (perf_counter() - start_time) * 1000 + response_data = f'dur={response_time:.2f}' + response = ( + b'Server-Timing', + f'resp;{response_data};desc="Response Time"'.encode(), + ) + + message['headers'] = message['headers'] + [response, self.version] + + self.logger.info( + '%s - %s %s %d [%0.2fms]', + client_ip, + scope['method'], + scope['path'], + message['status'], + response_time, + extra={ + 'tags': { + 'method': scope['method'], + 'path': scope['path'], + 'status': message['status'], + 'response_time': response_time, + 'client_ip': client_ip, + 'browser': browser, + }, + }, + ) + + await send(message) + + return await self.app(scope, receive, send_wrapper) diff --git a/src/migrations/README b/src/migrations/README new file mode 100644 index 0000000..2500aa1 --- /dev/null +++ b/src/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. diff --git a/src/migrations/__init__.py b/src/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/migrations/env.py b/src/migrations/env.py new file mode 100644 index 0000000..00d508f --- /dev/null +++ b/src/migrations/env.py @@ -0,0 +1,53 @@ +from logging.config import dictConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool +from sqlmodel import SQLModel + +from core.log import config as log_config +from database import db_manager + +dictConfig(log_config) + + +config = context.config +url = db_manager.sync_url +target_metadata = SQLModel.metadata + + +def run_migrations_offline() -> None: + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={'paramstyle': 'named'}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + cfg = config.get_section(config.config_ini_section, {}) + cfg['sqlalchemy.url'] = url + connectable = engine_from_config( + cfg, + prefix='sqlalchemy.', + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + render_as_batch=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/src/migrations/script.py.mako b/src/migrations/script.py.mako new file mode 100644 index 0000000..480b130 --- /dev/null +++ b/src/migrations/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/src/server.py b/src/server.py new file mode 100644 index 0000000..38d00e6 --- /dev/null +++ b/src/server.py @@ -0,0 +1,22 @@ +from uvicorn import Config, Server + +from core.config import settings +from core.log import config as log_config + + +def main(): + config = Config( + 'core.main:app', + host='0.0.0.0', + port=settings.APP_PORT, + log_config=log_config, + log_level='info', + reload=settings.DEBUG, + access_log=False, + ) + server = Server(config) + server.run() + + +if __name__ == '__main__': + main() diff --git a/src/shared/__init__.py b/src/shared/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/shared/exceptions.py b/src/shared/exceptions.py new file mode 100644 index 0000000..898838e --- /dev/null +++ b/src/shared/exceptions.py @@ -0,0 +1,66 @@ +from fastapi import HTTPException, status + + +class BasicException(HTTPException): + base_status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR + base_detail: str = 'Something went wrong' + + def __init__( + self, *, status_code: int | None = None, detail: str | None = None + ): + status_code = status_code or self.base_status_code + detail = detail or self.base_detail + super().__init__(status_code=status_code, detail=detail) + + @classmethod + def description(cls, detail: str | None = None): + return { + 'description': detail or cls.base_detail, + 'content': { + 'application/json': { + 'example': { + 'detail': detail or cls.base_detail, + } + } + }, + } + + +class BadRequestException(BasicException): + base_status_code: int = status.HTTP_400_BAD_REQUEST + base_detail: str = 'Bad Request' + + +class UnauthorizedException(BasicException): + base_status_code: int = status.HTTP_401_UNAUTHORIZED + base_detail: str = 'Unauthorized' + + +class ForbiddenException(BasicException): + base_status_code: int = status.HTTP_403_FORBIDDEN + base_detail: str = 'Forbidden' + + +class NotFoundException(BasicException): + base_status_code: int = status.HTTP_404_NOT_FOUND + base_detail: str = 'Not Found' + + +class ConflictException(BasicException): + base_status_code: int = status.HTTP_409_CONFLICT + base_detail: str = 'Conflict' + + +class TooManyRequestsException(BasicException): + base_status_code: int = status.HTTP_429_TOO_MANY_REQUESTS + base_detail: str = 'Too Many Requests' + + +class InternalServerErrorException(BasicException): + base_status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR + base_detail: str = 'Internal Server Error' + + +class UnknownException(BasicException): + base_status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR + base_detail: str = 'Unknown error' diff --git a/src/shared/redis.py b/src/shared/redis.py new file mode 100644 index 0000000..bdbc532 --- /dev/null +++ b/src/shared/redis.py @@ -0,0 +1,5 @@ +from redis import Redis + +from core.config import settings + +client = Redis.from_url(settings.REDIS_URL)