release
This commit is contained in:
Vendored
BIN
Binary file not shown.
@@ -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
|
||||
@@ -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)
|
||||
Vendored
BIN
Binary file not shown.
@@ -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
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Vendored
BIN
Binary file not shown.
@@ -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;
|
||||
}
|
||||
Vendored
+6
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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 |
+2106
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
+7
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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();
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -0,0 +1,21 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
<!-- Заголовок страницы -->
|
||||
{% block title %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
<!-- Блок шапки -->
|
||||
{% block head %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
<!-- Блок тела -->
|
||||
{% block body %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
<!-- Блок скриптов -->
|
||||
{% block scripts %}
|
||||
|
||||
{% endblock %}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
Vendored
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
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.
@@ -0,0 +1 @@
|
||||
some_data
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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": "Произошла ошибка при доставке конкретному получателю"
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
from .practitioners import *
|
||||
from .patients import *
|
||||
from .signings import *
|
||||
from .documents import *
|
||||
from .statuses import *
|
||||
from .settings import *
|
||||
@@ -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="Документ не найден")
|
||||
@@ -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="Пациент не удален")
|
||||
@@ -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="Сотрудник не удален")
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
@@ -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 (но оставляем как признак непустоты)
|
||||
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 = " " in row_html or " " 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)}",
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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_
|
||||
Reference in New Issue
Block a user