xMerge branch 'zorn-dev' of ssh://git.smartsoltech.kr:2222/SmartSolTech/touchh_bot into zorn-dev

This commit is contained in:
2025-02-02 09:28:20 +09:00
26 changed files with 617 additions and 221 deletions

1
.dockerignore Normal file
View File

@@ -0,0 +1 @@
/var

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/*

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

@@ -1,9 +1,11 @@
import json
from datetime import timedelta from datetime import timedelta
from urllib.parse import parse_qs from urllib.parse import parse_qs
from django.utils import timezone from django.utils import timezone
from django.db.models import Q from django.db.models import Q
from django.db import connection
from hotels.models import Reservation, Hotel from hotels.models import Reservation, Hotel
from .models import UserActivityLog, ViolationLog from .models import UserActivityLog, ViolationLog, RoomDiscrepancy
from touchh.utils.log import CustomLogger from touchh.utils.log import CustomLogger
# Настройка логирования # Настройка логирования
logger = CustomLogger(__name__).get_logger() logger = CustomLogger(__name__).get_logger()
@@ -18,9 +20,7 @@ class ReservationChecker:
""" """
Инициализация времени проверки и списка нарушений. Инициализация времени проверки и списка нарушений.
""" """
self.start_time = timezone.now() - timedelta(days=30) self.checkin_diff_hours = 3
self.end_time = timezone.now()
self.violations = []
def log_info(self, message): def log_info(self, message):
logger.info(message) logger.info(message)
@@ -31,102 +31,71 @@ class ReservationChecker:
def log_error(self, message): def log_error(self, message):
logger.error(message) logger.error(message)
def fetch_user_logs(self):
try:
self.log_info("Начинается извлечение логов активности пользователей.")
user_logs = UserActivityLog.objects.filter(created__range=(self.start_time, self.end_time))
self.log_info(f"Найдено {user_logs.count()} логов активности для анализа.")
return user_logs
except Exception as e:
self.log_error(f"Ошибка при извлечении логов активности: {e}")
return UserActivityLog.objects.none()
def fetch_reservations(self):
try:
self.log_info("Начинается извлечение бронирований.")
reservations = Reservation.objects.filter(
Q(check_in__lte=self.end_time) & Q(check_out__gte=self.start_time)
)
self.log_info(f"Найдено {reservations.count()} бронирований для анализа.")
return reservations
except Exception as e:
self.log_error(f"Ошибка при извлечении бронирований: {e}")
return Reservation.objects.none()
def find_violations(self):
self.log_info("Начинается анализ несоответствий.")
user_logs = self.fetch_user_logs()
reservations = self.fetch_reservations()
log_lookup = {}
for log in user_logs:
params = parse_qs(log.url_parameters or "")
hotel_id = params.get("utm_content", [None])[0]
room_number = params.get("utm_term", [None])[0]
if hotel_id and room_number:
key = (hotel_id, room_number)
log_lookup.setdefault(key, []).append(log)
for reservation in reservations:
key = (reservation.hotel.hotel_id, reservation.room_number)
logs = log_lookup.get(key, [])
if reservation.status == "заселен" and not logs:
self.record_violation(
hotel=reservation.hotel,
room_number=reservation.room_number,
violation_type="no_qr_scan",
details=f"Бронирование для номера {reservation.room_number} в отеле '{reservation.hotel.name}' "
f"не имеет записи сканирования QR."
)
for log in logs:
if log.created < reservation.check_in:
self.record_violation(
hotel=reservation.hotel,
room_number=reservation.room_number,
violation_type="early_check_in",
details=f"Раннее заселение: сканирование QR {log.created} раньше времени заезда "
f"{reservation.check_in} для номера {reservation.room_number}."
)
for (hotel_id, room_number), logs in log_lookup.items():
matching_reservations = reservations.filter(
hotel__hotel_id=hotel_id,
room_number=room_number
)
if not matching_reservations.exists():
for log in logs:
self.record_violation(
hotel=Hotel.objects.filter(hotel_id=hotel_id).first(),
room_number=room_number,
violation_type="no_reservation",
details=f"Сканирование QR {log.created} для номера {room_number} в отеле с ID '{hotel_id}' "
f"не соответствует ни одному бронированию."
)
def record_violation(self, hotel, room_number, violation_type, details):
if hotel:
self.violations.append(ViolationLog(
hotel=hotel,
room_number=room_number,
violation_type=violation_type,
violation_details=details
))
self.log_warning(f"Зафиксировано нарушение: {details}")
def save_violations(self):
if self.violations:
ViolationLog.objects.bulk_create(self.violations)
self.log_info(f"Создано {len(self.violations)} записей в ViolationLog.")
else:
self.log_info("Нарушений не обнаружено.")
def run_check(self): def run_check(self):
self.log_info(f"Запуск проверки с {self.start_time} по {self.end_time}.") self.log_info(f"Запуск проверки.")
try: try:
self.find_violations() hotels_map = {}
self.save_violations() hotels = Hotel.objects.all()
for hotel in hotels:
hotels_map[hotel.hotel_id] = hotel
user_logs = UserActivityLog.objects.filter(fraud_checked=False)
reservations = Reservation.objects.filter(fraud_checked=False).select_related('hotel')
missing = list(reservations)
violations = []
check_in_diff = timedelta(hours=self.checkin_diff_hours)
for user_log in user_logs:
try:
params = json.loads(user_log.url_parameters.replace("'", '"'))
hotel_id = params['utm_content']
room = params['utm_term']
reserv = next((x for x in reservations
if x.hotel.hotel_id == hotel_id and x.room_number == room
and user_log.date_time >= x.check_in - check_in_diff and user_log.date_time < x.check_out
), None)
v_type = None
if reserv:
if reserv in missing:
missing.remove(reserv)
if user_log.date_time < reserv.check_in:
v_type = 'early'
if user_log.date_time > reserv.check_in + check_in_diff:
v_type = 'late'
else:
v_type = 'no_booking'
if v_type:
violations.append(RoomDiscrepancy(
hotel=hotels_map[hotel_id],
room_number=room,
discrepancy_type=v_type,
booking_id=reserv.reservation_id if reserv else None,
check_in_date_expected=reserv.check_in if reserv else None,
check_in_date_actual=user_log.date_time,
))
user_log.fraud_checked = True
except Exception as e:
logger.error(e)
for miss_reserv in missing:
violations.append(RoomDiscrepancy(
hotel=miss_reserv.hotel,
room_number=miss_reserv.room_number,
discrepancy_type='missed',
booking_id=miss_reserv.reservation_id,
check_in_date_expected=miss_reserv.check_in,
))
for reserv in reservations:
reserv.fraud_checked = True
RoomDiscrepancy.objects.bulk_create(violations)
UserActivityLog.objects.bulk_update(user_logs, ['fraud_checked'], 1000)
Reservation.objects.bulk_update(reservations, ['fraud_checked'], 1000)
except Exception as e: except Exception as e:
self.log_error(f"Ошибка при выполнении проверки: {e}") self.log_error(f"Ошибка при выполнении проверки: {e}")
self.log_info("Проверка завершена.") self.log_info("Проверка завершена.")
@@ -139,4 +108,4 @@ def run_reservation_check():
checker.run_check() checker.run_check()
except Exception as e: except Exception as e:
logger.error(f"Ошибка при запуске проверки: {e}") logger.error(f"Ошибка при запуске проверки: {e}")
logger.info("run_reservation_check завершена.") logger.info("run_reservation_check завершена.")

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

@@ -0,0 +1,8 @@
from django.core.management.base import BaseCommand
from antifroud.check_fraud import run_reservation_check
class Command(BaseCommand):
help = "Запуск проверки на несоответствия"
def handle(self, *args, **options):
run_reservation_check()

View File

@@ -0,0 +1,28 @@
# Generated by Django 5.1.4 on 2025-02-01 06:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('antifroud', '0002_initial'),
]
operations = [
migrations.AlterField(
model_name='roomdiscrepancy',
name='booking_id',
field=models.CharField(max_length=255, null=True, verbose_name='ID бронирования'),
),
migrations.AlterField(
model_name='roomdiscrepancy',
name='check_in_date_actual',
field=models.DateField(null=True, verbose_name='Фактическая дата заселения'),
),
migrations.AlterField(
model_name='roomdiscrepancy',
name='check_in_date_expected',
field=models.DateField(null=True, verbose_name='Ожидаемая дата заселения'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.4 on 2025-02-01 06:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('antifroud', '0003_alter_roomdiscrepancy_booking_id_and_more'),
]
operations = [
migrations.AlterField(
model_name='roomdiscrepancy',
name='discrepancy_type',
field=models.CharField(choices=[('early', 'Раннее заселение'), ('late', 'Позднее заселение'), ('missed', 'Неявка'), ('no_booking', 'Без брони')], max_length=50, verbose_name='Тип несоответствия'),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.1.4 on 2025-02-01 09:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('antifroud', '0004_alter_roomdiscrepancy_discrepancy_type'),
]
operations = [
migrations.AddField(
model_name='roomdiscrepancy',
name='fraud_checked',
field=models.BooleanField(default=False, verbose_name='Проверено на несоответствия'),
),
migrations.AddField(
model_name='useractivitylog',
name='fraud_checked',
field=models.BooleanField(default=False, verbose_name='Проверено на несоответствия'),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.1.4 on 2025-02-01 09:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('antifroud', '0005_roomdiscrepancy_fraud_checked_and_more'),
]
operations = [
migrations.AlterField(
model_name='roomdiscrepancy',
name='fraud_checked',
field=models.BooleanField(db_index=True, default=False, verbose_name='Проверено на несоответствия'),
),
migrations.AlterField(
model_name='useractivitylog',
name='fraud_checked',
field=models.BooleanField(db_index=True, default=False, verbose_name='Проверено на несоответствия'),
),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 5.1.4 on 2025-02-01 09:48
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('antifroud', '0006_alter_roomdiscrepancy_fraud_checked_and_more'),
]
operations = [
migrations.RemoveField(
model_name='roomdiscrepancy',
name='fraud_checked',
),
]

View File

@@ -31,6 +31,7 @@ class UserActivityLog(models.Model):
honeypot = models.BooleanField(verbose_name="Метка honeypot", blank=True, null=True) honeypot = models.BooleanField(verbose_name="Метка honeypot", blank=True, null=True)
reply = models.BooleanField(verbose_name="Ответ пользователя", blank=True, null=True) reply = models.BooleanField(verbose_name="Ответ пользователя", blank=True, null=True)
page_url = models.URLField(blank=True, null=True, verbose_name="URL страницы") page_url = models.URLField(blank=True, null=True, verbose_name="URL страницы")
fraud_checked = models.BooleanField(default=False, verbose_name="Проверено на несоответствия", db_index=True)
@property @property
def formatted_timestamp(self): def formatted_timestamp(self):
@@ -42,7 +43,7 @@ class UserActivityLog(models.Model):
return "Нет данных" return "Нет данных"
# Изменение имени столбца # Изменение имени столбца
class Meta: class Meta:
indexes = [ indexes = [
models.Index(fields=["external_id"], name="idx_external_id"), models.Index(fields=["external_id"], name="idx_external_id"),
@@ -55,7 +56,7 @@ class UserActivityLog(models.Model):
verbose_name_plural = "Логи активности пользователей" verbose_name_plural = "Логи активности пользователей"
def __str__(self): def __str__(self):
return f"UserActivityLog {self.id}: {self.page_title}" return f"UserActivityLog {self.id}: {self.page_title}"
class Meta: class Meta:
verbose_name = "Регистрация посетителей" verbose_name = "Регистрация посетителей"
verbose_name_plural = "Регистрации посетителей" verbose_name_plural = "Регистрации посетителей"
@@ -63,7 +64,7 @@ class UserActivityLog(models.Model):
def get_location(self): def get_location(self):
if not self.ip: if not self.ip:
return "IP-адрес отсутствует" return "IP-адрес отсутствует"
try: try:
db_path = f"{settings.GEOIP_PATH}/GeoLite2-City.mmdb" db_path = f"{settings.GEOIP_PATH}/GeoLite2-City.mmdb"
geoip_reader = Reader(db_path) geoip_reader = Reader(db_path)
@@ -78,10 +79,8 @@ class UserActivityLog(models.Model):
except AddressNotFoundError: except AddressNotFoundError:
return "IP-адрес не найден в базе" return "IP-адрес не найден в базе"
except FileNotFoundError: except FileNotFoundError:
logger.error(f"Файл базы данных GeoIP не найден по пути: {db_path}")
return "Файл базы данных GeoIP не найден" return "Файл базы данных GeoIP не найден"
except Exception as e: except Exception as e:
logger.error(f"Ошибка при определении местоположения: {e}")
return "Местоположение недоступно" return "Местоположение недоступно"
class ExternalDBSettings(models.Model): class ExternalDBSettings(models.Model):
name = models.CharField(max_length=255, unique=True, help_text="Имя подключения для идентификации.") name = models.CharField(max_length=255, unique=True, help_text="Имя подключения для идентификации.")
@@ -107,12 +106,12 @@ class ExternalDBSettings(models.Model):
class RoomDiscrepancy(models.Model): class RoomDiscrepancy(models.Model):
hotel = models.ForeignKey(Hotel, on_delete=models.CASCADE, verbose_name="Отель") hotel = models.ForeignKey(Hotel, on_delete=models.CASCADE, verbose_name="Отель")
room_number = models.CharField(max_length=50, verbose_name="Номер комнаты") room_number = models.CharField(max_length=50, verbose_name="Номер комнаты")
booking_id = models.CharField(max_length=255, verbose_name="ID бронирования") booking_id = models.CharField(max_length=255, null=True, verbose_name="ID бронирования")
check_in_date_expected = models.DateField(verbose_name="Ожидаемая дата заселения") check_in_date_expected = models.DateField(null=True, verbose_name="Ожидаемая дата заселения")
check_in_date_actual = models.DateField(verbose_name="Фактическая дата заселения") check_in_date_actual = models.DateField(null=True, verbose_name="Фактическая дата заселения")
discrepancy_type = models.CharField( discrepancy_type = models.CharField(
max_length=50, max_length=50,
choices=[("early", "Раннее заселение"), ("late", "Позднее заселение"), ("missed", "Неявка")], choices=[("early", "Раннее заселение"), ("late", "Позднее заселение"), ("missed", "Неявка"), ("no_booking", "Без брони")],
verbose_name="Тип несоответствия" verbose_name="Тип несоответствия"
) )
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания") created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
@@ -216,8 +215,8 @@ class SyncLog(models.Model):
def __str__(self): def __str__(self):
return f"Отель: {self.hotel.name} | Получено: {self.recieved_records} | Обработано: {self.processed_records}" return f"Отель: {self.hotel.name} | Получено: {self.recieved_records} | Обработано: {self.processed_records}"
class ViolationLog(models.Model): class ViolationLog(models.Model):
hotel = models.ForeignKey(Hotel, on_delete=models.CASCADE, verbose_name="Отель") hotel = models.ForeignKey(Hotel, on_delete=models.CASCADE, verbose_name="Отель")
room_number = models.CharField(max_length=50, verbose_name="Номер комнаты", null=True, blank=True) room_number = models.CharField(max_length=50, verbose_name="Номер комнаты", null=True, blank=True)

View File

@@ -7,6 +7,11 @@ services:
- MYSQL_DATABASE=touchh - MYSQL_DATABASE=touchh
- MYSQL_USER=touchh - MYSQL_USER=touchh
- MYSQL_PASSWORD=${MYSQL_PASSWORD:-touchh} - MYSQL_PASSWORD=${MYSQL_PASSWORD:-touchh}
healthcheck:
test: mariadb -utouchh -p${MYSQL_PASSWORD:-touchh} -e 'SELECT 1;' touchh
interval: 1s
timeout: 10s
retries: 10
volumes: volumes:
- ./var/mysql:/var/lib/mysql - ./var/mysql:/var/lib/mysql
bot: bot:
@@ -15,7 +20,9 @@ services:
image: touchh-py image: touchh-py
restart: on-failure restart: on-failure
command: ['python3', 'manage.py', 'run_bot'] command: ['python3', 'manage.py', 'run_bot']
depends_on: ['db'] depends_on:
db:
condition: service_healthy
stop_signal: SIGINT stop_signal: SIGINT
volumes: volumes:
- .:/app - .:/app

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.4 on 2025-02-01 09:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('hotels', '0003_rename_external_id_hotel_external_id_pms_and_more'),
]
operations = [
migrations.AddField(
model_name='reservation',
name='fraud_checked',
field=models.BooleanField(db_index=True, default=False, verbose_name='Проверено на несоответствия'),
),
]

View File

@@ -116,6 +116,7 @@ class Reservation(models.Model):
status = models.CharField(max_length=50, verbose_name="Статус") status = models.CharField(max_length=50, verbose_name="Статус")
price = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True, verbose_name="Цена") price = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True, verbose_name="Цена")
discount = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True, verbose_name="Скидка") discount = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True, verbose_name="Скидка")
fraud_checked = models.BooleanField(default=False, verbose_name="Проверено на несоответствия", db_index=True)
def clean(self): def clean(self):
if self.check_out and self.check_in and self.check_out <= self.check_in: if self.check_out and self.check_in and self.check_out <= self.check_in:

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