diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b53920d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +# .dockerignore +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +env/ +venv/ +.env +*.db +*.db-journal +test_output/ +logs/ +.git +.gitignore +.idea/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7e6943e --- /dev/null +++ b/Dockerfile @@ -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"] \ No newline at end of file diff --git a/config.py b/config.py index e6a533c..ccb8559 100644 --- a/config.py +++ b/config.py @@ -1,4 +1,3 @@ - import os from dotenv import load_dotenv @@ -12,6 +11,8 @@ if not all([SD_ACCESS_KEY, SD_BASE_URL]): raise ValueError("Необходимо задать переменные окружения SDKEY и SD_BASE_URL") # --- Paths --- +# Директория для хранения лог-файлов +LOG_PATH = os.getenv("LOGPATH", ".") DB_PATH = os.getenv("BDPATH", ".") # По умолчанию - текущая папка DB_NAME = "fiscals.db" DB_FULL_PATH = os.path.join(DB_PATH, DB_NAME) diff --git a/main.py b/main.py index 37c7efd..ccb418c 100644 --- a/main.py +++ b/main.py @@ -1,8 +1,8 @@ -# main.py - import sys import time import logging +import logging.handlers +import os import schedule import config @@ -11,15 +11,53 @@ from sd_api import ServiceDeskClient from sync_logic import Synchronizer 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("urllib3").setLevel(logging.WARNING) + logging.getLogger("schedule").setLevel(logging.INFO) def job(): """ diff --git a/requirements.txt b/requirements.txt index 9371ec6..65a6a76 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ python-dotenv requests schedule -python-dateutil \ No newline at end of file +python-dateutil +urllib3 \ No newline at end of file diff --git a/sd_api.py b/sd_api.py index af2fb52..22726f5 100644 --- a/sd_api.py +++ b/sd_api.py @@ -6,6 +6,8 @@ import json import os from typing import List, Dict, Any from datetime import datetime +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry # Импортируем нашу конфигурацию import config @@ -19,6 +21,7 @@ class ServiceDeskAPIError(Exception): class ServiceDeskClient: """ Клиент для взаимодействия с REST API ServiceDesk. + Оснащен механизмом повторных запросов для повышения отказоустойчивости. """ def __init__(self, access_key: str, base_url: str): if not access_key or not base_url: @@ -29,32 +32,55 @@ class ServiceDeskClient: # Устанавливаем accessKey как параметр по умолчанию для всех запросов 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: """ Внутренний метод для выполнения HTTP-запросов. + Теперь он автоматически использует логику retry, настроенную в __init__. :param method: HTTP метод ('GET', 'POST', etc.) :param url: Полный URL для запроса. :param params: Дополнительные параметры URL (кроме accessKey). :param json_data: Тело запроса в формате JSON для POST/PUT. :return: Ответ сервера в виде JSON. - :raises ServiceDeskAPIError: в случае ошибки API или сети. + :raises ServiceDeskAPIError: в случае ошибки API или сети ПОСЛЕ всех попыток. """ try: + # Теперь этот вызов будет автоматически повторяться в случае сбоев response = self.session.request(method, url, params=params, json=json_data, timeout=30) + + # Проверяем на ошибки ПОСЛЕ успешного выполнения запроса (или исчерпания попыток) response.raise_for_status() # Вызовет исключение для кодов 4xx/5xx - # Некоторые ответы могут быть пустыми (например, при успешном редактировании) - if response.status_code == 204 or not response.text: + # Некоторые ответы могут быть пустыми (например, при успешном редактировании - 204) + # или успешном создании (201) + if response.status_code in [204, 201] or not response.text: return None return response.json() except requests.exceptions.HTTPError as e: + # Эта ошибка возникнет, если после всех попыток сервер все равно вернул 4xx или 5xx error_message = f"HTTP Error: {e.response.status_code} for URL {url}. Response: {e.response.text}" log.error(error_message) raise ServiceDeskAPIError(error_message) from e except requests.exceptions.RequestException as e: + # Эта ошибка возникнет, если после всех попыток не удалось подключиться к серверу (например, DNS или таймаут) error_message = f"Request failed for URL {url}: {e}" log.error(error_message) raise ServiceDeskAPIError(error_message) from e @@ -73,7 +99,8 @@ class ServiceDeskClient: } frs = self._make_request('POST', config.FIND_FRS_URL, params=params) log.info(f"Получено {len(frs)} записей о ФР.") - return frs + # frs может быть None если ответ пустой, вернем пустой список для консистентности + return frs or [] def get_all_workstations(self) -> List[Dict]: """Получает список всех рабочих станций.""" @@ -83,7 +110,7 @@ class ServiceDeskClient: } workstations = self._make_request('POST', config.FIND_WORKSTATIONS_URL, params=params) log.info(f"Получено {len(workstations)} записей о рабочих станциях.") - return workstations + return workstations or [] def get_lookup_values(self, metaclass: str) -> List[Dict]: """ @@ -97,7 +124,7 @@ class ServiceDeskClient: params = {'attrs': 'UUID,title'} lookups = self._make_request('POST', url, params=params) log.info(f"Получено {len(lookups)} значений для {metaclass}.") - return lookups + return lookups or [] def update_fr(self, uuid: str, data: Dict) -> None: """ @@ -125,9 +152,6 @@ class ServiceDeskClient: return 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) self._make_request('POST', url, params=data) log.info(f"Объект {uuid} успешно обновлен.")