Merge remote-tracking branch 'origin/PMSManager_refactor' into zorn-dev

This commit is contained in:
zorn
2024-12-29 21:57:41 +10:00
15 changed files with 395 additions and 111 deletions

View File

@@ -1,47 +1,111 @@
kind: pipeline kind: pipeline
type: docker name: Touchh Hotel AntiFraud Pipeline
name: Django CI/CD namespace: touchh
steps: steps:
# Шаг 1: Установка зависимостей, миграции и тесты # Шаг 1: Клонирование репозитория
- name: test - name: clone_repo
image: python:3.10 image: alpine/git
environment:
DATABASE_URL: mysql://root@R0sebud:0.0.0.0:3306/w1510415_wp832
commands: commands:
- python -m venv .venv - if [ ! -d .git ]; then git clone $DRONE_REPO_URL .; fi
- source .venv/bin/activate - git fetch --all
- pip install --upgrade pip - git reset --hard $DRONE_COMMIT
- pip install -r requirements.txt
- python manage.py migrate
- flake8 . # Линтер
- pytest # Запуск тестов
# Шаг 2: Запуск и проверка Telegram-бота # Шаг 2: Обновление и запуск с помощью update.sh
- name: bot-check - name: deploy_app
image: python:3.10 image: docker:24
environment: environment:
DATABASE_URL: mysql://root@R0sebud:0.0.0.0:3306/w1510415_wp832 MYSQL_PASSWORD: touchh
volumes:
- name: docker_sock
path: /var/run/docker.sock
commands: commands:
- python -m venv .venv - apk add --no-cache bash
- source .venv/bin/activate - chmod +x ./bin/update
- pip install --upgrade pip - docker-compose up -d
- until docker inspect -f '{{.State.Running}}' src-web-1 | grep true; do echo "Waiting for container to be running..."; sleep 5; done
- git branch --set-upstream-to=origin/PMSManager_refactor PMSManager_refactor || true
- ./bin/update
# Шаг 3: Миграция базы данных
- name: run_migrations
image: docker:24
environment:
MYSQL_PASSWORD: touchh
volumes:
- name: docker_sock
path: /var/run/docker.sock
depends_on:
- web
commands:
- apk add --no-cache bash
- until docker inspect -f '{{.State.Running}}' src-web-1 | grep true; do echo "Waiting for container to be running..."; sleep 5; done
- chmod +x ./bin/cli
- ./bin/cli migrate
# Шаг 4: Тестирование
- name: run_tests
image: python:3.12-alpine
environment:
MYSQL_PASSWORD: touchh
depends_on:
- db
commands:
- apk add --no-cache mariadb-client mariadb-connector-c-dev gcc musl-dev pkgconfig
- pip install -r requirements.txt - pip install -r requirements.txt
- python manage.py run_bot & # Запуск бота в фоне - python manage.py test
- sleep 5 # Ждём, чтобы бот запустился
- python test_bot.py # Проверка работы бота
# services: services:
# # Шаг 3: Сервис базы данных MySQL # Сервис базы данных
# - name: mysql - name: db
# image: mysql:8 image: mariadb:11.6
# environment: environment:
# MYSQL_ROOT_PASSWORD: R0sebud MYSQL_RANDOM_ROOT_PASSWORD: 1
# MYSQL_USER: user MYSQL_DATABASE: touchh
# MYSQL_PASSWORD: password MYSQL_USER: touchh
# MYSQL_DATABASE: w1510415_wp832 MYSQL_PASSWORD: touchh
volumes:
- name: mysql_data
temp: {}
trigger: # Сервис Django (Web)
event: - name: web
- push image: touchh-py
- pull_request environment:
MYSQL_PASSWORD: touchh
command: ['python3', 'manage.py', 'runserver', '0.0.0.0:8000']
ports:
- port: 8000
depends_on:
- db
volumes:
- name: app_volume
path: /app
# Сервис Telegram Bot
- name: bot
image: touchh-py
environment:
MYSQL_PASSWORD: touchh
command: ['python3', 'manage.py', 'run_bot']
depends_on:
- db
# Сервис планировщика задач
- name: scheduler
image: touchh-py
environment:
MYSQL_PASSWORD: touchh
command: ['python3', 'manage.py', 'start_scheduler']
depends_on:
- db
volumes:
- name: docker_sock
host:
path: /var/run/docker.sock
- name: mysql_data
temp: {}
- name: app_volume
host:
path: ./

2
.gitignore vendored
View File

@@ -16,3 +16,5 @@ db.sqlite3
# Ignore files # Ignore files
.fake .fake
docker-compose.override.yaml docker-compose.override.yaml
tmp/*
tmp_data/*

View File

@@ -1,7 +1,7 @@
FROM python:3.12-alpine FROM python:3.12-alpine
COPY requirements.txt / COPY requirements.txt /
COPY . .
RUN set -ex ;\ RUN set -ex ;\
apk add --no-cache musl-dev mariadb-connector-c-dev gcc ;\ apk add --no-cache musl-dev mariadb-connector-c-dev gcc ;\
pip3 install -r /requirements.txt ;\ pip3 install -r /requirements.txt ;\

112
README.md
View File

@@ -161,4 +161,114 @@ python manage.py runserver
#### Проверка интеграции с PMS #### Проверка интеграции с PMS
Для каждого отеля можно проверять статус интеграции с PMS (Bnovo, Travel Line, Realty) и получать ответ о доступности PMS. Для каждого отеля можно проверять статус интеграции с PMS (Bnovo, Travel Line, Realty) и получать ответ о доступности PMS.
#### Разработка плагинов для интеграции с PMS
Для разработки плагина используются следующие инструменты:
- Django
- Python
- Pydantic
*код примера для плагина*
```python
from datetime import datetime, timedelta
import requests
from asgiref.sync import sync_to_async
from hotels.models import Reservation
from .base_plugin import BasePMSPlugin
import logging
class ExamplePMSPlugin(BasePMSPlugin):
"""
Плагин для интеграции с PMS Example.
"""
def __init__(self, hotel):
super().__init__(hotel.pms)
self.hotel = hotel
self.api_url = self.hotel.pms.url
self.token = self.hotel.pms.token
self.logger = self._configure_logger()
def _configure_logger(self):
logger = logging.getLogger(self.__class__.__name__)
handler_console = logging.StreamHandler()
handler_file = logging.FileHandler(f'{self.__class__.__name__.lower()}.log')
formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
handler_console.setFormatter(formatter)
handler_file.setFormatter(formatter)
logger.addHandler(handler_console)
logger.addHandler(handler_file)
logger.setLevel(logging.DEBUG)
return logger
def get_default_parser_settings(self):
"""
Возвращает настройки для обработки данных.
"""
return {
"field_mapping": {
"check_in": "arrival_date",
"check_out": "departure_date",
"room_number": "room",
"status": "status",
},
"date_format": "%Y-%m-%d %H:%M:%S"
}
async def _fetch_data(self):
"""
Получает данные из API Example PMS.
"""
headers = {
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json",
}
now = datetime.now()
payload = {
"from_date": (now - timedelta(days=7)).strftime("%Y-%m-%d"),
"to_date": now.strftime("%Y-%m-%d"),
}
try:
response = await sync_to_async(requests.post)(
self.api_url, json=payload, headers=headers
)
response.raise_for_status()
data = response.json()
return await self._process_data(data)
except requests.exceptions.RequestException as e:
self.logger.error(f"Ошибка API: {e}")
return {"processed_items": 0, "errors": [str(e)]}
async def _process_data(self, data):
"""
Обрабатывает и сохраняет данные в базу.
"""
processed_items = 0
errors = []
for item in data.get("bookings", []):
try:
reservation, created = await sync_to_async(Reservation.objects.update_or_create)(
reservation_id=item['id'],
defaults={
'room_number': item['room'],
'check_in': datetime.strptime(item['arrival_date'], "%Y-%m-%d"),
'check_out': datetime.strptime(item['departure_date'], "%Y-%m-%d"),
'status': item['status'],
'hotel': self.hotel,
}
)
processed_items += 1
except Exception as e:
self.logger.error(f"Ошибка обработки записи {item['id']}: {e}")
errors.append(str(e))
return {"processed_items": processed_items, "errors": errors}
```

View File

@@ -15,7 +15,7 @@ from django.db.models import F
class DatabaseConnector: class DatabaseConnector:
def __init__(self, db_settings_id): def __init__(self, db_settings_id):
self.db_settings_id = db_settings_id self.db_settings_id = db_settings_id
self.logger = CustomLogger(name="DatabaseConnector", log_level="DEBUG").get_logger() self.logger = CustomLogger(name="DatabaseConnector", log_level="WARNING").get_logger()
self.connection = None self.connection = None
self.db_settings = self.get_db_settings() self.db_settings = self.get_db_settings()

View File

@@ -53,11 +53,25 @@ class PMSIntegrationManager:
Загружает плагин, соответствующий PMS конфигурации отеля. Загружает плагин, соответствующий PMS конфигурации отеля.
""" """
pms_name = self.hotel.pms.plugin_name.lower() # Приводим название плагина к нижнему регистру pms_name = self.hotel.pms.plugin_name.lower() # Приводим название плагина к нижнему регистру
if pms_name == "ecvi_intermark" or pms_name == "ecvi":
from pms_integration.plugins.ecvi_pms import EcviPMSPlugin # Формируем имя модуля и класса плагина
self.plugin = EcviPMSPlugin(self.hotel) plugin_module_name = f"pms_integration.plugins.{pms_name}_pms"
else: plugin_class_name = f"{pms_name.capitalize()}PMSPlugin"
raise ValueError(f"Неизвестный PMS: {pms_name}")
try:
# Динамически импортируем модуль плагина
plugin_module = importlib.import_module(plugin_module_name)
# Динамически получаем класс плагина
plugin_class = getattr(plugin_module, plugin_class_name, None)
if not plugin_class or not issubclass(plugin_class, BasePMSPlugin):
raise ImportError(f"Класс {plugin_class_name} не найден или не является наследником BasePMSPlugin.")
# Инициализируем плагин
self.plugin = plugin_class(self.hotel)
except ImportError as e:
raise ValueError(f"Ошибка загрузки плагина для PMS {pms_name}: {e}")
def fetch_data(self): def fetch_data(self):
""" """
Получает данные из PMS с использованием загруженного плагина. Получает данные из PMS с использованием загруженного плагина.

View File

@@ -176,13 +176,13 @@ class BnovoPMSPlugin(BasePMSPlugin):
"""Получение данных о бронированиях с помощью эндпоинта /dashboard.""" """Получение данных о бронированиях с помощью эндпоинта /dashboard."""
logger.info("Начало процесса получения данных о бронированиях.") logger.info("Начало процесса получения данных о бронированиях.")
# # Вызов функции получения данных аккаунта # Вызов функции получения данных аккаунта
# try: try:
# account_data = await self._fetch_and_log_account_data() account_data = await self._fetch_and_log_account_data()
# logger.info(f"Данные аккаунта успешно получены:") logger.info(f"Данные аккаунта успешно получены:")
# except Exception as e: except Exception as e:
# logger.error(f"Ошибка получения данных аккаунта: {e}") logger.error(f"Ошибка получения данных аккаунта: {e}")
# raise raise
url = f"{self.api_url}/dashboard" url = f"{self.api_url}/dashboard"
now = datetime.now() now = datetime.now()

View File

@@ -33,7 +33,7 @@ class EcviPMSPlugin(BasePMSPlugin):
handler_file.setFormatter(formatter) handler_file.setFormatter(formatter)
self.logger.addHandler(handler_console) self.logger.addHandler(handler_console)
self.logger.addHandler(handler_file) self.logger.addHandler(handler_file)
self.logger.setLevel(logging.DEBUG) self.logger.setLevel(logging.WARNING)
def get_default_parser_settings(self): def get_default_parser_settings(self):
""" """

View File

@@ -1,34 +1,39 @@
import logging import logging
import requests import requests
import json
import os
from datetime import datetime, timedelta from datetime import datetime, timedelta
from asgiref.sync import sync_to_async from asgiref.sync import sync_to_async
from pms_integration.models import PMSConfiguration
from hotels.models import Hotel, Reservation from hotels.models import Hotel, Reservation
from .base_plugin import BasePMSPlugin from .base_plugin import BasePMSPlugin
from touchh.utils.log import CustomLogger
class ShelterPMSPlugin(BasePMSPlugin):
class EcviPMSPlugin(BasePMSPlugin):
""" """
Плагин для интеграции с PMS Ecvi (интерфейс для получения данных об отеле). Плагин для интеграции с PMS Shelter.
""" """
def __init__(self, pms_config): def __init__(self, hotel):
super().__init__(pms_config) super().__init__(hotel.pms) # Передаем PMS-конфигурацию в базовый класс
self.hotel = hotel # Сохраняем объект отеля
# Инициализация логгера
self.logger = logging.getLogger(self.__class__.__name__) # Логгер с именем класса # Проверка PMS-конфигурации
handler = logging.StreamHandler() # Потоковый обработчик для вывода в консоль if not self.hotel.pms:
formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s') raise ValueError(f"Отель {self.hotel.name} не имеет связанной PMS конфигурации.")
handler.setFormatter(formatter)
self.logger.addHandler(handler)
self.logger.setLevel(logging.DEBUG) # Уровень логирования
# Инициализация параметров API # Инициализация параметров API
self.api_url = pms_config.url self.api_url = self.hotel.pms.url
self.token = pms_config.token self.token = self.hotel.pms.token
self.username = pms_config.username
self.password = pms_config.password # Настройка логгера
self.pagination_count = 50 # Максимальное количество записей на страницу (если используется пагинация) self.logger = CustomLogger(name="ShelterPMSPlugin", log_level="WARNING").get_logger()
handler_console = logging.StreamHandler()
handler_file = logging.FileHandler('var/log/shelter_pms_plugin.log')
formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
handler_console.setFormatter(formatter)
handler_file.setFormatter(formatter)
self.logger.addHandler(handler_console)
self.logger.addHandler(handler_file)
self.logger.setLevel(logging.WARNING)
def get_default_parser_settings(self): def get_default_parser_settings(self):
""" """
@@ -36,62 +41,145 @@ class EcviPMSPlugin(BasePMSPlugin):
""" """
return { return {
"field_mapping": { "field_mapping": {
"check_in": "checkin", "check_in": "from",
"check_out": "checkout", "check_out": "until",
"room_number": "room_name", "room_number": "roomNumber",
"room_type_name": "room_type", "room_type_name": "roomTypeName",
"status": "occupancy", "status": "checkInStatus",
}, },
"date_format": "%Y-%m-%dT%H:%M:%S" "date_format": "%Y-%m-%dT%H:%M:%S"
} }
async def _fetch_data(self): async def _fetch_data(self):
""" """
Получает данные из PMS API, фильтрует и сохраняет в базу данных. Получает данные из PMS API и сохраняет их в базу.
""" """
now = datetime.now() now = datetime.now()
current_date = now.strftime('%Y-%m-%d') start_date = (now - timedelta(days=60)).strftime('%Y-%m-%dT%H:%M:%SZ')
yesterday_date = (now - timedelta(days=1)).strftime('%Y-%m-%d') end_date = (now + timedelta(days=60)).strftime('%Y-%m-%dT%H:%M:%SZ')
headers = { headers = {
"Content-Type": "application/json", "Content-Type": "application/json",
"Accept": "text/plain",
"Authorization": f"Bearer {self.token}"
} }
data = {
"token": self.token, from_index = 0
} count_per_request = 50
all_items = []
try: try:
# Запрос данных из PMS API while True:
response = await sync_to_async(requests.post)(self.api_url, headers=headers, json=data, auth=(self.username, self.password)) data = {
response.raise_for_status() # Если ошибка, выбросит исключение "from": start_date,
data = response.json() # Преобразуем ответ в JSON "until": end_date,
self.logger.debug(f"Получены данные с API: {data}") "pagination": {
except requests.exceptions.RequestException as e: "from": from_index,
self.logger.error(f"Ошибка запроса: {e}") "count": count_per_request
return [] }
# Фильтрация данных
filtered_data = []
for item in data:
if item.get('occupancy') in ['проживание', 'под выезд', 'под заезд']:
filtered_item = {
'checkin': datetime.strptime(item.get('checkin'), '%Y-%m-%d %H:%M:%S'),
'checkout': datetime.strptime(item.get('checkout'), '%Y-%m-%d %H:%M:%S'),
'room_number': item.get('room_name'),
'room_type': item.get('room_type'),
'status': item.get('occupancy')
} }
filtered_data.append(filtered_item)
# Логируем результат фильтрации response = await sync_to_async(requests.post)(self.api_url, headers=headers, data=json.dumps(data))
self.logger.debug(f"Отфильтрованные данные: {filtered_data}") response.raise_for_status()
response_data = response.json()
# Сохранение данных в базу данных items = response_data.get("items", [])
for item in filtered_data: all_items.extend(items)
await self._save_to_db(item)
self.logger.debug(f"Данные успешно сохранены.") total_count = response_data.get("count", 0)
return filtered_data from_index += len(items)
if from_index >= total_count:
break
self.logger.info(f"Получено записей: {len(all_items)}")
# Сохранение данных во временный файл
temp_dir = os.path.join("temp", "shelter")
os.makedirs(temp_dir, exist_ok=True)
temp_file = os.path.join(temp_dir, f"shelter_data_{datetime.now().strftime('%Y%m%d%H%M%S')}.json")
with open(temp_file, 'w') as file:
json.dump(all_items, file)
self.logger.info(f"Данные сохранены во временный файл: {temp_file}")
return await self._process_data(all_items)
except requests.exceptions.RequestException as e:
self.logger.error(f"Ошибка API: {e}")
return {
"processed_intervals": 0,
"processed_items": 0,
"errors": [str(e)]
}
async def _process_data(self, data):
"""
Обрабатывает данные и сохраняет их в базу.
"""
processed_items = 0
errors = []
date_formats = ["%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S"] # Поддержка нескольких форматов даты
for item in data:
try:
# Парсинг даты с поддержкой нескольких форматов
checkin = self._parse_date(item['from'], date_formats)
checkout = self._parse_date(item['until'], date_formats)
reservation, created = await sync_to_async(Reservation.objects.update_or_create)(
reservation_id=item['id'],
defaults={
'room_number': item.get('roomNumber'),
'room_type': item.get('roomTypeName'),
'check_in': checkin,
'check_out': checkout,
'status': item.get('checkInStatus'),
'hotel': self.hotel,
}
)
if created:
self.logger.debug(f"Создана новая резервация: {reservation.reservation_id}")
else:
self.logger.debug(f"Обновлена существующая резервация: {reservation.reservation_id}")
processed_items += 1
except Exception as e:
self.logger.error(f"Ошибка обработки записи: {e}")
errors.append(str(e))
return {
"processed_intervals": 1,
"processed_items": processed_items,
"errors": errors
}
@staticmethod
def _parse_date(date_str, formats):
"""
Парсит дату, пытаясь использовать несколько форматов.
"""
for fmt in formats:
try:
return datetime.strptime(date_str, fmt)
except ValueError:
continue
raise ValueError(f"Дата '{date_str}' не соответствует ожидаемым форматам: {formats}")
def validate_plugin(self):
"""
Проверка корректности реализации плагина.
"""
required_methods = ["fetch_data", "get_default_parser_settings", "_fetch_data"]
for method in required_methods:
if not hasattr(self, method):
raise ValueError(f"Плагин {type(self).__name__} не реализует метод {method}.")
self.logger.debug(f"Плагин {self.__class__.__name__} прошел валидацию.")
return True
async def _save_to_db(self, item): async def _save_to_db(self, item):
""" """

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long