Compare commits

..

1 Commits

Author SHA1 Message Date
1c14f9bcc1 docker-ready 2025-07-22 01:15:08 +03:00
6 changed files with 117 additions and 19 deletions

16
.dockerignore Normal file
View File

@@ -0,0 +1,16 @@
# .dockerignore
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
env/
venv/
.env
*.db
*.db-journal
test_output/
logs/
.git
.gitignore
.idea/

18
Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
# Этап 1: Используем официальный легковесный образ Python
FROM python:3.9-slim
# Устанавливаем рабочую директорию внутри контейнера
WORKDIR /app
# Обновляем pip и устанавливаем зависимости
# Копируем requirements.txt отдельно, чтобы Docker мог кэшировать этот слой.
# Зависимости переустановятся только если изменится requirements.txt.
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Копируем весь исходный код приложения в рабочую директорию
COPY . .
# Указываем команду, которая будет выполняться при запуске контейнера
CMD ["python", "main.py"]

View File

@@ -1,4 +1,3 @@
import os import os
from dotenv import load_dotenv from dotenv import load_dotenv
@@ -12,6 +11,8 @@ if not all([SD_ACCESS_KEY, SD_BASE_URL]):
raise ValueError("Необходимо задать переменные окружения SDKEY и SD_BASE_URL") raise ValueError("Необходимо задать переменные окружения SDKEY и SD_BASE_URL")
# --- Paths --- # --- Paths ---
# Директория для хранения лог-файлов
LOG_PATH = os.getenv("LOGPATH", ".")
DB_PATH = os.getenv("BDPATH", ".") # По умолчанию - текущая папка DB_PATH = os.getenv("BDPATH", ".") # По умолчанию - текущая папка
DB_NAME = "fiscals.db" DB_NAME = "fiscals.db"
DB_FULL_PATH = os.path.join(DB_PATH, DB_NAME) DB_FULL_PATH = os.path.join(DB_PATH, DB_NAME)

54
main.py
View File

@@ -1,8 +1,8 @@
# main.py
import sys import sys
import time import time
import logging import logging
import logging.handlers
import os
import schedule import schedule
import config import config
@@ -11,15 +11,53 @@ from sd_api import ServiceDeskClient
from sync_logic import Synchronizer from sync_logic import Synchronizer
def setup_logging(): def setup_logging():
"""Настраивает базовую конфигурацию логирования.""" """
logging.basicConfig( Настраивает продвинутую конфигурацию логирования с ротацией файлов
level=logging.INFO, и разными уровнями для консоли и файла.
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', """
stream=sys.stdout, # Вывод логов в stdout # 1. Устанавливаем корневому логгеру самый низкий уровень (DEBUG).
# Это позволяет хэндлерам самим решать, что пропускать.
root_logger = logging.getLogger()
root_logger.setLevel(logging.DEBUG)
# 2. Создаем директорию для логов, если она не существует.
try:
if not os.path.exists(config.LOG_PATH):
os.makedirs(config.LOG_PATH)
except OSError as e:
print(f"Ошибка создания директории для логов {config.LOG_PATH}: {e}")
# В случае ошибки выходим, т.к. логирование в файл не будет работать
sys.exit(1)
# 3. Настраиваем форматтер для логов.
log_formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
# 4. Настраиваем хэндлер для вывода в консоль (уровень INFO).
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setLevel(logging.INFO)
console_handler.setFormatter(log_formatter)
# 5. Настраиваем хэндлер для записи в файл с ежедневной ротацией (уровень DEBUG).
# Файлы будут вида sync_service.log, sync_service.log.2023-10-27 и т.д.
log_file = os.path.join(config.LOG_PATH, 'sync_service.log')
file_handler = logging.handlers.TimedRotatingFileHandler(
log_file,
when='midnight', # Ротация в полночь
interval=1, # Каждый день
backupCount=7, # Хранить 7 старых файлов
encoding='utf-8'
) )
# Отключаем слишком "болтливые" логи от сторонних библиотек file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(log_formatter)
# 6. Добавляем оба хэндлера к корневому логгеру.
root_logger.addHandler(console_handler)
root_logger.addHandler(file_handler)
# 7. Отключаем слишком "болтливые" логи от сторонних библиотек.
logging.getLogger("requests").setLevel(logging.WARNING) logging.getLogger("requests").setLevel(logging.WARNING)
logging.getLogger("urllib3").setLevel(logging.WARNING) logging.getLogger("urllib3").setLevel(logging.WARNING)
logging.getLogger("schedule").setLevel(logging.INFO)
def job(): def job():
""" """

View File

@@ -2,3 +2,4 @@ python-dotenv
requests requests
schedule schedule
python-dateutil python-dateutil
urllib3

View File

@@ -6,6 +6,8 @@ import json
import os import os
from typing import List, Dict, Any from typing import List, Dict, Any
from datetime import datetime from datetime import datetime
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
# Импортируем нашу конфигурацию # Импортируем нашу конфигурацию
import config import config
@@ -19,6 +21,7 @@ class ServiceDeskAPIError(Exception):
class ServiceDeskClient: class ServiceDeskClient:
""" """
Клиент для взаимодействия с REST API ServiceDesk. Клиент для взаимодействия с REST API ServiceDesk.
Оснащен механизмом повторных запросов для повышения отказоустойчивости.
""" """
def __init__(self, access_key: str, base_url: str): def __init__(self, access_key: str, base_url: str):
if not access_key or not base_url: if not access_key or not base_url:
@@ -29,32 +32,55 @@ class ServiceDeskClient:
# Устанавливаем accessKey как параметр по умолчанию для всех запросов # Устанавливаем accessKey как параметр по умолчанию для всех запросов
self.session.params = {'accessKey': access_key} self.session.params = {'accessKey': access_key}
# --- НАСТРОЙКА ЛОГИКИ ПОВТОРНЫХ ЗАПРОСОВ ---
retry_strategy = Retry(
total=3, # Общее количество повторных попыток
backoff_factor=1, # Множитель для задержки (sleep_time = {backoff_factor} * (2 ** ({number of total retries} - 1)))
# Задержки будут примерно 0.5с, 1с, 2с
status_forcelist=[500, 502, 503, 504], # Коды, которые нужно повторять
allowed_methods=["POST", "GET"] # Методы, для которых будет работать retry. API использует POST даже для поиска.
)
# Создаем адаптер с нашей стратегией
adapter = HTTPAdapter(max_retries=retry_strategy)
# "Монтируем" адаптер для всех http и https запросов
self.session.mount("https://", adapter)
self.session.mount("http://", adapter)
log.info("Клиент ServiceDesk инициализирован с логикой повторных запросов (retries).")
def _make_request(self, method: str, url: str, params: Dict = None, json_data: Dict = None) -> Any: def _make_request(self, method: str, url: str, params: Dict = None, json_data: Dict = None) -> Any:
""" """
Внутренний метод для выполнения HTTP-запросов. Внутренний метод для выполнения HTTP-запросов.
Теперь он автоматически использует логику retry, настроенную в __init__.
:param method: HTTP метод ('GET', 'POST', etc.) :param method: HTTP метод ('GET', 'POST', etc.)
:param url: Полный URL для запроса. :param url: Полный URL для запроса.
:param params: Дополнительные параметры URL (кроме accessKey). :param params: Дополнительные параметры URL (кроме accessKey).
:param json_data: Тело запроса в формате JSON для POST/PUT. :param json_data: Тело запроса в формате JSON для POST/PUT.
:return: Ответ сервера в виде JSON. :return: Ответ сервера в виде JSON.
:raises ServiceDeskAPIError: в случае ошибки API или сети. :raises ServiceDeskAPIError: в случае ошибки API или сети ПОСЛЕ всех попыток.
""" """
try: try:
# Теперь этот вызов будет автоматически повторяться в случае сбоев
response = self.session.request(method, url, params=params, json=json_data, timeout=30) response = self.session.request(method, url, params=params, json=json_data, timeout=30)
# Проверяем на ошибки ПОСЛЕ успешного выполнения запроса (или исчерпания попыток)
response.raise_for_status() # Вызовет исключение для кодов 4xx/5xx response.raise_for_status() # Вызовет исключение для кодов 4xx/5xx
# Некоторые ответы могут быть пустыми (например, при успешном редактировании) # Некоторые ответы могут быть пустыми (например, при успешном редактировании - 204)
if response.status_code == 204 or not response.text: # или успешном создании (201)
if response.status_code in [204, 201] or not response.text:
return None return None
return response.json() return response.json()
except requests.exceptions.HTTPError as e: except requests.exceptions.HTTPError as e:
# Эта ошибка возникнет, если после всех попыток сервер все равно вернул 4xx или 5xx
error_message = f"HTTP Error: {e.response.status_code} for URL {url}. Response: {e.response.text}" error_message = f"HTTP Error: {e.response.status_code} for URL {url}. Response: {e.response.text}"
log.error(error_message) log.error(error_message)
raise ServiceDeskAPIError(error_message) from e raise ServiceDeskAPIError(error_message) from e
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
# Эта ошибка возникнет, если после всех попыток не удалось подключиться к серверу (например, DNS или таймаут)
error_message = f"Request failed for URL {url}: {e}" error_message = f"Request failed for URL {url}: {e}"
log.error(error_message) log.error(error_message)
raise ServiceDeskAPIError(error_message) from e raise ServiceDeskAPIError(error_message) from e
@@ -73,7 +99,8 @@ class ServiceDeskClient:
} }
frs = self._make_request('POST', config.FIND_FRS_URL, params=params) frs = self._make_request('POST', config.FIND_FRS_URL, params=params)
log.info(f"Получено {len(frs)} записей о ФР.") log.info(f"Получено {len(frs)} записей о ФР.")
return frs # frs может быть None если ответ пустой, вернем пустой список для консистентности
return frs or []
def get_all_workstations(self) -> List[Dict]: def get_all_workstations(self) -> List[Dict]:
"""Получает список всех рабочих станций.""" """Получает список всех рабочих станций."""
@@ -83,7 +110,7 @@ class ServiceDeskClient:
} }
workstations = self._make_request('POST', config.FIND_WORKSTATIONS_URL, params=params) workstations = self._make_request('POST', config.FIND_WORKSTATIONS_URL, params=params)
log.info(f"Получено {len(workstations)} записей о рабочих станциях.") log.info(f"Получено {len(workstations)} записей о рабочих станциях.")
return workstations return workstations or []
def get_lookup_values(self, metaclass: str) -> List[Dict]: def get_lookup_values(self, metaclass: str) -> List[Dict]:
""" """
@@ -97,7 +124,7 @@ class ServiceDeskClient:
params = {'attrs': 'UUID,title'} params = {'attrs': 'UUID,title'}
lookups = self._make_request('POST', url, params=params) lookups = self._make_request('POST', url, params=params)
log.info(f"Получено {len(lookups)} значений для {metaclass}.") log.info(f"Получено {len(lookups)} значений для {metaclass}.")
return lookups return lookups or []
def update_fr(self, uuid: str, data: Dict) -> None: def update_fr(self, uuid: str, data: Dict) -> None:
""" """
@@ -125,9 +152,6 @@ class ServiceDeskClient:
return return
log.info(f"Обновление объекта ФР с UUID: {uuid}...") log.info(f"Обновление объекта ФР с UUID: {uuid}...")
# Примечание: старый код использовал POST с параметрами для редактирования.
# Если API требует form-encoded data, а не JSON, нужно использовать `data=data` вместо `json=data`.
# Судя по вашему коду, это POST-запрос с параметрами в URL, а не в теле.
url = config.EDIT_FR_URL_TEMPLATE.format(uuid=uuid) url = config.EDIT_FR_URL_TEMPLATE.format(uuid=uuid)
self._make_request('POST', url, params=data) self._make_request('POST', url, params=data)
log.info(f"Объект {uuid} успешно обновлен.") log.info(f"Объект {uuid} успешно обновлен.")