This commit is contained in:
2026-02-15 17:02:40 +03:00
parent f79e4bcbb5
commit a042942446
78 changed files with 9863 additions and 0 deletions
BIN
View File
Binary file not shown.
+71
View File
@@ -0,0 +1,71 @@
import jwt
import time
from utils import logger, requestGET
def generate_token(identity: str, secret: str):
iat = int(time.time())
exp = iat + 60 # <= 64 сек
payload = {"iss": identity, "iat": iat, "exp": exp}
token = jwt.encode(payload, secret, algorithm="HS512")
return token
async def checkConnection(host: str, port: int, bearerToken: str):
headers = {"Authorization": f"Bearer {bearerToken}"}
params = {
"limit": 1,
"offset": 0,
}
return await requestGET(
f"http://{host}:{port}/api/v2/clinics",
headers=headers,
params=params,
verify_ssl=False,
)
async def getAllEmployees(host: str, port: int, identity: str, secret: str):
bearerToken = generate_token(identity, secret)
headers = {"Authorization": f"Bearer {bearerToken}"}
limit = 100
offset = 0
employees: list[dict] = []
while True:
params = {
"limit": limit,
"offset": offset,
}
response = await requestGET(
f"http://{host}:{port}/api/v2/users",
headers=headers,
params=params,
verify_ssl=False,
)
# 🔴 Ошибка запроса
if not response.success:
logger.error(response.message)
return employees
payload = response.data or {}
batch = payload.get("data", [])
total_items = payload.get("totalItems", 0)
employees.extend(batch)
# 🛑 если получили всё — выходим
if offset + limit >= total_items:
break
offset += limit
return employees
+198
View File
@@ -0,0 +1,198 @@
import config
from db.schemas import Settings
from utils import (
logger,
requestGET,
requestPOSTMultipart,
answer,
detect_and_save_attachment,
requestPOST,
)
from pathlib import Path
def getVerifySSL(url: str):
return "demo" not in url.lower()
# TODO Проработать лимит в 50 запросов за 15 минут
async def sendForSigning(inData: dict):
settings = await Settings.getSettings(False)
if not settings.success:
logger.error("Настройки не инициализированы")
return answer(success=False, message="Настройки не инициализированы")
settings = settings.data
meta = {
"organization": {
"idLpu": settings.n3healthXIdLpu,
},
"practitioner": inData.get("practitioner", {}),
"patients": inData.get("patients", {}),
"medDocument": inData.get("medDocument", {}),
"delivery": ["sms"],
}
headers = {
"Authorization": settings.n3healthAutorization,
"X-Id-Lpu": settings.n3healthXIdLpu,
}
response = await requestPOSTMultipart(
url=f"{settings.n3healthHost}/signing",
headers=headers,
meta=meta,
files=inData.get("files", []),
verify_ssl=getVerifySSL(settings.n3healthHost),
)
return response
async def resend(trackingID: str, IdPatientMis: str):
settings = await Settings.getSettings(False)
if not settings.success:
logger.error("Настройки не инициализированы")
return answer(success=False, message="Настройки не инициализированы")
settings = settings.data
headers = {
"Authorization": settings.n3healthAutorization,
"X-Id-Lpu": settings.n3healthXIdLpu,
}
data = {
"trackingID": trackingID,
"IdPatientMis": [str(IdPatientMis)],
"delivery": ["sms"],
}
response = await requestPOST(
f"{settings.n3healthHost}/resend",
headers=headers,
json=data,
verify_ssl=getVerifySSL(settings.n3healthHost),
)
logger.warning(response)
return response
async def revoke(trackingID: str):
settings = await Settings.getSettings(False)
if not settings.success:
logger.error("Настройки не инициализированы")
return answer(success=False, message="Настройки не инициализированы")
settings = settings.data
headers = {
"Authorization": settings.n3healthAutorization,
"X-Id-Lpu": settings.n3healthXIdLpu,
}
response = await requestPOST(
f"{settings.n3healthHost}/signing/{trackingID}/cancel",
headers=headers,
verify_ssl=getVerifySSL(settings.n3healthHost),
)
return response
async def checkConnection(host: str, authorization: str, xIdLpu: str):
headers = {"Authorization": authorization, "X-Id-Lpu": xIdLpu}
params = {
"limit": 1,
"offset": 0,
}
return await requestGET(
f"{host}/signing",
headers=headers,
params=params,
verify_ssl=getVerifySSL(host),
)
async def getAllSigning():
settings = await Settings.getSettings(False)
if not settings.success:
logger.error("Настройки не инициализированы")
return answer(success=False, message="Настройки не инициализированы")
apiUrl = settings.data.n3healthHost
path = "/signing"
headers = {"Authorization": config.N3_KEY, "X-Id-Lpu": config.N3_ID}
response = await requestGET(apiUrl + path, headers=headers, verify_ssl=False)
logger.info(response)
return response
async def getMisIdSigning(idPatientMis: str):
apiUrl = config.N3_URL
path = "/signing"
headers = {"Authorization": config.N3_KEY, "X-Id-Lpu": config.N3_ID}
parameters = {"idPatientMis": idPatientMis}
response = await requestGET(
apiUrl + path, headers=headers, params=parameters, verify_ssl=False
)
logger.info(response)
return response
async def getTrackingIDSigning(trackingID: str, latestStatusCode: int, singingId: int):
settings = await Settings.getSettings(False)
if not settings.success:
logger.error("Настройки не инициализированы")
return answer(success=False, message="Настройки не инициализированы")
apiUrl = settings.data.n3healthHost
path = f"/signing/{trackingID}"
headers = {
"Authorization": settings.data.n3healthAutorization,
"X-Id-Lpu": settings.data.n3healthXIdLpu,
}
response = await requestGET(
apiUrl + path, headers=headers, verify_ssl=getVerifySSL(apiUrl)
)
if not response.success:
logger.error(response.message)
return response
patients = response.data.get("data", {}).get("patients", [])
status = {"code": 0, "description": ""}
patientsIds = []
for patient in patients:
patientsIds.append(patient["idPatientMis"])
if patient.get("status", {}).get("code", 0) > status["code"]:
status = patient.get("status", {})
message = "Файлы успешно сохранены"
if latestStatusCode != status["code"]:
attachments = (
response.data.get("data", {}).get("medDocument", {}).get("attachments", [])
)
if len(attachments) > 0:
for idx, attachment in enumerate(attachments, start=1):
content = attachment.get("content")
if not content:
continue
ATTACHMENTS_DIR = Path("src/attachments/import")
for patientId in patientsIds:
result = detect_and_save_attachment(
b64_content=content,
output_dir=ATTACHMENTS_DIR / patientId / str(singingId),
filename_prefix=f"document_{idx}",
save_attachments=True,
)
if not result:
logger.error("Файлы не сохранены")
message = "Файлы не сохранены"
else:
status["storagePath"] = str(result["path"])
return answer(data=status, message=message)
BIN
View File
Binary file not shown.
+18
View File
@@ -0,0 +1,18 @@
from fastapi import APIRouter
from .api import router as api_router
from .ui import router as ui_router
router = APIRouter()
router.include_router(api_router, prefix="/api", tags=["API"])
router.include_router(ui_router, tags=["UI"])
@router.get("/test")
async def root():
from db.schemas.signings import Signing
from utils import logger
singing = await Signing.getSigningById(16)
return singing
+405
View File
@@ -0,0 +1,405 @@
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException
from api.n3health import resend, revoke, sendForSigning
from db.schemas.documents import Document
from db.schemas.patients import Patient
from db.schemas.practitioners import Practitioner
from db.schemas.settings import Settings
from db.schemas.signings import Signing
from db.schemas.statuses import Statuses
from utils import requestDict, logger, convert_docs_to_pdfs
from utils.attachment import pdfGet
router = APIRouter()
@router.get("/health", summary="Health")
async def health():
return {"status": "healthy"}
@router.post("/pre_singing", summary="pre_singing")
async def pre_singing(
reqData: dict = Depends(requestDict),
):
# signature - type: eSignature / eAttorney
logger.info(f"📥 Получен запрос на /pre_singing")
practitioner = await Practitioner.getPractitionerByIdLpu(
reqData.get("body", {}).get("userId", 0)
)
signature = {}
if practitioner.success:
signature["type"] = (
"eSignature" if practitioner.data.get("esiaAuth", False) else "eAttorney"
)
signature["expiration"] = practitioner.data.get("expired_at")[:10]
daysRemainingControl = 0
settings = await Settings.getSettings()
if settings.success:
daysRemainingControl = settings.data.get("expirationAlert", 0)
inProgress = []
complete = []
idPatientMis = reqData.get("body", {}).get("patientId", 0)
if type(idPatientMis) != str:
idPatientMis = str(idPatientMis)
patient = await Patient.getPatientByIdPatientMis(idPatientMis, False, True)
if patient.success:
docNumbers = reqData.get("body", {}).get("docNumbers", [])
for docNumber in docNumbers:
if type(docNumber) != int:
docNumber = int(docNumber)
docs = await Document.getDocumentsByIdPatientMisAndNumber(
idPatientMis, docNumber, True
)
if docs.success:
for doc in docs.data:
docSingingId = doc.singingId
docSinging = await Signing.getSigningById(docSingingId)
if docSinging.success:
if docSinging.data.get("storagePath"):
complete.append(docNumber)
else:
if docSinging.data.get("trackingId"):
inProgress.append(docNumber)
else:
logger.info(f"{docNumber} - {docs.message}")
exitData = {
"signature": signature,
"inProgress": inProgress,
"complete": complete,
"daysRemainingControl": daysRemainingControl,
}
return exitData
@router.post("/singing", summary="singing")
async def singing(
reqData: dict = Depends(requestDict),
):
def correctData(data):
data["practitioner"]["userIdLpu"] = str(data["practitioner"]["userIdLpu"])
for patient in data["patients"]:
patient["idPatientMis"] = str(patient["idPatientMis"])
return data
logger.info(f"📥 Получен запрос на /singing")
# Получаем данные из запроса
body = reqData.get("body", {})
docs = body.pop("docs", [])
# Проверяем наличие документов
if not docs:
logger.warning("Нет документов для обработки")
return {"status": "ERROR", "message": "Нет документов для обработки"}
practitioner = await Practitioner.getPractitionerByIdLpu(
body.get("practitioner", {}).get("userIdLpu", 0)
)
if not practitioner.success:
logger.error(
f"Сотрудник не найден, idLpu: {body.get('practitioner', {}).get('userIdLpu', 0)}"
)
return {"status": "ERROR", "message": "Сотрудник не найден"}
esiaAuth = practitioner.data.get("esiaAuth", False)
userIdLpu = practitioner.data.get("userIdLpu", 0)
patients = body.get("patients", [])
if len(patients) == 0:
logger.error("Данные пациента не получены, пациент не найден")
return {
"status": "ERROR",
"message": "Данные пациента не получены, пациент не найден",
}
patientsData = []
for patient in patients:
idPatientMis = patient.get("idPatientMis", None)
if not idPatientMis:
logger.error("Не указан идентификатор пациента")
return {
"status": "ERROR",
"message": "Не указан идентификатор пациента",
}
patientDB = await Patient.getPatientByIdPatientMis(idPatientMis, isCheck=True)
if not patientDB.success:
patientData = {
"idPatientMis": idPatientMis,
"familyName": patient.get("familyName", "N/A"),
"givenName": patient.get("givenName", "N/A"),
"middleName": patient.get("middleName", "N/A"),
"birthDate": patient.get("birthDate", "N/A"),
"sex": patient.get("sex", "N/A"),
}
newPatient = await Patient.addPatient(**patientData)
if not newPatient.success:
return {
"status": "ERROR",
"message": newPatient.message,
}
patientsData.append(newPatient.data.toDict())
else:
patientsData.append(patientDB.data)
# Логируем информацию о документах
logger.info(f"👨‍⚕️ Медработник: {practitioner.data.get("name")}")
logger.info(
f"👥 Пациенты ({len(patientsData)}): {', '.join([f'{p.get('name')}' for p in patientsData])}"
)
logger.info(f"📄 Документов для обработки: {len(docs)}")
logger.info(f"🔐 ЕСИА: {esiaAuth}")
try:
# Конвертируем документы в PDF
conversion_result = convert_docs_to_pdfs(docs, patientsData)
# logger.info(conversion_result)
if conversion_result.get("status", "") != "SUCCESS":
return {
"status": "ERROR",
"message": "Не удалось сохранить документы",
}
newSinging = await Signing.addSigning(userIdLpu, idPatientMis)
if not newSinging:
return {
"status": "ERROR",
"message": "Не удалось добавить подписание",
}
logger.info(f"🔐 Подписание добавлено: {newSinging.id}")
pdfFiles = conversion_result.get("files", {})
body["medDocument"] = {
"esiaAuth": esiaAuth,
"attachments": [],
}
body["files"] = []
for key in pdfFiles:
pdfFile = pdfFiles[key]
for doc in pdfFile:
newDocument = await Document.addDocument(
singingId=newSinging.id, idPatientMis=key, **doc
)
if not newDocument:
return {
"status": "ERROR",
"message": "Не удалось добавить документ",
}
logger.info(f"📄 Документ добавлен: {newDocument.id}")
partName = f'file{len(body["medDocument"]["attachments"])}'
body["medDocument"]["attachments"].append(
{
"partName": partName,
}
)
body["files"].append(
{
"field": partName,
"path": Path(doc["storagePath"]),
"content_type": "application/pdf",
}
)
if esiaAuth:
partName = f'file{len(body["medDocument"]["attachments"])}'
body["medDocument"]["attachments"].append(
{
"partName": partName,
}
)
body["files"].append(
{
"field": partName,
"path": Path(
f"src/attachments/secure/{userIdLpu}/signature.p7s"
).resolve(),
"content_type": "application/pkcs7-signature",
}
)
signingResult = await sendForSigning(correctData(body))
if not signingResult or signingResult.get("status", "") != "created":
return {
"status": "ERROR",
"message": "Не удалось отправить документы на подписание",
}
newSinging.trackingId = signingResult.get("trackingId", "")
await newSinging.save()
logger.info(
f"🔐 Подписание обновлено: {newSinging.id}. Добавлен Трек-номер подписания"
)
await Statuses.updateStatus(newSinging.id, True)
logger.info(f"✅ Обработка завершена!")
# Возвращаем ответ
response = {
"status": "SUCCESS",
"message": f"Документы успешно обработаны. Трек-номер подписания: {newSinging.trackingId}",
}
return response
except Exception as e:
error_msg = f"Ошибка при обработке документов: {str(e)}"
logger.error(error_msg)
raise HTTPException(status_code=500, detail=error_msg)
@router.post("/statuses", summary="statuses")
async def statuses(
reqData: dict = Depends(requestDict),
):
logger.info(f"📥 Получен запрос на /statuses")
# Получаем данные из запроса
body = reqData.get("body", {})
userIdLpu = str(body.get("userIdLpu"))
filters = body.get("filters", {})
if not userIdLpu or not filters:
logger.error("Не отправлены userIdLpu или filters")
return {"status": "ERROR", "message": "Не отправлены необходимые данные"}
result = await Signing.getFilteredSingings(userIdLpu, filters)
singingsData = result.data
return singingsData
@router.post("/documents", summary="documents")
async def documents(
reqData: dict = Depends(requestDict),
):
logger.info(f"📥 Получен запрос на /documents")
# Получаем данные из запроса
body = reqData.get("body", {})
if "idPatientMis" not in body:
logger.error("Не отправлен idPatientMis")
return {"status": "ERROR", "message": "Не отправлен idPatientMis"}
idPatientMis = body.get("idPatientMis")
singingData = await Signing.getSigningsByIdPatientMis(idPatientMis)
if not singingData.success:
logger.error("Подписания не найдены")
return {"status": "SUCCESS", "data": [], "message": "Подписания не найдены"}
userIdLpus = set([s.get("userIdLpu") for s in singingData.data])
practitioners = {}
for userIdLpu in userIdLpus:
practitioner = await Practitioner.getPractitionerByIdLpu(userIdLpu, False)
practitioners[userIdLpu] = practitioner.data.name
for s in singingData.data:
s["practitioner"] = practitioners[s.get("userIdLpu")]
responseData = {"status": "SUCCESS", "data": singingData.data}
return responseData
@router.post("/document", summary="document")
async def document(
reqData: dict = Depends(requestDict),
):
logger.info(f"📥 Получен запрос на /document")
documentPath = reqData.get("body", {}).get("documentPath", None)
if not documentPath:
return {"status": "ERROR", "message": "Документ не найден"}
docBytes = pdfGet(documentPath)
if not docBytes.success:
return {"status": "ERROR", "message": docBytes.message}
return {"status": "SUCCESS", "data": docBytes.data}
@router.post("/revoke", summary="revoke")
async def revoke_post(
reqData: dict = Depends(requestDict),
):
logger.info(f"📥 Получен запрос на /revoke")
trackingId = reqData.get("body", {}).get("trackingId", None)
if not trackingId:
return {"status": "ERROR", "message": "Документ не найден"}
revokeResult = await revoke(trackingId)
if revokeResult.get("status", "") != "success":
return {
"status": "ERROR",
"message": revokeResult.get("errorMessage", "Отозвать не удалось"),
}
import asyncio
await asyncio.sleep(2)
await Signing.updateStatuses()
return {"status": "SUCCESS", "message": "Документ отозван"}
@router.post("/resend", summary="resend")
async def resend_post(
reqData: dict = Depends(requestDict),
):
logger.info(f"📥 Получен запрос на /resend")
trackingId = reqData.get("body", {}).get("trackingId", None)
idPatientMis = reqData.get("body", {}).get("idPatientMis", None)
errorResponse = {"status": "ERROR", "message": "Повторная отправка не удалось"}
if not trackingId or not idPatientMis:
logger.error("Не отправлен trackingId или idPatientMis")
logger.error(f"trackingId: {trackingId}, idPatientMis: {idPatientMis}")
return errorResponse
resendResult = await resend(trackingId, idPatientMis)
if not resendResult or not resendResult.get("status", ""):
logger.error(resendResult)
return errorResponse
newTrackingId = resendResult.get("tracking_id", "")
singingId = reqData.get("body", {}).get("id", "")
if not newTrackingId or not singingId:
logger.error("Не отправлен trackingId или id")
logger.error(f"trackingId: {newTrackingId}, id: {singingId}")
return errorResponse
singing = await Signing.getSigningById(singingId)
if not singing.success:
logger.error(singing.message)
return errorResponse
await singing.data.edit(trackingId=newTrackingId)
logger.info("✅ Повторная отправка успешна")
return {"status": "SUCCESS", "message": resendResult.get("message", "")}
@router.post("/advanced-search", summary="advanced-search")
async def advanced_search_post(
reqData: dict = Depends(requestDict),
):
logger.info(f"📥 Получен запрос на /advanced-search")
result = await Signing.getFilteredSingings(
"", reqData.get("body", {}).get("filters", {})
)
return {"status": "SUCCESS", "data": result.data}
+155
View File
@@ -0,0 +1,155 @@
import re
from fastapi import APIRouter, Depends, Request
from api.n3health import checkConnection as n3healthCheckConnection
from api.medods import checkConnection as medodsCheckConnection, generate_token
from db.schemas import Settings
from utils import logger, render, requestDict
router = APIRouter()
@router.get("/", name="Settings", summary="Настройки")
async def settingsPage(request: Request):
return await render(request)
@router.get("/get", name="getSettings", summary="Получить настройки")
async def getSettings():
settings = await Settings.getSettings()
return settings.data
@router.post("/update-password", name="updatePassword", summary="Обновить пароль")
async def updatePassword(reqData: dict = Depends(requestDict)):
logger.info(f"📥 Получен запрос на /update-password")
response = {"status": "error"}
currentPassword = reqData.get("body", {}).get("currentPassword", "")
checkPassword = await Settings.verifyPassword(currentPassword)
if checkPassword.success:
newPassword = reqData.get("body", {}).get("newPassword", "")
await Settings.updateSettings(password=newPassword)
response["status"] = "ok"
return response
@router.post("/test-medods", name="testMedods", summary="Проверить соединение с Медодс")
async def testMedods(reqData: dict = Depends(requestDict)):
logger.info(f"📥 Получен запрос на /test-medods")
medodsApi = reqData.get("body", {})
responseSatus = {"status": "error"}
try:
apiToken = generate_token(
medodsApi.get("identity", ""), medodsApi.get("secretKey", "")
)
except Exception as e:
logger.error(f"Ошибка генерации токена: {str(e)}", exc_info=True)
responseSatus["message"] = "Ошибка генерации токена. Проверьте файл с ключами."
return responseSatus
host = medodsApi.get("host", None)
if host:
host = re.sub(r"^https?://", "", host)
connection = await medodsCheckConnection(host, medodsApi.get("port", 0), apiToken)
if not connection.success:
responseSatus["message"] = "Ошибка соединения с Медодс. Проверьте параметры."
return responseSatus
responseSatus = {"status": "ok"}
logger.info("✅ Соединение с Медодс установлено")
return responseSatus
@router.post(
"/save-medods", name="saveMedods", summary="Сохранить параметры соединения с Медодс"
)
async def saveMedods(reqData: dict = Depends(requestDict)):
logger.info(f"📥 Получен запрос на /save-medods")
medodsApi = reqData.get("body", {})
host = medodsApi.get("host", None)
if host:
host = re.sub(r"^https?://", "", host)
medodsData = {
"medodsApiHost": host,
"medodsApiPort": medodsApi.get("port", None),
"medodsApiIdentity": medodsApi.get("identity", None),
"medodsApiSecretKey": medodsApi.get("secretKey", None),
}
responseStatus = {"status": "ok"}
for value in medodsData.values():
if value is None:
responseStatus["status"] = "error"
responseStatus["message"] = "Некоторые поля не заполнены"
logger.error("Некоторые поля не заполнены")
return responseStatus
update = await Settings.updateSettings(**medodsData)
if not update.success:
responseStatus["status"] = "error"
responseStatus["message"] = update.message
logger.error(update.message)
else:
logger.info("✅ Параметры соединения с Медодс сохранены")
return responseStatus
@router.post(
"/test-n3health", name="testN3health", summary="Проверить соединение с N3Health"
)
async def testN3health(reqData: dict = Depends(requestDict)):
logger.info(f"📥 Получен запрос на /test-n3health")
n3healthApi = reqData.get("body", {})
responseSatus = {"status": "error"}
connection = await n3healthCheckConnection(**n3healthApi)
if not connection.success:
responseSatus["message"] = "Ошибка соединения с N3Health. Проверьте параметры."
return responseSatus
responseSatus = {"status": "ok"}
logger.info("✅ Соединение с N3Health установлено")
return responseSatus
@router.post(
"/save-n3health",
name="saveN3health",
summary="Сохранить параметры соединения с N3Health",
)
async def saveN3health(reqData: dict = Depends(requestDict)):
logger.info(f"📥 Получен запрос на /save-n3health")
n3healthApi = reqData.get("body", {})
n3healthData = {
"n3healthHost": n3healthApi.get("host", None),
"n3healthAutorization": n3healthApi.get("authorization", None),
"n3healthXIdLpu": n3healthApi.get("xIdLpu", None),
}
responseStatus = {"status": "ok"}
for value in n3healthData.values():
if value is None:
responseStatus["status"] = "error"
responseStatus["message"] = "Некоторые поля не заполнены"
logger.error("Некоторые поля не заполнены")
return responseStatus
update = await Settings.updateSettings(**n3healthData)
if not update.success:
responseStatus["status"] = "error"
responseStatus["message"] = update.message
@router.post(
"/update-expiration-alert",
name="updateExpirationAlert",
summary="Обновить настройку expirationAlert",
)
async def updateExpirationAlert(reqData: dict = Depends(requestDict)):
logger.info(f"📥 Получен запрос на /update-expiration-alert")
responseStatus = {"status": "error"}
try:
expirationAlert = reqData.get("body", {}).get("expirationAlert", "")
await Settings.updateSettings(expirationAlert=expirationAlert)
responseStatus["status"] = "ok"
logger.info("✅ Настройка expirationAlert обновлена")
except Exception as e:
logger.error(
f"Ошибка обновления настройки expirationAlert: {str(e)}", exc_info=True
)
return responseStatus
+131
View File
@@ -0,0 +1,131 @@
from datetime import datetime
from fastapi import APIRouter, Depends, Request
from api.medods import getAllEmployees
from db.schemas import Practitioner, Settings
from utils import logger, render, requestDict, p7s_save, p7s_delete
router = APIRouter()
@router.get("/", name="Staff", summary="Работа с МЧД и УКЭП")
async def settingsPage(request: Request):
logger.info(f"📥 Получен запрос на /staff/")
return await render(request)
@router.get("/getAll", name="getAll", summary="Получить всех работников")
async def getAll():
logger.info(f"📥 Получен запрос на /staff/getAll")
result = await Practitioner.getPractitioners()
if result.success:
practitioners = result.data
return {"status": "ok", "data": practitioners}
else:
return {"status": "error", "message": result.error}
@router.get("/available", name="available", summary="Получить доступных работников")
async def available():
logger.info(f"📥 Получен запрос на /staff/available")
settings = await Settings.getSettings(False)
if not settings.success:
return {"status": "error", "message": settings.error}
settings = settings.data
try:
return {
"status": "ok",
"data": await getAllEmployees(
settings.medodsApiHost,
settings.medodsApiPort,
settings.medodsApiIdentity,
settings.medodsApiSecretKey,
),
}
except Exception as e:
return {
"status": "error",
"message": str(e),
}
@router.post("/add", name="add", summary="Добавить работника")
async def addStaff(reqData: dict = Depends(requestDict)):
logger.info(f"📥 Получен запрос на /staff/add")
newPractitioner = reqData.get("body", {})
newPractitioner["expired_at"] = datetime.strptime(
newPractitioner.pop("expired_at"), "%Y-%m-%d"
).replace(hour=12, minute=0, second=0)
if "attorney" in newPractitioner and newPractitioner["attorney"] != "":
logger.info("🔐 МЧД получена")
if newPractitioner["esiaAuth"]:
logger.info("🔐 УКЭП получен")
fileData = newPractitioner.pop("signature")
fileInfo = p7s_save(fileData, newPractitioner["userIdLpu"])
if fileInfo.success:
logger.info(f"🔐 УКЭП сохранен: {fileInfo.data}")
result = await Practitioner.addPractitioner(**newPractitioner)
returnStatus = {"status": "ok"}
if not result.success:
returnStatus["status"] = "error"
returnStatus["message"] = result.message
if returnStatus["status"] == "ok":
logger.info("✅ Работник добавлен")
else:
logger.error(returnStatus["message"])
return returnStatus
@router.post("/update", name="update", summary="Обновить работника")
async def update(reqData: dict = Depends(requestDict)):
logger.info(f"📥 Получен запрос на /staff/update")
updatePractitioner = reqData.get("body", {})
updatePractitioner["expired_at"] = datetime.strptime(
updatePractitioner.pop("expired_at"), "%Y-%m-%d"
).replace(hour=12, minute=0, second=0)
if "attorney" in updatePractitioner and updatePractitioner["attorney"] != "":
logger.info("🔐 МЧД получена")
if "signature" in updatePractitioner:
logger.info("🔐 УКЭП получен")
fileData = updatePractitioner.pop("signature")
fileInfo = p7s_save(fileData, updatePractitioner["userIdLpu"])
if fileInfo.success:
logger.info(f"🔐 УКЭП сохранен: {fileInfo.data}")
result = await Practitioner.editPractitionerByIdLpu(
updatePractitioner.pop("userIdLpu"), **updatePractitioner
)
returnStatus = {"status": "ok"}
if not result.success:
returnStatus["status"] = "error"
returnStatus["message"] = result.message
if returnStatus["status"] == "ok":
logger.info("✅ Работник обновлен")
else:
logger.error(returnStatus["message"])
return returnStatus
@router.post("/delete", name="delete", summary="Удалить работника")
async def delete(reqData: dict = Depends(requestDict)):
logger.info(f"📥 Получен запрос на /staff/delete")
userIdLpu = reqData.get("body", {}).get("userIdLpu")
result = await Practitioner.deletePractitionerByIdLpu(userIdLpu)
returnStatus = {"status": "ok"}
if not result.success:
returnStatus["status"] = "error"
returnStatus["message"] = result.message
else:
esiaAuth = result.data
if esiaAuth:
fileInfo = p7s_delete(userIdLpu)
if fileInfo.success:
logger.info(f"🔐 УКЭП удален")
else:
returnStatus["status"] = "error"
returnStatus["message"] = fileInfo.message
if returnStatus["status"] == "ok":
logger.info("✅ Работник удален")
else:
logger.error(returnStatus["message"])
return returnStatus
+84
View File
@@ -0,0 +1,84 @@
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import JSONResponse, RedirectResponse
from db.schemas import Settings
from utils import logger, render, requestDict
from .settings import router as settingsRouter
from .staff import router as staffRouter
async def require_auth(request: Request):
# login и статика — без проверки
if request.url.path.startswith(("/login", "/static")):
return
auth_token = request.cookies.get("auth_token")
# 1️⃣ Нет токена → сразу на логин
if not auth_token:
raise HTTPException(status_code=302, headers={"Location": "/login"})
# 2️⃣ Токен есть → проверяем
token_check = await Settings.verifyToken(auth_token)
# 3️⃣ Токен НЕ валиден → удаляем cookie и редирект
if not token_check.success:
response = RedirectResponse(url="/login", status_code=302)
response.delete_cookie(key="auth_token", path="/")
return response # ← ВАЖНО: return, не raise
# 4️⃣ Токен валиден → пропускаем
return
router = APIRouter(dependencies=[Depends(require_auth)])
router.include_router(settingsRouter, prefix="/settings", tags=["Settings"])
router.include_router(staffRouter, prefix="/staff", tags=["Staff"])
@router.get("/", name="Main", summary="Главная страница")
async def mainPage(request: Request):
return await render(request)
@router.get("/login", name="Authentication", summary="Авторизация пользователя")
async def authenticationPage(request: Request):
return await render(request)
@router.post("/login", summary="Authentication")
async def authentication(
reqData: dict = Depends(requestDict),
):
logger.info("📥 Получен запрос на /login")
password = reqData.get("body", {}).get("password", "")
authSuccess = await Settings.verifyPassword(password)
if not authSuccess.success:
return JSONResponse(
status_code=401, content={"status": "error", "message": "Неверный пароль"}
)
response = JSONResponse(content={"status": "ok"})
response.set_cookie(
key="auth_token",
value=authSuccess.data,
httponly=True,
secure=False, # ⚠️ True в HTTPS
samesite="lax",
max_age=60 * 60 * 24 * 365, # 1 год
path="/",
)
logger.info("✅ Пользователь авторизован, cookie установлена")
return response
@router.get("/logout", summary="Logout")
async def logout():
response = RedirectResponse(url="/login", status_code=302)
response.delete_cookie(key="auth_token", path="/")
await Settings.clearToken()
return response
BIN
View File
Binary file not shown.
+28
View File
@@ -0,0 +1,28 @@
/* ===== Base application styles ===== */
html, body {
height: 100%;
}
body {
background-color: #f8f9fa;
color: #212529;
}
/* Utility */
.cursor-pointer {
cursor: pointer;
}
/* Disable selection on icons/buttons */
.noselect {
user-select: none;
}
/* Center helpers */
.full-center {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+10
View File
@@ -0,0 +1,10 @@
/* ===== Login page styles ===== */
.login-card {
width: 100%;
max-width: 360px;
}
.password-toggle i {
font-size: 1.1rem;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.
+43
View File
@@ -0,0 +1,43 @@
document.addEventListener("DOMContentLoaded", () => {
const path = window.location.pathname;
const navItems = {
login: document.getElementById("nav-login"),
settings: document.getElementById("nav-settings"),
staff: document.getElementById("nav-staff"),
logout: document.getElementById("nav-logout"),
};
const isLoginPage = path === "/login";
/* ---------- Видимость пунктов ---------- */
if (isLoginPage) {
navItems.login?.classList.remove("d-none");
navItems.settings?.classList.add("d-none");
navItems.staff?.classList.add("d-none");
navItems.logout?.classList.add("d-none");
} else {
navItems.login?.classList.add("d-none");
navItems.settings?.classList.remove("d-none");
navItems.staff?.classList.remove("d-none");
navItems.logout?.classList.remove("d-none");
}
/* ---------- Подсветка активной страницы ---------- */
// Для "/" — ничего не подсвечиваем
if (path === "/") {
return;
}
document
.querySelectorAll(".nav-link[data-path]")
.forEach(link => {
if (link.dataset.path === path) {
link.classList.add("active");
} else {
link.classList.remove("active");
}
});
});
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+283
View File
@@ -0,0 +1,283 @@
// Глобальный объект для состояния системы
let systemStatus = {
medods: { configured: false, details: {} },
n3health: { configured: false, details: {} },
password: { configured: true }
};
// Глобальные переменные для статистики сотрудников
let expirationAlertDays = 0;
let allPractitioners = [];
// Загрузка состояния системы
async function loadSystemStatus() {
try {
const response = await fetch('/settings/get', {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
});
if (response.ok) {
const data = await response.json();
// Сохраняем порог предупреждения
expirationAlertDays = data.expirationAlert || 0;
// Обновляем статус Медодс
const medodsConfigured = data.medodsApiHost && data.medodsApiPort &&
data.medodsApiIdentity && data.medodsApiSecretKey;
systemStatus.medods.configured = medodsConfigured;
systemStatus.medods.details = {
host: data.medodsApiHost || 'Не задан',
port: data.medodsApiPort || 'Не задан',
hasCredentials: !!(data.medodsApiIdentity && data.medodsApiSecretKey)
};
// Обновляем статус N3Health
const n3healthConfigured = data.n3healthHost && data.n3healthAutorization && data.n3healthXIdLpu;
systemStatus.n3health.configured = n3healthConfigured;
systemStatus.n3health.details = {
host: data.n3healthHost || 'Не задан',
hasToken: !!data.n3healthAutorization,
hasLpuId: !!data.n3healthXIdLpu
};
// Обновляем UI
updateStatusUI();
updateReadinessIndicator();
}
} catch (error) {
console.error('Ошибка при загрузке статуса:', error);
}
}
// Загрузка данных сотрудников
async function loadStaffData() {
try {
const response = await fetch('/staff/getAll', {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
});
const respJson = await response.json();
if (response.ok && respJson.status === "ok") {
allPractitioners = respJson.data || [];
updateStaffStatistics();
} else {
allPractitioners = [];
console.error('Ошибка при загрузке сотрудников:', respJson.message);
}
} catch (error) {
console.error('Ошибка при загрузке сотрудников:', error);
allPractitioners = [];
}
}
// Расчет дней до истечения
function getDaysUntilExpiry(expiryDate) {
const now = new Date();
const expiry = new Date(expiryDate);
const diffTime = expiry - now;
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
}
// Форматирование даты
function formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleDateString('ru-RU');
}
// Обновление статистики сотрудников
function updateStaffStatistics() {
// Общая статистика
const totalCount = allPractitioners.length;
const ukepCount = allPractitioners.filter(p => p.esiaAuth).length;
const mchdCount = allPractitioners.filter(p => !p.esiaAuth).length;
// Сотрудники с истекающими подписями
const expiringEmployees = allPractitioners.filter(p => {
if (!p.expired_at) return false;
const daysLeft = getDaysUntilExpiry(p.expired_at);
return daysLeft <= expirationAlertDays && daysLeft >= 0;
}).sort((a, b) => {
const daysA = getDaysUntilExpiry(a.expired_at);
const daysB = getDaysUntilExpiry(b.expired_at);
return daysA - daysB;
});
const expiringCount = expiringEmployees.length;
// Обновляем счетчики
document.getElementById('totalEmployeesCount').textContent = totalCount;
document.getElementById('ukepEmployeesCount').textContent = ukepCount;
document.getElementById('mchdEmployeesCount').textContent = mchdCount;
document.getElementById('expiringEmployeesCount').textContent = expiringCount;
// Обновляем контейнер с истекающими сотрудниками
const container = document.getElementById('expiringEmployeesContainer');
const noExpiringMessage = document.getElementById('noExpiringEmployeesMessage');
const thresholdDaysSpan = document.getElementById('expiringThresholdDays');
const noExpiringThresholdSpan = document.getElementById('noExpiringThresholdDays');
// Обновляем отображение порога предупреждения
const daysText = expirationAlertDays % 10 === 1 && expirationAlertDays % 100 !== 11 ? 'день' :
expirationAlertDays % 10 >= 2 && expirationAlertDays % 10 <= 4 &&
(expirationAlertDays % 100 < 10 || expirationAlertDays % 100 >= 20) ? 'дня' : 'дней';
thresholdDaysSpan.textContent = `(менее ${expirationAlertDays} ${daysText})`;
noExpiringThresholdSpan.textContent = expirationAlertDays;
if (expiringCount > 0) {
// Показываем таблицу
container.style.display = 'block';
noExpiringMessage.style.display = 'none';
// Заполняем таблицу
const tbody = document.getElementById('expiringEmployeesTableBody');
let rows = '';
expiringEmployees.forEach(p => {
const daysLeft = getDaysUntilExpiry(p.expired_at);
const signatureType = p.esiaAuth ?
'<span class="badge bg-success">УКЭП</span>' :
'<span class="badge bg-warning text-dark">МЧД</span>';
let badgeClass = 'bg-warning';
if (daysLeft < 0) {
badgeClass = 'bg-danger';
} else if (daysLeft <= 3) {
badgeClass = 'bg-danger';
} else if (daysLeft <= 7) {
badgeClass = 'bg-warning';
} else {
badgeClass = 'bg-info';
}
rows += `
<tr>
<td>${p.name}</td>
<td>${signatureType}</td>
<td>${formatDate(p.expired_at)}</td>
<td>
<span class="badge ${badgeClass}">${daysLeft} дн.</span>
</td>
</tr>
`;
});
tbody.innerHTML = rows;
} else {
// Показываем сообщение об отсутствии
container.style.display = 'none';
noExpiringMessage.style.display = 'block';
}
}
// Обновление UI на основе статуса
function updateStatusUI() {
// Медодс
const medodsBadge = document.getElementById('medodsStatusBadge');
const medodsDetails = document.getElementById('medodsDetails');
if (systemStatus.medods.configured) {
medodsBadge.className = 'badge bg-success';
medodsBadge.textContent = 'Настроено';
medodsDetails.innerHTML = `
<div class="text-success mb-1"><i class="bi bi-check-circle"></i> Хост: ${systemStatus.medods.details.host}</div>
<div class="text-success mb-1"><i class="bi bi-check-circle"></i> Порт: ${systemStatus.medods.details.port}</div>
<div class="text-success"><i class="bi bi-check-circle"></i> Учетные данные: есть</div>
`;
} else {
medodsBadge.className = 'badge bg-danger';
medodsBadge.textContent = 'Требуется настройка';
medodsDetails.innerHTML = `
<div class="text-danger mb-1"><i class="bi bi-exclamation-circle"></i> Хост: ${systemStatus.medods.details.host}</div>
<div class="text-danger mb-1"><i class="bi bi-exclamation-circle"></i> Порт: ${systemStatus.medods.details.port}</div>
<div class="text-danger"><i class="bi bi-exclamation-circle"></i> Учетные данные: нет</div>
`;
}
// N3Health
const n3healthBadge = document.getElementById('n3healthStatusBadge');
const n3healthDetails = document.getElementById('n3healthDetails');
if (systemStatus.n3health.configured) {
n3healthBadge.className = 'badge bg-success';
n3healthBadge.textContent = 'Настроено';
n3healthDetails.innerHTML = `
<div class="text-success mb-1"><i class="bi bi-check-circle"></i> Хост: ${systemStatus.n3health.details.host}</div>
<div class="text-success mb-1"><i class="bi bi-check-circle"></i> Токен доступа: есть</div>
<div class="text-success"><i class="bi bi-check-circle"></i> Идентификатор МО: есть</div>
`;
} else {
n3healthBadge.className = 'badge bg-danger';
n3healthBadge.textContent = 'Требуется настройка';
n3healthDetails.innerHTML = `
<div class="text-danger mb-1"><i class="bi bi-exclamation-circle"></i> Хост: ${systemStatus.n3health.details.host}</div>
<div class="text-danger mb-1"><i class="bi bi-exclamation-circle"></i> Токен доступа: нет</div>
<div class="text-danger"><i class="bi bi-exclamation-circle"></i> Идентификатор МО: нет</div>
`;
}
}
// Обновление индикатора готовности и управление кнопкой
function updateReadinessIndicator() {
const progressBar = document.querySelector('#systemReadiness .progress-bar');
const readinessDesc = document.getElementById('readinessDescription');
const settingsButtonContainer = document.getElementById('settingsButtonContainer');
let configuredCount = 0;
if (systemStatus.password.configured) configuredCount++;
if (systemStatus.medods.configured) configuredCount++;
if (systemStatus.n3health.configured) configuredCount++;
const readinessPercentage = Math.round((configuredCount / 3) * 100);
progressBar.style.width = `${readinessPercentage}%`;
progressBar.setAttribute('aria-valuenow', readinessPercentage);
progressBar.textContent = `${readinessPercentage}%`;
// Обновляем цвет прогресс-бара
if (readinessPercentage === 100) {
progressBar.className = 'progress-bar progress-bar-striped progress-bar-animated bg-success';
readinessDesc.textContent = 'Все системы настроены и готовы к работе!';
// Скрываем кнопку настроек при 100% готовности
settingsButtonContainer.style.display = 'none';
} else if (readinessPercentage >= 66) {
progressBar.className = 'progress-bar progress-bar-striped progress-bar-animated bg-info';
readinessDesc.textContent = 'Большинство систем настроены';
settingsButtonContainer.style.display = 'block';
} else if (readinessPercentage >= 33) {
progressBar.className = 'progress-bar progress-bar-striped progress-bar-animated bg-warning';
readinessDesc.textContent = 'Требуется дополнительная настройка';
settingsButtonContainer.style.display = 'block';
} else {
progressBar.className = 'progress-bar progress-bar-striped progress-bar-animated bg-danger';
readinessDesc.textContent = 'Все интеграции требуют настройки';
settingsButtonContainer.style.display = 'block';
}
}
// Инициализация при загрузке страницы
document.addEventListener('DOMContentLoaded', function () {
// Загружаем статус системы и данные сотрудников
Promise.all([
loadSystemStatus(),
loadStaffData()
]).then(() => {
// Обновляем статистику после загрузки всех данных
updateStaffStatistics();
});
// Обновляем индикатор готовности и управление кнопкой
updateReadinessIndicator();
});
+49
View File
@@ -0,0 +1,49 @@
const passwordInput = document.getElementById("password");
const togglePassword = document.getElementById("togglePassword");
const toggleIcon = togglePassword.querySelector("i");
const loginForm = document.getElementById("loginForm");
const errorBox = document.getElementById("error");
/* 👁 Показ / скрытие пароля */
togglePassword.addEventListener("click", () => {
const isPassword = passwordInput.type === "password";
passwordInput.type = isPassword ? "text" : "password";
toggleIcon.classList.toggle("bi-eye");
toggleIcon.classList.toggle("bi-eye-slash");
});
/* 📨 Отправка формы */
loginForm.addEventListener("submit", async (e) => {
e.preventDefault();
errorBox.classList.add("d-none");
const password = passwordInput.value.trim();
if (!password) {
showError("Введите пароль");
return;
}
try {
const response = await fetch("/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password })
});
const data = await response.json();
if (data.status?.toLowerCase() === "ok") {
window.location.href = "/";
} else {
showError(data.message || "Неверный пароль");
}
} catch {
showError("Ошибка соединения");
}
});
function showError(message) {
errorBox.textContent = message;
errorBox.classList.remove("d-none");
}
+535
View File
@@ -0,0 +1,535 @@
// Глобальный объект для хранения текущих настроек с сервера
let currentSettings = {
medods: {
host: '',
port: null,
// apiKey: false,
},
n3health: {
host: '',
authorization: '',
xIdLpu: ''
}
};
// Состояния изменений
let medodsChanged = false;
let n3healthChanged = false;
// Утилиты
function showAlert(message, type = 'success') {
const alertContainer = document.getElementById('alertContainer');
const alertId = 'alert-' + Date.now();
const alertHTML = `
<div id="${alertId}" class="alert alert-${type} alert-dismissible fade show" role="alert">
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
`;
alertContainer.innerHTML = alertHTML;
// Автоматическое скрытие для успешных сообщений
if (type === 'success') {
setTimeout(() => {
const alert = document.getElementById(alertId);
if (alert) {
const bsAlert = new bootstrap.Alert(alert);
bsAlert.close();
}
}, 3000);
}
}
// Проверка заполненности формы Медодс
function isMedodsFormValid() {
const host = document.getElementById('medodsHost').value;
const port = document.getElementById('medodsPort').value;
const file = document.getElementById('medodsCsvFile').files[0];
return host && port && (file || (currentSettings.medods.identityExists && currentSettings.medods.secretKeyExists));
}
// Проверка заполненности формы N3Health
function isN3HealthFormValid() {
const host = document.getElementById('n3healthHost').value;
const auth = document.getElementById('n3healthAuthorization').value;
const lpu = document.getElementById('n3healthXIdLpu').value;
return host && auth && lpu;
}
// Обновление статусов блоков
function updateStatusIndicators() {
// Пароль - всегда считаем настроенным если есть соединение с сервером
document.getElementById('passwordStatus').className = 'badge bg-success';
document.getElementById('passwordStatus').textContent = 'Настроен';
// Медодс
const medodsStatus = document.getElementById('medodsStatus');
const hasMedodsConfig = currentSettings.medods.host &&
currentSettings.medods.port &&
currentSettings.medods.identityExists &&
currentSettings.medods.secretKeyExists;
if (hasMedodsConfig) {
medodsStatus.className = 'badge bg-success';
medodsStatus.textContent = 'Настроен';
} else {
medodsStatus.className = 'badge bg-danger';
medodsStatus.textContent = 'Требуется настройка';
}
// N3Health
const n3healthStatus = document.getElementById('n3healthStatus');
const hasN3HealthConfig = currentSettings.n3health.host &&
currentSettings.n3health.authorization &&
currentSettings.n3health.xIdLpu;
if (hasN3HealthConfig) {
n3healthStatus.className = 'badge bg-success';
n3healthStatus.textContent = 'Настроен';
} else {
n3healthStatus.className = 'badge bg-danger';
n3healthStatus.textContent = 'Требуется настройка';
}
}
// Обновление кнопок Медодс
function updateMedodsButtons() {
const isValid = isMedodsFormValid();
const testBtn = document.getElementById('testMedodsBtn');
const saveBtn = document.getElementById('saveMedodsBtn');
const cancelBtn = document.getElementById('cancelMedodsBtn');
testBtn.disabled = !isValid || !medodsChanged;
saveBtn.disabled = true; // Сохранение только после успешной проверки
cancelBtn.disabled = !medodsChanged;
}
// Обновление кнопок N3Health
function updateN3HealthButtons() {
const isValid = isN3HealthFormValid();
const testBtn = document.getElementById('testN3HealthBtn');
const saveBtn = document.getElementById('saveN3HealthBtn');
const cancelBtn = document.getElementById('cancelN3HealthBtn');
testBtn.disabled = !isValid || !n3healthChanged;
saveBtn.disabled = true; // Сохранение только после успешной проверки
cancelBtn.disabled = !n3healthChanged;
}
// Чтение CSV файла
function parseCSV(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = function (e) {
try {
const content = e.target.result.trim();
// Удаляем BOM если есть
const cleanContent = content.replace(/^\uFEFF/, '');
// Ищем все непустые строки
const lines = cleanContent.split('\n')
.map(line => line.trim())
.filter(line => line && !line.startsWith('#') && !line.startsWith('//'));
if (lines.length === 0) {
reject(new Error('CSV файл пустой'));
return;
}
// Берем первую непустую строку
const dataLine = lines[1];
// Разделяем по точке с запятой
const parts = dataLine.split(';').map(part => part.trim());
if (parts.length >= 2) {
const identity = parts[0];
const secretKey = parts.slice(1).join(';'); // На случай если есть больше частей
if (identity && secretKey) {
resolve({ identity, secretKey });
} else {
reject(new Error('Не удалось извлечь identity и secretKey'));
}
} else {
reject(new Error('Формат CSV: значение1;значение2 (разделенные точкой с запятой)'));
}
} catch (error) {
console.error('CSV parsing error:', error);
reject(error);
}
};
reader.onerror = reject;
reader.readAsText(file);
});
}
// Загрузка текущих настроек
async function loadCurrentSettings() {
try {
const response = await fetch('/settings/get', {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
});
if (response.ok) {
const data = await response.json();
// Сохраняем текущие настройки
if (data.medodsApiHost) {
currentSettings.medods.host = data.medodsApiHost;
document.getElementById('medodsHost').value = data.medodsApiHost;
}
if (data.medodsApiPort) {
currentSettings.medods.port = data.medodsApiPort;
document.getElementById('medodsPort').value = data.medodsApiPort;
}
if (data.medodsApiIdentity && data.medodsApiSecretKey) {
currentSettings.medods.identityExists = true;
currentSettings.medods.secretKeyExists = true;
document.getElementById('medodsApiKeyStatus').className = 'badge bg-success';
document.getElementById('medodsApiKeyStatus').textContent = 'есть';
}
if (data.n3healthHost) {
currentSettings.n3health.host = data.n3healthHost;
document.getElementById('n3healthHost').value = data.n3healthHost;
}
if (data.n3healthAutorization) {
currentSettings.n3health.authorization = data.n3healthAutorization;
document.getElementById('n3healthAuthorization').value = data.n3healthAutorization;
}
if (data.n3healthXIdLpu) {
currentSettings.n3health.xIdLpu = data.n3healthXIdLpu;
document.getElementById('n3healthXIdLpu').value = data.n3healthXIdLpu;
}
updateStatusIndicators();
}
} catch (error) {
console.error('Ошибка при загрузке настроек:', error);
showAlert('Ошибка при загрузке настроек', 'danger');
}
}
// Сброс формы Медодс к исходным значениям
function resetMedodsForm() {
document.getElementById('medodsHost').value = currentSettings.medods.host;
document.getElementById('medodsPort').value = currentSettings.medods.port;
document.getElementById('medodsCsvFile').value = '';
medodsChanged = false;
updateMedodsButtons();
}
// Сброс формы N3Health к исходным значениям
function resetN3HealthForm() {
document.getElementById('n3healthHost').value = currentSettings.n3health.host;
document.getElementById('n3healthAuthorization').value = currentSettings.n3health.authorization;
document.getElementById('n3healthXIdLpu').value = currentSettings.n3health.xIdLpu;
n3healthChanged = false;
updateN3HealthButtons();
}
// Проверка изменений в формах
function setupChangeListeners() {
// Медодс
const medodsFields = ['medodsHost', 'medodsPort', 'medodsCsvFile'];
medodsFields.forEach(fieldId => {
document.getElementById(fieldId).addEventListener('input', () => {
medodsChanged = true;
updateMedodsButtons();
});
});
// N3Health
const n3healthFields = ['n3healthHost', 'n3healthAuthorization', 'n3healthXIdLpu'];
n3healthFields.forEach(fieldId => {
document.getElementById(fieldId).addEventListener('input', () => {
n3healthChanged = true;
updateN3HealthButtons();
});
});
}
// Инициализация
document.addEventListener('DOMContentLoaded', function () {
// Загружаем текущие настройки
loadCurrentSettings();
// Настраиваем отслеживание изменений
setupChangeListeners();
// Обработчик формы пароля
document.getElementById('passwordForm').addEventListener('submit', async function (e) {
e.preventDefault();
const currentPassword = document.getElementById('currentPassword').value;
const newPassword = document.getElementById('newPassword').value;
const confirmPassword = document.getElementById('confirmPassword').value;
if (newPassword !== confirmPassword) {
showAlert('Новый пароль и подтверждение не совпадают', 'danger');
return;
}
try {
const response = await fetch('/settings/update-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
currentPassword,
newPassword
})
});
const data = await response.json();
if (response.ok && data.status === "ok") {
showAlert('Пароль успешно изменен. Вы будете перенаправлены на страницу входа.');
// Редирект на страницу входа через 2 секунды
setTimeout(() => {
window.location.href = '/logout';
}, 2000);
} else {
showAlert(data.error || 'Ошибка при смене пароля', 'danger');
}
} catch (error) {
console.error('Ошибка:', error);
showAlert('Ошибка при смене пароля', 'danger');
}
});
// Обработчик проверки соединения Медодс
document.getElementById('testMedodsBtn').addEventListener('click', async function () {
const host = document.getElementById('medodsHost').value;
const port = document.getElementById('medodsPort').value;
const file = document.getElementById('medodsCsvFile').files[0];
let identity, secretKey;
// Если загружен новый файл - парсим его
if (file) {
try {
const csvData = await parseCSV(file);
identity = csvData.identity;
secretKey = csvData.secretKey;
} catch (error) {
showAlert(`Ошибка чтения CSV: ${error.message}`, 'danger');
return;
}
}
try {
const testBtn = this;
const originalText = testBtn.textContent;
testBtn.disabled = true;
testBtn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status"></span> Проверка...';
const response = await fetch('/settings/test-medods', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
host,
port: parseInt(port),
identity,
secretKey
})
});
const data = await response.json();
testBtn.disabled = false;
testBtn.textContent = originalText;
if (response.ok && data.status === "ok") {
showAlert('Соединение с Медодс успешно установлено', 'success');
// Активируем кнопку сохранения
document.getElementById('saveMedodsBtn').disabled = false;
} else {
showAlert(data.message || 'Ошибка при проверке соединения', 'danger');
}
} catch (error) {
console.error('Ошибка:', error);
showAlert('Ошибка при проверке соединения', 'danger');
}
});
// Обработчик сохранения настроек Медодс
document.getElementById('medodsForm').addEventListener('submit', async function (e) {
e.preventDefault();
const host = document.getElementById('medodsHost').value;
const port = document.getElementById('medodsPort').value;
const file = document.getElementById('medodsCsvFile').files[0];
let identity, secretKey;
// Если загружен новый файл - парсим его
if (file) {
try {
const csvData = await parseCSV(file);
identity = csvData.identity;
secretKey = csvData.secretKey;
} catch (error) {
showAlert(`Ошибка чтения CSV: ${error.message}`, 'danger');
return;
}
}
try {
const response = await fetch('/settings/save-medods', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
host,
port: parseInt(port),
identity,
secretKey
})
});
const data = await response.json();
if (response.ok && data.status === "ok") {
showAlert('Настройки Медодс успешно сохранены', 'success');
// Обновляем текущие настройки
currentSettings.medods.host = host;
currentSettings.medods.port = parseInt(port);
currentSettings.medods.identityExists = true;
currentSettings.medods.secretKeyExists = true;
// Обновляем индикаторы
document.getElementById('medodsApiKeyStatus').className = 'badge bg-success';
document.getElementById('medodsApiKeyStatus').textContent = 'есть';
// Сбрасываем флаги изменений
medodsChanged = false;
// Обновляем кнопки и статусы
updateMedodsButtons();
updateStatusIndicators();
} else {
showAlert(data.message || 'Ошибка при сохранении', 'danger');
}
} catch (error) {
console.error('Ошибка:', error);
showAlert('Ошибка при сохранении', 'danger');
}
});
// Обработчик отмены изменений Медодс
document.getElementById('cancelMedodsBtn').addEventListener('click', resetMedodsForm);
// Обработчик проверки соединения N3Health
document.getElementById('testN3HealthBtn').addEventListener('click', async function () {
const host = document.getElementById('n3healthHost').value;
const authorization = document.getElementById('n3healthAuthorization').value;
const xIdLpu = document.getElementById('n3healthXIdLpu').value;
try {
const testBtn = this;
const originalText = testBtn.textContent;
testBtn.disabled = true;
testBtn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status"></span> Проверка...';
const response = await fetch('/settings/test-n3health', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
host,
authorization,
xIdLpu
})
});
const data = await response.json();
testBtn.disabled = false;
testBtn.textContent = originalText;
if (response.ok && data.status === "ok") {
showAlert('Соединение с N3Health успешно установлено', 'success');
// Активируем кнопку сохранения
document.getElementById('saveN3HealthBtn').disabled = false;
} else {
showAlert(data.message || 'Ошибка при проверке соединения', 'danger');
}
} catch (error) {
console.error('Ошибка:', error);
showAlert('Ошибка при проверке соединения', 'danger');
}
});
// Обработчик сохранения настроек N3Health
document.getElementById('n3healthForm').addEventListener('submit', async function (e) {
e.preventDefault();
const host = document.getElementById('n3healthHost').value;
const authorization = document.getElementById('n3healthAuthorization').value;
const xIdLpu = document.getElementById('n3healthXIdLpu').value;
try {
const response = await fetch('/settings/save-n3health', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
host,
authorization,
xIdLpu
})
});
const data = await response.json();
if (response.ok) {
showAlert('Настройки N3Health успешно сохранены', 'success');
// Обновляем текущие настройки
currentSettings.n3health.host = host;
currentSettings.n3health.authorization = authorization;
currentSettings.n3health.xIdLpu = xIdLpu;
// Сбрасываем флаги изменений
n3healthChanged = false;
// Обновляем кнопки и статусы
updateN3HealthButtons();
updateStatusIndicators();
} else {
showAlert(data.error || 'Ошибка при сохранении', 'danger');
}
} catch (error) {
console.error('Ошибка:', error);
showAlert('Ошибка при сохранении', 'danger');
}
});
// Обработчик отмены изменений N3Health
document.getElementById('cancelN3HealthBtn').addEventListener('click', resetN3HealthForm);
});
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
{% extends "base.html" %}
<!-- Заголовок страницы -->
{% block title %}
{% endblock %}
<!-- Блок шапки -->
{% block head %}
{% endblock %}
<!-- Блок тела -->
{% block body %}
{% endblock %}
<!-- Блок скриптов -->
{% block scripts %}
{% endblock %}
+74
View File
@@ -0,0 +1,74 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>{% block title %}{% endblock %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- Favicon -->
<link rel="icon" href="{{ url_for('static', path='favicon.ico') }}">
<!-- Bootstrap CSS (local) -->
<link rel="stylesheet" href="{{ url_for('static', path='css/bootstrap.min.css') }}">
<!-- Bootstrap Icons (local) -->
<link rel="stylesheet" href="{{ url_for('static', path='icons/bootstrap-icons.css') }}">
<!-- Base styles -->
<link rel="stylesheet" href="{{ url_for('static', path='css/base.css') }}">
{% block head %}{% endblock %}
</head>
<body class="bg-light">
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container-fluid">
<a class="navbar-brand" href="/">ЭДО для MEDODS</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto">
<li class="nav-item" id="nav-login">
<a class="nav-link" href="/login" data-path="/login">
<i class="bi bi-box-arrow-in-right"></i> Вход
</a>
</li>
<li class="nav-item" id="nav-settings">
<a class="nav-link" href="/settings/" data-path="/settings/">
<i class="bi bi-gear"></i> Настройки</a>
</li>
<li class="nav-item" id="nav-staff">
<a class="nav-link" href="/staff/" data-path="/staff/">
<i class="bi bi-file-earmark-lock2"></i> МЧД и УКЭП</a>
</li>
<li class="nav-item" id="nav-logout">
<a class="nav-link" href="/logout">
<i class="bi bi-box-arrow-in-left"></i> Выход
</a>
</li>
</ul>
</div>
</div>
</nav>
<main class="container py-4">
{% block body %}{% endblock %}
</main>
<!-- Bootstrap JS Bundle (with Popper, local) -->
<script src="{{ url_for('static', path='js/bootstrap.bundle.min.js') }}"></script>
<script src="{{ url_for('static', path='js/base.js') }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>
+205
View File
@@ -0,0 +1,205 @@
{% extends "base.html" %}
<!-- Заголовок страницы -->
{% block title %}
ЭДО для MEDODS
{% endblock %}
<!-- Блок шапки -->
{% block head %}
{% endblock %}
<!-- Блок тела -->
{% block body %}
<div class="container mt-4">
<h1 class="mb-4">Обзор системы</h1>
<!-- Визуальный блок с карточками и индикатором прогресса -->
<div class="card mb-4">
<div class="card-body">
<div class="row g-4">
<!-- Карточка настройки пароля -->
<div class="col-md-4">
<div class="p-3 border rounded h-100">
<div class="d-flex align-items-start mb-3">
<div class="flex-shrink-0">
<i class="bi bi-shield-lock fs-4 text-primary"></i>
</div>
<div class="flex-grow-1 ms-3">
<h6 class="mb-1">Пароль администратора</h6>
<span id="passwordStatusBadge" class="badge bg-success">Настроен</span>
</div>
</div>
<p class="text-muted small mb-0">
Пароль администратора установлен и защищает доступ к настройкам системы.
</p>
</div>
</div>
<!-- Карточка интеграции с Медодс -->
<div class="col-md-4">
<div class="p-3 border rounded h-100">
<div class="d-flex align-items-start mb-3">
<div class="flex-shrink-0">
<i class="bi bi-heart-pulse fs-4 text-danger"></i>
</div>
<div class="flex-grow-1 ms-3">
<h6 class="mb-1">Интеграция с Медодс</h6>
<span id="medodsStatusBadge" class="badge bg-secondary">Не настроен</span>
</div>
</div>
<div id="medodsDetails" class="small">
<!-- Динамически заполняемые детали -->
</div>
</div>
</div>
<!-- Карточка интеграции с N3Health -->
<div class="col-md-4">
<div class="p-3 border rounded h-100">
<div class="d-flex align-items-start mb-3">
<div class="flex-shrink-0">
<i class="bi bi-hospital fs-4 text-success"></i>
</div>
<div class="flex-grow-1 ms-3">
<h6 class="mb-1">Интеграция с N3Health</h6>
<span id="n3healthStatusBadge" class="badge bg-secondary">Не настроен</span>
</div>
</div>
<div id="n3healthDetails" class="small">
<!-- Динамически заполняемые детали -->
</div>
</div>
</div>
</div>
<!-- Индикатор готовности системы с кнопкой настроек -->
<div class="mt-4 pt-4 border-top">
<div class="d-flex align-items-center justify-content-between">
<div class="flex-grow-1 me-3">
<div class="d-flex align-items-center mb-2">
<h6 class="mb-0 me-3">Готовность системы:</h6>
<div id="systemReadiness" class="progress flex-grow-1" style="height: 20px;">
<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar"
style="width: 0%" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">0%</div>
</div>
</div>
<p class="text-muted small mb-0" id="readinessDescription">
Все интеграции требуют настройки
</p>
</div>
<!-- Кнопка настроек (будет скрыта при 100% готовности) -->
<div id="settingsButtonContainer">
<a href="/settings" class="btn btn-primary">
<i class="bi bi-gear"></i> Настроить
</a>
</div>
</div>
</div>
</div>
</div>
<!-- БЛОК СТАТИСТИКИ СОТРУДНИКОВ -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header bg-white d-flex align-items-center">
<i class="bi bi-people fs-4 me-2"></i>
<h5 class="mb-0 flex-grow-1">Статистика сотрудников</h5>
<a href="/staff" class="btn btn-outline-primary btn-sm">
<i class="bi bi-arrow-right"></i> Перейти к управлению
</a>
</div>
<div class="card-body">
<!-- Счетчики сотрудников -->
<div class="row g-4 mb-4">
<div class="col-md-3">
<div class="p-3 bg-light rounded d-flex align-items-center">
<div class="flex-shrink-0">
<i class="bi bi-people fs-1 text-primary"></i>
</div>
<div class="flex-grow-1 ms-3">
<h6 class="mb-1">Всего сотрудников</h6>
<span id="totalEmployeesCount" class="h3 fw-bold">0</span>
</div>
</div>
</div>
<div class="col-md-3">
<div class="p-3 bg-light rounded d-flex align-items-center">
<div class="flex-shrink-0">
<i class="bi bi-filetype-key fs-1 text-success"></i>
</div>
<div class="flex-grow-1 ms-3">
<h6 class="mb-1">УКЭП</h6>
<span id="ukepEmployeesCount" class="h3 fw-bold text-success">0</span>
</div>
</div>
</div>
<div class="col-md-3">
<div class="p-3 bg-light rounded d-flex align-items-center">
<div class="flex-shrink-0">
<i class="bi bi-motherboard fs-1 text-warning"></i>
</div>
<div class="flex-grow-1 ms-3">
<h6 class="mb-1">МЧД</h6>
<span id="mchdEmployeesCount" class="h3 fw-bold text-warning">0</span>
</div>
</div>
</div>
<div class="col-md-3">
<div class="p-3 bg-light rounded d-flex align-items-center">
<div class="flex-shrink-0">
<i class="bi bi-exclamation-triangle fs-1 text-danger"></i>
</div>
<div class="flex-grow-1 ms-3">
<h6 class="mb-1">Истекают скоро</h6>
<span id="expiringEmployeesCount" class="h3 fw-bold text-danger">0</span>
</div>
</div>
</div>
</div>
<!-- Список сотрудников с истекающими подписями -->
<div id="expiringEmployeesContainer" style="display: none;">
<div class="d-flex align-items-center mb-3">
<h6 class="mb-0 me-2">
<i class="bi bi-exclamation-triangle-fill text-warning me-1"></i>
Сотрудники с истекающими подписями
</h6>
<span id="expiringThresholdDays" class="badge bg-secondary"></span>
</div>
<div class="table-responsive">
<table class="table table-sm table-hover" id="expiringEmployeesTable">
<thead>
<tr>
<th>ФИО</th>
<th>Тип подписи</th>
<th>Дата окончания</th>
<th>Осталось дней</th>
</tr>
</thead>
<tbody id="expiringEmployeesTableBody">
<!-- Данные будут загружены через JS -->
</tbody>
</table>
</div>
</div>
<!-- Сообщение об отсутствии истекающих подписей -->
<div id="noExpiringEmployeesMessage" class="text-center text-muted py-4" style="display: none;">
<i class="bi bi-check-circle-fill text-success fs-2"></i>
<p class="mt-2 mb-0">Нет сотрудников с истекающими подписями</p>
<small>Порог предупреждения: <span id="noExpiringThresholdDays">0</span> дней</small>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
<!-- Блок скриптов -->
{% block scripts %}
<script src="{{ url_for('static', path='js/index.js') }}"></script>
{% endblock %}
+39
View File
@@ -0,0 +1,39 @@
{% extends "base.html" %}
{% block title %}Авторизация{% endblock %}
{% block head %}
<link rel="stylesheet" href="{{ url_for('static', path='css/login.css') }}">
{% endblock %}
{% block body %}
<div class="full-center">
<div class="card login-card shadow">
<div class="card-body">
<h4 class="text-center mb-4">Вход</h4>
<form id="loginForm">
<div class="mb-3">
<div class="input-group">
<input type="password" class="form-control" id="password" placeholder="Введите пароль"
autofocus>
<span class="input-group-text password-toggle cursor-pointer noselect" id="togglePassword">
<i class="bi bi-eye"></i>
</span>
</div>
</div>
<button type="submit" class="btn btn-success w-100">
Войти
</button>
</form>
<div id="error" class="alert alert-danger mt-3 d-none"></div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', path='js/login.js') }}"></script>
{% endblock %}
+171
View File
@@ -0,0 +1,171 @@
{% extends "base.html" %}
<!-- Заголовок страницы -->
{% block title %}
Настройки системы
{% endblock %}
<!-- Блок шапки -->
{% block head %}
{% endblock %}
<!-- Блок тела -->
{% block body %}
<div class="container mt-4">
<h1 class="mb-4">Настройки системы</h1>
<!-- Сообщения об успехе/ошибке -->
<div id="alertContainer"></div>
<div class="accordion" id="settingsAccordion">
<!-- Блок пароля -->
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#passwordSection">
<div class="d-flex align-items-center w-100">
<span class="me-3">Пароль администратора</span>
<span id="passwordStatus" class="badge bg-secondary">Не настроен</span>
</div>
</button>
</h2>
<div id="passwordSection" class="accordion-collapse collapse" data-bs-parent="#settingsAccordion">
<div class="accordion-body">
<form id="passwordForm">
<div class="row">
<div class="col-md-6 mb-3">
<label for="currentPassword" class="form-label">Текущий пароль</label>
<input type="password" class="form-control" id="currentPassword" required>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="newPassword" class="form-label">Новый пароль</label>
<input type="password" class="form-control" id="newPassword" required>
</div>
<div class="col-md-6 mb-3">
<label for="confirmPassword" class="form-label">Подтверждение пароля</label>
<input type="password" class="form-control" id="confirmPassword" required>
</div>
</div>
<button type="submit" class="btn btn-danger">Сменить пароль</button>
<small class="text-muted d-block mt-2">После смены пароля произойдет автоматический выход из
системы</small>
</form>
</div>
</div>
</div>
<!-- Блок Медодс -->
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#medodsSection">
<div class="d-flex align-items-center w-100">
<span class="me-3">Интеграция с Медодс</span>
<span id="medodsStatus" class="badge bg-secondary">Не настроен</span>
</div>
</button>
</h2>
<div id="medodsSection" class="accordion-collapse collapse" data-bs-parent="#settingsAccordion">
<div class="accordion-body">
<form id="medodsForm">
<div class="row mb-3">
<div class="col-md-6">
<label for="medodsHost" class="form-label">Host API (без 'https://' или
'http://')</label>
<input type="text" class="form-control" id="medodsHost" placeholder="api.medods.ru">
</div>
<div class="col-md-6">
<label for="medodsPort" class="form-label">Port</label>
<input type="number" class="form-control" id="medodsPort" placeholder="443" min="1"
max="65535">
</div>
</div>
<div class="mb-3">
<label for="medodsCsvFile" class="form-label">CSV файл с учетными данными:
<span id="medodsApiKeyStatus" class="badge bg-secondary">нет</span>
</label>
<input class="form-control" type="file" id="medodsCsvFile" accept=".csv">
<div class="form-text">
Загрузите CSV файл, содержащий identity и secretKey
</div>
</div>
<div class="d-flex gap-2">
<button type="button" id="testMedodsBtn" class="btn btn-outline-primary" disabled>
Проверить соединение
</button>
<button type="submit" id="saveMedodsBtn" class="btn btn-primary" disabled>
Сохранить настройки
</button>
<button type="button" id="cancelMedodsBtn" class="btn btn-outline-secondary" disabled>
Отменить изменения
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Блок N3Health -->
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#n3healthSection">
<div class="d-flex align-items-center w-100">
<span class="me-3">Интеграция с N3Health</span>
<span id="n3healthStatus" class="badge bg-secondary">Не настроен</span>
</div>
</button>
</h2>
<div id="n3healthSection" class="accordion-collapse collapse" data-bs-parent="#settingsAccordion">
<div class="accordion-body">
<form id="n3healthForm">
<div class="row">
<div class="col-md-6 mb-3">
<label for="n3healthHost" class="form-label">Host API</label>
<input type="text" class="form-control" id="n3healthHost"
placeholder="Вида: https://b2b-demo.n3health.ru/sep/api">
</div>
<div class="col-md-6 mb-3">
<label for="n3healthAuthorization" class="form-label">Authorization (Токен доступа
СЭП)</label>
<input type="text" class="form-control" id="n3healthAuthorization"
placeholder="Вида: 44556afd-0e84-b847-2322-75999668f590">
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="n3healthXIdLpu" class="form-label">idLPU (Идентификатор МО)</label>
<input type="text" class="form-control" id="n3healthXIdLpu"
placeholder="Вида: 0453937e-fad5-402d-aacb-3ce6df2bf6fe">
</div>
</div>
<div class="d-flex gap-2">
<button type="button" id="testN3HealthBtn" class="btn btn-outline-primary" disabled>
Проверить соединение
</button>
<button type="submit" id="saveN3HealthBtn" class="btn btn-primary" disabled>
Сохранить настройки
</button>
<button type="button" id="cancelN3HealthBtn" class="btn btn-outline-secondary" disabled>
Отменить изменения
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
<!-- Блок скриптов -->
{% block scripts %}
<script src="{{ url_for('static', path='js/settings.js') }}"></script>
{% endblock %}
+249
View File
@@ -0,0 +1,249 @@
{% extends "base.html" %}
<!-- Заголовок страницы -->
{% block title %}
Работа с МЧД и УКЭП
{% endblock %}
<!-- Блок шапки -->
{% block head %}
{% endblock %}
<!-- Блок тела -->
{% block body %}
<div class="container mt-4">
<!-- Заголовок и настройка expirationAlert -->
<div class="d-flex align-items-center mb-4">
<h1 class="mb-0 me-4 flex-shrink-0">
Работа с МЧД и УКЭП
</h1>
<!-- 🔍 Поиск — занимает всё свободное место -->
<div class="input-group flex-grow-1 me-4">
<span class="input-group-text">
<i class="bi bi-search"></i>
</span>
<input type="text" id="staffTableSearch" class="form-control"
placeholder="Поиск по ФИО, типу подписи или номеру МЧД...">
<button type="button" id="clearTableSearchBtn" class="btn btn-outline-secondary">
<i class="bi bi-x"></i>
</button>
</div>
<!-- ⚠️ Настройка предупреждений -->
<div class="d-flex align-items-center flex-shrink-0">
<span class="me-2">Предупреждение за дней:</span>
<div class="d-flex align-items-center" id="expirationAlertContainer">
<span id="expirationAlertValue" class="badge bg-secondary fs-6">0</span>
<button type="button" id="editExpirationAlertBtn" class="btn btn-sm btn-outline-primary ms-2">
<i class="bi bi-pencil"></i>
</button>
</div>
<!-- Счетчик сотрудников с истекающими подписями -->
<div id="expiringCountContainer" class="ms-3" style="display: none;">
<span id="expiringCountBadge" class="badge bg-warning text-dark fs-6" data-bs-toggle="tooltip"
data-bs-placement="bottom" title="Сотрудников с истекающим сроком подписи">
0
</span>
</div>
</div>
</div>
<!-- Сообщения -->
<div id="alertContainer"></div>
<!-- Обновляем таблицу с добавлением сортируемых заголовков -->
<div class="card">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover" id="staffTable">
<thead>
<tr>
<th data-sort="familyName" style="cursor: pointer;">
Фамилия <i class="bi bi-arrow-down-up sort-icon"></i>
</th>
<th data-sort="givenName" style="cursor: pointer;">
Имя <i class="bi bi-arrow-down-up sort-icon"></i>
</th>
<th data-sort="middleName" style="cursor: pointer;">
Отчество <i class="bi bi-arrow-down-up sort-icon"></i>
</th>
<th data-sort="esiaAuth" style="cursor: pointer;">
Тип подписи <i class="bi bi-arrow-down-up sort-icon"></i>
</th>
<th data-sort="expired_at" style="cursor: pointer;">
Срок действия <i class="bi bi-arrow-down-up sort-icon"></i>
</th>
<th style="width: 100px;">Действия</th>
</tr>
</thead>
<tbody id="staffTableBody">
<!-- Данные будут загружены через JS -->
</tbody>
</table>
</div>
</div>
</div>
<!-- Кнопка добавления -->
<div class="text-end mt-3">
<button type="button" id="addStaffBtn" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Добавить сотрудника
</button>
</div>
</div>
<!-- Модальное окно для добавления сотрудника -->
<div class="modal fade" id="addStaffModal" tabindex="-1">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Добавление сотрудника</h5>
<!-- Поле поиска -->
<div class="ms-3 w-50">
<div class="input-group">
<span class="input-group-text">
<i class="bi bi-search"></i>
</span>
<input type="text" id="staffSearchInput" class="form-control" placeholder="Поиск по фамилии...">
<button type="button" id="clearSearchBtn" class="btn btn-outline-secondary">
<i class="bi bi-x"></i>
</button>
</div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<!-- Таблица с фиксированной высотой и прокруткой -->
<div class="table-responsive" style="max-height: 80vh; overflow-y: auto;">
<table class="table table-hover" id="availableStaffTable">
<thead>
<tr>
<th>Фамилия</th>
<th>Имя</th>
<th>Отчество</th>
<th>Специальности</th>
<th>Добавить как</th>
</tr>
</thead>
<tbody id="availableStaffTableBody">
<!-- Данные будут загружены через JS -->
</tbody>
</table>
</div>
<!-- Индикатор загрузки -->
<div id="staffLoading" class="text-center py-4" style="display: none;">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Загрузка...</span>
</div>
<p class="mt-2 text-muted">Загрузка данных...</p>
</div>
</div>
</div>
</div>
</div>
<!-- Модальное окно для редактирования МЧД -->
<div class="modal fade" id="editMCHDModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Настройка МЧД</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="mchdForm">
<input type="hidden" id="mchdPractitionerId">
<div class="mb-3">
<label for="mchdNumber" class="form-label">Номер МЧД</label>
<input type="text" class="form-control" id="mchdNumber" required>
</div>
<div class="mb-3">
<label for="mchdExpiryDate" class="form-label">Дата окончания действия</label>
<input type="date" class="form-control" id="mchdExpiryDate" required>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="button" class="btn btn-primary" id="saveMCHDBtn">Сохранить</button>
</div>
</div>
</div>
</div>
<!-- Модальное окно для редактирования УКЭП -->
<div class="modal fade" id="editUKEPModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Настройка УКЭП</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="ukepForm">
<input type="hidden" id="ukepPractitionerId">
<div class="mb-3">
<label for="ukepFile" class="form-label">Файл УКЭП (.p7s)</label>
<input type="file" class="form-control" id="ukepFile" accept=".p7s" required>
</div>
<div class="mb-3">
<label for="ukepExpiryDate" class="form-label">Дата окончания действия</label>
<input type="date" class="form-control" id="ukepExpiryDate" required>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="button" class="btn btn-primary" id="saveUKEPBtn">Сохранить</button>
</div>
</div>
</div>
</div>
<!-- Модальное окно подтверждения удаления -->
<div class="modal fade" id="confirmDeleteModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Подтверждение удаления</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>Вы уверены, что хотите удалить данные сотрудника?</p>
<p class="text-danger fw-bold">Это действие нельзя отменить.</p>
<input type="hidden" id="deletePractitionerId">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="button" class="btn btn-danger" id="confirmDeleteBtn">Удалить</button>
</div>
</div>
</div>
</div>
<!-- Окно для отображения номера МЧД -->
<div id="attorneyPopup" class="position-fixed bg-white border rounded shadow p-3"
style="display: none; z-index: 1060; max-width: 400px;">
<div class="d-flex justify-content-between align-items-start mb-2">
<h6 class="mb-0">Номер МЧД</h6>
<button type="button" class="btn-close btn-sm" id="closeAttorneyPopupBtn"></button>
</div>
<div class="mb-2">
<code id="attorneyNumber" class="d-block p-2 bg-light rounded"></code>
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-sm btn-outline-primary" id="copyAttorneyBtn">
<i class="bi bi-clipboard"></i> Копировать
</button>
<button type="button" class="btn btn-sm btn-secondary" id="closeAttorneyBtn">Закрыть</button>
</div>
</div>
{% endblock %}
<!-- Блок скриптов -->
{% block scripts %}
<script src="{{ url_for('static', path='js/staff.js') }}"></script>
{% endblock %}
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
some_data
+5
View File
@@ -0,0 +1,5 @@
from .variables import *
import os
RELOAD_DIR = os.path.dirname(os.path.abspath(__file__)).replace("config", "")
TEMPLATES_DIR = os.path.join(RELOAD_DIR, "app/templates")
+23
View File
@@ -0,0 +1,23 @@
[loggers]
keys=root
[handlers]
keys=logconsole
[formatters]
keys=formatter
[logger_root]
level=INFO
handlers=logconsole
[formatter_formatter]
class=colorlog.ColoredFormatter
format=%(log_color)s%(asctime)s [%(name)s]: [%(levelname)s] %(message)s [%(filename)s:%(lineno)d '%(funcName)s']
datefmt=%Y-%m-%d %H:%M:%S
[handler_logconsole]
class=colorlog.StreamHandler
level=INFO
args=(sys.stdout,)
formatter=formatter
+87
View File
@@ -0,0 +1,87 @@
{
"201": {
"ru": "Создан",
"en": "Created",
"description": "Документ создан через API, готов к обработке"
},
"202": {
"ru": "Отправлен на подписание",
"en": "Sent for Signing",
"description": "Документ отправлен получателю"
},
"203": {
"ru": "Просмотрел",
"en": "Viewed",
"description": "Документ просмотрен получателем"
},
"204": {
"ru": "Подписал",
"en": "Signed",
"description": "Документ подписан получателем"
},
"205": {
"ru": "Отказался",
"en": "Rejected",
"description": "Получатель отказался от подписания"
},
"206": {
"ru": "Срок для подписания истек",
"en": "Signing Period Expired",
"description": "Срок действия ссылки истек до момента проставления подписи или отказа"
},
"207": {
"ru": "Ожидание действий всех получателей",
"en": "Awaiting Actions from All Recipients",
"description": "Ожидание действий от всех получателей"
},
"208": {
"ru": "Успешно. Подписан всеми — обработка",
"en": "Success. Fully Signed — Processing",
"description": "Все получатели подписали, документ обрабатывается"
},
"209": {
"ru": "Успешно. Подписан не всеми — обработка",
"en": "Success. Partially Signed — Processing",
"description": "Не все получатели подписали, документ обрабатывается"
},
"210": {
"ru": "Завершено. Подписано всеми",
"en": "Completed. Fully Signed",
"description": "Все получатели подписали, документ сохранен"
},
"211": {
"ru": "Завершено. Подписано не всеми",
"en": "Completed. Partially Signed",
"description": "Не все получатели подписали, документ сохранен"
},
"212": {
"ru": "Отменен для получателя",
"en": "Cancelled for Recipient",
"description": "Подписание отменено для конкретного получателя"
},
"213": {
"ru": "Завершено. Отменено для всех",
"en": "Completed. Cancelled for All",
"description": "Подписание отменено для всех получателей"
},
"498": {
"ru": "Завершено. Отклонено всеми",
"en": "Completed. Rejected by All",
"description": "Все получатели отказались от подписания"
},
"499": {
"ru": "Завершено. Истекло для всех",
"en": "Completed. Expired for All",
"description": "Срок ссылки истек для всех получателей"
},
"500": {
"ru": "Ошибка обработки",
"en": "Processing Error",
"description": "Ошибка во время обработки документа"
},
"501": {
"ru": "Ошибка доставки получателю",
"en": "Delivery Error",
"description": "Произошла ошибка при доставке конкретному получателю"
}
}
+11
View File
@@ -0,0 +1,11 @@
import os
from dotenv import load_dotenv
load_dotenv()
# Database
DB_NAME = os.environ.get("DB_NAME")
DB_USER = os.environ.get("DB_USER")
DB_PASSWORD = os.environ.get("DB_PASSWORD")
DB_HOST = os.environ.get("DB_HOST")
DB_PORT = os.environ.get("DB_PORT")
+129
View File
@@ -0,0 +1,129 @@
from sqlalchemy import delete
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from sqlalchemy.exc import InvalidRequestError
from sqlalchemy.pool import NullPool
import config
from utils import logger
DATABASE_URL = f"postgresql+asyncpg://{config.DB_USER}:{config.DB_PASSWORD}@{config.DB_HOST}:{config.DB_PORT}/{config.DB_NAME}"
engine = create_async_engine(DATABASE_URL, poolclass=NullPool)
SessionLocal = async_sessionmaker(engine)
Base = declarative_base()
class CRUD:
@staticmethod
async def create(db_data, refresh: bool = False):
try:
is_lst = isinstance(db_data, list)
async with SessionLocal() as db:
if is_lst:
logger.debug(f"Создаю {len(db_data)} записей")
try:
db.add_all(db_data)
except InvalidRequestError:
for data in db_data:
await db.merge(data)
else:
logger.debug("Создаю запись")
db.add(db_data)
await db.commit()
if refresh:
if is_lst:
logger.debug(f"Обновляю {len(db_data)} записей")
for data in db_data:
await db.refresh(data)
else:
logger.debug("Обновляю запись")
await db.refresh(db_data)
logger.debug("Запись создана")
return db_data if refresh else None
except Exception as e:
logger.error(f"Ошибка создания: {str(e)}", exc_info=True)
return None
@staticmethod
async def read(query, all: bool = False):
try:
async with SessionLocal() as db:
logger.debug(f"Чтение записей. Все: {all}")
results = await db.execute(query)
logger.debug(f"Чтение завершено")
return (
results.unique().scalars().all()
if all
else results.unique().scalars().first()
)
except Exception as e:
logger.error(f"Ошибка чтения: {str(e)}", exc_info=True)
return None
@staticmethod
async def delete(db_data) -> bool:
def itemdebug(instance):
from sqlalchemy import inspect
state = inspect(instance)
if state.identity is None:
pKey = None
pValue = None
else:
mapper = state.mapper
pKey = mapper.primary_key[0].name
pValue = getattr(instance, pKey)
return {"key": pKey, "value": pValue, "class": instance.__class__}
async def deleteFromDB(data, db):
itemData = itemdebug(data)
query = delete(itemData["class"]).where(
getattr(itemData["class"], itemData["key"]) == itemData["value"]
)
await db.execute(query)
async with SessionLocal() as db:
try:
if isinstance(db_data, list):
logger.debug(f"Удаляю записей: {len(db_data)}")
for data in db_data:
await deleteFromDB(data, db)
else:
logger.debug("Удаляю запись")
await deleteFromDB(db_data, db)
await db.commit()
logger.debug("Запись удалена")
return True
except Exception as e:
await db.rollback()
logger.error(f"Ошибка удаления: {str(e)}", exc_info=True)
return False
@staticmethod
async def update(model, id: int, **kwargs):
from sqlalchemy import update as sa_update
async with SessionLocal() as db:
try:
query = (
sa_update(model)
.where(model.id == id)
.values(**kwargs)
.execution_options(synchronize_session="fetch")
)
await db.execute(query)
await db.commit()
logger.debug("Запись обновлена")
return await db.get(model, id)
except Exception as e:
await db.rollback()
logger.error(f"Ошибка обновления: {str(e)}", exc_info=True)
return None
+218
View File
@@ -0,0 +1,218 @@
from typing import Optional
from sqlalchemy import inspect, text
from sqlalchemy.ext.asyncio import create_async_engine, AsyncEngine
from sqlalchemy.schema import CreateTable
from sqlalchemy.dialects.postgresql import dialect as pg_dialect
from utils import logger
# Import initial data
from db.schemas import *
class DatabaseInitializer:
existing_tables: Optional[list[str]] = None
def __init__(self, database_url: str):
from db import Base
self.database_url = database_url
self.engine: Optional[AsyncEngine] = None
self.metadata = Base.metadata
# ==========================================================
# PUBLIC
# ==========================================================
async def initialize(self, force: bool = False, reNewDB: bool = False):
"""Main database initialization method"""
try:
self.engine = create_async_engine(self.database_url)
async with self.engine.begin() as conn:
if force:
logger.warning("Принудительное удаление и создание БД...")
await self._drop_all()
await self._create_tables_directly()
await self._initialize_data()
return
tables_exist = await self._check_tables_exist(conn)
if not tables_exist:
logger.warning("Не все таблицы существуют. Создаем недостающие...")
await self._create_tables_directly()
# 🔥 СИНХРОНИЗАЦИЯ СХЕМЫ
logger.info("Синхронизация схемы БД...")
await self._sync_schema(conn)
if reNewDB:
logger.warning("Принудительная загрузка данных...")
await self._initialize_data()
logger.info("Инициализация БД завершена успешно")
except Exception as e:
logger.exception("Инициализация базы завершилась ошибкой")
raise
finally:
if self.engine:
await self.engine.dispose()
# ==========================================================
# CHECK
# ==========================================================
async def _check_tables_exist(self, conn) -> bool:
"""Check if all tables from metadata exist"""
try:
DatabaseInitializer.existing_tables = await conn.run_sync(
lambda sync_conn: inspect(sync_conn).get_table_names()
)
required_tables = set(self.metadata.tables.keys())
if not required_tables:
logger.error("Нет данных о таблицах в метаданных")
return False
missing_tables = required_tables - set(DatabaseInitializer.existing_tables)
if missing_tables:
logger.warning("Отсутствующие таблицы:")
logger.warning(missing_tables)
return False
return True
except Exception as e:
logger.warning(f"Проверка таблиц завершилась ошибкой: {str(e)}")
return False
# ==========================================================
# CREATE / DROP
# ==========================================================
async def _create_tables_directly(self):
"""Create tables directly using SQLAlchemy (bypass Alembic)"""
async with self.engine.begin() as conn:
for table in self.metadata.sorted_tables:
if (
DatabaseInitializer.existing_tables
and table.name in DatabaseInitializer.existing_tables
):
logger.debug(f"Таблица {table.name} уже существует")
continue
logger.info(f"Создаем таблицу: {table.name}")
await conn.execute(CreateTable(table))
async def _drop_all(self):
"""Drop all tables"""
async with self.engine.begin() as conn:
existing_tables = await conn.run_sync(
lambda sync_conn: inspect(sync_conn).get_table_names()
)
for table in existing_tables:
logger.warning(f"Удаляем таблицу: {table}")
await conn.execute(text(f'DROP TABLE "{table}" CASCADE'))
# ==========================================================
# SCHEMA SYNC
# ==========================================================
async def _sync_schema(self, conn):
await self._add_missing_columns(conn)
await self._add_missing_foreign_keys(conn)
async def _add_missing_columns(self, conn):
for table_name, table in self.metadata.tables.items():
db_columns = await conn.run_sync(
lambda sync_conn: {
col["name"] for col in inspect(sync_conn).get_columns(table_name)
}
)
for column in table.columns:
if column.name in db_columns:
continue
ddl = (
f"ALTER TABLE {table_name} "
f"ADD COLUMN {self._compile_column(column)}"
)
logger.warning(f"[ADD COLUMN] {table_name}.{column.name}")
await conn.execute(text(ddl))
async def _add_missing_foreign_keys(self, conn):
for table_name, table in self.metadata.tables.items():
db_fks = await conn.run_sync(
lambda sync_conn: inspect(sync_conn).get_foreign_keys(table_name)
)
db_fk_pairs = {
(
tuple(fk["constrained_columns"]),
fk["referred_table"],
)
for fk in db_fks
}
for fk in table.foreign_keys:
col = fk.parent.name
ref_table = fk.column.table.name
ref_col = fk.column.name
key = ((col,), ref_table)
if key in db_fk_pairs:
continue
constraint_name = f"fk_{table_name}_{col}"
ddl = f"""
ALTER TABLE {table_name}
ADD CONSTRAINT {constraint_name}
FOREIGN KEY ({col})
REFERENCES {ref_table}({ref_col})
"""
if fk.ondelete:
ddl += f" ON DELETE {fk.ondelete}"
logger.warning(f"[ADD FK] {table_name}.{col}{ref_table}.{ref_col}")
await conn.execute(text(ddl))
# ==========================================================
# HELPERS
# ==========================================================
def _compile_column(self, column) -> str:
"""Compile SQLAlchemy Column to SQL"""
ddl = f"{column.name} {column.type.compile(dialect=pg_dialect())}"
if not column.nullable:
ddl += " NULL" # безопасно, NOT NULL позже вручную
if column.server_default is not None:
ddl += f" DEFAULT {column.server_default.arg}"
return ddl
# ==========================================================
# DATA INIT
# ==========================================================
async def _initialize_data(self):
try:
logger.info("Инициализация данных...")
await Settings.initSettings()
logger.info("Данные успешно инициализированы")
except Exception:
logger.exception("Ошибка инициализации данных")
raise
+6
View File
@@ -0,0 +1,6 @@
from .practitioners import *
from .patients import *
from .signings import *
from .documents import *
from .statuses import *
from .settings import *
+51
View File
@@ -0,0 +1,51 @@
from datetime import datetime
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, select
from db import CRUD, Base
from utils import answer, logger, toDict
class Document(Base):
__tablename__ = "documents"
id = Column(Integer, primary_key=True, index=True)
singingId = Column(Integer, ForeignKey("signings.id", ondelete="CASCADE"))
idPatientMis = Column(
String, ForeignKey("patients.idPatientMis", ondelete="CASCADE")
)
number = Column(Integer)
title = Column(String)
storagePath = Column(String)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
def __init__(self, **kwargs):
for key, value in kwargs.items():
setattr(self, key, value)
def toDict(self):
return toDict(self)
async def save(self) -> "Document":
return await CRUD.create(self, refresh=True)
@staticmethod
async def addDocument(**kwargs):
return await Document(**kwargs).save()
@staticmethod
async def getDocumentsByIdPatientMisAndNumber(
idPatientMis: str, number: int, isCheck: bool = False
):
document = await CRUD.read(
select(Document)
.where(Document.idPatientMis == idPatientMis)
.where(Document.number == number),
True,
)
if document:
return answer(data=document)
if not isCheck:
logger.warning(
f"Документ не найден, idPatientMis: {idPatientMis}, number: {number}"
)
return answer(success=False, message="Документ не найден")
+118
View File
@@ -0,0 +1,118 @@
from datetime import datetime
from sqlalchemy import Column, DateTime, Integer, String, select
from db import Base, CRUD
import utils
class Patient(Base):
__tablename__ = "patients"
id = Column(Integer, primary_key=True, index=True)
idPatientMis = Column(String, unique=True)
familyName = Column(String)
givenName = Column(String)
middleName = Column(String)
name = Column(String)
fullName = Column(String)
birthDate = Column(DateTime)
sex = Column(String)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
def __init__(self, **kwargs):
for key, value in kwargs.items():
setattr(self, key, value)
def toDict(self):
return utils.toDict(self)
async def save(self) -> "Patient":
return await CRUD.create(self, refresh=True)
async def edit(self, **kwargs) -> "Patient":
return await CRUD.update(Patient, self.id, **kwargs)
async def delete(self) -> bool:
return await CRUD.delete(self)
@staticmethod
async def addPatient(**kwargs):
idPatientMis = kwargs.get("idPatientMis", None)
if not idPatientMis:
utils.logger.error("Не указан идентификатор пациента")
return utils.answer(
success=False, message="Не указан идентификатор пациента"
)
if type(idPatientMis) != str:
idPatientMis = str(idPatientMis)
kwargs["idPatientMis"] = idPatientMis
patient = await Patient.getPatientByIdPatientMis(
idPatientMis=idPatientMis, toDict=False, isCheck=True
)
if patient.success:
utils.logger.error(
f"Пациент {patient.data.name} с идентификатором {idPatientMis} уже существует"
)
return utils.answer(
success=False,
message=f"Пациент {patient.data.name} с идентификатором {idPatientMis} уже существует",
)
kwargs["name"] = (
f"{kwargs.get('familyName', '')} {kwargs.get('givenName', ' ')[:1]}. {kwargs.get('middleName', ' ')[:1]}."
)
kwargs["fullName"] = (
f"{kwargs.get('familyName', '')} {kwargs.get('givenName', '')} {kwargs.get('middleName', '')}"
)
kwargs["birthDate"] = datetime.fromisoformat(kwargs.pop("birthDate"))
utils.logger.info(
f"Добавление пациента {kwargs.get('name', '')} с идентификатором {idPatientMis}"
)
patient = Patient(**kwargs)
data = await patient.save()
return utils.answer(data=data)
@staticmethod
async def getPatients(toDict: bool = True):
patients = await CRUD.read(select(Patient), all=True)
data = [patient.toDict() for patient in patients] if toDict else patients
return utils.answer(data=data)
@staticmethod
async def getPatientByIdPatientMis(
idPatientMis: str, toDict: bool = True, isCheck: bool = False
):
if type(idPatientMis) != str:
idPatientMis = str(idPatientMis)
patient = await CRUD.read(
select(Patient).where(Patient.idPatientMis == idPatientMis)
)
if patient:
data = patient.toDict() if toDict else patient
return utils.answer(data=data)
if not isCheck:
utils.logger.error(f"Пациент не найден, idPatientMis: {idPatientMis}")
return utils.answer(success=False, message="Пациент не найден")
@staticmethod
async def editPatientrByIdPatientMis(idPatientMis: str, **kwargs):
patient = await Patient.getPatientByIdPatientMis(idPatientMis, False)
if not patient.success:
utils.logger.error(f"Пациент не найден, idPatientMis: {idPatientMis}")
return utils.answer(success=False, message="Пациент не найден")
data = await patient.edit(**kwargs)
return utils.answer(data=data)
@staticmethod
async def deletePatientrByIdPatientMis(idPatientMis: str) -> bool:
try:
patient = await Patient.getPatientByIdPatientMis(idPatientMis, False)
await patient.data.delete()
utils.logger.info(f"Пациент удален, idPatientMis: {idPatientMis}")
return utils.answer()
except:
utils.logger.error(f"Пациент не удален, idPatientMis: {idPatientMis}")
return utils.answer(success=False, message="Пациент не удален")
+115
View File
@@ -0,0 +1,115 @@
from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, Integer, String, select
from db import Base, CRUD
import utils
class Practitioner(Base):
__tablename__ = "practitioners"
id = Column(Integer, primary_key=True, index=True)
familyName = Column(String)
givenName = Column(String)
middleName = Column(String)
userIdLpu = Column(String, unique=True)
name = Column(String)
fullName = Column(String)
esiaAuth = Column(Boolean)
attorney = Column(String, nullable=True)
expired_at = Column(DateTime)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
def __init__(self, **kwargs):
for key, value in kwargs.items():
setattr(self, key, value)
def toDict(self):
return utils.toDict(self)
async def save(self) -> "Practitioner":
return await CRUD.create(self, refresh=True)
async def edit(self, **kwargs) -> "Practitioner":
return await CRUD.update(Practitioner, self.id, **kwargs)
async def delete(self) -> bool:
return await CRUD.delete(self)
@staticmethod
async def addPractitioner(**kwargs):
userIdLpu = kwargs.get("userIdLpu", None)
if not userIdLpu:
utils.logger.error("Не указан идентификатор сотрудника")
return utils.answer(
success=False, message="Не указан идентификатор сотрудника"
)
practitioner = await Practitioner.getPractitionerByIdLpu(userIdLpu, False, True)
if practitioner.success:
utils.logger.error(
f"Сотрудник {practitioner.data.name} с идентификатором {userIdLpu} уже существует"
)
return utils.answer(
success=False,
message=f"Сотрудник {practitioner.data.name} с идентификатором {userIdLpu} уже существует",
)
kwargs["name"] = (
f"{kwargs.get('familyName', '')} {kwargs.get('givenName', ' ')[:1]}. {kwargs.get('middleName', ' ')[:1]}."
)
kwargs["fullName"] = (
f"{kwargs.get('familyName', '')} {kwargs.get('givenName', '')} {kwargs.get('middleName', '')}"
)
utils.logger.info(
f"Добавление сотрудника {kwargs.get('name', '')} с идентификатором {userIdLpu}"
)
practitioner = Practitioner(**kwargs)
data = await practitioner.save()
return utils.answer(data=data)
@staticmethod
async def getPractitioners(toDict: bool = True):
practitioners = await CRUD.read(select(Practitioner), all=True)
data = (
[practitioner.toDict() for practitioner in practitioners]
if toDict
else practitioners
)
return utils.answer(data=data)
@staticmethod
async def getPractitionerByIdLpu(
idLpu: str, toDict: bool = True, isCheck: bool = False
):
if type(idLpu) != str:
idLpu = str(idLpu)
practitioner = await CRUD.read(
select(Practitioner).where(Practitioner.userIdLpu == idLpu)
)
if practitioner:
data = practitioner.toDict() if toDict else practitioner
return utils.answer(data=data)
if not isCheck:
utils.logger.error(f"Сотрудник не найден, idLpu: {idLpu}")
return utils.answer(success=False, message="Сотрудник не найден")
@staticmethod
async def editPractitionerByIdLpu(idLpu: str, **kwargs):
practitioner = await Practitioner.getPractitionerByIdLpu(idLpu, False)
if not practitioner.success:
utils.logger.error(f"Сотрудник не найден, idLpu: {idLpu}")
return utils.answer(success=False, message="Сотрудник не найден")
data = await practitioner.data.edit(**kwargs)
return utils.answer(data=data)
@staticmethod
async def deletePractitionerByIdLpu(idLpu: str) -> bool:
try:
practitioner = await Practitioner.getPractitionerByIdLpu(idLpu, False)
esiaAuth = practitioner.data.esiaAuth
await practitioner.data.delete()
utils.logger.info(f"Сотрудник удален, idLpu: {idLpu}")
return utils.answer(data=esiaAuth)
except:
utils.logger.error(f"Сотрудник не удален, idLpu: {idLpu}")
return utils.answer(success=False, message="Сотрудник не удален")
+131
View File
@@ -0,0 +1,131 @@
from datetime import datetime
import secrets
from sqlalchemy import Column, DateTime, Integer, String, select
from db import Base, CRUD
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
import utils
from utils import logger
ph = PasswordHasher()
class Settings(Base):
__tablename__ = "settings"
id = Column(Integer, primary_key=True, index=True)
hashedPassword = Column(String)
actualToken = Column(String, nullable=True)
medodsApiHost = Column(String, nullable=True)
medodsApiPort = Column(Integer, nullable=True)
medodsApiIdentity = Column(String, nullable=True)
medodsApiSecretKey = Column(String, nullable=True)
n3healthHost = Column(String, nullable=True)
n3healthAutorization = Column(String, nullable=True)
n3healthXIdLpu = Column(String, nullable=True)
expirationAlert = Column(Integer, nullable=True)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
def __init__(self, **kwargs):
for key, value in kwargs.items():
setattr(self, key, value)
def toDict(self):
return utils.toDict(self)
async def save(self) -> "Settings":
return await CRUD.create(self, refresh=True)
async def edit(self, **kwargs) -> "Settings":
return await CRUD.update(Settings, self.id, **kwargs)
async def delete(self) -> bool:
return await CRUD.delete(self)
@staticmethod
async def getSettings(toDict: bool = True, isCheck: bool = False):
settings = await CRUD.read(select(Settings))
if not settings:
if not isCheck:
utils.logger.error("Настройки не инициализированы")
return utils.answer(success=False, message="Настройки не инициализированы")
data = settings.toDict() if toDict else settings
return utils.answer(data=data)
@staticmethod
async def initSettings():
utils.logger.info("Инициализация настроек...")
settings = await Settings.getSettings(False, True)
if settings.success:
utils.logger.info("Настройки уже инициализированы, обновляем...")
await settings.data.delete()
settingsData = {
"hashedPassword": ph.hash("123"),
"medodsApiHost": "188.64.134.62",
"medodsApiPort": 3000,
"medodsApiIdentity": "56ae4f7e-7053-41a7-816e-7b31d91f869f",
"medodsApiSecretKey": "f90306f7abe623ec4394159c6ac2cbd3f0783a085d217bcc0a5a3d5ea7537902",
"n3healthHost": "https://b2b-demo.n3health.ru/sep/api",
"n3healthAutorization": "42e2080c-e33f-0800-3080-4e6989e3d97f",
"n3healthXIdLpu": "8fc38b60-035b-462d-ad1b-31863e0fd2f0",
"expirationAlert": 45,
}
settings = Settings(**settingsData)
data = await settings.save()
utils.logger.info("Настройки успешно инициализированы")
return utils.answer(data=data)
@staticmethod
async def verifyPassword(password: str):
settings = await Settings.getSettings(False)
if not settings.success:
utils.logger.error("Настройки не инициализированы")
return utils.answer(success=False, message="Настройки не инициализированы")
try:
success = ph.verify(settings.data.hashedPassword, password)
if success:
newToken = secrets.token_hex(16)
await settings.data.edit(actualToken=newToken)
return utils.answer(data=newToken)
except VerifyMismatchError:
logger.error("Проверка пароля завершилась ошибкой")
return utils.answer(success=False)
@staticmethod
async def verifyToken(token: str):
settings = await Settings.getSettings(False)
if not settings.success:
utils.logger.error("Настройки не инициализированы")
return utils.answer(success=False, message="Настройки не инициализированы")
if settings.data.actualToken == token:
return utils.answer()
logger.error("Проверка токена завершилась ошибкой")
return utils.answer(success=False)
@staticmethod
async def clearToken():
settings = await Settings.getSettings(False)
if not settings.success:
utils.logger.error("Настройки не инициализированы")
return utils.answer(success=False, message="Настройки не инициализированы")
await settings.data.edit(actualToken=None)
return utils.answer()
@staticmethod
async def updateSettings(**kwargs):
if len(kwargs) == 0:
utils.logger.error("Нет данных для обновления")
return utils.answer(success=False, message="Нет данных для обновления")
settings = await Settings.getSettings(False)
if not settings.success:
utils.logger.error("Настройки не инициализированы")
return utils.answer(success=False, message="Настройки не инициализированы")
if "password" in kwargs:
kwargs["hashedPassword"] = ph.hash(kwargs.pop("password"))
try:
data = await settings.data.edit(**kwargs)
return utils.answer(data=data)
except Exception as e:
utils.logger.error(str(e))
return utils.answer(success=False, message=str(e))
+374
View File
@@ -0,0 +1,374 @@
from datetime import datetime, timedelta
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, func, select
from sqlalchemy.orm import relationship
from db import Base, CRUD
from utils import logger, answer, toDict
class Signing(Base):
__tablename__ = "signings"
id = Column(Integer, primary_key=True, index=True)
userIdLpu = Column(
String, ForeignKey("practitioners.userIdLpu", ondelete="CASCADE")
)
idPatientMis = Column(
String, ForeignKey("patients.idPatientMis", ondelete="CASCADE")
)
storagePath = Column(String, nullable=True)
trackingId = Column(String, nullable=True)
documents = relationship(
"Document",
cascade="all, delete-orphan",
lazy="joined",
uselist=True,
single_parent=True,
)
statuses = relationship(
"Statuses",
cascade="all, delete-orphan",
lazy="joined",
uselist=True,
single_parent=True,
)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
def __init__(self, **kwargs):
for key, value in kwargs.items():
setattr(self, key, value)
def toDict(self):
return toDict(self)
async def save(self) -> "Signing":
return await CRUD.create(self, refresh=True)
async def edit(self, **kwargs) -> "Signing":
return await CRUD.update(Signing, self.id, **kwargs)
async def delete(self) -> bool:
return await CRUD.delete(self)
@staticmethod
async def addSigning(userIdLpu: str, idPatientMis: str):
from utils.background import enable_job
if type(userIdLpu) != str:
userIdLpu = str(userIdLpu)
if type(idPatientMis) != str:
idPatientMis = str(idPatientMis)
enable_job()
return await Signing(userIdLpu=userIdLpu, idPatientMis=idPatientMis).save()
@staticmethod
async def getSigningById(id: int, toDict: bool = True):
singing = await CRUD.read(select(Signing).where(Signing.id == id))
if singing:
data = singing.toDict() if toDict else singing
return answer(data=data)
logger.error(f"Подписание не найдено, id: {id}")
return answer(success=False, message="Подписание не найдено")
@staticmethod
async def getSigningsByIdPatientMis(idPatientMis: str, toDict: bool = True):
if type(idPatientMis) != str:
idPatientMis = str(idPatientMis)
signings = await CRUD.read(
select(Signing).where(Signing.idPatientMis == idPatientMis), True
)
if signings:
data = [signing.toDict() for signing in signings] if toDict else signings
return answer(data=data)
logger.error(f"Подписания не найдены, idPatientMis: {idPatientMis}")
return answer(success=False, message="Подписания не найдены")
@staticmethod
async def getSigningsByUserIdLpu(userIdLpu: str, toDict: bool = True):
signings = await CRUD.read(
select(Signing).where(Signing.userIdLpu == userIdLpu), True
)
if signings:
data = [signing.toDict() for signing in signings] if toDict else signings
return answer(data=data)
logger.error(f"Подписания не найдены, userIdLpu: {userIdLpu}")
return answer(success=False, message="Подписания не найдены")
@staticmethod
async def getSigningsByUserIdLpuAndIdPatientMis(
userIdLpu: str, idPatientMis: str, toDict: bool = True
):
signings = await CRUD.read(
select(Signing)
.where(Signing.userIdLpu == userIdLpu)
.where(Signing.idPatientMis == idPatientMis),
True,
)
if signings:
data = [signing.toDict() for signing in signings] if toDict else signings
return answer(data=data)
logger.error(
f"Подписания не найдены, userIdLpu: {userIdLpu}, idPatientMis: {idPatientMis}"
)
return answer(success=False, message="Подписания не найдены")
@staticmethod
async def updateStatuses():
checkList = []
singingsInProgress = await CRUD.read(
select(Signing).where(Signing.storagePath == None), True
)
if singingsInProgress and len(singingsInProgress) > 0:
for signing in singingsInProgress:
if len(signing.statuses) > 0:
maxCode = max([status.status for status in signing.statuses])
if maxCode >= 204:
continue
checkList.append(signing.id)
logger.info(f"Проверка статусов для {len(checkList)} подписаний")
if len(checkList) > 0:
from db.schemas.statuses import Statuses
for id in checkList:
await Statuses.updateStatus(id)
else:
from utils.background import disable_job
logger.info("Нет подписаний для проверки")
disable_job()
@staticmethod
async def getFilteredSingings(userIdLpu: str, filters: dict):
def checkResult(result):
if not result or len(result) == 0:
return False
else:
return True
userFilter = None
match filters.get("sender"):
case "my":
userFilter = True
case "not_my":
userFilter = False
case _:
pass
periodFilterStart = None
periodFilterStop = None
match filters.get("period"):
case "today":
periodFilterStart = datetime.now().replace(
hour=0, minute=0, second=0, microsecond=0
)
case "3days":
periodFilterStart = (datetime.now() - timedelta(days=2)).replace(
hour=0, minute=0, second=0, microsecond=0
)
case "7days":
periodFilterStart = (datetime.now() - timedelta(days=6)).replace(
hour=0, minute=0, second=0, microsecond=0
)
case "30days":
periodFilterStart = (datetime.now() - timedelta(days=30)).replace(
hour=0, minute=0, second=0, microsecond=0
)
case "90days":
periodFilterStart = (datetime.now() - timedelta(days=90)).replace(
hour=0, minute=0, second=0, microsecond=0
)
case "custom":
periodFilterStart = datetime.strptime(
filters.get("dateFrom"), "%Y-%m-%d"
)
periodFilterStop = datetime.strptime(filters.get("dateTo"), "%Y-%m-%d")
if periodFilterStop < periodFilterStart:
periodFilterStop, periodFilterStart = (
periodFilterStart,
periodFilterStop,
)
case _:
pass
query = select(Signing)
if userFilter is not None:
if userFilter: # True - только свои
query = query.where(Signing.userIdLpu == userIdLpu)
else: # False - все кроме своих
query = query.where(Signing.userIdLpu != userIdLpu)
if periodFilterStart is not None:
query = query.where(Signing.created_at >= periodFilterStart)
if periodFilterStop is not None:
query = query.where(Signing.created_at <= periodFilterStop)
query = query.order_by(Signing.created_at.desc())
result = await CRUD.read(query, True)
if not checkResult(result):
return answer(data=[])
match filters.get("status"):
case "processing":
result = [
signing
for signing in result
if (signing.storagePath is None and signing.trackingId is not None)
]
case "completed":
result = [
signing for signing in result if signing.storagePath is not None
]
case "error":
result = [
signing
for signing in result
if (signing.trackingId is None and signing.storagePath is None)
]
case _:
pass
if not checkResult(result):
return answer(data=[])
esiaFilter = None
match filters.get("esia", filters.get("signatureType")):
case "esia":
esiaFilter = True
case "not_esia":
esiaFilter = False
case _:
pass
from db.schemas.practitioners import Practitioner
userIdLpus = set([signing.userIdLpu for signing in result])
query = select(Practitioner).where(Practitioner.userIdLpu.in_(userIdLpus))
if filters.get("senderName") is not None and filters.get("senderName") != "":
query = query.where(
func.lower(Practitioner.familyName).like(
"%" + filters.get("senderName").lower() + "%"
)
)
practitioners = await CRUD.read(query, True)
if not practitioners or len(practitioners) == 0:
result = []
else:
practitionersData = {
practitioner.userIdLpu: {
"userName": practitioner.name,
"esiaAuth": practitioner.esiaAuth,
}
for practitioner in practitioners
}
result = [signing.toDict() for signing in result]
match esiaFilter:
case True:
result = [
{
**signing,
"userName": practitionersData[signing.get("userIdLpu")][
"userName"
],
"esiaAuth": practitionersData[signing.get("userIdLpu")][
"esiaAuth"
],
}
for signing in result
if practitionersData[signing.get("userIdLpu")]["esiaAuth"]
]
case False:
result = [
{
**signing,
"userName": practitionersData[signing.get("userIdLpu")][
"userName"
],
"esiaAuth": practitionersData[signing.get("userIdLpu")][
"esiaAuth"
],
}
for signing in result
if not practitionersData[signing.get("userIdLpu")]["esiaAuth"]
]
case _:
result = [
{
**signing,
"userName": practitionersData[signing.get("userIdLpu")][
"userName"
],
"esiaAuth": practitionersData[signing.get("userIdLpu")][
"esiaAuth"
],
}
for signing in result
]
if esiaFilter is not None and not checkResult(result):
return answer(data=[])
from db.schemas.patients import Patient
idPatientMises = set([signing.get("idPatientMis") for signing in result])
query = select(Patient).where(Patient.idPatientMis.in_(idPatientMises))
if filters.get("patientName") is not None and filters.get("patientName") != "":
query = query.where(
func.lower(Patient.name).like(
"%" + filters.get("patientName").lower() + "%"
)
)
patients = await CRUD.read(query, True)
if not patients or len(patients) == 0:
result = []
else:
patientsData = {patient.idPatientMis: patient.name for patient in patients}
result = [
{
**signing,
"patientName": patientsData[signing.get("idPatientMis")],
}
for signing in result
]
if (
filters.get("documentNumber") is not None
and filters.get("documentNumber") != ""
):
try:
search_number = int(filters.get("documentNumber"))
result = [
signing
for signing in result
if any(
doc.get("number") == search_number
for doc in signing.get("documents", [])
)
]
except ValueError:
# Если не удалось преобразовать в число, пропускаем фильтр
pass
return answer(data=result)
+93
View File
@@ -0,0 +1,93 @@
from datetime import datetime
from sqlalchemy import Column, DateTime, ForeignKey, Integer, select
from db import CRUD, Base
from db.schemas.signings import Signing
from utils import answer, logger, toDict
class Statuses(Base):
__tablename__ = "statuses"
id = Column(Integer, primary_key=True, index=True)
singingId = Column(Integer, ForeignKey("signings.id", ondelete="CASCADE"))
status = Column(Integer)
created_at = Column(DateTime, default=datetime.now)
def __init__(self, **kwargs):
for key, value in kwargs.items():
setattr(self, key, value)
def toDict(self):
return toDict(self)
async def save(self) -> "Statuses":
return await CRUD.create(self, refresh=True)
async def edit(self, **kwargs) -> "Statuses":
return await CRUD.update(Statuses, self.id, **kwargs)
@staticmethod
async def addStatus(singingId: int, status: int):
if type(singingId) != int:
singingId = int(singingId)
if type(status) != int:
status = int(status)
await CRUD.create(Statuses(singingId=singingId, status=status))
@staticmethod
async def getStatus(singingId: int) -> list:
if type(singingId) != int:
singingId = int(singingId)
return await CRUD.read(
select(Statuses)
.where(Statuses.singingId == singingId)
.order_by(Statuses.created_at),
True,
)
@staticmethod
async def updateStatus(singingId: int, isNew: bool = False):
from api.n3health import getTrackingIDSigning
if type(singingId) != int:
singingId = int(singingId)
singingData = await Signing.getSigningById(singingId, False)
if not singingData.success:
logger.error(singingData.message)
return singingData
statusDB = await Statuses.getStatus(singingId)
latestStatusCode = 0
try:
latestStatusCode = statusDB[-1].status
except IndexError:
pass
statusData = await getTrackingIDSigning(
singingData.data.trackingId, latestStatusCode, singingId
)
if not statusData.success:
logger.error(statusData.message)
return statusData
actualStatusCode = int(statusData.data["code"])
if actualStatusCode == 0 and isNew:
actualStatusCode = 201
if latestStatusCode != actualStatusCode:
logger.info(f"✔️ Обновился статус: {singingId} - {actualStatusCode}")
await Statuses.addStatus(singingId, actualStatusCode)
else:
logger.info(f"📌 Статус не изменился: {singingId} - {actualStatusCode}")
if actualStatusCode == 204:
logger.info(f"✅ Подписание завершено: {singingId}")
await singingData.data.edit(storagePath=statusData.data["storagePath"])
if actualStatusCode > 204:
logger.info(f"❌ Подписание не успешно: {statusData.data['description']}")
await singingData.data.edit(trackingId=None)
return answer()
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+101
View File
@@ -0,0 +1,101 @@
from fastapi import FastAPI, Request
from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles
from contextlib import asynccontextmanager
from app.routers import router
import config
from utils import logger
from utils.background import init_scheduler, scheduler_lifespan
# Инициализация задач планировщика (без запуска)
init_scheduler()
async def initDB():
from db import DATABASE_URL
from db.initialize import DatabaseInitializer
try:
force = False
reNewDB = False
await DatabaseInitializer(DATABASE_URL).initialize(force, reNewDB)
logger.info("База данных инициализирована")
except Exception as e:
logger.error(f"Инициализация базы завершилась ошибкой: {str(e)}", exc_info=True)
@asynccontextmanager
async def lifespan(app: FastAPI):
# Запускаем lifespan планировщика
async with scheduler_lifespan():
yield
app = FastAPI(
title="Medods to N3 Health API",
description="API for medods-to-n3-health",
version="0.1.0",
lifespan=lifespan,
)
async def auth_middleware(request: Request, call_next):
from db.schemas import Settings
path = request.url.path
if path.startswith(("/login", "/static", "/favicon.ico", "/api", "/test")):
return await call_next(request)
auth_token = request.cookies.get("auth_token")
# 1️⃣ Нет токена
if not auth_token:
return RedirectResponse("/login", status_code=302)
# 2️⃣ Проверка токена
token_check = await Settings.verifyToken(auth_token)
# 3️⃣ Невалидный токен → удалить cookie + редирект
if not token_check.success:
response = RedirectResponse("/login", status_code=302)
response.delete_cookie("auth_token", path="/")
return response
# 4️⃣ Всё ок
return await call_next(request)
app.mount(
"/static", StaticFiles(directory=f"{config.RELOAD_DIR}/app/static"), name="static"
)
app.middleware("http")(auth_middleware)
app.include_router(router)
def startServer():
import uvicorn
logger.info("Starting server...")
uvicorn.run(
"init:app",
host="0.0.0.0",
port=80,
reload=True,
reload_dirs=[config.RELOAD_DIR],
)
async def main():
startServer()
if __name__ == "__main__":
import asyncio
asyncio.run(initDB())
asyncio.run(main())
+33
View File
@@ -0,0 +1,33 @@
from typing import Any
from pydantic import BaseModel
from .async_request import *
from .logger import *
from .attachment import *
from .request_parser import RequestParser
from .pdf_saver import *
from .to_dict import *
from .web import *
requestDict = RequestParser()
class Answer(BaseModel):
success: bool
message: Optional[str]
data: Optional[Any]
def answer(success: bool = True, data: Any = None, message: str = None) -> Answer:
"""
Docstring для answer
:param success: Успешность выполнения
:type success: bool
:param data: Данные
:type data: Any
:param message: Сообщение
:type message: str
:return: Экземпляр Answer
:rtype: Answer
"""
return Answer(success=success, message=message, data=data)
+190
View File
@@ -0,0 +1,190 @@
import json
import ssl
from utils.logger import logger
from typing import Dict, List
from aiohttp import ClientSession, ClientError, FormData, TCPConnector
async def requestGET(
url: str, headers: dict = None, params: dict = None, verify_ssl: bool = True
):
"""GET запрос к url, возвращает результат запроса в формате json
Параметры:
- url: адрес, к которому будет выполнен GET запрос
- headers: заголовки запроса, словарь, где ключи - имена заголовков, а значения - их значения
- params: параметры запроса, словарь, где ключи - имена параметров, а значения - их значения
Возвращается:
- результат запроса в формате json
"""
from utils import answer
ssl_context = None
if not verify_ssl:
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
try:
connector = TCPConnector(ssl=ssl_context)
async with ClientSession(connector=connector) as session:
async with session.get(url, params=params, headers=headers) as response:
response.raise_for_status() # Проверка на успешность выполнения запроса
data = await response.json()
return answer(data=data)
except ClientError as e:
logger.error(f"Request failed: {e}")
return answer(success=False, message=str(e))
async def requestPOST(
url: str,
headers: dict = None,
json: dict = None,
**kwargs,
):
"""POST запрос к url, возвращает результат запроса в формате json
Параметры:
- url: адрес, к которому будет выполнен POST запрос
- headers: заголовки запроса, словарь, где ключи - имена заголовков, а значения - их значения
- json: данные, которые будут отправлены в теле запроса, в формате json
- **kwargs: необязательные параметры, передаваемые в метод request
Возвращается:
- результат запроса в формате json
"""
try:
async with ClientSession() as session:
async with session.post(
url, json=json, headers=headers, **kwargs
) as response:
try:
responseJson = await response.json()
logger.warning(responseJson)
except:
logger.error("Не удалось распарсить ответ сервера")
response.raise_for_status()
try:
return await response.json()
except:
return {"status": "OK"}
except ClientError as e:
logger.error(f"Request failed: {e}")
return None
async def requestPOSTMultipart(
url: str,
headers: dict,
meta: dict,
files: List[Dict],
verify_ssl: bool = True,
):
"""
files: [
{
"field": "file0",
"path": Path("document.pdf"),
"content_type": "application/pdf",
},
...
]
"""
form = FormData()
# meta part (JSON)
form.add_field(
name="meta",
value=json.dumps(meta, ensure_ascii=False),
content_type="application/json; charset=utf-8",
)
# file parts
for file in files:
form.add_field(
name=file["field"],
value=file["path"].read_bytes(),
filename=file["path"].name,
content_type=file["content_type"],
)
try:
async with ClientSession() as session:
async with session.post(
url,
data=form,
headers=headers,
ssl=verify_ssl,
) as response:
# try:
# responseJson = await response.json()
# logger.warning(responseJson)
# except:
# logger.error("Не удалось распарсить ответ сервера")
response.raise_for_status()
try:
return await response.json()
except Exception:
return {"status": "OK"}
except ClientError as e:
logger.error(f"Request failed: {e}")
return None
async def requestPATCH(
url: str,
headers: dict = None,
json: dict = None,
**kwargs,
):
"""PATCH запрос к url, возвращает результат запроса в формате json
Параметры:
- url: адрес, к которому будет выполнен PATCH запрос
- headers: заголовки запроса, словарь, где ключи - имена заголовков, а значения - их значения
- json: данные, которые будут отправлены в теле запроса, в формате json
- **kwargs: необязательные параметры, передаваемые в метод request
Возвращается:
- результат запроса в формате json
"""
try:
async with ClientSession() as session:
async with session.patch(
url, json=json, headers=headers, **kwargs
) as response:
response.raise_for_status()
try:
return await response.json()
except:
return {"status": "OK"}
except ClientError as e:
logger.error(f"Request failed: {e}")
return None
async def requestDELETE(url: str, headers: dict = None, json: dict = None):
"""
DELETE запрос к url, возвращает результат запроса в формате json
Параметры:
- url: адрес, к которому будет выполнен DELETE запрос
- headers: заголовки запроса, словарь, где ключи - имена заголовков, а значения - их значения
- json: данные, которые будут отправлены в теле запроса, в формате json
Возвращается:
- результат запроса в формате json
"""
try:
async with ClientSession() as session:
async with session.delete(url, headers=headers, json=json) as response:
response.raise_for_status()
return await response.json()
except ClientError as e:
logger.error(f"Request failed: {e}")
return None
+184
View File
@@ -0,0 +1,184 @@
import base64
from pathlib import Path
from typing import Optional
def detect_and_save_attachment(
b64_content: str,
output_dir: Path,
filename_prefix: str = "attachment",
save_attachments: bool = False,
) -> Optional[dict]:
"""
Определяет тип файла по сигнатуре, сохраняет на диск.
Возвращает:
{
"path": Path,
"mime_type": str,
"extension": str,
"size": int,
"sha256": str
}
"""
ATTACHMENT_SIGNATURES = [
(b"%PDF-", "application/pdf", ".pdf"),
(b"\x89PNG\r\n\x1a\n", "image/png", ".png"),
(b"\xff\xd8\xff", "image/jpeg", ".jpg"),
(b"PK\x03\x04", "application/zip", ".zip"),
(b"<?xml", "application/xml", ".xml"),
]
try:
raw = base64.b64decode(b64_content, validate=True)
except Exception:
return None
mime_type = "application/octet-stream"
extension = ".bin"
for signature, detected_mime, detected_ext in ATTACHMENT_SIGNATURES:
if raw.startswith(signature):
mime_type = detected_mime
extension = detected_ext
break
if save_attachments:
output_dir = Path(output_dir).resolve()
output_dir.mkdir(parents=True, exist_ok=True)
file_path = output_dir / f"{filename_prefix}{extension}"
with open(file_path, "wb") as f:
f.write(raw)
else:
file_path = None
return {
"path": file_path,
"mime_type": mime_type,
"extension": extension,
"size": len(raw),
}
def pdfGet(path: str):
from utils import answer
try:
filePath = Path(path)
if filePath.exists():
fileBytes = filePath.read_bytes()
encoded_bytes = base64.b64encode(fileBytes).decode("utf-8")
return answer(data=encoded_bytes)
return answer(success=False, message="Файл не найден")
except Exception as e:
return answer(success=False, message=str(e))
def p7s_save(signature_data, employee_id):
"""
Сохраняет файл подписи .p7s для сотрудника
Args:
signature_data: данные файла (может быть bytes, list[int], или base64 строка)
employee_id: идентификатор сотрудника
Returns:
dict: статус операции и путь к сохраненному файлу
"""
from utils import answer
try:
# Базовый путь к директории
base_path = Path("src/attachments/secure")
# Путь к папке сотрудника
employee_path = base_path / str(employee_id)
# Создаем директорию, если её нет
employee_path.mkdir(parents=True, exist_ok=True)
# Полный путь к файлу подписи
signature_path = employee_path / "signature.p7s"
# Преобразуем входные данные в bytes
if isinstance(signature_data, list):
# Если пришел список чисел (как из staff.js)
file_bytes = bytes(signature_data)
elif isinstance(signature_data, str):
# Если пришла base64 строка
import base64
file_bytes = base64.b64decode(signature_data)
elif isinstance(signature_data, bytes):
# Если уже bytes
file_bytes = signature_data
else:
raise ValueError(f"Неподдерживаемый тип данных: {type(signature_data)}")
# Сохраняем файл
with open(signature_path, "wb") as f:
f.write(file_bytes)
return answer(data=str(signature_path))
except Exception as e:
return answer(success=False, message=str(e))
def p7s_delete(employee_id):
"""
Удаляет файл подписи сотрудника
Args:
employee_id: идентификатор сотрудника
Returns:
dict: статус операции
"""
from utils import answer
try:
signature_dir = Path(f"src/attachments/secure/{employee_id}")
if signature_dir.exists():
for file in signature_dir.iterdir():
file.unlink()
signature_dir.rmdir()
return answer()
else:
return answer(success=False, message="Папка подписи не найдена")
except Exception as e:
return answer(success=False, message=str(e))
def p7s_get(employee_id):
"""
Получает файл подписи сотрудника
Args:
employee_id: идентификатор сотрудника
Returns:
dict: статус операции и данные файла
"""
from utils import answer
try:
signature_path = Path(f"src/attachments/secure/{employee_id}/signature.p7s")
if signature_path.exists():
with open(signature_path, "rb") as f:
file_bytes = f.read()
return answer(data=file_bytes)
else:
return answer(success=False, message="Файл подписи не найден")
except Exception as e:
return answer(success=False, message=str(e))
+127
View File
@@ -0,0 +1,127 @@
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.interval import IntervalTrigger
from contextlib import asynccontextmanager
from utils import logger
# Глобальный планировщик
scheduler = AsyncIOScheduler()
# ID нашей задачи
JOB_ID = "update_signings"
async def update_signings_job():
"""Задача обновления статусов подписей"""
try:
from db.schemas.signings import Signing
await Signing.updateStatuses()
logger.info("APScheduler: задача обновления статусов выполнена")
except Exception as e:
logger.error(f"APScheduler ошибка: {e}", exc_info=True)
def init_scheduler():
"""Инициализация планировщика (добавление задач)"""
if not scheduler.running:
# Добавляем задачу при инициализации
_add_job_to_scheduler()
logger.info("APScheduler задачи добавлены")
def _add_job_to_scheduler():
"""Внутренняя функция добавления задачи"""
scheduler.add_job(
update_signings_job,
trigger=IntervalTrigger(seconds=60),
id=JOB_ID,
replace_existing=True,
)
@asynccontextmanager
async def scheduler_lifespan():
"""Lifespan только для планировщика"""
# Startup
if not scheduler.running:
scheduler.start()
logger.info("APScheduler запущен")
yield
# Shutdown
if scheduler.running:
scheduler.shutdown()
logger.info("APScheduler остановлен")
logger.info("APScheduler завершен")
# ========== ГЛОБАЛЬНОЕ УПРАВЛЕНИЕ ==========
def enable_job():
"""Полностью включить задачу (добавить в планировщик)"""
try:
# Проверяем, есть ли уже задача
existing_job = scheduler.get_job(JOB_ID)
if not existing_job:
# Добавляем задачу
_add_job_to_scheduler()
logger.info("✅ Задача обновления статусов ДОБАВЛЕНА в планировщик")
return True
else:
logger.info("ℹ️ Задача обновления статусов уже существует в планировщике")
return True
except Exception as e:
logger.error(f"❌ Ошибка при включении задачи: {e}")
return False
def disable_job():
"""Полностью выключить задачу (удалить из планировщика)"""
try:
# Проверяем, есть ли задача
existing_job = scheduler.get_job(JOB_ID)
if existing_job:
# Удаляем задачу
scheduler.remove_job(JOB_ID)
logger.info("❌ Задача обновления статусов УДАЛЕНА из планировщика")
return True
else:
logger.info("ℹ️ Задача обновления статусов и так отсутствует в планировщике")
return True
except Exception as e:
logger.error(f"❌ Ошибка при выключении задачи: {e}")
return False
def toggle_job():
"""Переключить состояние задачи (добавить/удалить)"""
existing_job = scheduler.get_job(JOB_ID)
if existing_job:
return disable_job()
else:
return enable_job()
def is_job_enabled() -> bool:
"""Проверить, есть ли задача в планировщике"""
return scheduler.get_job(JOB_ID) is not None
def get_job_info():
"""Получить информацию о задаче"""
job = scheduler.get_job(JOB_ID)
if job:
return {
"enabled": True,
"id": job.id,
"next_run": str(job.next_run_time) if job.next_run_time else None,
"trigger": str(job.trigger),
}
else:
return {"enabled": False, "id": JOB_ID, "next_run": None}
+57
View File
@@ -0,0 +1,57 @@
import logging
import logging.config
import json
from pathlib import Path
import config
class SmartLogger(logging.Logger):
def _log(
self,
level,
msg,
args,
exc_info=None,
extra=None,
stack_info=False,
stacklevel=1,
):
if isinstance(msg, (dict, list)):
msg = json.dumps(msg, indent=4, ensure_ascii=False, default=str)
super()._log(
level,
msg,
args,
exc_info,
extra,
stack_info,
stacklevel + 1,
)
logging.setLoggerClass(SmartLogger)
log_config_path = Path(f"{config.RELOAD_DIR}/config/logger.ini")
logging.config.fileConfig(log_config_path, disable_existing_loggers=False)
logger = logging.getLogger("medods-to-n3health")
def setLogLevel(level: str = ""):
match level:
case "DEBUG":
loggerLevel = logging.DEBUG
case "WARNING":
loggerLevel = logging.WARNING
case "ERROR":
loggerLevel = logging.ERROR
case _:
loggerLevel = logging.INFO
root_logger = logging.getLogger()
for handler in root_logger.handlers:
handler.setLevel(loggerLevel)
# Также меняем уровень самого логгера
logger.setLevel(loggerLevel)
+600
View File
@@ -0,0 +1,600 @@
"""
Модуль для конвертации HTML в PDF с использованием системного WeasyPrint (Homebrew)
Фильтрация пустых строк таблиц, центрирование по text-align: center.
"""
import os
import re
import subprocess
import tempfile
from typing import List, Dict, Tuple
from pathlib import Path
from utils import logger
class WeasyPrintConverter:
def __init__(self, output_dirs: list[str]):
self.output_dirs = [Path(d).resolve() for d in output_dirs]
self.max_files = 5
for output_dir in self.output_dirs:
output_dir.mkdir(parents=True, exist_ok=True)
self.weasyprint_path = self._find_weasyprint()
def _find_weasyprint(self):
try:
result = subprocess.run(
["which", "weasyprint"], capture_output=True, text=True, check=True
)
weasyprint_path = result.stdout.strip()
logger.info(f"✅ WeasyPrint найден: {weasyprint_path}")
return weasyprint_path
except subprocess.CalledProcessError:
possible_paths = [
"/opt/homebrew/bin/weasyprint",
"/usr/local/bin/weasyprint",
"/usr/bin/weasyprint",
]
for path in possible_paths:
if os.path.exists(path):
logger.info(f"✅ WeasyPrint найден: {path}")
return path
logger.error("❌ WeasyPrint не найден. Установите: brew install weasyprint")
return None
def _clean_filename(self, filename: str) -> str:
"""Очищает имя файла от недопустимых символов"""
# Только заменяем запрещённые символы на подчёркивание
clean_name = re.sub(r'[<>:"/\\|?*]', "_", filename)
# Убираем пробелы по краям
clean_name = clean_name.strip()
# Ограничиваем длину
if len(clean_name) > 200:
clean_name = clean_name[:200]
# Защита от пустого имени
return clean_name if clean_name else "document"
def _remove_empty_table_rows_comprehensive(self, html_content: str) -> str:
"""
Комплексное удаление пустых строк таблиц ВНУТРИ таблиц
Убирает строки без текстового содержимого внутри <table>...</table>
"""
try:
# Находим все таблицы
tables = list(
re.finditer(
r"<table[^>]*>.*?</table>",
html_content,
flags=re.IGNORECASE | re.DOTALL,
)
)
if not tables:
return html_content
# Обрабатываем с конца, чтобы не сбивать индексы
for table_match in reversed(tables):
table_html = table_match.group(0)
table_start = table_match.start()
table_end = table_match.end()
# Разбираем таблицу на строки
rows = []
pos = 0
table_length = len(table_html)
while pos < table_length:
# Ищем начало строки
tr_start = table_html.lower().find("<tr", pos)
if tr_start == -1:
break
# Ищем конец строки
tr_end = table_html.lower().find("</tr>", tr_start)
if tr_end == -1:
# Нет закрывающего тега - ищем конец таблицы
tr_end = table_html.lower().find("</table>", tr_start)
if tr_end == -1:
tr_end = table_length
tr_end += 5 # Длина '</tr>'
row_html = table_html[tr_start:tr_end]
# Проверяем строку на пустоту
is_empty_row = self._is_empty_table_row(row_html)
if not is_empty_row:
# Сохраняем непустую строку
rows.append(row_html)
else:
logger.debug(f"Удалена пустая строка таблицы")
pos = tr_end
if len(rows) == 0:
# Если все строки пустые, удаляем всю таблицу
logger.info(f"🗑️ Удалена полностью пустая таблица")
html_content = html_content[:table_start] + html_content[table_end:]
else:
# Собираем таблицу обратно
new_table_html = table_html
for row in rows:
# Убедимся, что строка уже в таблице
if row not in new_table_html:
# Это не должно происходить, но на всякий случай
pass
# Заменяем оригинальную таблицу (она осталась неизменной если строки не удалялись)
# Но мы все равно пересобираем для чистоты
table_header_end = (
table_html.lower().find(">", table_html.lower().find("<table"))
+ 1
)
table_footer_start = table_html.lower().rfind("</table>")
new_table = (
table_html[:table_header_end]
+ "".join(rows)
+ table_html[table_footer_start:]
)
html_content = (
html_content[:table_start]
+ new_table
+ html_content[table_end:]
)
return html_content
except Exception as e:
logger.warning(f"⚠ Ошибка удаления пустых строк таблиц: {e}")
return html_content
def _is_empty_table_row(self, row_html: str) -> bool:
"""
Проверяет, является ли строка таблицы пустой
"""
# Убираем все HTML теги
text_only = re.sub(r"<[^>]+>", "", row_html)
# Убираем все пробелы, невидимые символы и спецсимволы
clean_text = re.sub(r"[\s\xa0\u200b\u200c\u200d\u2060\ufeff]+", "", text_only)
# Убираем HTML entities (но оставляем &nbsp; как признак непустоты)
clean_text = re.sub(r"&[^;]{2,6};", "", clean_text)
# Проверяем специальные случаи
has_colspan = "colspan" in row_html.lower()
has_rowspan = "rowspan" in row_html.lower()
has_nbsp = "&nbsp;" in row_html or "&#160;" in row_html
# Строка считается НЕ пустой если:
# 1. Есть текст после очистки
# 2. Есть объединенные ячейки
# 3. Есть неразрывные пробелы
is_not_empty = bool(clean_text) or has_colspan or has_rowspan or has_nbsp
return not is_not_empty
def _center_elements_with_center_alignment(self, html_content: str) -> str:
"""
Находит элементы с text-align: center и гарантирует их центрирование
"""
try:
# Ищем элементы со стилем text-align: center
def add_center_alignment(match):
full_match = match.group(0)
element_with_style = match.group(1)
element_name = match.group(2)
style_content = match.group(3)
# Если уже есть align="center", оставляем как есть
if (
'align="center"' in element_with_style
or "align='center'" in element_with_style
):
return full_match
# Добавляем align="center" к элементу
if element_name in [
"p",
"div",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"td",
"th",
]:
new_element = element_with_style.replace(
f"<{element_name}", f'<{element_name} align="center"'
)
return new_element + match.group(4)
return full_match
# Паттерн для поиска элементов с text-align: center
pattern = r'(<(\w+)[^>]*style\s*=\s*["\'][^"\']*text-align\s*:\s*center[^"\']*["\'][^>]*>)(.*?)'
html_content = re.sub(
pattern,
add_center_alignment,
html_content,
flags=re.IGNORECASE | re.DOTALL,
)
return html_content
except Exception as e:
# logger.warning(f"⚠ Ошибка центрирования элементов: {e}")
return html_content
def _prepare_html(self, html_content: str) -> str:
"""Подготавливает HTML для конвертации"""
try:
# 1. Удаляем изображения
html_content = re.sub(r"<img[^>]*>", "", html_content, flags=re.IGNORECASE)
# 2. Убираем ВСЕ пустые строки таблиц (основная задача)
html_content = self._remove_empty_table_rows_comprehensive(html_content)
# 3. Центрируем элементы с text-align: center
html_content = self._center_elements_with_center_alignment(html_content)
# 4. Добавляем базовую структуру если её нет
if not html_content.strip().startswith("<!DOCTYPE"):
html_content = f"""<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
/* МИНИМАЛЬНЫЕ СТИЛИ - почти не вмешиваемся в оригинальный формат */
@page {{
size: A4;
margin: 0.5cm !important;
}}
/* Основные настройки */
body {{
font-family: "DejaVu Sans", Arial, sans-serif;
font-size: 9pt !important;
line-height: 1.0 !important;
color: #000000;
}}
/* ТАБЛИЦЫ - сохраняем оригинальный формат! */
table {{
border-collapse: collapse !important;
margin: 0 !important; /* Убрали все отступы вокруг таблиц */
font-size: 8.5pt !important;
page-break-inside: auto !important;
}}
/* ЯЧЕЙКИ ТАБЛИЦ - только вертикальное центрирование и небольшие отступы */
th, td {{
padding: 3px 5px !important; /* Минимальные отступы */
vertical-align: middle !important; /* Вертикальное центрирование */
}}
/* НЕ добавляем границы автоматически! */
/* Границы будут ТОЛЬКО там, где они были в оригинале */
/* Горизонтальное центрирование заголовков таблиц */
th {{
text-align: center !important;
}}
/* Элементы с align="center" */
[align="center"] {{
text-align: center !important;
}}
/* Элементы со style*="text-align: center" */
[style*="text-align: center"] {{
text-align: center !important;
}}
/* Заголовки документа */
h1, h2, h3, h4, h5, h6 {{
font-weight: bold !important;
margin-top: 8px !important;
margin-bottom: 4px !important;
}}
h1 {{ font-size: 11pt !important; page-break-before: always !important; }}
h2 {{ font-size: 10pt !important; }}
h3 {{ font-size: 9.5pt !important; }}
/* Параграфы - минимальные отступы */
p {{
margin: 0 0 3px 0 !important;
}}
/* Форматирование текста */
strong, b {{
font-weight: bold !important;
}}
em, i {{
font-style: italic !important;
}}
/* Списки */
ul, ol {{
margin: 3px 0 3px 15px !important;
padding: 0 !important;
}}
li {{
margin-bottom: 1px !important;
}}
/* Убираем лишние отступы */
div, p, span {{
margin: 0 !important;
padding: 0 !important;
}}
br {{
line-height: 0.5 !important;
margin: 0 !important;
padding: 0 !important;
}}
/* Убираем отступы перед таблицами на уровне CSS */
:before(table) {{
margin-bottom: 0 !important;
padding-bottom: 0 !important;
}}
</style>
</head>
<body>
{html_content}
</body>
</html>"""
# 5. Убеждаемся в наличии charset
if "<meta charset=" not in html_content.lower():
html_content = html_content.replace(
"<head>", '<head><meta charset="UTF-8">', 1
)
# 6. ОБРАБОТКА ТАБЛИЦ:
# Только добавляем вертикальное центрирование и отступы, НЕ меняем границы!
# Добавляем vertical-align: middle ко всем ячейкам если его нет
def add_vertical_align(match):
cell_tag = match.group(0)
if (
"valign=" not in cell_tag.lower()
and "vertical-align:" not in cell_tag.lower()
):
return cell_tag.replace("<td", '<td valign="middle"').replace(
"<th", '<th valign="middle"'
)
return cell_tag
html_content = re.sub(
r"<(td|th)(?![^>]*(?:valign|vertical-align))[^>]*>",
add_vertical_align,
html_content,
flags=re.IGNORECASE,
)
# Добавляем text-align: center к заголовкам таблиц если нет своего выравнивания
def add_th_center(match):
th_tag = match.group(0)
if (
"align=" not in th_tag.lower()
and "text-align:" not in th_tag.lower()
):
return th_tag.replace("<th", '<th align="center"')
return th_tag
html_content = re.sub(
r"<th[^>]*>", add_th_center, html_content, flags=re.IGNORECASE
)
# 7. ФИНАЛЬНАЯ ПРОВЕРКА: еще раз удаляем пустые строки таблиц после всех модификаций
html_content = self._remove_empty_table_rows_comprehensive(html_content)
logger.debug(f"HTML подготовлен, размер: {len(html_content)} символов")
return html_content
except Exception as e:
logger.error(f"❌ Ошибка подготовки HTML: {e}")
# Минимальная обработка в случае ошибки
return f"""<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
@page {{ margin: 0.5cm; }}
body {{ font-size: 9pt; line-height: 1.0; }}
table {{ border-collapse: collapse; margin: 0; }}
th, td {{ padding: 3px 5px; vertical-align: middle; }}
th {{ text-align: center; }}
</style>
</head>
<body>{html_content}</body>
</html>"""
def convert_html_to_pdf(
self, html_content: str, output_paths: list[Path]
) -> Tuple[bool, str]:
"""
Конвертирует HTML в PDF и сохраняет во все указанные папки
Оптимизированная версия: конвертирует один раз, копирует результат
"""
if not self.weasyprint_path:
return False, "WeasyPrint не найден. Установите: brew install weasyprint"
try:
prepared_html = self._prepare_html(html_content)
with tempfile.NamedTemporaryFile(
mode="w", suffix=".html", delete=False, encoding="utf-8"
) as f:
f.write(prepared_html)
temp_html = f.name
try:
# Создаем временный PDF
with tempfile.NamedTemporaryFile(
suffix=".pdf", delete=False
) as temp_pdf_file:
temp_pdf = temp_pdf_file.name
# Конвертируем во временный файл
cmd = [
self.weasyprint_path,
temp_html,
temp_pdf,
"--encoding",
"utf-8",
"--presentational-hints",
]
logger.debug("Выполняем однократную конвертацию WeasyPrint")
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=180
)
if result.returncode != 0:
error_msg = (
result.stderr[:300] if result.stderr else "Неизвестная ошибка"
)
return False, f"Ошибка конвертации: {error_msg}"
# Проверяем временный PDF
temp_pdf_path = Path(temp_pdf)
if not temp_pdf_path.exists() or temp_pdf_path.stat().st_size < 1024:
return False, "Созданный PDF слишком мал или отсутствует"
# Копируем во все целевые папки
successful_files = []
failed_files = []
for output_path in output_paths:
try:
# Создаем родительскую папку
output_path.parent.mkdir(parents=True, exist_ok=True)
# Копируем файл
import shutil
shutil.copy2(temp_pdf, output_path)
# Проверяем результат
if output_path.exists() and output_path.stat().st_size > 1024:
successful_files.append(str(output_path))
logger.debug(f"✅ PDF сохранен: {output_path}")
else:
failed_files.append(
f"{output_path}: файл поврежден или слишком мал"
)
except Exception as e:
failed_files.append(f"{output_path}: {str(e)}")
logger.error(f"❌ Ошибка копирования в {output_path}: {str(e)}")
# Удаляем временный PDF
os.unlink(temp_pdf)
# Формируем результат
if successful_files and not failed_files:
return (
True,
f"✅ PDF создан и сохранен во все {len(successful_files)} папок",
)
elif successful_files:
return (
True,
f"⚠️ PDF создан, но сохранен только в {len(successful_files)}/{len(output_paths)} папок",
)
else:
return False, f"❌ Не удалось сохранить PDF ни в одну папку"
finally:
if os.path.exists(temp_html):
os.unlink(temp_html)
except subprocess.TimeoutExpired:
return False, "Таймаут при конвертации (180 секунд)"
except Exception as e:
logger.error(f"❌ Неожиданная ошибка: {str(e)}")
return False, f"Ошибка конвертации: {str(e)}"
def convert_documents(self, docs: List[Dict]) -> Dict[str, Dict]:
results = {
"status": "SUCCESS",
"files": {},
}
if len(docs) > self.max_files:
logger.warning(
f"⚠ Получено {len(docs)} документов, обработано {self.max_files}"
)
docs = docs[: self.max_files]
for i, doc in enumerate(docs, 1):
doc_number = str(doc.get("number", f"doc_{i}")).replace("/", "_")
doc_title = str(doc.get("title", f"Документ_{i}"))
html_content = doc.get("data", "")
safe_title = self._clean_filename(doc_title)
filename = f"{doc_number}_{safe_title}.pdf"
output_paths = [output_dir / filename for output_dir in self.output_dirs]
for path in self.output_dirs:
key = path.parts[-1]
if key not in results["files"]:
results["files"][key] = []
results["files"][key].append(
{
"number": int(doc_number),
"title": doc_title,
"storagePath": str(path / filename),
}
)
logger.info(f"📄 Документ {i}/{len(docs)}: {filename}")
if not html_content or len(html_content.strip()) < 100:
results["status"] = "ERROR"
logger.warning(f" ⚠ Пропущен: недостаточно контента")
continue
success, message = self.convert_html_to_pdf(html_content, output_paths)
if success:
logger.info(f" ✅ Успешно")
else:
logger.error(f" ❌ Ошибка: {message}")
return results
def convert_docs_to_pdfs(docs: List[Dict], patientsData: list[dict]) -> Dict:
try:
baseDir = "src/attachments/export/"
outputDirs = [f"{baseDir}{patient['idPatientMis']}" for patient in patientsData]
converter = WeasyPrintConverter(outputDirs)
return converter.convert_documents(docs)
except Exception as e:
logger.error(f"❌ Критическая ошибка в convert_docs_to_pdfs: {str(e)}")
return {
"status": "ERROR",
"message": f"Критическая ошибка в convert_docs_to_pdfs: {str(e)}",
}
+58
View File
@@ -0,0 +1,58 @@
# async def upload(requestData: dict = Depends(RequestParser())):
import json
from fastapi import Request, UploadFile
class RequestParser:
def __init__(self, include_files=True):
self.include_files = include_files
async def __call__(self, request: Request) -> dict:
parsed = {
"method": request.method,
"url": str(request.url),
"query": dict(request.query_params),
"headers": dict(request.headers),
"cookies": request.cookies,
"body": None,
"files": {},
}
# ----- BODY (JSON, Text) -----
raw_body = await request.body()
if raw_body:
try:
parsed["body"] = json.loads(raw_body.decode("utf-8"))
except Exception:
parsed["body"] = raw_body.decode("utf-8", errors="ignore")
# ----- FORM / FILES -----
try:
form = await request.form()
for key, value in form.items():
# Файл
if isinstance(value, UploadFile):
if self.include_files:
file_bytes = await value.read()
parsed["files"][key] = {
"filename": value.filename,
"content_type": value.content_type,
"content": file_bytes,
}
else:
# Добавляем как текстовое поле
if parsed["body"] is None:
parsed["body"] = {}
if isinstance(parsed["body"], dict):
parsed["body"][key] = value
except Exception:
pass
parsed["headers"].pop("cookie", None)
# logger.info(f"[RequestParser] Parsed request:")
# logger.info(parsed)
return parsed
+44
View File
@@ -0,0 +1,44 @@
def toDict(data) -> dict:
from sqlalchemy import DateTime, Date
def dateToStr(date):
if date is None:
return None
return date.strftime("%Y-%m-%d %H:%M:%S")
def dateOnlyToStr(date):
if date is None:
return None
return date.strftime("%Y-%m-%d")
result = {}
# Обрабатываем колонки таблицы с автоматическим определением типа
for c in data.__table__.columns:
value = getattr(data, c.name)
# Проверяем тип колонки
if isinstance(c.type, DateTime):
result[c.name] = dateToStr(value)
elif isinstance(c.type, Date):
result[c.name] = dateOnlyToStr(value)
else:
result[c.name] = value
# Обрабатываем relationship поля
for rel in data.__mapper__.relationships.keys():
related_data = getattr(data, rel)
if related_data is not None:
if isinstance(related_data, list):
result[rel] = [
item.toDict() if hasattr(item, "toDict") else str(item)
for item in related_data
]
else:
result[rel] = (
related_data.toDict()
if hasattr(related_data, "toDict")
else str(related_data)
)
return result
+40
View File
@@ -0,0 +1,40 @@
from fastapi import Request
from fastapi.templating import Jinja2Templates
from typing import Any
import config
templates = Jinja2Templates(directory=config.TEMPLATES_DIR)
def getUrl(name: str, **path_params: Any):
from api import app
try:
url = app.url_path_for(name, **path_params)
except Exception:
url = app.url_path_for(name)
return url
async def render(
request: Request,
):
context = {
"request": request,
"content": {},
# "content": {"app_secret": config.APP_SECRET},
}
fileName = f"{request.scope['path']}/index.html"
response = templates.TemplateResponse(fileName, context)
return response
def_list = [
getUrl,
]
for def_ in def_list:
templates.env.globals[def_.__name__] = def_