xMerge branch 'zorn-dev' of ssh://git.smartsoltech.kr:2222/SmartSolTech/touchh_bot into zorn-dev
This commit is contained in:
1
.dockerignore
Normal file
1
.dockerignore
Normal file
@@ -0,0 +1 @@
|
||||
/var
|
||||
138
.drone.yml
138
.drone.yml
@@ -1,47 +1,111 @@
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: Django CI/CD
|
||||
name: Touchh Hotel AntiFraud Pipeline
|
||||
namespace: touchh
|
||||
|
||||
steps:
|
||||
# Шаг 1: Установка зависимостей, миграции и тесты
|
||||
- name: test
|
||||
image: python:3.10
|
||||
environment:
|
||||
DATABASE_URL: mysql://root@R0sebud:0.0.0.0:3306/w1510415_wp832
|
||||
# Шаг 1: Клонирование репозитория
|
||||
- name: clone_repo
|
||||
image: alpine/git
|
||||
commands:
|
||||
- python -m venv .venv
|
||||
- source .venv/bin/activate
|
||||
- pip install --upgrade pip
|
||||
- pip install -r requirements.txt
|
||||
- python manage.py migrate
|
||||
- flake8 . # Линтер
|
||||
- pytest # Запуск тестов
|
||||
- if [ ! -d .git ]; then git clone $DRONE_REPO_URL .; fi
|
||||
- git fetch --all
|
||||
- git reset --hard $DRONE_COMMIT
|
||||
|
||||
# Шаг 2: Запуск и проверка Telegram-бота
|
||||
- name: bot-check
|
||||
image: python:3.10
|
||||
# Шаг 2: Обновление и запуск с помощью update.sh
|
||||
- name: deploy_app
|
||||
image: docker:24
|
||||
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:
|
||||
- python -m venv .venv
|
||||
- source .venv/bin/activate
|
||||
- pip install --upgrade pip
|
||||
- apk add --no-cache bash
|
||||
- chmod +x ./bin/update
|
||||
- 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
|
||||
- python manage.py run_bot & # Запуск бота в фоне
|
||||
- sleep 5 # Ждём, чтобы бот запустился
|
||||
- python test_bot.py # Проверка работы бота
|
||||
- python manage.py test
|
||||
|
||||
# services:
|
||||
# # Шаг 3: Сервис базы данных MySQL
|
||||
# - name: mysql
|
||||
# image: mysql:8
|
||||
# environment:
|
||||
# MYSQL_ROOT_PASSWORD: R0sebud
|
||||
# MYSQL_USER: user
|
||||
# MYSQL_PASSWORD: password
|
||||
# MYSQL_DATABASE: w1510415_wp832
|
||||
services:
|
||||
# Сервис базы данных
|
||||
- name: db
|
||||
image: mariadb:11.6
|
||||
environment:
|
||||
MYSQL_RANDOM_ROOT_PASSWORD: 1
|
||||
MYSQL_DATABASE: touchh
|
||||
MYSQL_USER: touchh
|
||||
MYSQL_PASSWORD: touchh
|
||||
volumes:
|
||||
- name: mysql_data
|
||||
temp: {}
|
||||
|
||||
trigger:
|
||||
event:
|
||||
- push
|
||||
- pull_request
|
||||
# Сервис Django (Web)
|
||||
- name: web
|
||||
image: touchh-py
|
||||
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
2
.gitignore
vendored
@@ -16,3 +16,5 @@ db.sqlite3
|
||||
# Ignore files
|
||||
.fake
|
||||
docker-compose.override.yaml
|
||||
tmp/*
|
||||
tmp_data/*
|
||||
110
README.md
110
README.md
@@ -162,3 +162,113 @@ python manage.py runserver
|
||||
#### Проверка интеграции с 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}
|
||||
|
||||
```
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import json
|
||||
from datetime import timedelta
|
||||
from urllib.parse import parse_qs
|
||||
from django.utils import timezone
|
||||
from django.db.models import Q
|
||||
from django.db import connection
|
||||
from hotels.models import Reservation, Hotel
|
||||
from .models import UserActivityLog, ViolationLog
|
||||
from .models import UserActivityLog, ViolationLog, RoomDiscrepancy
|
||||
from touchh.utils.log import CustomLogger
|
||||
# Настройка логирования
|
||||
logger = CustomLogger(__name__).get_logger()
|
||||
@@ -18,9 +20,7 @@ class ReservationChecker:
|
||||
"""
|
||||
Инициализация времени проверки и списка нарушений.
|
||||
"""
|
||||
self.start_time = timezone.now() - timedelta(days=30)
|
||||
self.end_time = timezone.now()
|
||||
self.violations = []
|
||||
self.checkin_diff_hours = 3
|
||||
|
||||
def log_info(self, message):
|
||||
logger.info(message)
|
||||
@@ -31,102 +31,71 @@ class ReservationChecker:
|
||||
def log_error(self, 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):
|
||||
self.log_info(f"Запуск проверки с {self.start_time} по {self.end_time}.")
|
||||
self.log_info(f"Запуск проверки.")
|
||||
try:
|
||||
self.find_violations()
|
||||
self.save_violations()
|
||||
hotels_map = {}
|
||||
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:
|
||||
self.log_error(f"Ошибка при выполнении проверки: {e}")
|
||||
self.log_info("Проверка завершена.")
|
||||
|
||||
@@ -15,7 +15,7 @@ from django.db.models import F
|
||||
class DatabaseConnector:
|
||||
def __init__(self, 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.db_settings = self.get_db_settings()
|
||||
|
||||
|
||||
8
antifroud/management/commands/fraud_check.py
Normal file
8
antifroud/management/commands/fraud_check.py
Normal 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()
|
||||
@@ -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='Ожидаемая дата заселения'),
|
||||
),
|
||||
]
|
||||
@@ -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='Тип несоответствия'),
|
||||
),
|
||||
]
|
||||
@@ -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='Проверено на несоответствия'),
|
||||
),
|
||||
]
|
||||
@@ -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='Проверено на несоответствия'),
|
||||
),
|
||||
]
|
||||
@@ -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',
|
||||
),
|
||||
]
|
||||
@@ -31,6 +31,7 @@ class UserActivityLog(models.Model):
|
||||
honeypot = models.BooleanField(verbose_name="Метка honeypot", blank=True, null=True)
|
||||
reply = models.BooleanField(verbose_name="Ответ пользователя", blank=True, null=True)
|
||||
page_url = models.URLField(blank=True, null=True, verbose_name="URL страницы")
|
||||
fraud_checked = models.BooleanField(default=False, verbose_name="Проверено на несоответствия", db_index=True)
|
||||
|
||||
@property
|
||||
def formatted_timestamp(self):
|
||||
@@ -78,10 +79,8 @@ class UserActivityLog(models.Model):
|
||||
except AddressNotFoundError:
|
||||
return "IP-адрес не найден в базе"
|
||||
except FileNotFoundError:
|
||||
logger.error(f"Файл базы данных GeoIP не найден по пути: {db_path}")
|
||||
return "Файл базы данных GeoIP не найден"
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при определении местоположения: {e}")
|
||||
return "Местоположение недоступно"
|
||||
class ExternalDBSettings(models.Model):
|
||||
name = models.CharField(max_length=255, unique=True, help_text="Имя подключения для идентификации.")
|
||||
@@ -107,12 +106,12 @@ class ExternalDBSettings(models.Model):
|
||||
class RoomDiscrepancy(models.Model):
|
||||
hotel = models.ForeignKey(Hotel, on_delete=models.CASCADE, verbose_name="Отель")
|
||||
room_number = models.CharField(max_length=50, verbose_name="Номер комнаты")
|
||||
booking_id = models.CharField(max_length=255, verbose_name="ID бронирования")
|
||||
check_in_date_expected = models.DateField(verbose_name="Ожидаемая дата заселения")
|
||||
check_in_date_actual = models.DateField(verbose_name="Фактическая дата заселения")
|
||||
booking_id = models.CharField(max_length=255, null=True, verbose_name="ID бронирования")
|
||||
check_in_date_expected = models.DateField(null=True, verbose_name="Ожидаемая дата заселения")
|
||||
check_in_date_actual = models.DateField(null=True, verbose_name="Фактическая дата заселения")
|
||||
discrepancy_type = models.CharField(
|
||||
max_length=50,
|
||||
choices=[("early", "Раннее заселение"), ("late", "Позднее заселение"), ("missed", "Неявка")],
|
||||
choices=[("early", "Раннее заселение"), ("late", "Позднее заселение"), ("missed", "Неявка"), ("no_booking", "Без брони")],
|
||||
verbose_name="Тип несоответствия"
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
|
||||
|
||||
@@ -7,6 +7,11 @@ services:
|
||||
- MYSQL_DATABASE=touchh
|
||||
- MYSQL_USER=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:
|
||||
- ./var/mysql:/var/lib/mysql
|
||||
bot:
|
||||
@@ -15,7 +20,9 @@ services:
|
||||
image: touchh-py
|
||||
restart: on-failure
|
||||
command: ['python3', 'manage.py', 'run_bot']
|
||||
depends_on: ['db']
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
stop_signal: SIGINT
|
||||
volumes:
|
||||
- .:/app
|
||||
|
||||
18
hotels/migrations/0004_reservation_fraud_checked.py
Normal file
18
hotels/migrations/0004_reservation_fraud_checked.py
Normal 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='Проверено на несоответствия'),
|
||||
),
|
||||
]
|
||||
@@ -116,6 +116,7 @@ class Reservation(models.Model):
|
||||
status = models.CharField(max_length=50, 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="Скидка")
|
||||
fraud_checked = models.BooleanField(default=False, verbose_name="Проверено на несоответствия", db_index=True)
|
||||
|
||||
def clean(self):
|
||||
if self.check_out and self.check_in and self.check_out <= self.check_in:
|
||||
|
||||
@@ -53,11 +53,25 @@ class PMSIntegrationManager:
|
||||
Загружает плагин, соответствующий PMS конфигурации отеля.
|
||||
"""
|
||||
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)
|
||||
else:
|
||||
raise ValueError(f"Неизвестный PMS: {pms_name}")
|
||||
|
||||
# Формируем имя модуля и класса плагина
|
||||
plugin_module_name = f"pms_integration.plugins.{pms_name}_pms"
|
||||
plugin_class_name = f"{pms_name.capitalize()}PMSPlugin"
|
||||
|
||||
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):
|
||||
"""
|
||||
Получает данные из PMS с использованием загруженного плагина.
|
||||
|
||||
@@ -176,13 +176,13 @@ class BnovoPMSPlugin(BasePMSPlugin):
|
||||
"""Получение данных о бронированиях с помощью эндпоинта /dashboard."""
|
||||
logger.info("Начало процесса получения данных о бронированиях.")
|
||||
|
||||
# # Вызов функции получения данных аккаунта
|
||||
# try:
|
||||
# account_data = await self._fetch_and_log_account_data()
|
||||
# logger.info(f"Данные аккаунта успешно получены:")
|
||||
# except Exception as e:
|
||||
# logger.error(f"Ошибка получения данных аккаунта: {e}")
|
||||
# raise
|
||||
# Вызов функции получения данных аккаунта
|
||||
try:
|
||||
account_data = await self._fetch_and_log_account_data()
|
||||
logger.info(f"Данные аккаунта успешно получены:")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения данных аккаунта: {e}")
|
||||
raise
|
||||
|
||||
url = f"{self.api_url}/dashboard"
|
||||
now = datetime.now()
|
||||
|
||||
@@ -33,7 +33,7 @@ class EcviPMSPlugin(BasePMSPlugin):
|
||||
handler_file.setFormatter(formatter)
|
||||
self.logger.addHandler(handler_console)
|
||||
self.logger.addHandler(handler_file)
|
||||
self.logger.setLevel(logging.DEBUG)
|
||||
self.logger.setLevel(logging.WARNING)
|
||||
|
||||
def get_default_parser_settings(self):
|
||||
"""
|
||||
|
||||
@@ -1,34 +1,39 @@
|
||||
import logging
|
||||
import requests
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from asgiref.sync import sync_to_async
|
||||
from pms_integration.models import PMSConfiguration
|
||||
from hotels.models import Hotel, Reservation
|
||||
from .base_plugin import BasePMSPlugin
|
||||
|
||||
|
||||
class EcviPMSPlugin(BasePMSPlugin):
|
||||
from touchh.utils.log import CustomLogger
|
||||
class ShelterPMSPlugin(BasePMSPlugin):
|
||||
"""
|
||||
Плагин для интеграции с PMS Ecvi (интерфейс для получения данных об отеле).
|
||||
Плагин для интеграции с PMS Shelter.
|
||||
"""
|
||||
|
||||
def __init__(self, pms_config):
|
||||
super().__init__(pms_config)
|
||||
def __init__(self, hotel):
|
||||
super().__init__(hotel.pms) # Передаем PMS-конфигурацию в базовый класс
|
||||
self.hotel = hotel # Сохраняем объект отеля
|
||||
|
||||
# Инициализация логгера
|
||||
self.logger = logging.getLogger(self.__class__.__name__) # Логгер с именем класса
|
||||
handler = logging.StreamHandler() # Потоковый обработчик для вывода в консоль
|
||||
formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
|
||||
handler.setFormatter(formatter)
|
||||
self.logger.addHandler(handler)
|
||||
self.logger.setLevel(logging.DEBUG) # Уровень логирования
|
||||
# Проверка PMS-конфигурации
|
||||
if not self.hotel.pms:
|
||||
raise ValueError(f"Отель {self.hotel.name} не имеет связанной PMS конфигурации.")
|
||||
|
||||
# Инициализация параметров API
|
||||
self.api_url = pms_config.url
|
||||
self.token = pms_config.token
|
||||
self.username = pms_config.username
|
||||
self.password = pms_config.password
|
||||
self.pagination_count = 50 # Максимальное количество записей на страницу (если используется пагинация)
|
||||
self.api_url = self.hotel.pms.url
|
||||
self.token = self.hotel.pms.token
|
||||
|
||||
# Настройка логгера
|
||||
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):
|
||||
"""
|
||||
@@ -36,62 +41,145 @@ class EcviPMSPlugin(BasePMSPlugin):
|
||||
"""
|
||||
return {
|
||||
"field_mapping": {
|
||||
"check_in": "checkin",
|
||||
"check_out": "checkout",
|
||||
"room_number": "room_name",
|
||||
"room_type_name": "room_type",
|
||||
"status": "occupancy",
|
||||
"check_in": "from",
|
||||
"check_out": "until",
|
||||
"room_number": "roomNumber",
|
||||
"room_type_name": "roomTypeName",
|
||||
"status": "checkInStatus",
|
||||
},
|
||||
"date_format": "%Y-%m-%dT%H:%M:%S"
|
||||
}
|
||||
|
||||
async def _fetch_data(self):
|
||||
"""
|
||||
Получает данные из PMS API, фильтрует и сохраняет в базу данных.
|
||||
Получает данные из PMS API и сохраняет их в базу.
|
||||
"""
|
||||
now = datetime.now()
|
||||
current_date = now.strftime('%Y-%m-%d')
|
||||
yesterday_date = (now - timedelta(days=1)).strftime('%Y-%m-%d')
|
||||
start_date = (now - timedelta(days=60)).strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
end_date = (now + timedelta(days=60)).strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
|
||||
headers = {
|
||||
"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:
|
||||
# Запрос данных из PMS API
|
||||
response = await sync_to_async(requests.post)(self.api_url, headers=headers, json=data, auth=(self.username, self.password))
|
||||
response.raise_for_status() # Если ошибка, выбросит исключение
|
||||
data = response.json() # Преобразуем ответ в JSON
|
||||
self.logger.debug(f"Получены данные с API: {data}")
|
||||
except requests.exceptions.RequestException as e:
|
||||
self.logger.error(f"Ошибка запроса: {e}")
|
||||
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')
|
||||
while True:
|
||||
data = {
|
||||
"from": start_date,
|
||||
"until": end_date,
|
||||
"pagination": {
|
||||
"from": from_index,
|
||||
"count": count_per_request
|
||||
}
|
||||
}
|
||||
filtered_data.append(filtered_item)
|
||||
|
||||
# Логируем результат фильтрации
|
||||
self.logger.debug(f"Отфильтрованные данные: {filtered_data}")
|
||||
response = await sync_to_async(requests.post)(self.api_url, headers=headers, data=json.dumps(data))
|
||||
response.raise_for_status()
|
||||
response_data = response.json()
|
||||
|
||||
# Сохранение данных в базу данных
|
||||
for item in filtered_data:
|
||||
await self._save_to_db(item)
|
||||
items = response_data.get("items", [])
|
||||
all_items.extend(items)
|
||||
|
||||
self.logger.debug(f"Данные успешно сохранены.")
|
||||
return filtered_data
|
||||
total_count = response_data.get("count", 0)
|
||||
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):
|
||||
"""
|
||||
|
||||
1
temp/shelter/shelter_data_20241228120750.json
Normal file
1
temp/shelter/shelter_data_20241228120750.json
Normal file
File diff suppressed because one or more lines are too long
1
temp/shelter/shelter_data_20241228120811.json
Normal file
1
temp/shelter/shelter_data_20241228120811.json
Normal file
File diff suppressed because one or more lines are too long
1
temp/shelter/shelter_data_20241228121023.json
Normal file
1
temp/shelter/shelter_data_20241228121023.json
Normal file
File diff suppressed because one or more lines are too long
1
temp/shelter/shelter_data_20241228122016.json
Normal file
1
temp/shelter/shelter_data_20241228122016.json
Normal file
File diff suppressed because one or more lines are too long
1
temp/shelter/shelter_data_20241228125616.json
Normal file
1
temp/shelter/shelter_data_20241228125616.json
Normal file
File diff suppressed because one or more lines are too long
1
temp/shelter/shelter_data_20241228125705.json
Normal file
1
temp/shelter/shelter_data_20241228125705.json
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user