Compare commits

..

56 Commits

Author SHA1 Message Date
344c37c202 Фикс
All checks were successful
Build And Push / publish (push) Successful in 1m23s
2025-12-09 15:40:10 +03:00
5bc260cbfa Фикс
All checks were successful
Build And Push / publish (push) Successful in 5m32s
2025-12-09 11:45:10 +03:00
defebaaa4f Патч
All checks were successful
Build And Push / publish (push) Successful in 1m19s
2025-12-04 09:21:59 +03:00
4016c4fa5d Патч
All checks were successful
Build And Push / publish (push) Successful in 1m18s
2025-12-04 09:05:43 +03:00
d090932cce Патч
All checks were successful
Build And Push / publish (push) Successful in 1m24s
2025-12-03 16:55:11 +03:00
2333301225 141
All checks were successful
Build And Push / publish (push) Successful in 1m21s
2025-12-02 16:25:34 +03:00
187396c003 141 xsl
Some checks failed
Build And Push / publish (push) Failing after 51s
2025-12-02 16:22:51 +03:00
9e735c2cc6 Патч
All checks were successful
Build And Push / publish (push) Successful in 1m20s
2025-12-02 12:08:38 +03:00
0b907afec3 Патч
All checks were successful
Build And Push / publish (push) Successful in 1m22s
2025-12-02 11:48:44 +03:00
1a45238dfc Патч
All checks were successful
Build And Push / publish (push) Successful in 4m45s
2025-12-02 11:38:31 +03:00
f0d72e6af9 Патч
All checks were successful
Build And Push / publish (push) Successful in 54s
2025-12-02 07:18:08 +03:00
633125cf7b Патч
All checks were successful
Build And Push / publish (push) Successful in 2m44s
2025-12-02 04:31:47 +03:00
3749f9b0bb Патч
All checks were successful
Build And Push / publish (push) Successful in 50s
2025-12-02 03:54:04 +03:00
bf39b8e574 Патч
All checks were successful
Build And Push / publish (push) Successful in 53s
2025-12-02 03:45:25 +03:00
9612a8be67 Форматтер
All checks were successful
Build And Push / publish (push) Successful in 1m0s
2025-12-01 18:10:32 +03:00
eceefd1dff Патч
Some checks failed
Build And Push / publish (push) Failing after 47s
2025-12-01 18:05:07 +03:00
780cc15c44 Патч
All checks were successful
Build And Push / publish (push) Successful in 1m0s
2025-11-29 16:08:35 +03:00
84e4801085 Патч
All checks were successful
Build And Push / publish (push) Successful in 55s
2025-11-27 21:19:28 +03:00
50300627ed патч
All checks were successful
Build And Push / publish (push) Successful in 56s
2025-11-27 21:11:26 +03:00
aebaac40f0 Патч
All checks were successful
Build And Push / publish (push) Successful in 57s
2025-11-27 20:33:25 +03:00
18e70ceb7d Патч
All checks were successful
Build And Push / publish (push) Successful in 1m0s
2025-11-27 20:28:49 +03:00
d440ecfc7d Патч
All checks were successful
Build And Push / publish (push) Successful in 56s
2025-11-27 13:44:51 +03:00
8eb149ee0d Патч
All checks were successful
Build And Push / publish (push) Successful in 56s
2025-11-27 13:29:36 +03:00
f3c9cb42d6 Патч
Some checks failed
Build And Push / publish (push) Has been cancelled
2025-11-27 13:28:58 +03:00
45a4123708 патч
All checks were successful
Build And Push / publish (push) Successful in 54s
2025-11-26 02:31:26 +03:00
f25e20a8bf Патч
All checks were successful
Build And Push / publish (push) Successful in 1m0s
2025-11-24 19:11:00 +03:00
0158acb9b9 Патч
All checks were successful
Build And Push / publish (push) Successful in 1m0s
2025-11-24 18:02:46 +03:00
c862c4dd35 Патч
All checks were successful
Build And Push / publish (push) Successful in 1m8s
2025-11-24 18:00:52 +03:00
439cb147d8 Патч
All checks were successful
Build And Push / publish (push) Successful in 55s
2025-11-24 03:01:49 +03:00
7c36a938d8 Патч
All checks were successful
Build And Push / publish (push) Successful in 59s
2025-11-21 14:18:23 +03:00
98155a0c77 Патч
All checks were successful
Build And Push / publish (push) Successful in 58s
2025-11-21 10:52:00 +03:00
179d2310a3 Временно убраны тестовые данные пациента
All checks were successful
Build And Push / publish (push) Successful in 1m7s
2025-11-21 10:05:57 +03:00
c56468b521 патч
All checks were successful
Build And Push / publish (push) Successful in 3m30s
2025-11-18 09:42:42 +03:00
5b0fb8f536 Патч
All checks were successful
Build And Push / publish (push) Successful in 1m5s
2025-11-17 13:13:05 +03:00
d1e327e55c Патч
All checks were successful
Build And Push / publish (push) Successful in 1m1s
2025-11-14 10:16:59 +03:00
a71bed1e18 Патч
All checks were successful
Build And Push / publish (push) Successful in 1m4s
2025-11-13 10:10:55 +03:00
043de7e034 Патч
All checks were successful
Build And Push / publish (push) Successful in 1m12s
2025-11-12 12:19:30 +03:00
cf1324633d Патч
All checks were successful
Build And Push / publish (push) Successful in 5m5s
2025-11-11 12:58:58 +03:00
b602b75234 Патч
All checks were successful
Build And Push / publish (push) Successful in 5m43s
2025-10-31 15:11:38 +03:00
44ed7796ab Патч
All checks were successful
Build And Push / publish (push) Successful in 1m5s
2025-10-20 10:05:02 +03:00
e0d7ab5442 Патч
Some checks failed
Build And Push / publish (push) Failing after 47s
2025-10-20 10:00:02 +03:00
cb8b48fcca Патч
All checks were successful
Build And Push / publish (push) Successful in 1m7s
2025-10-20 09:58:05 +03:00
6523a65dd5 Патч
All checks were successful
Build And Push / publish (push) Successful in 1m11s
2025-10-19 19:58:48 +03:00
265d4b31aa Патч
All checks were successful
Build And Push / publish (push) Successful in 6m18s
2025-10-16 16:50:54 +03:00
db48b14ac5 Патч
Some checks failed
Build And Push / publish (push) Failing after 6m24s
2025-10-16 16:43:50 +03:00
a4239a0c52 Патч 2025-10-16 16:43:18 +03:00
cb3b138241 Патч
All checks were successful
Build And Push / publish (push) Successful in 8m57s
2025-10-05 10:57:21 +03:00
ebd4d14508 Патч
Some checks failed
Build And Push / publish (push) Failing after 1m56s
2025-10-05 10:51:39 +03:00
848d314718 фикс
All checks were successful
Build And Push / publish (push) Successful in 2m25s
2025-10-02 10:59:16 +03:00
b16e077c1c Фикс
All checks were successful
Build And Push / publish (push) Successful in 2m25s
2025-10-02 10:53:27 +03:00
c78da4c979 Еще один фикс
All checks were successful
Build And Push / publish (push) Successful in 2m21s
2025-10-02 09:04:14 +03:00
a34deaedb3 Фиксы
All checks were successful
Build And Push / publish (push) Successful in 2m25s
2025-10-02 08:55:24 +03:00
1a7bd7120a фикс
All checks were successful
Build And Push / publish (push) Successful in 2m1s
2025-09-30 18:14:12 +03:00
87eb228210 Временный обход авторизации
All checks were successful
Build And Push / publish (push) Successful in 1m59s
2025-09-30 18:05:17 +03:00
c3fcd2021d Добавлена возможность пробрасывать есиа через прокси
All checks were successful
Build And Push / publish (push) Successful in 7m20s
2025-09-30 16:44:47 +03:00
c7f439e48f Фиксы
All checks were successful
Build And Push / publish (push) Successful in 1m54s
2025-09-30 12:34:39 +03:00
59 changed files with 16676 additions and 1338 deletions

View File

@ -20,3 +20,6 @@ uv.lock
# Container
container
# Postgres
postgres

3
.gitignore vendored
View File

@ -20,3 +20,6 @@ uv.lock
# Container
container
# Postgres
postgres

View File

@ -67,6 +67,16 @@ FROM builder-base AS production
WORKDIR /app
# ────────────────────── WEASYPRINT SYSTEM DEPENDENCIES ──────────────────────
# These are the exact packages required for WeasyPrint to work on Debian Bookworm
RUN apt-get update && \
apt-get install -y gcc libpq-dev \
libcairo2 libcairo2-dev libpangocairo-1.0-0 weasyprint && \
apt clean && \
rm -rf /var/cache/apt/*
# ─────────────────────────────────────────────────────────────────────────────
RUN chown -R appuser:appuser /app
COPY --from=python-base /app/.python /app/.python

View File

@ -8,13 +8,14 @@ x-app-common: &app-common
restart: unless-stopped
stop_signal: SIGINT
env_file:
- .env
- .test.env
environment:
DATABASE_URL: "postgresql://postgres:example@db:5432/postgres"
REDIS_URL: "redis://valkey:6379/0"
volumes:
- "./container/certt.cer:/var/opt/cprocsp/keys/cert.cer"
- "./container/cont:/app/cont"
# - "./container/certt.cer:/var/opt/cprocsp/keys/cert.cer"
- "./container/test.cer:/var/opt/cprocsp/keys/cert.cer"
- "./container/cont2:/app/cont"
services:
valkey:
@ -28,6 +29,21 @@ services:
timeout: 10s
retries: 5
db:
image: postgres:17.2-alpine
restart: unless-stopped
ports:
- ${POSTGRES_PORT:-5432}:5432
environment:
POSTGRES_PASSWORD: example
volumes:
- "${POSTGRES_DATA:-./postgres}:/var/lib/postgresql/data/"
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U postgres" ]
interval: 5s
timeout: 10s
retries: 5
web:
<<: *app-common
ports:

View File

@ -24,10 +24,15 @@ dependencies = [
"sqlmodel==0.0.24",
# Types
"pydantic==2.11.7",
"pydantic-xml==2.18.0",
"pydantic-settings==2.10.1",
"pydantic-extra-types==2.10.5",
"semver==3.0.4",
"pyjwt==2.10.1",
"xmltodict==1.0.2",
"python-multipart==0.0.20",
"weasyprint==66.0",
"lxml==6.0.2; sys_platform != 'win32'",
# CLI
"typer-slim==0.16.1",
]
@ -50,9 +55,9 @@ _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"
run = "uv run --env-file ../.env --directory ./src/ server.py"
manage = "uv run --env-file ../.env --directory ./src/ manage.py"
migrate = "uv run --env-file ../.env --directory ./src/ alembic revision --autogenerate"
[tool.uv]
required-version = ">=0.7.0"

View File

@ -24,8 +24,6 @@ def csp_sign(data: str):
with open(source_path, 'w', encoding='utf-8') as f:
f.write(data)
print(data)
cmd = [
'cryptcp',
'-signf',

View File

@ -2,9 +2,12 @@ import secrets
from logging import getLogger
from fastapi import APIRouter
from sqlmodel import select
from apps.esia.sign import get_url
from apps.users.models import User
from clients import clients as c
from database import AsyncSessionDep
from shared import exceptions as e
from shared.redis import client as cache
@ -26,7 +29,7 @@ async def login():
@router.post('/callback')
async def callback(code: str):
async def callback(session: AsyncSessionDep, code: str):
token = None
for i in range(3):
try:
@ -42,9 +45,32 @@ async def callback(code: str):
if token is None:
raise e.BadRequestException
await c.esia_api.get_user_info(token.access_token, token.id_token)
esia_user = await c.esia_api.get_user_info(
token.access_token, token.id_token
)
try:
vita_user = await c.vitacore_api.findBySnils(esia_user.snils)
patId = vita_user.patId
except e.UnknownException:
patId = 'b66a85f1-4aaa-4db8-942a-2de44341824e'
# raise e.BadRequestException(detail='Patient not found') from None
existing_user_stmt = select(User).where(User.vita_id == patId).limit(1)
existing_user = (
await session.execute(existing_user_stmt)
).scalar_one_or_none()
if existing_user is None:
user = User(vita_id=patId)
session.add(user)
await session.commit()
await session.refresh(user)
else:
user = existing_user
access_token = secrets.token_urlsafe(32)
cache.set(access_token, access_token)
await cache.set(access_token, f'user:{user.id}')
return s.Token(access_token=access_token)

View File

View File

@ -0,0 +1,33 @@
from anyio import Path
from fastapi import HTTPException
from lxml import etree # type: ignore
from weasyprint import HTML # type: ignore
async def get_parsable_ids():
parsable_files_dir = await Path('/app/apps/remd/xls').resolve()
parsable_ids: list[str] = [
file.name.split('.')[0]
async for file in parsable_files_dir.iterdir()
if await file.is_file()
]
return parsable_ids
async def convert_aemd_to_pdf(xml_str: bytes, docKind: str):
xml = etree.fromstring(xml_str) # type: ignore
xsl = etree.parse(f'/app/apps/remd/xls/{docKind}.xsl') # type: ignore
transform = etree.XSLT(xsl) # type: ignore
html = transform(xml) # type: ignore
html_str = etree.tostring( # type: ignore
html, pretty_print=True, encoding='unicode', method='html'
)
pdf = HTML(string=html_str).write_pdf() # type: ignore
if not pdf:
raise HTTPException(status_code=500, detail='PDF not generated')
return pdf

View File

View File

@ -0,0 +1,45 @@
from logging import getLogger
import xmltodict
from fastapi import APIRouter, HTTPException, Request
from orjson import dumps, loads
from apps.users.v1.schema import AEMDDemandContent
from shared.redis import client
from . import schema as s
logger = getLogger(__name__)
router = APIRouter(
prefix='/remd',
tags=[
'REMD',
],
)
@router.post('/callback')
async def callback(request: Request):
body_bytes = await request.body()
if not body_bytes:
logger.warning('Empty body')
raise HTTPException(status_code=400, detail='Empty body')
body = body_bytes.decode('utf-8').replace('\r', '').replace('\n', '')
body_dict = xmltodict.parse(body)
body = s.Remd.model_validate(body_dict)
file_request = body.Envelope.body.sendDocumentFileRequest
data = await client.get(f'aemd_messages:{file_request.relatesToMessage}')
if not data:
raise HTTPException(status_code=400, detail='Message not found')
messageData = AEMDDemandContent(**loads(data))
await client.delete(f'aemd_messages:{file_request.relatesToMessage}')
await client.set(
f'aemd:{messageData["vitaId"]}:{messageData["emdrId"]}',
dumps(s.AEMDFileData(data=file_request.file.data)),
)
return

View File

@ -0,0 +1,28 @@
from typing import TypedDict
from pydantic import BaseModel, Field
class FileModel(BaseModel):
data: str
class RequestModel(BaseModel):
relatesToMessage: str
file: FileModel
class BodyModel(BaseModel):
sendDocumentFileRequest: RequestModel
class EnvelopeModel(BaseModel):
body: BodyModel = Field(..., alias='s:Body')
class Remd(BaseModel):
Envelope: EnvelopeModel = Field(..., alias='s:Envelope')
class AEMDFileData(TypedDict):
data: str

1221
src/apps/remd/xls/110.xsl Normal file

File diff suppressed because it is too large Load Diff

1928
src/apps/remd/xls/111.xsl Normal file

File diff suppressed because it is too large Load Diff

1374
src/apps/remd/xls/119.xsl Normal file

File diff suppressed because it is too large Load Diff

1161
src/apps/remd/xls/122.xsl Normal file

File diff suppressed because it is too large Load Diff

1834
src/apps/remd/xls/141.xsl Normal file

File diff suppressed because it is too large Load Diff

2019
src/apps/remd/xls/148.xsl Normal file

File diff suppressed because it is too large Load Diff

1329
src/apps/remd/xls/75.xsl Normal file

File diff suppressed because it is too large Load Diff

3702
src/apps/remd/xls/92.xsl Normal file

File diff suppressed because it is too large Load Diff

0
src/apps/tdn/__init__.py Normal file
View File

15
src/apps/tdn/auth.py Normal file
View File

@ -0,0 +1,15 @@
from clients import clients as c
from shared.redis import client as cache
async def token():
access_token = await cache.get('tdn_token')
if access_token is None:
tokens = await c.tdn_api.signin()
await cache.set('tdn_token', tokens.accessToken, 60)
return tokens.accessToken
else:
return access_token.decode()

0
src/apps/tmk/__init__.py Normal file
View File

View File

43
src/apps/tmk/v1/router.py Normal file
View File

@ -0,0 +1,43 @@
from json import dumps
from logging import getLogger
from fastapi import APIRouter
from sqlmodel import select
from apps.users.models import User
from clients import clients as c
from database import AsyncSessionDep
from shared.exceptions import UnknownException
from shared.redis import client as cache
from . import schema as s
logger = getLogger(__name__)
router = APIRouter(
prefix='/tmk',
tags=[
'TMK',
],
)
@router.post('/update')
async def update(session: AsyncSessionDep, update: s.TMKUpdate):
guid = update.tmk_guid
info = await c.tmk_api.getInfo(guid)
snils = info.patient_snils
try:
patient = await c.vitacore_api.findBySnils(snils)
except UnknownException:
return
user_stmt = select(User).where(User.vita_id == patient.patId).limit(1)
user = await session.scalar(user_stmt)
if user is None:
return
key = f'tmk:{user.id}:{guid}'
value = {'id': info.id, 'status': info.tmk_status_name, 'is_read': False}
await cache.set(key, dumps(value))

View File

@ -0,0 +1,5 @@
from pydantic import BaseModel
class TMKUpdate(BaseModel):
tmk_guid: str

View File

@ -2,7 +2,10 @@ from typing import Annotated
from fastapi import Depends
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlmodel import select
from apps.users.models import User
from database import AsyncSessionDep
from shared import exceptions as e
from shared.redis import client as cache
@ -10,11 +13,23 @@ BEARER = HTTPBearer()
async def login(
session: AsyncSessionDep,
credentials: Annotated[HTTPAuthorizationCredentials, Depends(BEARER)],
):
is_exist = cache.get(credentials.credentials)
user = await cache.get(credentials.credentials)
if is_exist is None:
if user is None:
raise e.UnauthorizedException
return True
try:
_, user_id = user.decode().split(':')
except ValueError:
raise e.UnauthorizedException from None
user_model_stmt = select(User).where(User.id == int(user_id))
user_model = (await session.execute(user_model_stmt)).scalar_one_or_none()
if user_model is None:
raise e.UnauthorizedException
return user_model

20
src/apps/users/models.py Normal file
View File

@ -0,0 +1,20 @@
from datetime import datetime
from sqlmodel import Field, SQLModel
class User(SQLModel, table=True):
__tablename__: str = 'users' # type: ignore
id: int = Field(default=None, primary_key=True)
vita_id: str = Field(unique=True)
class UserDocument(SQLModel, table=True):
__tablename__: str = 'user_documents' # type: ignore
id: int = Field(default=None, primary_key=True)
user_id: str = Field()
token: str
base64: str | None = Field(default=None)
created_at: datetime

File diff suppressed because it is too large Load Diff

View File

@ -1,16 +1,24 @@
from datetime import datetime
import base64
from datetime import UTC, datetime
from json import dumps
from logging import getLogger
from secrets import token_urlsafe
from typing import Annotated
from fastapi import APIRouter, Body, Depends, status
from fastapi import APIRouter, Body, Depends, UploadFile, status
from orjson import loads
from apps.remd.dependencies import convert_aemd_to_pdf, get_parsable_ids
from apps.tdn.auth import token
from apps.users.auth import login
from apps.users.models import User
from clients import clients as c
from clients.vitacore import schema as s
from clients.tmk import schema as ts
from clients.vitacore import schema as vs
from shared import exceptions as e
from shared.redis import client as cache
from . import mock
from . import schema as s
logger = getLogger(__name__)
router = APIRouter(
@ -21,17 +29,25 @@ router = APIRouter(
)
@router.get('/getProfile', response_model=s.ProfileModel)
async def get_profile():
@router.get(
'/getProfile',
responses={
status.HTTP_200_OK: {'model': vs.ProfileModel},
},
)
async def get_profile(user: Annotated[User, Depends(login)]):
"""
Get profile of user by id.
Get profile of user.
"""
return await c.vitacore_api.getProfile(
'b62e9f22-a871-4c52-96d6-559c707a716d'
)
return await c.vitacore_api.getProfile(user.vita_id)
@router.get('/getDepartments', response_model=list[s.DepartmentModel])
@router.get(
'/getDepartments',
responses={
status.HTTP_200_OK: {'model': vs.OrganizationsModel},
},
)
async def get_departments():
"""
Get list of departments.
@ -39,44 +55,54 @@ async def get_departments():
return await c.vitacore_api.getDepartments()
@router.get('/getWorkers', response_model=s.WorkersModel)
async def get_workers(departmentId: str):
@router.get(
'/getWorkers', responses={status.HTTP_200_OK: {'model': vs.WorkersModel}}
)
async def get_workers(
user: Annotated[User, Depends(login)], departmentId: str
):
"""
Get list of workers by department id.
"""
return await c.vitacore_api.getWorkers(departmentId)
@router.get('/getSpecs', response_model=s.SpecsV021Model)
async def get_specs():
@router.get(
'/getSpecs',
responses={status.HTTP_200_OK: {'model': vs.SpecsV021Model}},
)
async def get_specs(user: Annotated[User, Depends(login)]):
"""
Get list of specialties.
"""
return await c.vitacore_api.getSpecsV021()
@router.get('/getEntries', response_model=s.EntriesModel)
async def get_entries():
@router.get(
'/getEntries', responses={status.HTTP_200_OK: {'model': vs.EntriesModel}}
)
async def get_entries(user: Annotated[User, Depends(login)]):
"""
Get list of entries for user by id.
"""
return await c.vitacore_api.getEntries(
'b172ddc1-bd94-407f-885f-725193dcc502'
)
return await c.vitacore_api.getEntries(user.vita_id)
@router.get('/getVaccsReport')
async def get_vaccs_report():
async def get_vaccs_report(
user: Annotated[User, Depends(login)], resultId: str | None = None
):
"""
Get report of vaccinations for user by id.
"""
return await c.vitacore_api.getVaccsReport(
'6fe66cae-409a-4f56-8ae9-d55d3c38569b'
)
if resultId is not None:
return await c.vitacore_api.getDiagResultFile(resultId)
return await c.vitacore_api.getVaccsReport(user.vita_id)
@router.get('/getMedExamDict')
async def get_med_exam_dict():
async def get_med_exam_dict(user: Annotated[User, Depends(login)]):
"""
Get medical examination dictionary.
"""
@ -84,105 +110,313 @@ async def get_med_exam_dict():
@router.get('/getRoutesList')
async def get_routes_list():
async def get_routes_list(user: Annotated[User, Depends(login)]):
"""
Get list of routes.
"""
return await c.vitacore_api.getRoutesList(
'4e6de5f7-4dc9-451b-bf0d-7a64a9b8c279'
)
return await c.vitacore_api.getRoutesList(user.vita_id)
@router.get('/getHospExaminations')
async def get_hosp_examinations():
async def get_hosp_examinations(
user: Annotated[User, Depends(login)], examId: str | None = None
):
"""
Get list of hospital examinations.
"""
return await c.vitacore_api.getHospExaminations(
'7bbdac30-9a33-4f13-9458-2c229c0c20f5',
'f22be2c9-8e68-42d6-851e-fbf4a5e8f657',
user.vita_id,
examId,
)
@router.get('/getCurrHosp')
async def get_curr_hosp():
async def get_curr_hosp(user: Annotated[User, Depends(login)]):
"""
Get current hospitalization.
"""
return await c.vitacore_api.getCurrHosp(
'b708e782-4f83-4f3b-8639-512c0c9637bf'
)
return await c.vitacore_api.getCurrHosp(user.vita_id)
@router.get('/getHosps')
async def get_hosps():
async def get_hosps(user: Annotated[User, Depends(login)]):
"""
Get list of hospitals.
"""
return await c.vitacore_api.getHosps(
'b708e782-4f83-4f3b-8639-512c0c9637bf'
)
return await c.vitacore_api.getHosps(user.vita_id)
@router.get('/getHospRecommendations')
async def get_hosp_recommendations():
async def get_hosp_recommendations(user: Annotated[User, Depends(login)]):
"""
Get list of recommended hospitals.
"""
return await c.vitacore_api.getHospRecommendations(
'b708e782-4f83-4f3b-8639-512c0c9637bf'
)
return await c.vitacore_api.getHospRecommendations(user.vita_id)
@router.get('/getHospRoutes')
async def get_hosp_routes():
async def get_hosp_routes(user: Annotated[User, Depends(login)]):
"""
Get list of recommended hospitals.
"""
return await c.vitacore_api.getHospRoutes(
'3092e1c5-e08b-4654-a027-82be90fe8a49'
)
return await c.vitacore_api.getHospRoutes(user.vita_id)
@router.get('/getDiagnosticResults')
async def get_diagnostic_results():
async def get_diagnostic_results(user: Annotated[User, Depends(login)]):
"""
Get list of diagnostic results.
"""
return await c.vitacore_api.getDiagnosticResults(
'4867cc79-9805-4ae2-98d3-2f822848635e'
)
return await c.vitacore_api.getDiagnosticResults(user.vita_id)
@router.get('/getELNs')
async def get_eln():
async def get_eln(user: Annotated[User, Depends(login)]):
"""
Get list of ELNs.
"""
return await c.vitacore_api.getELNs('d4493f1c-fcbb-4242-99e6-32328bed53b9')
return await c.vitacore_api.getELNs(user.vita_id)
@router.get('/getPatFLG')
async def get_pat_flg():
async def get_pat_flg(user: Annotated[User, Depends(login)]):
"""
Get list of ELNs.
"""
return await c.vitacore_api.getPatFLG(
'0bf2e271-e565-42a8-924e-0017bcdedecd'
return await c.vitacore_api.getPatFLG(user.vita_id)
@router.get('/queue', response_model=list[ts.QueueModel])
async def queue(_: Annotated[User, Depends(login)]):
"""
Get list of VKS queues.
"""
return await c.tmk_api.getQueue()
@router.get('/aemd')
async def get_aemd(
user: Annotated[User, Depends(login)],
parsable_ids: Annotated[list[str], Depends(get_parsable_ids)],
):
profile = await c.vitacore_api.getProfile(user.vita_id)
snils = profile.SNILS.replace('-', '').replace(' ', '')
docs = await c.aemd_api.searchRegistryItem(patient_snils=snils)
items: list[s.AEMDFile] = docs['items']
return_items: list[s.AEMDReturnFile] = []
for item in items:
if item['DocKind'] not in parsable_ids:
continue
is_cached = await cache.get(f'aemd:{user.vita_id}:{item["emdrId"]}')
return_items.append(
s.AEMDReturnFile(
emdrId=item['emdrId'],
registrationDate=item['registrationDate'],
DocKind=item['DocKind'],
IsSemd=item['IsSemd'],
isCached=bool(is_cached),
)
)
return return_items
@router.post('/aemd', status_code=status.HTTP_202_ACCEPTED)
async def post_aemd(user: Annotated[User, Depends(login)], emdrId: str):
messageId = token_urlsafe(32)
data = s.AEMDDemandContent(
messageId=messageId, emdrId=emdrId, vitaId=user.vita_id
)
await cache.set(f'aemd_messages:{messageId}', dumps(data))
await c.aemd_api.demandContent(messageId=messageId, emdrId=emdrId)
@router.get('/aemd/{emdrId}')
async def get_aemd_file(
user: Annotated[User, Depends(login)], emdrId: str, docKind: str
):
data = await cache.get(f'aemd:{user.vita_id}:{emdrId}')
if not data:
raise e.NotFoundException(status_code=404, detail='File not found')
b64 = loads(data)['data']
decoded = base64.b64decode(b64)
pdf = await convert_aemd_to_pdf(decoded, docKind)
b64_pdf = base64.b64encode(pdf).decode('utf-8')
return {
'filename': f'{emdrId}.pdf',
'content_type': 'application/pdf',
'data': b64_pdf,
}
@router.post('/measurement', status_code=status.HTTP_202_ACCEPTED)
async def measurement(
user: Annotated[str, Depends(login)],
tdn_access_token: Annotated[str, Depends(token)],
user: Annotated[User, 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()],
status: Annotated[int, Body(ge=1, le=3)],
serial_number: Annotated[str, Body()],
ekg: UploadFile,
):
created = created_at.strftime('%Y-%m-%d %H:%M:%S')
vitaId = '109df850-2268-49fb-bb78-2e0f3c57314d'
patient = await c.tdn_api.patient_search(tdn_access_token, vitaId)
patientUid = patient['items'][0]['uid']
observations = await c.tdn_api.observations_search(
tdn_access_token, patientUid
)
if observations.total == 0:
raise e.NotFoundException(detail='No observations found')
ad_obsrvMeasurementUid = None
ad_observationUid = None
health_obsrvMeasurementUid = None
health_observationUid = None
sad_measurement = None
dad_measurement = None
pulse_measurement = None
health_measurement = None
health_measurement_created_at = None
observations = observations.items[::-1]
for observation in observations:
observation_measurements = (
await c.tdn_api.observations_measurement_search(
tdn_access_token, observation.uid
)
)
for measurement in observation_measurements.items:
if measurement.measurement.code == 'ADPULSE':
ad_obsrvMeasurementUid = measurement.uid
ad_observationUid = measurement.observationUid
for metric in measurement.obsrvMtMetrics:
if metric.metric.code == 'SAD':
sad_measurement = metric.uid
if metric.metric.code == 'DAD':
dad_measurement = metric.uid
if metric.metric.code == 'PULSE':
pulse_measurement = metric.uid
if measurement.measurement.code == 'HEALTH':
health_obsrvMeasurementUid = measurement.uid
health_observationUid = measurement.observationUid
for metric in measurement.obsrvMtMetrics:
if metric.metric.code == 'HEALTH' and (
health_measurement_created_at is None
or metric.createdAt > health_measurement_created_at
):
health_measurement = metric.uid
health_measurement_created_at = metric.createdAt
if (
not ad_obsrvMeasurementUid
or not sad_measurement
or not dad_measurement
or not pulse_measurement
or not ad_observationUid
or not health_obsrvMeasurementUid
or not health_observationUid
or not health_measurement
or not health_measurement_created_at
):
ad_obsrvMeasurementUid = None
sad_measurement = None
dad_measurement = None
pulse_measurement = None
ad_observationUid = None
health_obsrvMeasurementUid = None
health_observationUid = None
health_measurement = None
health_measurement_created_at = None
else:
break
if not ad_obsrvMeasurementUid or not ad_observationUid:
raise e.NotFoundException(detail='No ADPULSE measurement found')
if not sad_measurement:
raise e.NotFoundException(detail='No SAD measurement found')
if not dad_measurement:
raise e.NotFoundException(detail='No DAD measurement found')
if not pulse_measurement:
raise e.NotFoundException(detail='No PULSE measurement found')
if not health_obsrvMeasurementUid or not health_observationUid:
raise e.NotFoundException(detail='No HEALTH measurement found')
if not health_measurement:
raise e.NotFoundException(detail='No HEALTH measurement found')
if not health_obsrvMeasurementUid or not health_observationUid:
raise e.NotFoundException(detail='No HEALTH measurement found')
ad_series = await c.tdn_api.create_series(
tdn_access_token,
ad_observationUid,
ad_obsrvMeasurementUid,
)
ad_series_uid = ad_series.uid
# SAD
await c.tdn_api.create_series_values(
tdn_access_token, ad_series_uid, sad_measurement, nvalue=sd
)
# DAD
await c.tdn_api.create_series_values(
tdn_access_token, ad_series_uid, dad_measurement, nvalue=ad
)
# PULSE
await c.tdn_api.create_series_values(
tdn_access_token, ad_series_uid, pulse_measurement, nvalue=pulse
)
health_series = await c.tdn_api.create_series(
tdn_access_token,
health_observationUid,
health_obsrvMeasurementUid,
)
health_series_uid = health_series.uid
# HEALTH
await c.tdn_api.create_series_values(
tdn_access_token,
health_series_uid,
health_measurement,
nvalue=status,
svalue=comment,
)
# EKG
await c.tdn_api.ekg(
tdn_access_token,
patientUid,
serial_number,
ekg,
)
created = datetime.now(UTC).strftime('%Y-%m-%d %H:%M:%S')
data = {
'ad': ad,
'sd': sd,
@ -191,46 +425,53 @@ async def measurement(
'comment': comment,
'status': status,
}
cache_key = f'tdn:measurement:{user}:{created}'
cache.set(cache_key, dumps(data))
return
cache_key = f'tdn:measurement:{user.id}:{created}'
await cache.set(cache_key, dumps(data))
@router.get('/measurements')
async def measurements(
user: Annotated[str, Depends(login)],
user: Annotated[User, Depends(login)],
):
data = [cache.get(key) for key in cache.keys(f'tdn:measurement:{user}:*')]
data = [
await cache.get(key)
for key in await cache.keys(f'tdn:measurement:{user.id}:*')
]
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.delete('/account', status_code=status.HTTP_204_NO_CONTENT)
async def delete_account(user: Annotated[User, Depends(login)]):
return
@router.get('/findPat')
async def find_pat(user: Annotated[str, Depends(login)]):
return mock.findpat[0]
@router.get('/notifications')
async def notifications(user: Annotated[User, Depends(login)]):
keys = await cache.keys(f'tmk:{user.id}:*')
notif: list[dict[str, str | bool]] = []
for key in keys:
val = await cache.get(key)
if val is None:
continue
value = loads(val)
notif_val = value.copy()
notif.append(notif_val)
if value['is_read'] is False:
value['is_read'] = True
await cache.set(key, dumps(value))
return s.Notifications(
notifications=notif,
)
@router.post('/complaint', status_code=status.HTTP_204_NO_CONTENT)
async def complaint(
user: Annotated[User, Depends(login)], complaints: s.Complaints
):
cache_key = f'complaint:{user.vita_id}'
await cache.set(cache_key, dumps(complaints.complaints))

View File

@ -0,0 +1,35 @@
from typing import TypedDict
from pydantic import BaseModel
class AEMDFile(TypedDict):
emdrId: str
localUid: str
registrationDate: str
registrationDateTime: str
storeTillDate: str
DocKind: str
IsSemd: bool
class AEMDReturnFile(TypedDict):
emdrId: str
registrationDate: str
DocKind: str
IsSemd: bool
isCached: bool
class AEMDDemandContent(TypedDict):
messageId: str
emdrId: str
vitaId: str
class Notifications(TypedDict):
notifications: list[dict[str, str | bool]]
class Complaints(BaseModel):
complaints: str

View File

View File

View File

@ -0,0 +1,29 @@
from logging import getLogger
from fastapi import APIRouter
from shared.redis import client as cache
from . import schema as s
logger = getLogger(__name__)
router = APIRouter(
prefix='/vitacore',
tags=[
'Витакор',
],
)
@router.post('/hospComplaint')
async def callback(complaint: s.HospComplaint):
value = await cache.get(f'complaint:{complaint.patID}')
value = value.decode() if value else ''
return s.ComplaintData(
patId=complaint.patID,
complaints=(
f'{value} | {complaint.eventId} | {complaint.datetime} | '
f'{complaint.MO_id}'
),
)

View File

@ -0,0 +1,16 @@
from datetime import datetime
from typing import TypedDict
from pydantic import BaseModel
class HospComplaint(BaseModel):
patID: str
eventId: str
datetime: datetime
MO_id: str
class ComplaintData(TypedDict):
patId: str
complaints: str

View File

@ -1,10 +1,16 @@
from .aemd.api import AEMD_API
from .esia.api import ESIA_API
from .tdn.api import TDN_API
from .tmk.api import TMK_API
from .vitacore.api import VITACORE_API
class ClientsObject:
_esia_api = None
_vitacore_api = None
_tdn_api = None
_tmk_api = None
_aemd_api = None
@property
def esia_api(self):
@ -20,5 +26,26 @@ class ClientsObject:
return self._vitacore_api
@property
def tdn_api(self):
if not self._tdn_api:
self._tdn_api = TDN_API()
return self._tdn_api
@property
def tmk_api(self):
if not self._tmk_api:
self._tmk_api = TMK_API()
return self._tmk_api
@property
def aemd_api(self):
if not self._aemd_api:
self._aemd_api = AEMD_API()
return self._aemd_api
clients = ClientsObject()

View File

75
src/clients/aemd/api.py Normal file
View File

@ -0,0 +1,75 @@
from logging import getLogger
from fastapi import status
from httpx import AsyncClient
from core.config import settings
from shared import exceptions as e
from . import schema as s
class AEMD_API(AsyncClient):
def __init__(self):
self.logger = getLogger(__name__)
super().__init__(
base_url=settings.AEMD_BASE_URL,
headers={'Content-Type': 'application/soap+xml'},
)
def get_envelope(self, endpoint: str, body: str):
return (
'<?xml version="1.0" encoding="utf-8"?>'
'<s:Envelope xmlns:s= "http://www.w3.org/2003/05/soap-envelope" '
'xmlns:a= "http://www.w3.org/2005/08/addressing">'
'<s:Header>'
f'<a:Action s:mustUnderstand="1">{endpoint}</a:Action>'
'<h:transportHeader xmlns:h="http://egisz.rosminzdrav.ru" '
'xmlns="http://egisz.rosminzdrav.ru" '
'xmlns:xsd="http://www.w3.org/2001/XMLSchema" '
'xmlns:xsi= "http://www.w3.org/2001/XMLSchema-instance">'
'<authInfo>'
f'<clientEntityId>{settings.AEMD_TOKEN}</clientEntityId>'
'</authInfo>'
'</h:transportHeader>'
'<a:To s:mustUnderstand= "1">http://gist-sdw.ezdrav.ru:8708/EMDAService</a:To>'
'</s:Header>'
'<s:Body xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">'
f'{body}'
'</s:Body>'
'</s:Envelope>'
)
async def searchRegistryItem(self, patient_snils: str):
envelope = self.get_envelope(
'searchRegistryItem',
f'<searchRegistryItemRequest xmlns="http://egisz.rosminzdrav.ru/iehr/emdr/service/"><patientSnils>{patient_snils}</patientSnils></searchRegistryItemRequest>',
)
req = await self.post('/', content=envelope)
match req.status_code:
case status.HTTP_200_OK:
envelope = s.Envelope.from_xml(req.text)
case _:
self.logger.error(req.text)
raise e.UnknownException
return envelope.model_dump()['body']['response']['matches']
async def demandContent(self, messageId: str, emdrId: str):
envelope = self.get_envelope(
'demandContent',
f'<demandContentRequest xmlns="http://egisz.rosminzdrav.ru/iehr/emdr/service/"><messageId>{messageId}</messageId><emdrId>{emdrId}</emdrId></demandContentRequest>',
)
req = await self.post('/', content=envelope)
match req.status_code:
case status.HTTP_200_OK:
envelope = s.DemandContentEnvelope.from_xml(req.text)
case _:
self.logger.error(req.text)
raise e.UnknownException
return envelope.model_dump()['body']

168
src/clients/aemd/schema.py Normal file
View File

@ -0,0 +1,168 @@
from pydantic import constr
from pydantic_xml import BaseXmlModel, RootXmlModel, attr, element
NSMAP = {
'a': 'http://www.w3.org/2005/08/addressing',
'b': 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd',
's': 'http://www.w3.org/2003/05/soap-envelope',
'xsi': 'http://www.w3.org/2001/XMLSchema-instance',
'xsd': 'http://www.w3.org/2001/XMLSchema',
'd2p1': 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd',
'd3p1': 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd',
}
SECURITY_NSMAP = NSMAP | {
'': 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd'
}
SIGNATURE_NSMAP = NSMAP | {'': 'http://www.w3.org/2000/09/xmldsig#'}
RESPONSE_NSMAP = NSMAP | {'': 'http://egisz.rosminzdrav.ru/iehr/emdr/service/'}
class Action(BaseXmlModel, tag='Action', ns='a', nsmap=NSMAP):
id: str = attr(name='Id', ns='d3p1')
class MessageID(BaseXmlModel, tag='MessageID', ns='a', nsmap=NSMAP):
id: str = attr(name='Id', ns='d3p1')
description: constr(strip_whitespace=True) # type: ignore
class BinarySecurityToken(
BaseXmlModel, tag='BinarySecurityToken', nsmap=NSMAP
):
id: str = attr(name='Id', ns='b')
ValueType: str = attr(name='ValueType')
EncodingType: str = attr(name='EncodingType')
description: constr(strip_whitespace=True) # type: ignore
class CanonicalizationMethod(
BaseXmlModel, tag='CanonicalizationMethod', nsmap=SIGNATURE_NSMAP
):
algorithm: str = attr(name='Algorithm')
class SignatureMethod(
BaseXmlModel, tag='SignatureMethod', nsmap=SIGNATURE_NSMAP
):
algorithm: str = attr(name='Algorithm')
class Transform(BaseXmlModel, tag='Transform', nsmap=SIGNATURE_NSMAP):
algorithm: str = attr(name='Algorithm')
class Transforms(RootXmlModel, tag='Transforms', nsmap=SIGNATURE_NSMAP): # type: ignore
root: list[Transform] = element()
class DigestMethod(BaseXmlModel, tag='DigestMethod', nsmap=SIGNATURE_NSMAP):
algorithm: str = attr(name='Algorithm')
class DigestValue(BaseXmlModel, tag='DigestValue', nsmap=SIGNATURE_NSMAP):
value: str
class Reference(BaseXmlModel, tag='Reference', nsmap=SIGNATURE_NSMAP):
uri: str = attr(name='URI')
transforms: Transforms = element()
digest_method: DigestMethod = element()
digest_value: DigestValue = element()
class SignedInfo(BaseXmlModel, tag='SignedInfo', nsmap=SIGNATURE_NSMAP):
canonicalization_method: CanonicalizationMethod = element()
signature_method: SignatureMethod = element()
references: list[Reference] = element()
class SignatureValue(
BaseXmlModel, tag='SignatureValue', nsmap=SIGNATURE_NSMAP
):
description: constr(strip_whitespace=True) # type: ignore
class SecurityTokenReference(
BaseXmlModel, tag='Reference', nsmap=SECURITY_NSMAP
):
ValueType: str = attr(name='ValueType')
URI: str = attr(name='URI')
class SecurityToken(
BaseXmlModel, tag='SecurityTokenReference', ns='', nsmap=SECURITY_NSMAP
):
reference: SecurityTokenReference = element()
class KeyInfo(BaseXmlModel, tag='KeyInfo', nsmap=SIGNATURE_NSMAP):
security_token_reference: SecurityToken = element()
class Signature(BaseXmlModel, tag='Signature', ns='', nsmap=SIGNATURE_NSMAP):
signed_info: SignedInfo = element()
signature_value: SignatureValue = element()
key_info: KeyInfo = element()
class Security(BaseXmlModel, tag='Security', ns='', nsmap=SECURITY_NSMAP):
binary_security_token: BinarySecurityToken = element()
signature: Signature = element()
class Header(BaseXmlModel, tag='Header', ns='s', nsmap=NSMAP):
action: Action = element()
message_id: MessageID = element()
security: Security = element()
class searchRegistryItemResponseItem(
BaseXmlModel, tag='item', nsmap=RESPONSE_NSMAP
):
emdrId: str = element()
localUid: str = element()
registrationDate: str = element()
registrationDateTime: str = element()
storeTillDate: str = element()
DocKind: str = element()
IsSemd: bool = element()
class searchRegistryItemResponseMatches(
BaseXmlModel, tag='matches', nsmap=RESPONSE_NSMAP
):
items: list[searchRegistryItemResponseItem] = element()
class searchRegistryItemResponse(
BaseXmlModel, tag='searchRegistryItemResponse', ns='', nsmap=RESPONSE_NSMAP
):
status: str = element()
matches: searchRegistryItemResponseMatches
class Body(BaseXmlModel, tag='Body', ns='s', nsmap=NSMAP):
id: str = attr(name='Id', ns='d2p1')
response: searchRegistryItemResponse = element()
class Envelope(BaseXmlModel, tag='Envelope', ns='s', nsmap=NSMAP):
header: Header = element()
body: Body = element()
class Acknowledgment(
BaseXmlModel, tag='acknowledgment', ns='', nsmap=RESPONSE_NSMAP
):
status: str = element()
class DemandContentBody(BaseXmlModel, tag='Body', ns='s', nsmap=NSMAP):
id: str = attr(name='Id', ns='d2p1')
acknowledgment: Acknowledgment
class DemandContentEnvelope(BaseXmlModel, tag='Envelope', ns='s', nsmap=NSMAP):
header: Header = element()
body: DemandContentBody = element()

View File

@ -18,7 +18,9 @@ from . import schema as s
class ESIA_API(AsyncClient):
def __init__(self):
self.logger = getLogger(__name__)
super().__init__(base_url=settings.ESIA_BASE_URL)
super().__init__(
base_url=settings.ESIA_BASE_URL, proxy=settings.ESIA_PROXY
)
async def sign_request(self, data: dict[str, Any]):
timestamp = datetime.now(UTC).strftime('%Y.%m.%d %H:%M:%S %z').strip()

View File

@ -33,7 +33,7 @@ class IDTokenModel(BaseModel):
exp: int
iat: int
iss: str
acr: IDTokenACRModel
# 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')
@ -44,10 +44,10 @@ class UserInfoModel(BaseModel):
firstName: str
lastName: str
middleName: str
birthDate: str
gender: str
# birthDate: str
# gender: str
trusted: bool
citizenship: str
# citizenship: str
snils: str
inn: int
updatedOn: int

View File

204
src/clients/tdn/api.py Normal file
View File

@ -0,0 +1,204 @@
from datetime import UTC, datetime
from json import dumps
from logging import getLogger
from urllib.parse import quote, urlencode
from fastapi import UploadFile
from fastapi import status as st
from httpx import AsyncClient
from core.config import settings
from shared import exceptions as e
from . import schema as s
class TDN_API(AsyncClient):
def __init__(self):
self.logger = getLogger(__name__)
super().__init__(
base_url=settings.TDN_BASE_URL,
verify=False,
headers={
'Content-Type': 'application/json',
},
)
async def signin(self):
data = {
'username': settings.TDN_LOGIN,
'password': settings.TDN_PASSWORD,
}
res = await self.post('/core/auth/signin', json=data)
match res.status_code:
case st.HTTP_200_OK:
return s.SignInModel.model_validate(res.json())
case _:
self.logger.error(res.json())
raise e.UnknownException
async def patient_search(self, access_token: str, vitaId: str):
query = (
quote(dumps({'where': {'vitaId': vitaId}}))
.replace('%7B', '{')
.replace('%7D', '}')
)
req = await self.get(
f'/ddn/patients/search?query={query}',
headers={'Authorization': f'Bearer {access_token}'},
)
return req.json()
async def observations_search(self, access_token: str, patientUid: str):
query = (
quote(dumps({'where': {'patientUid': patientUid}}))
.replace('%7B', '{')
.replace('%7D', '}')
)
res = await self.get(
f'/ddn/observations/search?query={query}',
headers={'Authorization': f'Bearer {access_token}'},
)
match res.status_code:
case st.HTTP_200_OK:
return s.ObservationsModel.model_validate(res.json())
case _:
self.logger.error(res.json())
raise e.UnknownException
async def observations_measurement_search(
self, access_token: str, observationUid: str
):
encoded_query = urlencode(
{
'query': dumps(
{
'where': {'observationUid': observationUid},
'relations': [
'measurement',
'obsrvMtMetrics',
'obsrvMtMetrics.metric',
],
}
)
}
)
res = await self.get(
f'/ddn/observation/obsrv-measurements/search?{encoded_query}',
headers={'Authorization': f'Bearer {access_token}'},
)
match res.status_code:
case st.HTTP_200_OK:
return s.ObservationMeasurementsModel.model_validate(
res.json()
)
case _:
self.logger.error(res.json())
raise e.UnknownException
async def create_series(
self, access_token: str, observationUid: str, obsrvMeasurementUid: str
):
now = datetime.now(UTC)
tz_offset = now.strftime('%z')
formatted_offset = (
f'{tz_offset[:3]}:{tz_offset[3:]}' if tz_offset else '+0000'
)
date_str = (
f'{now.strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]} {formatted_offset}'
)
res = await self.patch(
'/ddn/observation/series',
headers={'Authorization': f'Bearer {access_token}'},
json={
'observationUid': observationUid,
'obsrvMeasurementUid': obsrvMeasurementUid,
'date': date_str,
},
)
match res.status_code:
case st.HTTP_200_OK:
return s.SeriesModel.model_validate(res.json())
case _:
self.logger.error(res.json())
raise e.UnknownException
async def create_series_values(
self,
access_token: str,
seriesUid: str,
obsrvMtMetricUid: str,
*,
nvalue: int | None = None,
fvalue: float | None = None,
svalue: str | None = None,
):
data: dict[str, str | int | float] = {
'seriesUid': seriesUid,
'obsrvMtMetricUid': obsrvMtMetricUid,
}
if nvalue is not None:
data['nvalue'] = nvalue
if fvalue is not None:
data['fvalue'] = fvalue
if svalue is not None:
data['svalue'] = svalue
res = await self.patch(
'/ddn/observation/series-values',
headers={'Authorization': f'Bearer {access_token}'},
json=data,
)
match res.status_code:
case st.HTTP_200_OK:
return s.SeriesValueModel.model_validate(res.json())
case _:
self.logger.error(res.json())
raise e.UnknownException
async def ekg(
self,
access_token: str,
patientUid: str,
serial_number: str,
ecg_file: UploadFile,
):
req = await self.post(
'/ddn/observation/series-values/ecg/krb02',
headers={
'Authorization': f'Bearer {access_token}',
'Content-Type': 'multipart/form-data; boundary=+++',
},
data={
'patientUid': patientUid,
'serialNumber': serial_number,
},
files={
'ecg': (
ecg_file.filename,
ecg_file.file,
ecg_file.content_type or 'application/octet-stream',
)
},
)
match req.status_code:
case st.HTTP_201_CREATED:
return s.EkgModel.model_validate(req.json())
case _:
self.logger.error(req.json())
raise e.UnknownException

115
src/clients/tdn/schema.py Normal file
View File

@ -0,0 +1,115 @@
from datetime import datetime
from pydantic import BaseModel
class SignInModel(BaseModel):
accessToken: str
refreshToken: str
class ObservationModel(BaseModel):
uid: str
createdAt: datetime
updatedAt: datetime
realmUid: str
patientUid: str
nosologyUid: str
exclusionReasonUid: str | None
exclusionComment: str | None
exclusionDate: datetime | None
employeeUid: str
mobileId: str | None
class ObservationsModel(BaseModel):
items: list[ObservationModel]
total: int
class MeasurementModel(BaseModel):
uid: str
createdAt: datetime
updatedAt: datetime
code: str
title: str
order: int
isSelfControl: bool
class MetricModel(BaseModel):
uid: str
createdAt: datetime
updatedAt: datetime
code: str
title: str
order: int
shortName: str
measureUid: str | None
format: str
class ObservationMtMetricModel(BaseModel):
uid: str
createdAt: datetime
updatedAt: datetime
obsrvMeasurementUid: str
metricUid: str
mobileId: str | None
metric: MetricModel
class ObservationMeasurementModel(BaseModel):
uid: str
createdAt: datetime
updatedAt: datetime
observationUid: str
measurementUid: str
timeFrequency: int
timePeriod: int
timePeriodMeasureUid: str
timeOfDay: list[str] | None
comment: str | None
mobileId: str | None
measurement: MeasurementModel
obsrvMtMetrics: list[ObservationMtMetricModel]
class ObservationMeasurementsModel(BaseModel):
items: list[ObservationMeasurementModel]
total: int
class SeriesRealmModel(BaseModel):
uid: str
class SeriesModel(BaseModel):
observationUid: str
obsrvMeasurementUid: str
date: str
realm: SeriesRealmModel
mobileId: str | None
uid: str
createdAt: datetime
updatedAt: datetime
class SeriesValueModel(BaseModel):
uid: str
seriesUid: str
obsrvMtMetricUid: str
realm: SeriesRealmModel
createdAt: datetime
updatedAt: datetime
nvalue: int | None
fvalue: float | None
svalue: str | None
filepath: str | None
mobileId: str | None
tisId: str | None
class EkgModel(BaseModel):
success: bool
# result: ...

View File

111
src/clients/tmk/api.py Normal file
View File

@ -0,0 +1,111 @@
from datetime import date
from logging import getLogger
from typing import Literal
from fastapi import status as st
from httpx import AsyncClient
from core.config import settings
from shared import exceptions as e
from shared.functions import clean_params
from shared.redis import client as cache
from . import schema as s
class TMK_API(AsyncClient):
def __init__(self):
self.logger = getLogger(__name__)
super().__init__(base_url=settings.TMK_BASE_URL, verify=False)
async def get_token(self):
token = await cache.get('tmk_token')
if token is None:
token = await self.login()
await cache.set('tmk_token', token, 10800)
else:
token = token.decode()
return token
async def login(self):
req = await self.post(
'/auth',
json={
'login': settings.TMK_LOGIN,
'password': settings.TMK_PASSWORD,
},
)
match req.status_code:
case st.HTTP_200_OK:
return s.AccessTokenModel.model_validate(
req.json()
).access_token
case _:
self.logger.error(req.json())
raise e.UnknownException
async def getQueue(
self,
code_mo: str | None = None,
doctor_spec: str | None = None,
doctor_snils: str | None = None,
doctor_snils_strict: Literal['y', 'n'] = 'n',
date_begin: date | None = None,
date_end: date | None = None,
patient_snils: str | None = None,
patient_fio: str | None = None,
patient_policy: str | None = None,
patient_phone: str | None = None,
patient_birthdate: date | None = None,
tk_status: str | None = None,
):
token = await self.get_token()
req = await self.get(
'/getQueue',
headers={'Authorization': f'Bearer {token}'},
params=clean_params(
{
'code_mo': code_mo,
'doctor_spec': doctor_spec,
'doctor_snils': doctor_snils,
'doctor_snils_strict': doctor_snils_strict,
'date_begin': date_begin,
'date_end': date_end,
'patient_snils': patient_snils,
'patient_fio': patient_fio,
'patient_policy': patient_policy,
'patient_phone': patient_phone,
'patient_birthdate': patient_birthdate,
'tk_status': tk_status,
}
),
)
match req.status_code:
case st.HTTP_200_OK:
return [s.QueueModel.model_validate(i) for i in req.json()]
case _:
self.logger.error(req.json())
raise e.UnknownException
async def getInfo(self, guid: str):
token = await self.get_token()
req = await self.get(
'/getTMKInfo',
headers={'Authorization': f'Bearer {token}'},
params={'guid': guid},
)
match req.status_code:
case st.HTTP_200_OK:
return s.QueueModel.model_validate(req.json())
case _:
self.logger.error(req.json())
raise e.UnknownException

31
src/clients/tmk/schema.py Normal file
View File

@ -0,0 +1,31 @@
from datetime import datetime
from pydantic import BaseModel
class AccessTokenModel(BaseModel):
access_token: str
class QueueModel(BaseModel):
id: int
guid: str
created_at: datetime
code_mo: int | None
mo_name: str | None
doctor_spec: str
doctor_snils: str | None
doctor_fio: str | None
patient_name: str | None
patient_birthday: datetime | None
patient_snils: str
patient_policy: str | None
patient_phone: str | None
patient_email: str | None
tmk_status: int
tmk_status_name: str
tmk_cancel_reason: int | None
tmk_cancel_reason_name: str | None
vks_doctor_link: str | None
vks_patient_link: str | None
doctor_spec_name: str | None

View File

@ -1,10 +1,13 @@
from datetime import UTC, datetime
from logging import getLogger
from fastapi import status as st
from httpx import AsyncClient
from httpx import AsyncClient, BasicAuth
from orjson import dumps, loads
from core.config import settings
from shared import exceptions as e
from shared.redis import client as cache
from . import schema as s
@ -14,150 +17,435 @@ class VITACORE_API(AsyncClient):
self.logger = getLogger(__name__)
super().__init__(base_url=settings.VITACORE_BASE_URL)
async def findBySnils(self, snils: str):
return
async def get_token(self):
token = await self.get_cache('vitacore_token')
async def getProfile(self, patId: str):
req = await self.get('/getProfile', params={'patId': patId})
if token is None:
token = await self.login()
await self.set_cache(
'vitacore_token', dumps({'token': token}).decode(), 10800
)
return token
return token['token']
async def get_cache(self, key: str):
data = await cache.get(key)
if data:
return loads(data.decode())
return None
async def set_cache(self, key: str, value: str, ttl: int = 600):
await cache.set(key, value, ttl)
async def login(self):
req = await self.post(
'/auth',
auth=BasicAuth(
settings.VITACORE_USERNAME, settings.VITACORE_PASSWORD
),
)
match req.status_code:
case st.HTTP_200_OK:
return s.ProfileModel.model_validate(req.json())
return req.text
case _:
self.logger.error(req.text)
raise e.UnknownException
async def findBySnils(self, snils: str):
data = await self.get_cache(f'vitacore_findBySnils2:{snils}')
if data:
return s.PatientModel.model_validate(data)
token = await self.get_token()
req = await self.get(
'/findBySnils',
params={'snils': snils},
headers={'Authorization': f'Bearer {token}'},
)
match req.status_code:
case st.HTTP_200_OK:
model = s.PatientModel.model_validate(req.json())
await self.set_cache(
f'vitacore_findBySnils2:{snils}',
model.model_dump_json(),
14400,
)
return model
case _:
self.logger.error(req.json())
raise e.UnknownException
async def getProfile(self, patId: str):
data = await self.get_cache(f'vitacore_getProfile:{patId}')
if data:
return s.ProfileModel.model_validate(data)
token = await self.get_token()
req = await self.get(
'/getProfile',
params={'patId': patId},
headers={'Authorization': f'Bearer {token}'},
)
match req.status_code:
case st.HTTP_200_OK:
model = s.ProfileModel.model_validate(req.json())
await self.set_cache(
f'vitacore_getProfile:{patId}',
model.model_dump_json(),
14400,
)
return model
case _:
self.logger.error(req.json())
raise e.UnknownException
async def getDepartments(self):
req = await self.get('/getDepartments')
data = await self.get_cache('vitacore_getDepartments')
match req.status_code:
case st.HTTP_200_OK:
return [
s.DepartmentModel.model_validate(i) for i in req.json()
]
case _:
self.logger.error(req.json())
raise e.UnknownException
if data:
return s.OrganizationsModel.model_validate(data)
async def getWorkers(self, departmentId: str):
token = await self.get_token()
req = await self.get(
'/getWorkers', params={'departmentId': departmentId}
'/getDepartments', headers={'Authorization': f'Bearer {token}'}
)
match req.status_code:
case st.HTTP_200_OK:
return s.WorkersModel.model_validate(req.json())
model = s.OrganizationsModel.model_validate(req.json())
await self.set_cache(
'vitacore_getDepartments',
model.model_dump_json(),
14400,
)
return model
case _:
self.logger.error(req.text)
raise e.UnknownException
async def getWorkers(self, departmentId: str):
data = await self.get_cache(f'vitacore_getWorkers:{departmentId}')
if data:
return s.WorkersModel.model_validate(data)
token = await self.get_token()
req = await self.get(
'/getWorkers',
params={'departmentId': departmentId},
headers={'Authorization': f'Bearer {token}'},
)
match req.status_code:
case st.HTTP_200_OK:
data = s.WorkersModel.model_validate(req.json())
await self.set_cache(
f'vitacore_getWorkers:{departmentId}',
data.model_dump_json(),
14400,
)
return data
case st.HTTP_206_PARTIAL_CONTENT:
return s.WorkersModel(Workers=[])
case _:
self.logger.error(req.json())
raise e.UnknownException
async def getSpecsV021(self):
req = await self.get('/getSpecsV021')
data = await self.get_cache('vitacore_getSpecsV021')
if data:
return s.SpecsV021Model.model_validate(data)
token = await self.get_token()
req = await self.get(
'/getSpecsV021', headers={'Authorization': f'Bearer {token}'}
)
match req.status_code:
case st.HTTP_200_OK:
return s.SpecsV021Model.model_validate(req.json())
data = s.SpecsV021Model.model_validate(req.json())
await self.set_cache(
'vitacore_getSpecsV021',
data.model_dump_json(),
14400,
)
return data
case _:
self.logger.error(req.json())
raise e.UnknownException
async def getEntries(self, patId: str):
req = await self.get('/getEntries', params={'patId': patId})
token = await self.get_token()
if patId == '9a4d4b06-5928-4101-b95e-e5ba03a1abfd':
patId = 'b66a85f1-4aaa-4db8-942a-2de44341824e'
req = await self.get(
'/getEntries',
params={'patId': patId},
headers={'Authorization': f'Bearer {token}'},
)
match req.status_code:
case st.HTTP_200_OK:
return s.EntriesModel.model_validate(req.json())
case st.HTTP_206_PARTIAL_CONTENT:
error = s.ErrorModel.model_validate(req.json())
if error.error == 'Не найдены записи по указанному patId':
return s.EntriesModel(Entries=[])
case _:
self.logger.error(req.json())
raise e.UnknownException
async def getVaccsReport(self, patId: str):
req = await self.get('/getVaccsReport', params={'patId': patId})
token = await self.get_token()
if patId == '9a4d4b06-5928-4101-b95e-e5ba03a1abfd':
patId = 'b66a85f1-4aaa-4db8-942a-2de44341824e'
req = await self.get(
'/getVaccsReport',
params={'patId': patId},
headers={'Authorization': f'Bearer {token}'},
)
match req.status_code:
case st.HTTP_200_OK:
return s.VaccsReportModel.model_validate(req.json())
case st.HTTP_206_PARTIAL_CONTENT:
error = s.ErrorModel.model_validate(req.json())
if (
error.error == 'Не найдены записи по указанному patId'
or error.error
== 'Не найдены вакцинации по данному пациенту'
):
return s.VaccsReportModel(content='')
case _:
self.logger.error(req.json())
raise e.UnknownException
async def getMedExamDict(self):
req = await self.get('/getMedExamDict')
data = await self.get_cache('vitacore_getMedExamDict')
if data:
return s.MedExamDictModel.model_validate(data)
token = await self.get_token()
req = await self.get(
'/getMedExamDict', headers={'Authorization': f'Bearer {token}'}
)
match req.status_code:
case st.HTTP_200_OK:
return s.MedExamDictModel.model_validate(req.json())
data = s.MedExamDictModel.model_validate(req.json())
await self.set_cache(
'vitacore_getMedExamDict',
data.model_dump_json(),
14400,
)
return data
case _:
self.logger.error(req.json())
raise e.UnknownException
async def getRoutesList(self, patId: str):
req = await self.get('/getRoutesList', params={'patId': patId})
token = await self.get_token()
if patId == '9a4d4b06-5928-4101-b95e-e5ba03a1abfd':
patId = 'b66a85f1-4aaa-4db8-942a-2de44341824e'
req = await self.get(
'/getRoutesList',
params={'patId': patId},
headers={'Authorization': f'Bearer {token}'},
)
match req.status_code:
case st.HTTP_200_OK:
return s.RoutesListModel.model_validate(req.json())
case st.HTTP_206_PARTIAL_CONTENT:
error = s.ErrorModel.model_validate(req.json())
if error.error == 'Не найдены случаи по указанному patId':
return s.RoutesListModel(
EventID='none',
EventDate=datetime.now(UTC),
LpuName='',
Routes=[],
)
case _:
self.logger.error(req.json())
raise e.UnknownException
async def getHospExaminations(self, patId: str, examId: str):
async def getHospExaminations(self, patId: str, examId: str | None = None):
if patId == '9a4d4b06-5928-4101-b95e-e5ba03a1abfd':
patId = 'b66a85f1-4aaa-4db8-942a-2de44341824e'
token = await self.get_token()
req = await self.get(
'/getHospExaminations',
params={'patId': patId, 'examId': examId},
headers={'Authorization': f'Bearer {token}'},
)
match req.status_code:
case st.HTTP_200_OK:
return s.HospExaminationsModel.model_validate(req.json())
case st.HTTP_206_PARTIAL_CONTENT:
error = s.ErrorModel.model_validate(req.json())
if error.error == 'Пациент не госпитализирован!':
return s.HospExaminationsModel(
EventID='none',
EventDate=datetime.now(UTC),
LpuName='',
Examinations=[],
)
case _:
self.logger.error(req.json())
raise e.UnknownException
async def getCurrHosp(self, patId: str):
req = await self.get('/getCurrHosp', params={'patId': patId})
token = await self.get_token()
if patId == '9a4d4b06-5928-4101-b95e-e5ba03a1abfd':
patId = 'b66a85f1-4aaa-4db8-942a-2de44341824e'
req = await self.get(
'/getCurrHosp',
params={'patId': patId},
headers={'Authorization': f'Bearer {token}'},
)
match req.status_code:
case st.HTTP_200_OK:
return s.HospitalizationsModel.model_validate(req.json())
return s.CurHospitalizationsModel.model_validate(req.json())
case st.HTTP_206_PARTIAL_CONTENT:
return s.CurHospitalizationsModel(
Hospitalizations=[],
)
case _:
self.logger.error(req.json())
raise e.UnknownException
async def getHosps(self, patId: str):
req = await self.get('/getHosps', params={'patId': patId})
token = await self.get_token()
if patId == '9a4d4b06-5928-4101-b95e-e5ba03a1abfd':
patId = 'b66a85f1-4aaa-4db8-942a-2de44341824e'
req = await self.get(
'/getHosps',
params={'patId': patId},
headers={'Authorization': f'Bearer {token}'},
)
match req.status_code:
case st.HTTP_200_OK:
return s.HospitalizationsModel.model_validate(req.json())
case st.HTTP_206_PARTIAL_CONTENT:
error = s.ErrorModel.model_validate(req.json())
if (
error.error
== 'Не найдены госпитализации по указанному patId'
):
return s.HospitalizationsModel(
Hospitalizations=[],
)
case _:
self.logger.error(req.json())
raise e.UnknownException
async def getHospRecommendations(self, patId: str):
token = await self.get_token()
if patId == '9a4d4b06-5928-4101-b95e-e5ba03a1abfd':
patId = 'b66a85f1-4aaa-4db8-942a-2de44341824e'
req = await self.get(
'/getHospRecommendations', params={'patId': patId}
'/getHospRecommendations',
params={'patId': patId},
headers={'Authorization': f'Bearer {token}'},
)
match req.status_code:
case st.HTTP_200_OK:
return s.HospRecommendationsModel.model_validate(req.json())
case st.HTTP_206_PARTIAL_CONTENT:
error = s.ErrorModel.model_validate(req.json())
if (
error.error
== 'Не найдены госпитализации по указанному patId'
):
return s.HospRecommendationsModel(
EventID='none',
EventDate=datetime.now(UTC),
Recommendations=[],
)
case _:
self.logger.error(req.json())
raise e.UnknownException
async def getHospRoutes(self, patId: str):
req = await self.get('/getHospRoutes', params={'patId': patId})
token = await self.get_token()
if patId == '9a4d4b06-5928-4101-b95e-e5ba03a1abfd':
patId = 'b66a85f1-4aaa-4db8-942a-2de44341824e'
req = await self.get(
'/getHospRoutes',
params={'patId': patId},
headers={'Authorization': f'Bearer {token}'},
)
match req.status_code:
case st.HTTP_200_OK:
return s.HospRoutesModel.model_validate(req.json())
case st.HTTP_206_PARTIAL_CONTENT:
error = s.ErrorModel.model_validate(req.json())
if error.error == 'Пациент не госпитализирован!':
return s.HospRoutesModel(
EventID='none',
EventDate=datetime.now(UTC),
RoutesToDoctor=[],
RoutesToDiagnostic=[],
)
case _:
self.logger.error(req.json())
raise e.UnknownException
async def getDiagnosticResults(self, patId: str):
req = await self.get('/getDiagnosticResults', params={'patId': patId})
token = await self.get_token()
req = await self.get(
'/getDiagnosticResults',
params={'patId': patId},
headers={'Authorization': f'Bearer {token}'},
)
match req.status_code:
case st.HTTP_200_OK:
@ -167,17 +455,34 @@ class VITACORE_API(AsyncClient):
raise e.UnknownException
async def getELNs(self, patId: str):
req = await self.get('/getELNs', params={'patId': patId})
token = await self.get_token()
req = await self.get(
'/getELNs',
params={'patId': patId},
headers={'Authorization': f'Bearer {token}'},
)
match req.status_code:
case st.HTTP_200_OK:
return s.ELNsModel.model_validate(req.json())
case st.HTTP_206_PARTIAL_CONTENT:
error = s.ErrorModel.model_validate(req.json())
if error.error == 'Пациент не госпитализирован!':
return s.ELNsModel(PatientELNs=[])
case _:
self.logger.error(req.json())
raise e.UnknownException
async def getPatFLG(self, patId: str):
req = await self.get('/getPatFLG', params={'patId': patId})
token = await self.get_token()
req = await self.get(
'/getPatFLG',
params={'patId': patId},
headers={'Authorization': f'Bearer {token}'},
)
match req.status_code:
case st.HTTP_200_OK:
@ -185,3 +490,28 @@ class VITACORE_API(AsyncClient):
case _:
self.logger.error(req.json())
raise e.UnknownException
async def getDiagResultFile(self, resultId: str):
token = await self.get_token()
req = await self.get(
'/getDiagResultFile',
params={'resultId': resultId},
headers={'Authorization': f'Bearer {token}'},
)
match req.status_code:
case st.HTTP_200_OK:
return s.DiagResultFileModel.model_validate(req.json())
case st.HTTP_206_PARTIAL_CONTENT:
error = s.ErrorModel.model_validate(req.json())
if (
error.error == 'Не найдены проведенные исследования по '
'данному идентификатору'
):
return s.DiagResultFileModel(content='')
case _:
self.logger.error(req.json())
raise e.UnknownException

View File

@ -1,6 +1,28 @@
from datetime import datetime
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field, field_validator
class ErrorModel(BaseModel):
model_config = ConfigDict(
validate_by_alias=True,
validate_by_name=True,
)
error: str = Field(title='Текст ошибки', alias='message')
class PatientModel(BaseModel):
patId: str = Field(
title='Идентификатор пациента',
examples=['b62e9f22-a871-4c52-96d6-559c707a716d'],
)
SNILS: str = Field(title='СНИЛС', examples=['000-000-600 18'])
lastName: str = Field(title='Фамилия', examples=['Тестовый'])
firstName: str = Field(title='Имя', examples=['Пациент'])
middleName: str = Field(title='Отчество', examples=['Пациентович'])
birthDate: datetime = Field(title='Дата рождения', examples=['2024-10-16'])
gender: str = Field(title='Пол', examples=['М'])
class TrustedPersonModel(BaseModel):
@ -43,26 +65,38 @@ class ProfileModel(BaseModel):
)
docSer: str = Field(title='Серия документа', examples=['III-КБ'])
docNum: str = Field(title='Номер документа', examples=['999999'])
# ENP: str = Field(
# title='Единый номер полиса ОМС (16-ти значный)', # noqa: RUF003
# examples=['?'],
# )
ENP: str | None = Field(
title='Единый номер полиса ОМС (16-ти значный)',
examples=['?'],
default=None,
)
phones: str | None = Field(
title='Номера телефонов',
examples=['+7 (999) 112-33-21 (моб.)'],
default=None,
)
addressReal: str = Field(
title='Адрес проживания',
examples=[
'420000, Татарстан Респ, г.Казань, ул.Магистральная (Большие Клыки), д.1, кв.1' # noqa: E501
],
)
# attachBranchId: str = Field(
# title='Идентификатор СТП прикреплния',
# examples=['string'],
# )
# attachState: str = Field(
# title='Номер участка',
# examples=['99'],
# )
attachBranchId: str = Field(
title='Идентификатор СТП прикреплния',
examples=['string'],
)
attachState: str = Field(
title='Номер участка',
examples=['99'],
)
trustedPersons: list[TrustedPersonModel] = Field(
title='Информация о представителе',
title='Информация о представителе', default=[]
)
dispFlag: bool | None = Field(
title='Флаг диспансеризации', examples=['false'], default=None
)
dispDate: datetime | None = Field(
title='Дата диспансеризации', default=None
)
@ -75,10 +109,10 @@ class DepartmentAddressModel(BaseModel):
title='Адрес строкой',
examples=['420097, г.Казань, ул.Заслонова, д.5'],
)
latitude: float | None = Field(
latitude: str | None = Field(
title='Широта, при наличии', examples=[55.789], default=None
)
longitude: float | None = Field(
longitude: str | None = Field(
title='Долгота, при наличии', examples=[37.789], default=None
)
@ -88,20 +122,19 @@ class DepartmentModel(BaseModel):
title='Идентификатор МО/Филиала',
examples=['a3677271-3385-4f27-a65d-c3430b7c61c2'],
)
OID: str = Field(
title='OID МО / Филиала', examples=['1.2.643.5.1.13.13.12.2.16.1084']
OID: str | None = Field(
title='OID МО / Филиала',
examples=['1.2.643.5.1.13.13.12.2.16.1084'],
default=None,
)
parentId: str | None = Field(
title='Идентификатор вышестоящего подразделения',
examples=['a3677271-3385-4f27-a65d-c3430b7c61c2'],
)
code: str = Field(
title='Региональный код или код ТФОМС',
examples=['0000000000'],
)
fullname: str = Field(
fullname: str | None = Field(
title='Полное наименование',
examples=['ГБУЗС "Тестовая медицинская организация"'],
default=None,
)
shortname: str = Field(
title='Краткое наименование',
@ -112,10 +145,20 @@ class DepartmentModel(BaseModel):
', для филиалов: Стационар / Поликлиника / ФАП / Амбулатория)',
examples=['Юридическое лицо'],
)
inn: str = Field(title='ИНН', examples=['0000000000'])
kpp: str = Field(title='КПП', examples=['0000000000'])
ogrn: str = Field(title='ОГРН', examples=['1149204047816'])
address: list[DepartmentAddressModel]
phone: str | None = Field(
title='Номер телефона',
default=None,
)
inn: str | None = Field(title='ИНН', examples=['0000000000'], default=None)
ogrn: str | None = Field(
title='ОГРН', examples=['1149204047816'], default=None
)
kpp: str | None = Field(title='КПП', examples=['0000000000'], default=None)
address: list[DepartmentAddressModel] | None = None
class OrganizationsModel(BaseModel):
Organizations: list[DepartmentModel]
class WorkersPositionModel(BaseModel):
@ -146,21 +189,30 @@ class WorkersPositionModel(BaseModel):
title='ФРМР. Должности медицинского персонала',
examples=['врач-онколог'],
)
positionSpecialityCode: str = Field(
positionSpecialityCode: str | None = Field(
title='Код специальности по V021',
examples=['41'],
default=None,
)
positionSpecialityName: str = Field(
positionSpecialityName: str | None = Field(
title='Наименование специальности по V021',
examples=['Онкология'],
default=None,
)
rate: float = Field(
title='Ставка',
examples=[0.5],
)
@field_validator('rate', mode='before')
def convert_comma(cls, v: str | float):
if isinstance(v, str):
return v.replace(',', '.')
class WorkersModel(BaseModel):
return v
class WorkerModel(BaseModel):
id: str = Field(
title='Идентификатор сотрудника',
examples=['dc911302-5044-46f4-b935-c6ffd85eb68f'],
@ -171,9 +223,17 @@ class WorkersModel(BaseModel):
)
firstName: str = Field(title='Имя', examples=['Владимир'])
lastName: str = Field(title='Фамилия', examples=['Камашев'])
middleName: str = Field(title='Отчество', examples=['Михайлович'])
birthDate: datetime = Field(title='Дата рождения', examples=['30.05.1961'])
positions: list[WorkersPositionModel]
middleName: str | None = Field(
title='Отчество', examples=['Михайлович'], default=None
)
birthDate: datetime | None = Field(
title='Дата рождения', examples=['30.05.1961'], default=None
)
positions: list[WorkersPositionModel] = []
class WorkersModel(BaseModel):
Workers: list[WorkerModel]
class SpecialityV021Model(BaseModel):
@ -258,9 +318,10 @@ class MedExamItemModel(BaseModel):
title='Наименрование услуги',
examples=['Осмотр фельдшером (акушеркой) или врачом акушером'],
)
SpecialityName: str = Field(
SpecialityName: str | None = Field(
title='Специальность',
examples=['Акушер-гинеколог'],
default=None,
)
@ -282,13 +343,15 @@ class MedExamDictModel(BaseModel):
class ResultModel(BaseModel):
Resource: str = Field(title='Врач', examples=['Изотова Г.М.'])
ExaminationId: str = Field(
ExaminationId: str | None = Field(
title='Идентификатор осмотра',
examples=['4d8550e6-7ad8-46f7-b874-9b20c673d168'],
default=None,
)
DiagResultId: str = Field(
DiagResultId: str | None = Field(
title='Идентификатор результата исследования',
examples=['77312a5f-0498-4656-8215-9cf151ec2a9b'],
default=None,
)
@ -350,9 +413,7 @@ class ExaminationModel(BaseModel):
DateTime: str = Field(
title='Дата и время создания', examples=['01.08.2025 15:47:15']
)
Resource: str = Field(
title='Врач', examples=['Абдуллина Ирина Владимировна']
)
Post: str = Field(title='Врач', examples=['Абдуллина Ирина Владимировна'])
Speciality: str = Field(
title='Специальность врача', examples=['Акушер-гинеколог']
)
@ -363,11 +424,12 @@ class ExaminationModel(BaseModel):
'style="BORDER-TOP: #ffffff 1px..... </TABLE>'
],
)
Recommendation: str = Field(
Recommendation: str | None = Field(
title='Идентификатор результата исследования',
examples=['рекомендации 1 тест'],
default=None,
)
SEMDs: list[SEMDModel] = Field(title='Список СЭМД')
SEMDs: list[SEMDModel] | None = Field(title='Список СЭМД', default=None)
class HospExaminationsModel(BaseModel):
@ -379,13 +441,13 @@ class HospExaminationsModel(BaseModel):
title='Дата обращения', examples=['2025-08-01']
)
LpuName: str = Field(title='Наименование МО', examples=['ГАУЗ "ГКБ №7"'])
Examinations: list[ExaminationModel] = Field(title='Список осмотров')
Examinations: list[ExaminationModel] | None = Field(
title='Список осмотров', default=None
)
class RouteToDoctorModel(BaseModel):
RouteDate: datetime = Field(
title='Дата направления', examples=['2025-07-17']
)
RouteDate: str = Field(title='Дата направления', examples=['2025-07-17'])
SpecialityCode: str = Field(title='Код специальности', examples=['013'])
SpecialityName: str = Field(
title='Наименование специальности', examples=['Кардиолог']
@ -393,9 +455,7 @@ class RouteToDoctorModel(BaseModel):
class RouteToDiagnosticModel(BaseModel):
RouteDate: datetime = Field(
title='Дата направления', examples=['2025-08-06']
)
RouteDate: str = Field(title='Дата направления', examples=['2025-08-06'])
ResearchCode: str = Field(title='Код услуги', examples=['B03.016.003'])
ResearchName: str = Field(
title='Наименование услуги',
@ -417,9 +477,7 @@ class HospDestinationModel(BaseModel):
class ExamModel(BaseModel):
ExaminationDate: datetime = Field(
title='Дата осмотра', examples=['2025-08-04']
)
ExaminationDate: str = Field(title='Дата осмотра', examples=['2025-08-04'])
ExaminationTime: str = Field(title='Время осмотра', examples=['09:21'])
Status: str = Field(title='Состояние при осмотре', examples=[''])
Post: str = Field(title='Врач', examples=['Шайдуллина Г.И. (Кардиолог)'])
@ -438,7 +496,7 @@ class ExamModel(BaseModel):
)
class HospitalizationModel(BaseModel):
class BaseHospitalizationModel(BaseModel):
EventID: str = Field(
title='Идентификатор случая госпитализации',
examples=['ddfa23ea-b0de-4d88-8abe-7d6a7a241df1'],
@ -446,14 +504,28 @@ class HospitalizationModel(BaseModel):
CreationDateTime: datetime = Field(
title='Дата и время регистрации', examples=['2025-07-10 17:29']
)
ReceptionDiagnosis: str = Field(
ReceptionDiagnosis: str | None = Field(
title='Диагноз при поступлении',
examples=[
'I11.9 | Гипертензивная [гипертоническая] болезнь с '
'преимущественным поражением сердца без (застойной) '
'сердечной недостаточности'
],
default=None,
)
HospitalizationType: str | None = Field(
title='Тип госпитализации', examples=['экстренная'], default=None
)
HospitalizationReason: str | None = Field(
title='Причина госпитализации', examples=['заболевание'], default=None
)
Division: str | None = Field(
title='Отделение', examples=['Кардиология №1'], default=None
)
LpuName: str = Field(title='Наименование МО', examples=['ГАУЗ "ГКБ №7"'])
class HospitalizationModel(BaseHospitalizationModel):
Diagnosis: str = Field(
title='Текущий диагноз',
examples=[
@ -462,31 +534,31 @@ class HospitalizationModel(BaseModel):
'сердечной недостаточности'
],
)
HospitalizationType: str = Field(
title='Тип госпитализации', examples=['экстренная']
)
HospitalizationReason: str = Field(
title='Причина госпитализации', examples=['заболевание']
)
Division: str = Field(title='Отделение', examples=['Кардиология №1'])
LpuName: str = Field(title='Наименование МО', examples=['ГАУЗ "ГКБ №7"'])
Exams: list[ExamModel] = Field(title='Список осмотров')
class HospitalizationsModel(BaseModel):
class CurHospitalizationsModel(BaseModel):
Hospitalizations: list[HospitalizationModel] = Field(
title='Список госпитализаций'
)
class HospitalizationsModel(BaseModel):
Hospitalizations: list[BaseHospitalizationModel] = Field(
title='Список госпитализаций'
)
class RecommendationModel(BaseModel):
Type: str = Field(title='Тип осмотра', examples=['Осмотр', 'Эпикриз'])
DateTime: datetime = Field(
title='Дата и время создания', examples=['18.07.2025 8:30:43']
DateTime: str = Field(
title='Дата и время создания',
examples=['18.07.2025 8:30:43'],
)
Recommendation: str = Field(
title='Текст рекомендации', examples=['рекомендации тест']
)
Post: str = Field(title='Врач', examples=['Тестовый В.С.'])
class HospRecommendationsModel(BaseModel):
@ -523,7 +595,7 @@ class DiagnosticResultModel(BaseModel):
title='Идентификатор результата исследования',
examples=['1407910a-1901-4b21-be2d-0ef89041f4fe'],
)
ContainsFile: str = Field(
ContainsFile: int = Field(
title='Признак наличия файла исследования (из ЛИС)', examples=['0']
)
PostingDate: datetime = Field(
@ -568,16 +640,20 @@ class ProlongationModel(BaseModel):
class PatientELNModel(BaseModel):
PatientSNILS: str = Field(
PatientSNILS: str | None = Field(
title='СНИЛС болеющего', examples=['000-000-600 01']
)
Number: str = Field(
Number: str | None = Field(
title='Номер листа нетрудоспособности', examples=['910040906829']
)
OpenDate: datetime = Field(title='Дата открытия', examples=['2020-10-21'])
DateClose: datetime = Field(title='Дата закрытия', examples=['2020-10-25'])
WorkDate: datetime = Field(
title='Дата выхода на работу', examples=['2020-10-26']
OpenDate: datetime | None = Field(
title='Дата открытия', examples=['2020-10-21'], default=None
)
DateClose: datetime | None = Field(
title='Дата закрытия', examples=['2020-10-25'], default=None
)
WorkDate: datetime | None = Field(
title='Дата выхода на работу', examples=['2020-10-26'], default=None
)
DaysCount: int = Field(
title='Количество дней нетрудоспособности', examples=[5]
@ -589,11 +665,13 @@ class PatientELNModel(BaseModel):
'обострение)'
],
)
Prolongations: list[ProlongationModel] = Field(title='Продления')
LpuName: str = Field(
Prolongations: list[ProlongationModel] | None = Field(
title='Продления', default=None
)
LpuName: str | None = Field(
title='Наименование МО', examples=['ГАУЗ Новошешминская ЦРБ']
)
BranchAddress: str = Field(
BranchAddress: str | None = Field(
title='Адрес подразделения выдачи',
examples=['Республика Татарстан,с.Новошешминск, ул.Майская, д.8'],
)
@ -624,7 +702,16 @@ class PatientFLGModel(BaseModel):
title='Дата следующего флюорографического осмотра',
examples=['2021-09-24'],
)
PrgContingent: str = Field(
PrgContingent: str | None = Field(
title='Контингент (флюорография)',
examples=['Неорганизованное население'],
)
PrgDecision: str | None = Field(
title='Решение (флюорография)',
examples=['Требует дообследования'],
default=None,
)
class DiagResultFileModel(BaseModel):
content: str

View File

@ -41,20 +41,36 @@ class Settings(BaseSettings):
# 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='')
ESIA_PROXY: str | None = Field(default=None)
# Vitacore
VITACORE_BASE_URL: str = Field(
default='https://gist-cws.ezdrav.ru:8899/MP_API'
)
VITACORE_USERNAME: str = Field(default='')
VITACORE_PASSWORD: str = Field(default='')
# AEMD
AEMD_BASE_URL: str = Field(
default='http://gist-sdw.ezdrav.ru:8708/EMDAService'
)
AEMD_TOKEN: str = Field(default='')
# TDN
TDN_BASE_URL: str = Field(default='https://tdn.tatar.ru/api')
TDN_LOGIN: str = Field(default='')
TDN_PASSWORD: str = Field(default='')
# TMK
TMK_BASE_URL: str = Field(default='https://tmk-api.tatar.ru/api')
TMK_LOGIN: str = Field(default='admin')
TMK_PASSWORD: str = Field(default='12345')
@model_validator(mode='after')
def celery_env(self):

View File

@ -26,9 +26,21 @@ class Config:
def __init__(self):
self.version = 1
self.disable_existing_loggers = False
self.formatters = self._get_formatters()
self.handlers = self._get_handlers()
self.loggers = self._get_loggers()
@staticmethod
def _get_formatters() -> dict[str, Any]:
# Common formatter that includes logger name
fmt = '%(asctime)s | %(levelname)-8s | %(message)s'
return {
'default': {
'format': fmt,
'datefmt': '%Y-%m-%d %H:%M:%S',
}
}
@staticmethod
def _get_handlers():
handlers: dict[str, Any] = {
@ -36,6 +48,7 @@ class Config:
'class': 'logging.StreamHandler',
'level': logging.INFO,
'stream': 'ext://sys.stderr',
'formatter': 'default',
}
}
@ -53,12 +66,16 @@ class Config:
},
}
loggers['fontTools'] = {'level': logging.CRITICAL, 'propagate': False}
loggers['weasyprint'] = {'level': logging.CRITICAL, 'propagate': False}
return loggers
def render(self):
return {
'version': self.version,
'disable_existing_loggers': self.disable_existing_loggers,
'formatters': self.formatters,
'handlers': self.handlers,
'loggers': self.loggers,
}

View File

@ -3,7 +3,7 @@ from logging import getLogger
from fastapi import FastAPI
from fastapi.responses import ORJSONResponse
# from database import lifespan
from database import lifespan
from middlewares import register_middlewares
from .config import settings
@ -19,7 +19,7 @@ app = FastAPI(
version=str(settings.VERSION),
openapi_url=None,
default_response_class=ORJSONResponse,
# lifespan=lifespan,
lifespan=lifespan,
docs_url=None,
redoc_url=None,
)

View File

@ -1,7 +1,10 @@
from fastapi import APIRouter, HTTPException
from apps.esia.v1.router import router as esia_router
from apps.remd.v1.router import router as remd_router
from apps.tmk.v1.router import router as tmk_router
from apps.users.v1.router import router as users_router
from apps.vitacore.v1.router import router as vitacore_router
from . import get_openapi_schema, get_swagger_html
@ -9,6 +12,9 @@ router = APIRouter(prefix='/v1')
router.include_router(esia_router)
router.include_router(users_router)
router.include_router(remd_router)
router.include_router(vitacore_router)
router.include_router(tmk_router)
openapi_schema = get_openapi_schema(router)
swagger_ui_html = get_swagger_html(router)

View File

@ -4,6 +4,7 @@ from alembic import context
from sqlalchemy import engine_from_config, pool
from sqlmodel import SQLModel
from apps.users.models import * # noqa: F403
from core.log import config as log_config
from database import db_manager

View File

@ -1,28 +1,32 @@
"""${message}
"""
${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
import sqlmodel.sql.sqltypes
from alembic import op
# 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)}
down_revision: str | None = ${repr(down_revision)}
branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
depends_on: str | Sequence[str] | None = ${repr(depends_on)}
if not sqlmodel.sql:
msg = 'Something went wrong'
raise Exception(msg)
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}

View File

@ -0,0 +1,46 @@
"""
empty message
Revision ID: a74bcd05c7b8
Revises:
Create Date: 2025-10-09 17:42:25.347412
"""
from collections.abc import Sequence
import sqlalchemy as sa
import sqlmodel.sql.sqltypes
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'a74bcd05c7b8'
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
if not sqlmodel.sql:
msg = 'Something went wrong'
raise Exception(msg)
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
'users',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column(
'vita_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False
),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id'),
sa.UniqueConstraint('vita_id'),
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('users')
# ### end Alembic commands ###

View File

5
src/shared/functions.py Normal file
View File

@ -0,0 +1,5 @@
from typing import Any
def clean_params(data: dict[Any, Any]):
return {k: v for k, v in data.items() if v is not None}

View File

@ -1,5 +1,45 @@
from redis import Redis
from collections.abc import Callable, Coroutine
from functools import wraps
from typing import Any, TypeVar
from fastapi.responses import ORJSONResponse
from orjson import dumps, loads
from redis.asyncio import Redis
from apps.users.models import User
from core.config import settings
client = Redis.from_url(settings.REDIS_URL)
T = TypeVar('T')
def cache_response(ttl: int = 60, namespace: str = 'main'):
def decorator(
func: Callable[..., Coroutine[Any, Any, T]],
) -> Callable[..., Coroutine[Any, Any, T | ORJSONResponse]]:
@wraps(func)
async def wrapper(*args: Any, **kwargs: Any):
cache_key = f'endpoints:{namespace}_{func.__name__}'
user = kwargs.get('user')
user_id = kwargs.get('user_id')
if user_id:
cache_key += f':{user_id}'
if isinstance(user, User):
cache_key += f':{user.id}'
cached_value = await client.get(cache_key)
if cached_value:
return ORJSONResponse(
content=loads(cached_value), headers={'X-Cache': 'hit'}
)
response = await func(*args, **kwargs)
await client.set(cache_key, dumps(response), ex=ttl)
return response
return wrapper
return decorator