PMSManager_refactor #1
1
.dockerignore
Normal file
1
.dockerignore
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/var
|
||||||
102
.drone.yml
Normal file
102
.drone.yml
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
kind: pipeline
|
||||||
|
name: Touchh Hotel AntiFraud Pipeline
|
||||||
|
namespace: touchh
|
||||||
|
|
||||||
|
steps:
|
||||||
|
# Шаг 1: Клонирование репозитория
|
||||||
|
- name: clone_repo
|
||||||
|
image: alpine/git
|
||||||
|
commands:
|
||||||
|
- if [ ! -d .git ]; then git clone $DRONE_REPO_URL .; fi
|
||||||
|
- git fetch --all
|
||||||
|
- git reset --hard $DRONE_COMMIT
|
||||||
|
|
||||||
|
# Шаг 2: Обновление и запуск с помощью update.sh
|
||||||
|
- name: docker-build
|
||||||
|
image: plugins/docker
|
||||||
|
settings:
|
||||||
|
repo: trevor198507/touchh-py
|
||||||
|
dry_run: true
|
||||||
|
|
||||||
|
# Шаг 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 test
|
||||||
|
|
||||||
|
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: {}
|
||||||
|
|
||||||
|
# Сервис 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: ./
|
||||||
20
.gitignore
vendored
Normal file
20
.gitignore
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
.venv
|
||||||
|
.env
|
||||||
|
__pycache__
|
||||||
|
.vscode
|
||||||
|
.history
|
||||||
|
.idea
|
||||||
|
.git
|
||||||
|
.DS_Store
|
||||||
|
node_modules
|
||||||
|
package-lock.json
|
||||||
|
package.json
|
||||||
|
old_bot
|
||||||
|
*.mmdb
|
||||||
|
*.log
|
||||||
|
db.sqlite3
|
||||||
|
# Ignore files
|
||||||
|
.fake
|
||||||
|
docker-compose.override.yaml
|
||||||
|
tmp/*
|
||||||
|
tmp_data/*
|
||||||
11
Dockerfile
Normal file
11
Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
FROM python:3.12-alpine
|
||||||
|
|
||||||
|
COPY requirements.txt /
|
||||||
|
|
||||||
|
RUN set -ex ;\
|
||||||
|
apk add --no-cache musl-dev mariadb-connector-c-dev gcc ;\
|
||||||
|
pip3 install -r /requirements.txt ;\
|
||||||
|
pip3 cache purge ;\
|
||||||
|
apk del --rdepends --purge musl-dev gcc
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
274
README.md
274
README.md
@@ -1,2 +1,274 @@
|
|||||||
# Touchh
|
# Проект управления отелями
|
||||||
|
|
||||||
|
## Описание
|
||||||
|
|
||||||
|
Проект для управления отелями, пользователями, интеграциями с PMS, а также для контроля статистики и уведомлений. Включает веб-админку, REST API для управления данными и Telegram-бота для взаимодействия с пользователями.
|
||||||
|
|
||||||
|
## Стек технологий
|
||||||
|
|
||||||
|
- Django (для бэкенда)
|
||||||
|
- Jazzmin (для кастомизации админки)
|
||||||
|
- MySQL (для хранения данных)
|
||||||
|
- python-telegram-bot (для Telegram-бота)
|
||||||
|
- Docker (для контейнеризации базы данных и проекта)
|
||||||
|
|
||||||
|
## Установка
|
||||||
|
|
||||||
|
### 1. Клонирование репозитория
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repo url>
|
||||||
|
cd yourrepository
|
||||||
|
````
|
||||||
|
|
||||||
|
### 2. Установка зависимостей
|
||||||
|
|
||||||
|
```bash
|
||||||
|
|
||||||
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate # Для Linux/MacOS
|
||||||
|
.venv\Scripts\activate # Для Windows
|
||||||
|
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
### 3. Настройка базы данных MySQL
|
||||||
|
#### 3.1. Запуск контейнера с MySQL
|
||||||
|
|
||||||
|
Используем Docker для поднятия MySQL контейнера:
|
||||||
|
|
||||||
|
``` bash
|
||||||
|
docker-compose up -d mysql
|
||||||
|
```
|
||||||
|
#### 3.2. Создание базы данных и пользователя
|
||||||
|
|
||||||
|
После поднятия контейнера с MySQL, создайте базу данных и пользователя:
|
||||||
|
|
||||||
|
### 3.2. Настройка базы данных
|
||||||
|
|
||||||
|
После поднятия контейнера с MySQL, создайте таблицы и загрузите дамп:
|
||||||
|
|
||||||
|
```
|
||||||
|
docker exec -it mysql_container bash
|
||||||
|
mysql -u root -p
|
||||||
|
```
|
||||||
|
|
||||||
|
Введите пароль от MySQL, а затем загрузите ваш дамп:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mysql -u root -p your_database_name < /path/to/your_dump.sql
|
||||||
|
```
|
||||||
|
### 3.3. Применение миграций
|
||||||
|
|
||||||
|
После настройки базы данных выполните миграции для вашего Django-проекта:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python manage.py migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Настройка админки
|
||||||
|
|
||||||
|
Для настройки админки с использованием Dazzling и Jazzmin, добавьте соответствующие настройки в settings.py:
|
||||||
|
|
||||||
|
```python
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
'dazzle',
|
||||||
|
'jazzmin',
|
||||||
|
...
|
||||||
|
]
|
||||||
|
|
||||||
|
JAZZMIN_SETTINGS = {
|
||||||
|
"site_title": "My Admin",
|
||||||
|
"site_header": "My Administration",
|
||||||
|
"site_brand": "My Brand",
|
||||||
|
"footer": {
|
||||||
|
"copyright": False,
|
||||||
|
"version": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Запуск проекта
|
||||||
|
|
||||||
|
Запустите сервер Django:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python manage.py runserver
|
||||||
|
```
|
||||||
|
|
||||||
|
Теперь проект будет доступен по адресу http://127.0.0.1:8000.
|
||||||
|
|
||||||
|
##### Структура проекта
|
||||||
|
|
||||||
|
hotel_manager/ — основная директория проекта.
|
||||||
|
hotel_manager/settings.py — настройки Django.
|
||||||
|
hotel_manager/models.py — модели данных для отелей, пользователей и статистики.
|
||||||
|
hotel_manager/views.py — представления для работы с данными.
|
||||||
|
hotel_manager/urls.py — маршруты проекта.
|
||||||
|
bot/ — директория для бота, использующего python-telegram-bot.
|
||||||
|
|
||||||
|
##### Модели
|
||||||
|
|
||||||
|
Отель (Hotel)
|
||||||
|
Название отеля
|
||||||
|
ID отеля
|
||||||
|
PMS (Bnovo, Travel Line, Realty)
|
||||||
|
Статус интеграции с PMS
|
||||||
|
|
||||||
|
Пользователь (User)
|
||||||
|
Имя пользователя
|
||||||
|
Роль (Admin или Hotel User)
|
||||||
|
Связь с отелем
|
||||||
|
|
||||||
|
Настройки уведомлений (Notification Settings)
|
||||||
|
Включено/выключено уведомление
|
||||||
|
Часовой пояс
|
||||||
|
Время отправки уведомлений
|
||||||
|
|
||||||
|
Статистика (Statistics)
|
||||||
|
Количество несанкционированных заселений за период
|
||||||
|
Статус ошибок
|
||||||
|
Даты и номера нарушений
|
||||||
|
|
||||||
|
##### API
|
||||||
|
|
||||||
|
Администратор:
|
||||||
|
Добавить/удалить отель
|
||||||
|
Добавить/удалить пользователя
|
||||||
|
Проверка статуса интеграции с PMS
|
||||||
|
Управление уведомлениями
|
||||||
|
|
||||||
|
Пользователь отеля:
|
||||||
|
Получение статистики по заселениям
|
||||||
|
Управление уведомлениями
|
||||||
|
|
||||||
|
##### Интеграция с Telegram-ботом
|
||||||
|
|
||||||
|
Бот для администраторов позволяет управлять отелями, пользователями, уведомлениями и проверять статус интеграций.
|
||||||
|
|
||||||
|
Бот для пользователей отелей позволяет получать статистику по заселениям и управлять уведомлениями.
|
||||||
|
|
||||||
|
##### Пример команды для администратора
|
||||||
|
|
||||||
|
Добавить отель
|
||||||
|
Список отелей
|
||||||
|
Удалить отель
|
||||||
|
Проверить статус PMS
|
||||||
|
|
||||||
|
##### Пример команды для пользователя отеля
|
||||||
|
|
||||||
|
Показать статистику за вчера
|
||||||
|
Управление уведомлениями
|
||||||
|
|
||||||
|
#### Проверка интеграции с 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}
|
||||||
|
|
||||||
|
```
|
||||||
|
|||||||
0
antifroud/__init__.py
Normal file
0
antifroud/__init__.py
Normal file
250
antifroud/admin.py
Normal file
250
antifroud/admin.py
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from django.urls import path
|
||||||
|
from django.http import JsonResponse
|
||||||
|
from django.shortcuts import redirect, get_object_or_404
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.db import transaction
|
||||||
|
from antifroud.models import UserActivityLog, ExternalDBSettings, RoomDiscrepancy, ImportedHotel, SyncLog, ViolationLog
|
||||||
|
|
||||||
|
from hotels.models import Hotel, Room
|
||||||
|
import pymysql
|
||||||
|
import logging
|
||||||
|
from django.urls import reverse
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ExternalDBSettings)
|
||||||
|
class ExternalDBSettingsAdmin(admin.ModelAdmin):
|
||||||
|
change_form_template = "antifroud/admin/external_db_settings_change_form.html"
|
||||||
|
list_display = ("name", "host", "port", "user", "database", "table_name", "is_active", "created_at", "updated_at")
|
||||||
|
search_fields = ("name", "host", "user", "database")
|
||||||
|
list_filter = ("is_active", "created_at", "updated_at")
|
||||||
|
readonly_fields = ("created_at", "updated_at")
|
||||||
|
|
||||||
|
def add_view(self, request, form_url='', extra_context=None):
|
||||||
|
new_instance = ExternalDBSettings.objects.create(
|
||||||
|
name="Новая настройка", # Значение по умолчанию
|
||||||
|
host="",
|
||||||
|
port=3306,
|
||||||
|
user="",
|
||||||
|
password="",
|
||||||
|
is_active=False
|
||||||
|
)
|
||||||
|
return redirect(reverse('admin:antifroud_externaldbsettings_change', args=(new_instance.id,)))
|
||||||
|
|
||||||
|
def get_urls(self):
|
||||||
|
urls = super().get_urls()
|
||||||
|
custom_urls = [
|
||||||
|
path('test-connection/', self.admin_site.admin_view(self.test_connection), name='test_connection'),
|
||||||
|
path('fetch-tables/', self.admin_site.admin_view(self.fetch_tables), name='fetch_tables'),
|
||||||
|
path('fetch-table-data/', self.admin_site.admin_view(self.fetch_table_data), name='fetch_table_data'),
|
||||||
|
]
|
||||||
|
return custom_urls + urls
|
||||||
|
|
||||||
|
def test_connection(self, request):
|
||||||
|
db_id = request.GET.get('db_id')
|
||||||
|
if not db_id:
|
||||||
|
return JsonResponse({"status": "error", "message": "ID подключения отсутствует."}, status=400)
|
||||||
|
try:
|
||||||
|
db_settings = ExternalDBSettings.objects.get(id=db_id)
|
||||||
|
if not db_settings.user or not db_settings.password:
|
||||||
|
return JsonResponse({"status": "error", "message": "Имя пользователя или пароль не указаны."}, status=400)
|
||||||
|
|
||||||
|
connection = pymysql.connect(
|
||||||
|
host=db_settings.host,
|
||||||
|
port=db_settings.port,
|
||||||
|
user=db_settings.user,
|
||||||
|
password=db_settings.password,
|
||||||
|
database=db_settings.database
|
||||||
|
)
|
||||||
|
connection.close()
|
||||||
|
return JsonResponse({"status": "success", "message": "Подключение успешно установлено."})
|
||||||
|
except ExternalDBSettings.DoesNotExist:
|
||||||
|
return JsonResponse({"status": "error", "message": "Настройки подключения не найдены."}, status=404)
|
||||||
|
except pymysql.MySQLError as e:
|
||||||
|
return JsonResponse({"status": "error", "message": f"Ошибка MySQL: {str(e)}"}, status=500)
|
||||||
|
except Exception as e:
|
||||||
|
return JsonResponse({"status": "error", "message": f"Неизвестная ошибка: {str(e)}"}, status=500)
|
||||||
|
|
||||||
|
def fetch_tables(self, request):
|
||||||
|
try:
|
||||||
|
db_id = request.GET.get('db_id')
|
||||||
|
db_settings = ExternalDBSettings.objects.get(id=db_id)
|
||||||
|
connection = pymysql.connect(
|
||||||
|
host=db_settings.host,
|
||||||
|
port=db_settings.port,
|
||||||
|
user=db_settings.user,
|
||||||
|
password=db_settings.password,
|
||||||
|
database=db_settings.database
|
||||||
|
)
|
||||||
|
cursor = connection.cursor()
|
||||||
|
cursor.execute("SHOW TABLES;")
|
||||||
|
tables = [row[0] for row in cursor.fetchall()]
|
||||||
|
connection.close()
|
||||||
|
return JsonResponse({"status": "success", "tables": tables})
|
||||||
|
except Exception as e:
|
||||||
|
return JsonResponse({"status": "error", "message": str(e)})
|
||||||
|
|
||||||
|
def fetch_table_data(self, request):
|
||||||
|
try:
|
||||||
|
db_id = request.GET.get('db_id')
|
||||||
|
table_name = request.GET.get('table_name')
|
||||||
|
db_settings = ExternalDBSettings.objects.get(id=db_id)
|
||||||
|
connection = pymysql.connect(
|
||||||
|
host=db_settings.host,
|
||||||
|
port=db_settings.port,
|
||||||
|
user=db_settings.user,
|
||||||
|
password=db_settings.password,
|
||||||
|
database=db_settings.database
|
||||||
|
)
|
||||||
|
cursor = connection.cursor()
|
||||||
|
cursor.execute(f"SELECT * FROM `{table_name}` LIMIT 10;")
|
||||||
|
columns = [desc[0] for desc in cursor.description]
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
connection.close()
|
||||||
|
return JsonResponse({"status": "success", "columns": columns, "rows": rows})
|
||||||
|
except Exception as e:
|
||||||
|
return JsonResponse({"status": "error", "message": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(UserActivityLog)
|
||||||
|
class UserActivityLogAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("id", 'get_location',"formatted_timestamp", "date_time", "page_id", "url_parameters", "page_url" ,"created", "page_title", "type", "hits")
|
||||||
|
search_fields = ("page_title", "url_parameters", "page_title")
|
||||||
|
list_filter = ("page_title", "created")
|
||||||
|
readonly_fields = ("created", "timestamp")
|
||||||
|
|
||||||
|
def get_formatted_timestamp(self, obj):
|
||||||
|
"""
|
||||||
|
Метод для админки для преобразования timestamp в читаемый формат.
|
||||||
|
"""
|
||||||
|
return obj.formatted_timestamp # Используем свойство модели
|
||||||
|
get_formatted_timestamp.short_description = "Таймштамп"
|
||||||
|
|
||||||
|
|
||||||
|
def get_hotel_name(self):
|
||||||
|
"""
|
||||||
|
Возвращает название отеля на основе связанного page_id.
|
||||||
|
"""
|
||||||
|
if self.page_id:
|
||||||
|
try:
|
||||||
|
room = Room.objects.get(id=self.page_id)
|
||||||
|
return room.hotel.name
|
||||||
|
except Room.DoesNotExist:
|
||||||
|
return "Отель не найден"
|
||||||
|
return "Нет данных"
|
||||||
|
|
||||||
|
def get_room_number(self):
|
||||||
|
"""
|
||||||
|
Возвращает номер комнаты на основе связанного page_id.
|
||||||
|
"""
|
||||||
|
if self.page_id:
|
||||||
|
try:
|
||||||
|
room = Room.objects.get(id=self.page_id)
|
||||||
|
return room.number
|
||||||
|
except Room.DoesNotExist:
|
||||||
|
return "Комната не найдена"
|
||||||
|
return "Нет данных"
|
||||||
|
|
||||||
|
get_hotel_name.short_description = "Отель"
|
||||||
|
get_room_number.short_description = "Комната"
|
||||||
|
|
||||||
|
|
||||||
|
# from .views import import_selected_hotels
|
||||||
|
# # Регистрируем admin класс для ImportedHotel
|
||||||
|
# @admin.register(ImportedHotel)
|
||||||
|
# class ImportedHotelAdmin(admin.ModelAdmin):
|
||||||
|
# change_list_template = "antifroud/admin/import_hotels.html"
|
||||||
|
# list_display = ("external_id", "display_name", "name", "created", "updated", "imported")
|
||||||
|
# search_fields = ("name", "display_name", "external_id")
|
||||||
|
# list_filter = ("name", "display_name", "external_id")
|
||||||
|
# actions = ['mark_as_imported', 'delete_selected_hotels_action']
|
||||||
|
|
||||||
|
# def get_urls(self):
|
||||||
|
# # Получаем стандартные URL-адреса и добавляем наши
|
||||||
|
# urls = super().get_urls()
|
||||||
|
# custom_urls = [
|
||||||
|
# path('import_selected_hotels/', import_selected_hotels, name='antifroud_importedhotels_import_selected_hotels'),
|
||||||
|
# path('delete_selected_hotels/', self.delete_selected_hotels, name='delete_selected_hotels'),
|
||||||
|
# path('delete_hotel/<int:hotel_id>/', self.delete_hotel, name='delete_hotel'), # Изменили на URL параметр
|
||||||
|
# ]
|
||||||
|
# return custom_urls + urls
|
||||||
|
|
||||||
|
# @transaction.atomic
|
||||||
|
# def delete_selected_hotels(self, request):
|
||||||
|
# if request.method == 'POST':
|
||||||
|
# selected = request.POST.get('selected', '')
|
||||||
|
# if selected:
|
||||||
|
# external_ids = selected.split(',')
|
||||||
|
# deleted_count, _ = ImportedHotel.objects.filter(external_id__in=external_ids).delete()
|
||||||
|
# messages.success(request, f"Удалено отелей: {deleted_count}")
|
||||||
|
# else:
|
||||||
|
# messages.warning(request, "Не выбрано ни одного отеля для удаления.")
|
||||||
|
# return redirect('admin:antifroud_importedhotel_changelist')
|
||||||
|
|
||||||
|
# def delete_selected_hotels(self, request, queryset):
|
||||||
|
# deleted_count, _ = queryset.delete()
|
||||||
|
# self.message_user(request, f'{deleted_count} отелей было удалено.')
|
||||||
|
# delete_selected_hotels.short_description = "Удалить выбранные отели"
|
||||||
|
|
||||||
|
# def mark_as_imported(self, request, queryset):
|
||||||
|
# updated = queryset.update(imported=True)
|
||||||
|
# self.message_user(request, f"Отмечено как импортированное: {updated}", messages.SUCCESS)
|
||||||
|
# mark_as_imported.short_description = "Отметить выбранные как импортированные"
|
||||||
|
|
||||||
|
# # Метод для удаления одного отеля
|
||||||
|
# @transaction.atomic
|
||||||
|
# def delete_hotel(self, request, hotel_id):
|
||||||
|
# imported_hotel = get_object_or_404(ImportedHotel, id=hotel_id)
|
||||||
|
# imported_hotel.delete()
|
||||||
|
# messages.success(request, f"Отель {imported_hotel.name} успешно удалён.")
|
||||||
|
# return redirect('admin:antifroud_importedhotel_changelist')
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(SyncLog)
|
||||||
|
class SyncLogAdmin(admin.ModelAdmin):
|
||||||
|
change_list_template = "antifroud/admin/sync_log.html" # Путь к вашему кастомному шаблону
|
||||||
|
list_display = ['id', 'hotel', 'created', 'recieved_records', 'processed_records']
|
||||||
|
search_fields = ['id', 'hotel__name', 'recieved_records', 'processed_records']
|
||||||
|
list_filter = ['hotel', 'created']
|
||||||
|
|
||||||
|
def changelist_view(self, request, extra_context=None):
|
||||||
|
"""
|
||||||
|
Добавляет фильтрацию по отелям в шаблон.
|
||||||
|
"""
|
||||||
|
extra_context = extra_context or {}
|
||||||
|
|
||||||
|
# Получаем выбранный фильтр отеля из GET-параметров
|
||||||
|
hotel_id = request.GET.get('hotel')
|
||||||
|
hotels = Hotel.objects.all()
|
||||||
|
sync_logs = SyncLog.objects.all()
|
||||||
|
|
||||||
|
if hotel_id:
|
||||||
|
sync_logs = sync_logs.filter(hotel_id=hotel_id)
|
||||||
|
|
||||||
|
extra_context['sync_logs'] = sync_logs
|
||||||
|
extra_context['hotels'] = hotels
|
||||||
|
extra_context['selected_hotel'] = hotel_id # Чтобы отобразить выбранный отель
|
||||||
|
|
||||||
|
return super().changelist_view(request, extra_context=extra_context)
|
||||||
|
@admin.register(ViolationLog)
|
||||||
|
class ViolationLogAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ['id', 'hotel', 'room_number' , 'hits', 'created_at', 'violation_type', 'violation_details', 'detected_at']
|
||||||
|
search_fields = ['id', 'hotel', 'room_number', 'created_at', 'violation_type', 'violation_details', 'detected_at']
|
||||||
|
list_filter = ['id', 'hotel', 'room_number', 'created_at', 'violation_type', 'violation_details', 'detected_at']
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ViolationLog
|
||||||
|
fields = ['hotel', 'room_number', 'created_at', 'violation_type', 'violation_details', 'detected_at']
|
||||||
|
|
||||||
|
@admin.register(RoomDiscrepancy)
|
||||||
|
class RoomDiscrepancyAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ['hotel', 'room_number', 'booking_id','created_at', 'check_in_date_expected','check_in_date_actual','discrepancy_type']
|
||||||
|
search_fields = ['hotel', 'room_number', 'booking_id','created_at', 'check_in_date_expected','check_in_date_actual','discrepancy_type']
|
||||||
|
list_filter = ['hotel', 'room_number', 'booking_id','created_at', 'check_in_date_expected','check_in_date_actual','discrepancy_type']
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = RoomDiscrepancy
|
||||||
|
fields = ['hotel', 'room_number', 'booking_id','created_at', 'check_in_date_expected','check_in_date_actual','discrepancy_type']
|
||||||
7
antifroud/apps.py
Normal file
7
antifroud/apps.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class AntifroudConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'antifroud'
|
||||||
|
verbose_name="Анти-Fraud"
|
||||||
259
antifroud/check_fraud.py
Normal file
259
antifroud/check_fraud.py
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
# import json
|
||||||
|
# from datetime import timedelta
|
||||||
|
# from django.utils import timezone
|
||||||
|
# from django.db.models import Q
|
||||||
|
# from hotels.models import Reservation, Hotel
|
||||||
|
# from .models import UserActivityLog, RoomDiscrepancy
|
||||||
|
# from touchh.utils.log import CustomLogger
|
||||||
|
|
||||||
|
# # Настройка логирования
|
||||||
|
# logger = CustomLogger(__name__).get_logger()
|
||||||
|
|
||||||
|
# class ReservationChecker:
|
||||||
|
# """
|
||||||
|
# Класс для проверки несоответствий между бронированиями и логами заселения.
|
||||||
|
# """
|
||||||
|
|
||||||
|
# def __init__(self):
|
||||||
|
# self.checkin_diff_hours = 3
|
||||||
|
|
||||||
|
# def log_info(self, message):
|
||||||
|
# logger.info(message)
|
||||||
|
|
||||||
|
# def log_warning(self, message):
|
||||||
|
# logger.warning(message)
|
||||||
|
|
||||||
|
# def log_error(self, message):
|
||||||
|
# logger.error(message)
|
||||||
|
|
||||||
|
# def run_check(self):
|
||||||
|
# """Запуск проверки фродовых событий."""
|
||||||
|
# self.log_info("Запуск проверки фродовых данных.")
|
||||||
|
# try:
|
||||||
|
# check_in_diff = timedelta(hours=self.checkin_diff_hours)
|
||||||
|
|
||||||
|
# # Кэшируем отели в словарь для быстрого доступа
|
||||||
|
# hotels_map = {hotel.hotel_id: hotel for hotel in Hotel.objects.all()}
|
||||||
|
|
||||||
|
# # Загружаем бронирования и активности пользователей
|
||||||
|
# user_logs = UserActivityLog.objects.filter(fraud_checked=False)
|
||||||
|
# reservations = Reservation.objects.filter(fraud_checked=False).select_related('hotel')
|
||||||
|
|
||||||
|
# # Преобразуем бронирования в словарь для быстрого поиска
|
||||||
|
# reservations_map = {
|
||||||
|
# (res.hotel.hotel_id, res.room_number): res for res in reservations
|
||||||
|
# }
|
||||||
|
|
||||||
|
# violations = []
|
||||||
|
# missing_reservations = set(reservations) # Сет для поиска пропавших бронирований
|
||||||
|
|
||||||
|
# for user_log in user_logs:
|
||||||
|
# try:
|
||||||
|
# params = json.loads(user_log.url_parameters.replace("'", '"')) if user_log.url_parameters else {}
|
||||||
|
# hotel_id = params.get('utm_content')
|
||||||
|
# room = params.get('utm_term')
|
||||||
|
|
||||||
|
# if not hotel_id or not room:
|
||||||
|
# continue # Пропускаем записи без нужных параметров
|
||||||
|
|
||||||
|
# key = (hotel_id, room)
|
||||||
|
# reserv = reservations_map.get(key)
|
||||||
|
|
||||||
|
# discrepancy_type = None
|
||||||
|
|
||||||
|
# if reserv:
|
||||||
|
# if reserv in missing_reservations:
|
||||||
|
# missing_reservations.remove(reserv)
|
||||||
|
|
||||||
|
# if user_log.date_time < reserv.check_in:
|
||||||
|
# discrepancy_type = 'early'
|
||||||
|
# elif user_log.date_time > reserv.check_in + check_in_diff:
|
||||||
|
# discrepancy_type = 'late'
|
||||||
|
# else:
|
||||||
|
# discrepancy_type = 'no_booking'
|
||||||
|
|
||||||
|
# if discrepancy_type:
|
||||||
|
# violations.append(RoomDiscrepancy(
|
||||||
|
# hotel=hotels_map.get(hotel_id),
|
||||||
|
# room_number=room,
|
||||||
|
# discrepancy_type=discrepancy_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 json.JSONDecodeError:
|
||||||
|
# self.log_error(f"Ошибка декодирования JSON в URL-параметрах: {user_log.url_parameters}")
|
||||||
|
# except Exception as e:
|
||||||
|
# self.log_error(f"Ошибка при обработке логов: {e}")
|
||||||
|
|
||||||
|
# # Добавляем пропущенные бронирования
|
||||||
|
# for miss_reserv in missing_reservations:
|
||||||
|
# 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,
|
||||||
|
# ))
|
||||||
|
|
||||||
|
# # Массово сохраняем нарушения
|
||||||
|
# if violations:
|
||||||
|
# RoomDiscrepancy.objects.bulk_create(violations)
|
||||||
|
# self.log_info(f"Записано {len(violations)} новых несоответствий.")
|
||||||
|
|
||||||
|
# # Обновляем флаги fraud_checked
|
||||||
|
# UserActivityLog.objects.filter(id__in=[log.id for log in user_logs]).update(fraud_checked=True)
|
||||||
|
# Reservation.objects.filter(id__in=[res.id for res in reservations]).update(fraud_checked=True)
|
||||||
|
|
||||||
|
# except Exception as e:
|
||||||
|
# self.log_error(f"Ошибка при выполнении проверки: {e}")
|
||||||
|
|
||||||
|
# self.log_info("Проверка завершена.")
|
||||||
|
|
||||||
|
# # Функция для запуска из планировщика
|
||||||
|
# def run_reservation_check():
|
||||||
|
# """Запуск проверки через планировщик."""
|
||||||
|
# logger.info("Планировщик вызывает run_reservation_check.")
|
||||||
|
# try:
|
||||||
|
# checker = ReservationChecker()
|
||||||
|
# checker.run_check()
|
||||||
|
# except Exception as e:
|
||||||
|
# logger.error(f"Ошибка при запуске проверки: {e}")
|
||||||
|
# logger.info("run_reservation_check завершена.")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import timedelta
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.db.models import Q
|
||||||
|
from hotels.models import Reservation, Hotel
|
||||||
|
from .models import UserActivityLog, RoomDiscrepancy
|
||||||
|
from touchh.utils.log import CustomLogger
|
||||||
|
|
||||||
|
# Настройка логирования
|
||||||
|
logger = CustomLogger(__name__).get_logger()
|
||||||
|
|
||||||
|
class ReservationChecker:
|
||||||
|
"""
|
||||||
|
Класс для проверки несоответствий между бронированиями и логами заселения.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.checkin_diff_hours = 3 # Разрешенное отклонение от времени заселения
|
||||||
|
|
||||||
|
def log_info(self, message):
|
||||||
|
logger.info(message)
|
||||||
|
|
||||||
|
def log_warning(self, message):
|
||||||
|
logger.warning(message)
|
||||||
|
|
||||||
|
def log_error(self, message):
|
||||||
|
logger.error(message)
|
||||||
|
|
||||||
|
def run_check(self):
|
||||||
|
"""Запуск проверки фродовых событий."""
|
||||||
|
self.log_info("🔍 Запуск проверки фродовых данных.")
|
||||||
|
try:
|
||||||
|
check_in_diff = timedelta(hours=self.checkin_diff_hours)
|
||||||
|
|
||||||
|
# Кэшируем отели в словарь для быстрого доступа
|
||||||
|
hotels_map = {hotel.hotel_id: hotel for hotel in Hotel.objects.all()}
|
||||||
|
|
||||||
|
# Загружаем бронирования и активности пользователей
|
||||||
|
user_logs = UserActivityLog.objects.filter(fraud_checked=False)
|
||||||
|
reservations = Reservation.objects.filter(fraud_checked=False).select_related('hotel')
|
||||||
|
|
||||||
|
# Преобразуем бронирования в словарь для быстрого поиска
|
||||||
|
reservations_map = {
|
||||||
|
(res.hotel.hotel_id, res.room_number): res for res in reservations
|
||||||
|
}
|
||||||
|
|
||||||
|
violations = []
|
||||||
|
checked_reservations = set() # Сет для бронирований, которые были проверены
|
||||||
|
|
||||||
|
self.log_info(f"✅ Загружено {len(user_logs)} логов активности и {len(reservations)} бронирований.")
|
||||||
|
|
||||||
|
for user_log in user_logs:
|
||||||
|
try:
|
||||||
|
params = json.loads(user_log.url_parameters.replace("'", '"')) if user_log.url_parameters else {}
|
||||||
|
hotel_id = params.get('utm_content')
|
||||||
|
room = params.get('utm_term')
|
||||||
|
|
||||||
|
if not hotel_id or not room:
|
||||||
|
self.log_warning(f"🚫 Пропущен лог без hotel_id или room_number: {user_log.url_parameters}")
|
||||||
|
continue # Пропускаем записи без нужных параметров
|
||||||
|
|
||||||
|
key = (hotel_id, room)
|
||||||
|
reserv = reservations_map.get(key)
|
||||||
|
|
||||||
|
discrepancy_type = "match" # По умолчанию считаем, что всё соответствует
|
||||||
|
|
||||||
|
if reserv:
|
||||||
|
checked_reservations.add(reserv)
|
||||||
|
|
||||||
|
if user_log.date_time < reserv.check_in:
|
||||||
|
discrepancy_type = 'early'
|
||||||
|
self.log_warning(f"⚠️ Обнаружено раннее заселение: {user_log.date_time} < {reserv.check_in}")
|
||||||
|
elif user_log.date_time > reserv.check_in + check_in_diff:
|
||||||
|
discrepancy_type = 'late'
|
||||||
|
self.log_warning(f"⚠️ Обнаружено позднее заселение: {user_log.date_time} > {reserv.check_in + check_in_diff}")
|
||||||
|
else:
|
||||||
|
discrepancy_type = 'no_booking'
|
||||||
|
self.log_warning(f"🚨 Заселение без бронирования: {user_log.date_time} (Отель {hotel_id}, Комната {room})")
|
||||||
|
|
||||||
|
violations.append(RoomDiscrepancy(
|
||||||
|
hotel=hotels_map.get(hotel_id),
|
||||||
|
room_number=room,
|
||||||
|
discrepancy_type=discrepancy_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 json.JSONDecodeError:
|
||||||
|
self.log_error(f"❌ Ошибка декодирования JSON в URL-параметрах: {user_log.url_parameters}")
|
||||||
|
except Exception as e:
|
||||||
|
self.log_error(f"❌ Ошибка при обработке логов: {e}")
|
||||||
|
|
||||||
|
# Добавляем пропущенные бронирования (неявки)
|
||||||
|
for reserv in reservations:
|
||||||
|
if reserv not in checked_reservations:
|
||||||
|
violations.append(RoomDiscrepancy(
|
||||||
|
hotel=reserv.hotel,
|
||||||
|
room_number=reserv.room_number,
|
||||||
|
discrepancy_type='missed',
|
||||||
|
booking_id=reserv.reservation_id,
|
||||||
|
check_in_date_expected=reserv.check_in,
|
||||||
|
))
|
||||||
|
self.log_warning(f"⚠️ Обнаружена неявка (missed) | Отель: {reserv.hotel.hotel_id}, Номер: {reserv.room_number}, Ожидаемая дата заезда: {reserv.check_in}")
|
||||||
|
|
||||||
|
# Массово сохраняем все записи, включая корректные совпадения
|
||||||
|
if violations:
|
||||||
|
RoomDiscrepancy.objects.bulk_create(violations)
|
||||||
|
self.log_info(f"✅ Записано {len(violations)} новых записей в RoomDiscrepancy.")
|
||||||
|
|
||||||
|
# Обновляем флаги fraud_checked
|
||||||
|
UserActivityLog.objects.filter(id__in=[log.id for log in user_logs]).update(fraud_checked=True)
|
||||||
|
Reservation.objects.filter(id__in=[res.id for res in reservations]).update(fraud_checked=True)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.log_error(f"❌ Ошибка при выполнении проверки: {e}")
|
||||||
|
|
||||||
|
self.log_info("✅ Проверка фродовых данных завершена.")
|
||||||
|
|
||||||
|
# Функция для запуска из планировщика
|
||||||
|
def run_reservation_check():
|
||||||
|
"""Запуск проверки через планировщик."""
|
||||||
|
logger.info("📅 Планировщик вызывает run_reservation_check.")
|
||||||
|
try:
|
||||||
|
checker = ReservationChecker()
|
||||||
|
checker.run_check()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Ошибка при запуске проверки: {e}")
|
||||||
|
logger.info("✅ run_reservation_check завершена.")
|
||||||
325
antifroud/data_sync.py
Normal file
325
antifroud/data_sync.py
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
|
||||||
|
import logging
|
||||||
|
import pymysql
|
||||||
|
from datetime import datetime
|
||||||
|
from urllib.parse import unquote, parse_qs
|
||||||
|
from django.utils import timezone
|
||||||
|
import html
|
||||||
|
from hotels.models import Room, Hotel
|
||||||
|
from .models import UserActivityLog, ExternalDBSettings, SyncLog
|
||||||
|
from touchh.utils.log import CustomLogger
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, TimeoutError
|
||||||
|
from decouple import config
|
||||||
|
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="WARNING").get_logger()
|
||||||
|
self.connection = None
|
||||||
|
self.db_settings = self.get_db_settings()
|
||||||
|
|
||||||
|
def get_db_settings(self):
|
||||||
|
try:
|
||||||
|
settings = ExternalDBSettings.objects.get(id=self.db_settings_id)
|
||||||
|
self.logger.info(f"Retrieved DB settings: {settings}")
|
||||||
|
return {
|
||||||
|
"host": settings.host,
|
||||||
|
"port": settings.port,
|
||||||
|
"user": settings.user,
|
||||||
|
"password": settings.password,
|
||||||
|
"database": settings.database,
|
||||||
|
"table_name": settings.table_name
|
||||||
|
|
||||||
|
}
|
||||||
|
except ExternalDBSettings.DoesNotExist:
|
||||||
|
self.logger.error(f"Settings with ID {self.db_settings_id} not found.")
|
||||||
|
raise ValueError("Invalid db_settings_id")
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error retrieving settings: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def connect(self):
|
||||||
|
try:
|
||||||
|
self.connection = pymysql.connect(
|
||||||
|
|
||||||
|
host=self.db_settings["host"],
|
||||||
|
port=self.db_settings["port"],
|
||||||
|
user=self.db_settings["user"],
|
||||||
|
password=self.db_settings["password"],
|
||||||
|
database=self.db_settings["database"],
|
||||||
|
charset="utf8mb4",
|
||||||
|
cursorclass=pymysql.cursors.DictCursor,
|
||||||
|
)
|
||||||
|
self.logger.info("Database connection established successfully.")
|
||||||
|
except pymysql.err.OperationalError as e:
|
||||||
|
self.logger.error(f"Operational error during DB connection: {e}")
|
||||||
|
raise ConnectionError(f"Operational error: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Unexpected database connection error: {e}")
|
||||||
|
raise ConnectionError(e)
|
||||||
|
|
||||||
|
def execute_query(self, query):
|
||||||
|
try:
|
||||||
|
with self.connection.cursor() as cursor:
|
||||||
|
cursor.execute(query)
|
||||||
|
return cursor.fetchall()
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Query execution error: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
if self.connection:
|
||||||
|
self.connection.close()
|
||||||
|
self.logger.info("Database connection closed.")
|
||||||
|
|
||||||
|
|
||||||
|
class DataProcessor:
|
||||||
|
def __init__(self, logger):
|
||||||
|
self.logger = logger
|
||||||
|
|
||||||
|
def decode_html_entities(self, text):
|
||||||
|
if not text:
|
||||||
|
self.logger.warning("Empty text received for decoding HTML entities.")
|
||||||
|
return html.unescape(unquote(text)) if text else text
|
||||||
|
|
||||||
|
|
||||||
|
def parse_datetime(self, dt_str):
|
||||||
|
try:
|
||||||
|
if isinstance(dt_str, datetime):
|
||||||
|
return timezone.make_aware(dt_str) if timezone.is_naive(dt_str) else dt_str
|
||||||
|
if dt_str:
|
||||||
|
return timezone.make_aware(datetime.strptime(dt_str, "%Y-%m-%d %H:%M:%S"))
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Datetime parsing error: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def parse_url_parameters(self, url_parameters):
|
||||||
|
"""
|
||||||
|
Парсит строку URL-параметров в словарь.
|
||||||
|
|
||||||
|
:param url_parameters: Строка с URL-параметрами.
|
||||||
|
:return: Словарь с распарсенными параметрами.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not url_parameters:
|
||||||
|
return {}
|
||||||
|
decoded_params = unquote(url_parameters)
|
||||||
|
parsed_params = parse_qs(decoded_params)
|
||||||
|
return {key: value[0] for key, value in parsed_params.items()}
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error parsing URL parameters: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
class HotelRoomManager:
|
||||||
|
def __init__(self, logger):
|
||||||
|
self.logger = logger
|
||||||
|
|
||||||
|
def get_or_create_hotel(self, hotel_id, page_title):
|
||||||
|
if not hotel_id:
|
||||||
|
self.logger.warning("Hotel creation skipped: missing hotel_id.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
hotel, created = Hotel.objects.get_or_create(
|
||||||
|
hotel_id=hotel_id,
|
||||||
|
defaults={
|
||||||
|
"name": html.unescape(page_title) or f"Отель {hotel_id}",
|
||||||
|
"description": "Автоматически созданный отель",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if created:
|
||||||
|
self.logger.info(f"Hotel '{hotel.name}' created with hotel_id: {hotel_id}")
|
||||||
|
else:
|
||||||
|
self.logger.info(f"Hotel '{hotel.name}' already exists with hotel_id: {hotel_id}")
|
||||||
|
return hotel
|
||||||
|
|
||||||
|
def get_or_create_room(self, hotel, room_number):
|
||||||
|
if not hotel:
|
||||||
|
self.logger.warning("Room creation skipped: missing hotel.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not room_number:
|
||||||
|
self.logger.warning(f"Room creation skipped: missing room_number for hotel {hotel.name}.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Проверяем существование комнаты
|
||||||
|
room = Room.objects.filter(hotel=hotel, number=room_number).first()
|
||||||
|
if room:
|
||||||
|
self.logger.info(f"Room '{room_number}' already exists in hotel '{hotel.name}'.")
|
||||||
|
return room
|
||||||
|
|
||||||
|
# Создаем комнату, если она не найдена
|
||||||
|
room = Room.objects.create(
|
||||||
|
hotel=hotel,
|
||||||
|
number=room_number,
|
||||||
|
external_id=f"{hotel.hotel_id}_{room_number}".lower(),
|
||||||
|
description="Automatically added room",
|
||||||
|
)
|
||||||
|
self.logger.info(f"Room '{room.number}' created in hotel '{hotel.name}'.")
|
||||||
|
return room
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error creating room '{room_number}' in hotel '{hotel.name}': {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
class DataSyncManager:
|
||||||
|
def __init__(self, db_settings_id):
|
||||||
|
self.logger = CustomLogger(name="DataSyncManager", log_level="DEBUG").get_logger()
|
||||||
|
self.db_connector = DatabaseConnector(db_settings_id)
|
||||||
|
self.db_settings = self.db_connector.db_settings # Сохраняем настройки базы данных
|
||||||
|
self.data_processor = DataProcessor(self.logger)
|
||||||
|
self.hotel_manager = HotelRoomManager(self.logger)
|
||||||
|
|
||||||
|
def get_last_saved_record(self):
|
||||||
|
record = UserActivityLog.objects.order_by("-id").first()
|
||||||
|
return record.id if record else 0
|
||||||
|
|
||||||
|
def fetch_new_data(self, last_id):
|
||||||
|
"""
|
||||||
|
Извлекает новые данные из таблицы для синхронизации.
|
||||||
|
|
||||||
|
:param last_id: Последний обработанный ID.
|
||||||
|
:return: Список строк, полученных из базы данных.
|
||||||
|
"""
|
||||||
|
query = f"""
|
||||||
|
SELECT * FROM `{self.db_settings.get('table_name')}`
|
||||||
|
WHERE id > {last_id}
|
||||||
|
AND url_parameters IS NOT NULL
|
||||||
|
AND url_parameters LIKE '%utm_medium%'
|
||||||
|
AND page_url IS NOT NULL
|
||||||
|
ORDER BY id ASC
|
||||||
|
LIMIT 1000;
|
||||||
|
"""
|
||||||
|
self.logger.info(f"Fetching new data with query: {query}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
rows = self.db_connector.execute_query(query)
|
||||||
|
self.logger.info(f"Fetched {len(rows)} records from the database.")
|
||||||
|
return rows
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error fetching data: {e}")
|
||||||
|
return []
|
||||||
|
def update_sync_log(self, hotel, recieved_records, processed_records):
|
||||||
|
"""
|
||||||
|
Обновляет или создает запись в таблице SyncLog.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
log, created = SyncLog.objects.update_or_create(
|
||||||
|
hotel=hotel,
|
||||||
|
defaults={
|
||||||
|
"recieved_records": recieved_records,
|
||||||
|
"processed_records": processed_records,
|
||||||
|
"created": timezone.now(), # Убедитесь, что дата обновляется
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if created:
|
||||||
|
self.logger.info(f"Sync log created for hotel '{hotel.name}'.")
|
||||||
|
else:
|
||||||
|
self.logger.info(f"Sync log updated for hotel '{hotel.name}'.")
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error updating sync log for hotel '{hotel.name}': {e}")
|
||||||
|
|
||||||
|
self.logger.info(f"Attempting to update sync log for hotel: {hotel.name}")
|
||||||
|
self.update_sync_log(hotel, recieved_records, processed_records)
|
||||||
|
|
||||||
|
def process_and_save_data(self, rows):
|
||||||
|
hotel_processed_counts = {} # Словарь для подсчёта записей по каждому отелю
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
try:
|
||||||
|
url_parameters = row.get("url_parameters")
|
||||||
|
if not url_parameters:
|
||||||
|
self.logger.warning(f"Skipping record with missing URL parameters: {row}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
parsed_params = self.data_processor.parse_url_parameters(url_parameters)
|
||||||
|
hotel_id = parsed_params.get("utm_content")
|
||||||
|
room_number = parsed_params.get("utm_term")
|
||||||
|
|
||||||
|
if not hotel_id or not room_number:
|
||||||
|
self.logger.warning(f"Skipping record with missing data: hotel_id={hotel_id}, room_number={room_number}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
hotel = self.hotel_manager.get_or_create_hotel(hotel_id, row.get("page_title"))
|
||||||
|
if not hotel:
|
||||||
|
self.logger.warning(f"Skipping record: Failed to create or retrieve hotel with ID {hotel_id}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
room = self.hotel_manager.get_or_create_room(hotel, room_number)
|
||||||
|
if not room:
|
||||||
|
self.logger.warning(f"Skipping record: Failed to create or retrieve room {room_number} in hotel {hotel.name}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
UserActivityLog.objects.update_or_create(
|
||||||
|
external_id=row.get("id"),
|
||||||
|
defaults={
|
||||||
|
"user_id": row.get("user_id") or 0,
|
||||||
|
"ip": row.get("ip") or "0.0.0.0",
|
||||||
|
"created": self.data_processor.parse_datetime(row.get("created")),
|
||||||
|
"timestamp": row.get("timestamp") or datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
"date_time": self.data_processor.parse_datetime(row.get("date_time")),
|
||||||
|
"url_parameters": parsed_params,
|
||||||
|
"page_title": self.data_processor.decode_html_entities(row.get("page_title")) or "Untitled",
|
||||||
|
"page_url": row.get("page_url") or "",
|
||||||
|
"page_id": row.get("page_id") or 0,
|
||||||
|
"hits": row.get("hits") or 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.logger.info(f"Record ID {row.get('id')} processed successfully.")
|
||||||
|
|
||||||
|
if hotel.id not in hotel_processed_counts:
|
||||||
|
hotel_processed_counts[hotel.id] = {"recieved_records": 0, "processed_records": 0}
|
||||||
|
hotel_processed_counts[hotel.id]["processed_records"] += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error processing record ID {row.get('id')}: {e}")
|
||||||
|
|
||||||
|
for hotel_id, counts in hotel_processed_counts.items():
|
||||||
|
hotel = Hotel.objects.get(id=hotel_id)
|
||||||
|
self.update_sync_log(hotel, recieved_records=len(rows), processed_records=counts["processed_records"])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def sync(self):
|
||||||
|
self.db_connector.connect()
|
||||||
|
try:
|
||||||
|
last_id = self.get_last_saved_record()
|
||||||
|
rows = self.fetch_new_data(last_id)
|
||||||
|
self.process_and_save_data(rows)
|
||||||
|
self.logger.info("Sync completed.")
|
||||||
|
finally:
|
||||||
|
self.db_connector.close()
|
||||||
|
|
||||||
|
|
||||||
|
def scheduled_sync():
|
||||||
|
import os
|
||||||
|
logger = CustomLogger(name="DatabaseSyncScheduler", log_level=os.getenv("SCHEDULED_SYNC_LOG_LEVEL", default="ERROR")).get_logger()
|
||||||
|
logger.info("Starting scheduled sync.")
|
||||||
|
|
||||||
|
active_db_settings = ExternalDBSettings.objects.filter(is_active=True)
|
||||||
|
if not active_db_settings.exists():
|
||||||
|
logger.warning("No active database connections found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"Found {len(active_db_settings)} active database connections.")
|
||||||
|
|
||||||
|
def sync_task(db_settings):
|
||||||
|
try:
|
||||||
|
logger.info(f"Syncing connection: {db_settings.name} (ID={db_settings.id})")
|
||||||
|
sync_manager = DataSyncManager(db_settings.id)
|
||||||
|
sync_manager.sync()
|
||||||
|
logger.info(f"Sync completed for connection: {db_settings}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error syncing connection {db_settings}: {e}")
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=10) as executor:
|
||||||
|
futures = [executor.submit(sync_task, db_settings) for db_settings in active_db_settings]
|
||||||
|
for future in futures:
|
||||||
|
try:
|
||||||
|
future.result(timeout=300)
|
||||||
|
except TimeoutError:
|
||||||
|
logger.error("Sync task timed out.")
|
||||||
|
|
||||||
|
logger.info("Scheduled sync completed.")
|
||||||
16
antifroud/forms.py
Normal file
16
antifroud/forms.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
from django import forms
|
||||||
|
from .models import Hotel
|
||||||
|
|
||||||
|
class HotelImportForm(forms.Form):
|
||||||
|
hotels = forms.ModelMultipleChoiceField(
|
||||||
|
queryset=Hotel.objects.all(),
|
||||||
|
widget=forms.CheckboxSelectMultiple,
|
||||||
|
required=True
|
||||||
|
)
|
||||||
|
|
||||||
|
from .models import SyncLog
|
||||||
|
|
||||||
|
class SyncLogForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = SyncLog
|
||||||
|
fields = ['hotel', 'processed_records']
|
||||||
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()
|
||||||
129
antifroud/migrations/0001_initial.py
Normal file
129
antifroud/migrations/0001_initial.py
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
# Generated by Django 5.1.4 on 2024-12-25 04:55
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ExternalDBSettings',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(help_text='Имя подключения для идентификации.', max_length=255, unique=True)),
|
||||||
|
('host', models.CharField(help_text='Адрес сервера базы данных.', max_length=255)),
|
||||||
|
('port', models.PositiveIntegerField(default=3306, help_text='Порт сервера базы данных.')),
|
||||||
|
('user', models.CharField(help_text='Имя пользователя базы данных.', max_length=255)),
|
||||||
|
('password', models.CharField(help_text='Пароль для подключения.', max_length=255)),
|
||||||
|
('database', models.CharField(default='u1510415_wp832', help_text='Имя базы данных.', max_length=255)),
|
||||||
|
('table_name', models.CharField(blank=True, default='wpts_user_activity_log', help_text='Имя таблицы для загрузки данных.', max_length=255, null=True)),
|
||||||
|
('selected_fields', models.TextField(blank=True, help_text='Список полей для загрузки (через запятую).', null=True)),
|
||||||
|
('is_active', models.BooleanField(default=True, help_text='Флаг активности подключения.')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Настройка подключения к БД',
|
||||||
|
'verbose_name_plural': 'Настройки подключений к БД',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ImportedHotel',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('external_id', models.CharField(max_length=255, verbose_name='Внешний ID отеля')),
|
||||||
|
('name', models.CharField(max_length=255, verbose_name='Имя отеля')),
|
||||||
|
('display_name', models.CharField(blank=True, max_length=255, null=True, verbose_name='Отображаемое имя')),
|
||||||
|
('created', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
||||||
|
('updated', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
|
||||||
|
('imported', models.BooleanField(default=False, verbose_name='Импортирован в основную базу')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Импортированный отель',
|
||||||
|
'verbose_name_plural': 'Импортированные отели',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='RoomDiscrepancy',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('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='Фактическая дата заселения')),
|
||||||
|
('discrepancy_type', models.CharField(choices=[('early', 'Раннее заселение'), ('late', 'Позднее заселение'), ('missed', 'Неявка')], max_length=50, verbose_name='Тип несоответствия')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Несовпадение в заселении',
|
||||||
|
'verbose_name_plural': 'Несовпадения в заселении',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='SyncLog',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('created', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
|
||||||
|
('recieved_records', models.IntegerField(default=0, verbose_name='Полученные записи')),
|
||||||
|
('processed_records', models.IntegerField(default=0, verbose_name='Обработанные записи')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Журнал синхронизации',
|
||||||
|
'verbose_name_plural': 'Журналы синхронизации',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='UserActivityLog',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('external_id', models.CharField(db_index=True, max_length=255, unique=True, verbose_name='Внешний ID')),
|
||||||
|
('user_id', models.BigIntegerField(blank=True, db_index=True, null=True, verbose_name='ID пользователя')),
|
||||||
|
('ip', models.GenericIPAddressField(blank=True, db_index=True, null=True, verbose_name='IP-адрес')),
|
||||||
|
('created', models.DateTimeField(blank=True, db_index=True, null=True, verbose_name='Дата создания')),
|
||||||
|
('timestamp', models.BigIntegerField(blank=True, null=True, verbose_name='Метка времени')),
|
||||||
|
('date_time', models.DateTimeField(blank=True, null=True, verbose_name='Дата и время')),
|
||||||
|
('referred', models.TextField(blank=True, null=True, verbose_name='Реферальная ссылка')),
|
||||||
|
('agent', models.TextField(blank=True, null=True, verbose_name='Агент пользователя')),
|
||||||
|
('platform', models.CharField(blank=True, max_length=255, null=True, verbose_name='Платформа')),
|
||||||
|
('version', models.CharField(blank=True, max_length=255, null=True, verbose_name='Версия')),
|
||||||
|
('model', models.CharField(blank=True, max_length=255, null=True, verbose_name='Модель устройства')),
|
||||||
|
('device', models.CharField(blank=True, max_length=255, null=True, verbose_name='Тип устройства')),
|
||||||
|
('UAString', models.TextField(blank=True, null=True, verbose_name='User-Agent строка')),
|
||||||
|
('location', models.CharField(blank=True, max_length=255, null=True, verbose_name='Местоположение')),
|
||||||
|
('page_id', models.BigIntegerField(blank=True, db_index=True, null=True, verbose_name='ID страницы')),
|
||||||
|
('url_parameters', models.TextField(blank=True, null=True, verbose_name='Параметры URL')),
|
||||||
|
('page_title', models.TextField(blank=True, null=True, verbose_name='Заголовок страницы')),
|
||||||
|
('type', models.CharField(blank=True, max_length=50, null=True, verbose_name='Тип')),
|
||||||
|
('last_counter', models.IntegerField(blank=True, null=True, verbose_name='Последний счетчик')),
|
||||||
|
('hits', models.IntegerField(blank=True, default='0', null=True, verbose_name='Количество обращений')),
|
||||||
|
('honeypot', models.BooleanField(blank=True, null=True, verbose_name='Метка honeypot')),
|
||||||
|
('reply', models.BooleanField(blank=True, null=True, verbose_name='Ответ пользователя')),
|
||||||
|
('page_url', models.URLField(blank=True, null=True, verbose_name='URL страницы')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Регистрация посетителей',
|
||||||
|
'verbose_name_plural': 'Регистрации посетителей',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ViolationLog',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('room_number', models.CharField(blank=True, max_length=50, null=True, verbose_name='Номер комнаты')),
|
||||||
|
('violation_type', models.CharField(choices=[('missed', 'Неявка'), ('early', 'Раннее заселение'), ('late', 'Позднее заселение')], max_length=50, verbose_name='Тип нарушения')),
|
||||||
|
('violation_details', models.TextField(blank=True, null=True, verbose_name='Детали нарушения')),
|
||||||
|
('hits', models.IntegerField(verbose_name='Срабатывания')),
|
||||||
|
('detected_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата обнаружения')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Журнал нарушений',
|
||||||
|
'verbose_name_plural': 'Журналы нарушений',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
32
antifroud/migrations/0002_initial.py
Normal file
32
antifroud/migrations/0002_initial.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Generated by Django 5.1.4 on 2024-12-25 04:55
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('antifroud', '0001_initial'),
|
||||||
|
('hotels', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='roomdiscrepancy',
|
||||||
|
name='hotel',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='hotels.hotel', verbose_name='Отель'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='synclog',
|
||||||
|
name='hotel',
|
||||||
|
field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='hotels.hotel', verbose_name='Отель'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='violationlog',
|
||||||
|
name='hotel',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='hotels.hotel', verbose_name='Отель'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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',
|
||||||
|
),
|
||||||
|
]
|
||||||
0
antifroud/migrations/__init__.py
Normal file
0
antifroud/migrations/__init__.py
Normal file
240
antifroud/models.py
Normal file
240
antifroud/models.py
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
from django.db import models
|
||||||
|
from hotels.models import Hotel
|
||||||
|
from hotels.models import Reservation
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from geoip2.errors import AddressNotFoundError
|
||||||
|
from geoip2.database import Reader
|
||||||
|
from django.conf import settings
|
||||||
|
import logging
|
||||||
|
|
||||||
|
class UserActivityLog(models.Model):
|
||||||
|
external_id = models.CharField(max_length=255, unique=True, verbose_name="Внешний ID", db_index=True)
|
||||||
|
user_id = models.BigIntegerField(verbose_name="ID пользователя", blank=True, null=True, db_index=True)
|
||||||
|
ip = models.GenericIPAddressField(verbose_name="IP-адрес", blank=True, null=True, db_index=True)
|
||||||
|
created = models.DateTimeField(verbose_name="Дата создания", blank=True, null=True, db_index=True)
|
||||||
|
timestamp = models.BigIntegerField(verbose_name="Метка времени", blank=True, null=True)
|
||||||
|
date_time = models.DateTimeField(verbose_name="Дата и время", blank=True, null=True)
|
||||||
|
referred = models.TextField(blank=True, null=True, verbose_name="Реферальная ссылка")
|
||||||
|
agent = models.TextField(verbose_name="Агент пользователя", blank=True, null=True)
|
||||||
|
platform = models.CharField(max_length=255, blank=True, null=True, verbose_name="Платформа")
|
||||||
|
version = models.CharField(max_length=255, blank=True, null=True, verbose_name="Версия")
|
||||||
|
model = models.CharField(max_length=255, blank=True, null=True, verbose_name="Модель устройства")
|
||||||
|
device = models.CharField(max_length=255, blank=True, null=True, verbose_name="Тип устройства")
|
||||||
|
UAString = models.TextField(verbose_name="User-Agent строка", blank=True, null=True)
|
||||||
|
location = models.CharField(max_length=255, blank=True, null=True, verbose_name="Местоположение")
|
||||||
|
page_id = models.BigIntegerField(blank=True, null=True, verbose_name="ID страницы", db_index=True)
|
||||||
|
url_parameters = models.TextField(blank=True, null=True, verbose_name="Параметры URL")
|
||||||
|
page_title = models.TextField(blank=True, null=True, verbose_name="Заголовок страницы")
|
||||||
|
type = models.CharField(max_length=50, verbose_name="Тип", blank=True, null=True)
|
||||||
|
last_counter = models.IntegerField(verbose_name="Последний счетчик", blank=True, null=True)
|
||||||
|
hits = models.IntegerField(verbose_name="Количество обращений",default="0", blank=True, null=True)
|
||||||
|
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):
|
||||||
|
"""
|
||||||
|
Преобразует Unix-временную метку в читаемую дату и время.
|
||||||
|
"""
|
||||||
|
if self.timestamp is not None:
|
||||||
|
return datetime.fromtimestamp(self.timestamp, tz=timezone.utc).strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
return "Нет данных"
|
||||||
|
|
||||||
|
# Изменение имени столбца
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["external_id"], name="idx_external_id"),
|
||||||
|
models.Index(fields=["user_id"], name="idx_user_id"),
|
||||||
|
models.Index(fields=["ip"], name="idx_ip"),
|
||||||
|
models.Index(fields=["created"], name="idx_created"),
|
||||||
|
models.Index(fields=["page_id"], name="idx_page_id"),
|
||||||
|
]
|
||||||
|
verbose_name = "Лог активности пользователя"
|
||||||
|
verbose_name_plural = "Логи активности пользователей"
|
||||||
|
def __str__(self):
|
||||||
|
return f"UserActivityLog {self.id}: {self.page_title}"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Регистрация посетителей"
|
||||||
|
verbose_name_plural = "Регистрации посетителей"
|
||||||
|
|
||||||
|
def get_location(self):
|
||||||
|
if not self.ip:
|
||||||
|
return "IP-адрес отсутствует"
|
||||||
|
|
||||||
|
try:
|
||||||
|
db_path = f"{settings.GEOIP_PATH}/GeoLite2-City.mmdb"
|
||||||
|
geoip_reader = Reader(db_path)
|
||||||
|
response = geoip_reader.city(self.ip)
|
||||||
|
|
||||||
|
# Извлекаем город и страну на русском языке
|
||||||
|
city = response.city.names.get('ru', "Город неизвестен")
|
||||||
|
country = response.country.names.get('ru', "Страна неизвестна")
|
||||||
|
|
||||||
|
return f"{city}, {country}"
|
||||||
|
|
||||||
|
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="Имя подключения для идентификации.")
|
||||||
|
host = models.CharField(max_length=255, help_text="Адрес сервера базы данных.")
|
||||||
|
port = models.PositiveIntegerField(default=3306, help_text="Порт сервера базы данных.")
|
||||||
|
user = models.CharField(max_length=255, help_text="Имя пользователя базы данных.")
|
||||||
|
password = models.CharField(max_length=255, help_text="Пароль для подключения.")
|
||||||
|
database = models.CharField(max_length=255, default="u1510415_wp832", help_text="Имя базы данных.")
|
||||||
|
table_name = models.CharField(max_length=255, blank=True, default="wpts_user_activity_log", null=True, help_text="Имя таблицы для загрузки данных.")
|
||||||
|
selected_fields = models.TextField(blank=True, null=True, help_text="Список полей для загрузки (через запятую).")
|
||||||
|
is_active = models.BooleanField(default=True, help_text="Флаг активности подключения.")
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.name} ({self.host}:{self.port})"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Настройка подключения к БД"
|
||||||
|
verbose_name_plural = "Настройки подключений к БД"
|
||||||
|
|
||||||
|
|
||||||
|
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, 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", "Неявка"), ("no_booking", "Без брони")],
|
||||||
|
verbose_name="Тип несоответствия"
|
||||||
|
)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.hotel.name} - Room {self.room_number}: {self.discrepancy_type}"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Несовпадение в заселении"
|
||||||
|
verbose_name_plural = "Несовпадения в заселении"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def detect_discrepancies(expected_bookings, actual_check_ins):
|
||||||
|
"""
|
||||||
|
Сравнение ожидаемых и фактических данных о заселении.
|
||||||
|
"""
|
||||||
|
discrepancies = []
|
||||||
|
|
||||||
|
# Преобразуем фактические заселения в словарь для быстрого доступа
|
||||||
|
actual_dict = {
|
||||||
|
(entry.hotel_id, entry.room_number): entry.check_in_date
|
||||||
|
for entry in actual_check_ins
|
||||||
|
}
|
||||||
|
|
||||||
|
for booking in expected_bookings:
|
||||||
|
key = (booking.hotel_id, booking.room_number)
|
||||||
|
actual_date = actual_dict.get(key)
|
||||||
|
|
||||||
|
if actual_date is None:
|
||||||
|
discrepancies.append(RoomDiscrepancy(
|
||||||
|
hotel=booking.hotel,
|
||||||
|
room_number=booking.room_number,
|
||||||
|
booking_id=booking.booking_id,
|
||||||
|
check_in_date_expected=booking.check_in_date,
|
||||||
|
discrepancy_type="missed"
|
||||||
|
))
|
||||||
|
elif actual_date < booking.check_in_date:
|
||||||
|
discrepancies.append(RoomDiscrepancy(
|
||||||
|
hotel=booking.hotel,
|
||||||
|
room_number=booking.room_number,
|
||||||
|
booking_id=booking.booking_id,
|
||||||
|
check_in_date_expected=booking.check_in_date,
|
||||||
|
check_in_date_actual=actual_date,
|
||||||
|
discrepancy_type="early"
|
||||||
|
))
|
||||||
|
elif actual_date > booking.check_in_date:
|
||||||
|
discrepancies.append(RoomDiscrepancy(
|
||||||
|
hotel=booking.hotel,
|
||||||
|
room_number=booking.room_number,
|
||||||
|
booking_id=booking.booking_id,
|
||||||
|
check_in_date_expected=booking.check_in_date,
|
||||||
|
check_in_date_actual=actual_date,
|
||||||
|
discrepancy_type="late"
|
||||||
|
))
|
||||||
|
|
||||||
|
RoomDiscrepancy.objects.bulk_create(discrepancies)
|
||||||
|
|
||||||
|
from urllib.parse import unquote
|
||||||
|
from html import unescape
|
||||||
|
|
||||||
|
class ImportedHotel(models.Model):
|
||||||
|
id = models.BigAutoField(primary_key=True, auto_created=True, verbose_name="ID")
|
||||||
|
external_id = models.CharField(max_length=255, verbose_name="Внешний ID отеля")
|
||||||
|
name = models.CharField(max_length=255, verbose_name="Имя отеля")
|
||||||
|
display_name = models.CharField(max_length=255, null=True, blank=True, verbose_name="Отображаемое имя")
|
||||||
|
created = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
|
||||||
|
updated = models.DateTimeField(auto_now=True, verbose_name="Дата обновления")
|
||||||
|
imported = models.BooleanField(default=False, verbose_name="Импортирован в основную базу")
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.display_name or self.name} ({self.external_id})"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Импортированный отель"
|
||||||
|
verbose_name_plural = "Импортированные отели"
|
||||||
|
|
||||||
|
def set_display_name_from_page_title(self, page_title):
|
||||||
|
"""
|
||||||
|
Декодирует HTML-сущности, URL-кодировку и устанавливает display_name.
|
||||||
|
"""
|
||||||
|
if page_title:
|
||||||
|
decoded = unquote(unescape(page_title))
|
||||||
|
self.display_name = decoded
|
||||||
|
else:
|
||||||
|
self.display_name = self.name
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
class SyncLog(models.Model):
|
||||||
|
"""
|
||||||
|
Журнал синхронизации в разрезе отелей.
|
||||||
|
"""
|
||||||
|
hotel = models.OneToOneField(Hotel, on_delete=models.CASCADE, verbose_name="Отель")
|
||||||
|
created = models.DateTimeField(auto_now=True, verbose_name="Дата обновления") # Последняя дата обновления записи
|
||||||
|
recieved_records = models.IntegerField(default=0, verbose_name="Полученные записи")
|
||||||
|
processed_records = models.IntegerField(default=0, verbose_name="Обработанные записи")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Журнал синхронизации"
|
||||||
|
verbose_name_plural = "Журналы синхронизации"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Отель: {self.hotel.name} | Получено: {self.recieved_records} | Обработано: {self.processed_records}"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class ViolationLog(models.Model):
|
||||||
|
hotel = models.ForeignKey(Hotel, on_delete=models.CASCADE, verbose_name="Отель")
|
||||||
|
room_number = models.CharField(max_length=50, verbose_name="Номер комнаты", null=True, blank=True)
|
||||||
|
violation_type = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
choices=[("missed", "Неявка"), ("early", "Раннее заселение"), ("late", "Позднее заселение")],
|
||||||
|
verbose_name="Тип нарушения"
|
||||||
|
)
|
||||||
|
violation_details = models.TextField(verbose_name="Детали нарушения", blank=True, null=True)
|
||||||
|
hits = models.IntegerField(verbose_name="Срабатывания")
|
||||||
|
detected_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата обнаружения")
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.hotel.name} - {self.room_number or 'N/A'}: {self.violation_type}"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Журнал нарушений"
|
||||||
|
verbose_name_plural = "Журналы нарушений"
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
{% extends "admin/base_site.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#table-data-preview {
|
||||||
|
max-height: 500px; /* Ограничиваем высоту предпросмотра */
|
||||||
|
overflow-y: auto; /* Прокрутка по вертикали */
|
||||||
|
overflow-x: auto; /* Прокрутка по горизонтали */
|
||||||
|
}
|
||||||
|
|
||||||
|
#table-data-preview table {
|
||||||
|
width: auto; /* Автоматическая ширина таблицы */
|
||||||
|
table-layout: auto; /* Автоматическая ширина колонок */
|
||||||
|
}
|
||||||
|
|
||||||
|
#table-data-preview th,
|
||||||
|
#table-data-preview td {
|
||||||
|
white-space: nowrap; /* Предотвращаем перенос текста */
|
||||||
|
overflow: hidden; /* Скрываем текст, выходящий за границы ячейки */
|
||||||
|
text-overflow: ellipsis; /* Добавляем многоточие для обрезанного текста */
|
||||||
|
padding: 8px; /* Внутренний отступ */
|
||||||
|
height: 40px; /* Фиксированная высота строк */
|
||||||
|
}
|
||||||
|
|
||||||
|
#table-data-preview th {
|
||||||
|
position: sticky; /* Фиксируем заголовки при прокрутке */
|
||||||
|
top: 0; /* Располагаем заголовки вверху таблицы */
|
||||||
|
background-color: #f8f9fa; /* Цвет фона заголовков */
|
||||||
|
z-index: 1; /* Заголовки перекрывают содержимое */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="container mt-4">
|
||||||
|
<h2 class="text-center">Настройки подключения к БД</h2>
|
||||||
|
<form id="connection-form" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="form-group mb-3">
|
||||||
|
<label for="db-name">Имя подключения</label>
|
||||||
|
<input id="db-name" class="form-control" type="text" name="name" value="{{ original.name }}" required />
|
||||||
|
</div>
|
||||||
|
<div class="form-group mb-3">
|
||||||
|
<label for="db-host">Сервер БД</label>
|
||||||
|
<input id="db-host" class="form-control" type="text" name="host" value="{{ original.host }}" required />
|
||||||
|
</div>
|
||||||
|
<div class="form-group mb-3">
|
||||||
|
<label for="db-port">Порт БД</label>
|
||||||
|
<input id="db-port" class="form-control" type="number" name="port" value="{{ original.port }}" required />
|
||||||
|
</div>
|
||||||
|
<div class="form-group mb-3">
|
||||||
|
<label for="db-user">Пользователь</label>
|
||||||
|
<input id="db-user" class="form-control" type="text" name="user" value="{{ original.user }}" required />
|
||||||
|
</div>
|
||||||
|
<div class="form-group mb-3">
|
||||||
|
<label for="db-password">Пароль</label>
|
||||||
|
<input id="db-password" class="form-control" type="password" name="password" value="{{ original.password }}" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group mb-3">
|
||||||
|
<label for="db-database">Имя Базы данных</label>
|
||||||
|
<input id="db-database" class="form-control" type="text" name="database" value="{{ original.database }}" required />
|
||||||
|
</div>
|
||||||
|
<div class="form-group mb-3">
|
||||||
|
<label for="table-selector">Таблицы</label>
|
||||||
|
<select id="table-selector" class="form-select" name="table_name">
|
||||||
|
{% if original.table_name %}
|
||||||
|
<option value="{{ original.table_name }}" selected>{{ original.table_name }}</option>
|
||||||
|
{% else %}
|
||||||
|
<option value="">-- Выберите таблицу --</option>
|
||||||
|
{% endif %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group mb-3">
|
||||||
|
<label for="table-data-preview">Столбцы и данные</label>
|
||||||
|
<div id="table-data-preview" class="table-responsive">
|
||||||
|
<table class="table table-bordered">
|
||||||
|
<thead id="table-header"></thead>
|
||||||
|
<tbody id="table-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group mb-3" style="text-center">
|
||||||
|
|
||||||
|
<input class="form-check-input" id="is-active" class="form-check-input" type="checkbox" name="is_active" {% if original.is_active %}checked{% endif %}>
|
||||||
|
<label class="form-check-label" for="inlineCheckbox2"><b>Активное подключение</b></label>
|
||||||
|
|
||||||
|
<div class="form-group text-center">
|
||||||
|
<button class="btn btn-success" type="submit">Сохранить</button>
|
||||||
|
<button class="btn btn-secondary" type="button" id="close-button">Закрыть</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<hr>
|
||||||
|
<div id="connection-status" class="mt-4"></div>
|
||||||
|
<div class="text-center mt-3">
|
||||||
|
<button id="test-connection" class="btn btn-primary" type="button">Проверить подключение</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if original.id %}
|
||||||
|
<script>
|
||||||
|
const dbId = "{{ original.id }}";
|
||||||
|
</script>
|
||||||
|
{% else %}
|
||||||
|
<script>
|
||||||
|
const dbId = null;
|
||||||
|
document.getElementById("test-connection").style.display = "none";
|
||||||
|
alert("Сохраните запись перед выполнением проверки подключения.");
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Закрыть окно
|
||||||
|
document.getElementById("close-button").addEventListener("click", function() {
|
||||||
|
window.history.back(); // Вернуться назад
|
||||||
|
});
|
||||||
|
|
||||||
|
// Проверить подключение и загрузить таблицы
|
||||||
|
document.getElementById("test-connection").addEventListener("click", function() {
|
||||||
|
if (!dbId) {
|
||||||
|
alert("ID подключения отсутствует.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetch(`/antifroud/externaldbsettings/test-connection/?db_id=${dbId}`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status === "success") {
|
||||||
|
document.getElementById("connection-status").innerHTML = `<div class="alert alert-success">${data.message}</div>`;
|
||||||
|
fetch(`/antifroud/externaldbsettings/fetch-tables/?db_id=${dbId}`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(tableData => {
|
||||||
|
if (tableData.status === "success") {
|
||||||
|
const selector = document.getElementById("table-selector");
|
||||||
|
selector.innerHTML = tableData.tables.map(table => `<option value="${table}">${table}</option>`).join("");
|
||||||
|
} else {
|
||||||
|
alert("Ошибка при загрузке таблиц: " + tableData.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
document.getElementById("connection-status").innerHTML = `<div class="alert alert-danger">${data.message}</div>`;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
alert("Ошибка при проверке подключения.");
|
||||||
|
console.error(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// При выборе таблицы загрузить столбцы и строки данных
|
||||||
|
document.getElementById("table-selector").addEventListener("change", function () {
|
||||||
|
const tableName = this.value;
|
||||||
|
if (!tableName) {
|
||||||
|
document.getElementById("table-header").innerHTML = "";
|
||||||
|
document.getElementById("table-body").innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(`/antifroud/externaldbsettings/fetch-table-data/?db_id=${dbId}&table_name=${tableName}`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status === "success") {
|
||||||
|
const headerRow = data.columns.map(col => `<th>${col}</th>`).join("");
|
||||||
|
document.getElementById("table-header").innerHTML = `<tr>${headerRow}</tr>`;
|
||||||
|
|
||||||
|
const rows = data.rows.map(row => {
|
||||||
|
const cells = row.map(cell => `<td>${cell}</td>`).join("");
|
||||||
|
return `<tr>${cells}</tr>`;
|
||||||
|
}).join("");
|
||||||
|
document.getElementById("table-body").innerHTML = rows;
|
||||||
|
} else {
|
||||||
|
alert("Ошибка при загрузке данных таблицы: " + data.message);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
alert("Ошибка при загрузке данных таблицы.");
|
||||||
|
console.error(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
97
antifroud/templates/antifroud/admin/import_hotels.html
Normal file
97
antifroud/templates/antifroud/admin/import_hotels.html
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
{% extends "admin/change_list.html" %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Кнопка импорта -->
|
||||||
|
<div class="form-group mb-3">
|
||||||
|
<button type="submit" class="btn btn-primary" form="importHotelsForm">
|
||||||
|
Импортировать выбранные отели
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Действия админки -->
|
||||||
|
{{ block.super }}
|
||||||
|
|
||||||
|
<!-- Уведомление -->
|
||||||
|
<div id="notification" class="alert alert-info d-none" role="alert">
|
||||||
|
Здесь появятся уведомления.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Форма для импорта отелей -->
|
||||||
|
<form id="importHotelsForm" method="POST">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form.as_p }} <!-- Отображаем форму -->
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extrahead %}
|
||||||
|
{{ block.super }}
|
||||||
|
<!-- Подключаем Bootstrap 4 -->
|
||||||
|
<link href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||||
|
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const importButton = document.querySelector("button[type='submit']");
|
||||||
|
const notificationElement = document.getElementById('notification');
|
||||||
|
|
||||||
|
// Слушатель для отправки формы
|
||||||
|
importButton.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault(); // предотвращаем стандартное поведение кнопки
|
||||||
|
|
||||||
|
// Извлекаем выбранные отели
|
||||||
|
const checkboxes = document.querySelectorAll('input[name="hotels"]:checked');
|
||||||
|
const selectedHotels = [];
|
||||||
|
console.log("Чекбоксы:", checkboxes); // Консольная отладка
|
||||||
|
|
||||||
|
checkboxes.forEach(function(checkbox) {
|
||||||
|
selectedHotels.push(checkbox.value);
|
||||||
|
console.log("Выбранный отель:", checkbox.value); // Консольная отладка
|
||||||
|
});
|
||||||
|
|
||||||
|
// Если выбраны отели
|
||||||
|
if (selectedHotels.length > 0) {
|
||||||
|
// Преобразуем CSRF токен
|
||||||
|
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value;
|
||||||
|
console.log("CSRF токен:", csrfToken); // Консольная отладка
|
||||||
|
|
||||||
|
// Отправляем данные на сервер
|
||||||
|
fetch('/import_hotels/', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': csrfToken,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ hotels: selectedHotels })
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
console.log("Ответ от сервера:", data); // Консольная отладка
|
||||||
|
// Показать успешное уведомление
|
||||||
|
notificationElement.classList.remove('d-none');
|
||||||
|
notificationElement.classList.add('alert-success');
|
||||||
|
notificationElement.textContent = data.message || "Отели успешно импортированы!";
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Ошибка при импорте:", error); // Консольная отладка
|
||||||
|
// Показать ошибку
|
||||||
|
notificationElement.classList.remove('d-none');
|
||||||
|
notificationElement.classList.add('alert-danger');
|
||||||
|
notificationElement.textContent = "Произошла ошибка при импорте отелей.";
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
// Сброс кнопки
|
||||||
|
importButton.disabled = false;
|
||||||
|
importButton.textContent = 'Импортировать выбранные отели';
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log("Не выбраны отели"); // Консольная отладка
|
||||||
|
notificationElement.classList.remove('d-none');
|
||||||
|
notificationElement.classList.add('alert-warning');
|
||||||
|
notificationElement.textContent = "Пожалуйста, выберите хотя бы один отель для импорта.";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{% extends "admin/base_site.html" %}
|
||||||
|
{% load i18n static %}
|
||||||
|
|
||||||
|
{% block title %}Редактирование отеля{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid">
|
||||||
|
<h3 class="text-dark mb-4">Редактирование отеля</h3>
|
||||||
|
|
||||||
|
<form method="post" action="{% url 'admin:save_edited_hotel' hotel.id %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="form-group mb-3">
|
||||||
|
<label for="display_name" class="form-label">Отображаемое имя</label>
|
||||||
|
<input class="form-control" type="text" id="display_name" name="display_name" value="{{ hotel.display_name }}" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group mb-3">
|
||||||
|
<label for="original_name" class="form-label">Оригинальное имя</label>
|
||||||
|
<input class="form-control" type="text" id="original_name" name="original_name" value="{{ hotel.name }}" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group mb-3">
|
||||||
|
<label for="imported" class="form-label">Импортирован</label>
|
||||||
|
<select class="form-select" id="imported" name="imported">
|
||||||
|
<option value="True" {% if hotel.imported %} selected {% endif %}>Да</option>
|
||||||
|
<option value="False" {% if not hotel.imported %} selected {% endif %}>Нет</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group mb-3">
|
||||||
|
<button type="submit" class="btn btn-primary">Сохранить изменения</button>
|
||||||
|
<a href="{% url 'admin:hotel_list' %}" class="btn btn-secondary">Назад</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
143
antifroud/templates/antifroud/admin/imported_hotels.html
Normal file
143
antifroud/templates/antifroud/admin/imported_hotels.html
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
{% extends "admin/change_list.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<!-- Кнопка импорта -->
|
||||||
|
<div class="form-group mb-3">
|
||||||
|
<button type="button" class="btn btn-primary" id="importHotelsButton">
|
||||||
|
Импортировать выбранные отели
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Уведомление -->
|
||||||
|
<div id="notification" class="alert alert-info d-none" role="alert">
|
||||||
|
Здесь появятся уведомления.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Действия админки -->
|
||||||
|
{{ block.super }}
|
||||||
|
|
||||||
|
<!-- Список отелей для выбора в виде таблицы -->
|
||||||
|
<form id="importHotelsForm" method="POST">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-bordered">
|
||||||
|
<thead class="thead-dark">
|
||||||
|
<tr>
|
||||||
|
<!-- Чекбокс для выбора всех отелей -->
|
||||||
|
<th><input type="checkbox" id="select-all" /></th>
|
||||||
|
<th>Внешний ID</th>
|
||||||
|
<th>Отображаемое имя</th>
|
||||||
|
<th>Имя отеля</th>
|
||||||
|
<th>Дата создания</th>
|
||||||
|
<th>Дата обновления</th>
|
||||||
|
<th>Импортирован в основную базу</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for hotel in imported_hotels %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<input type="checkbox" name="hotels" value="{{ hotel.id }}" id="hotel{{ hotel.id }}" class="select-row" />
|
||||||
|
</td>
|
||||||
|
<td>{{ hotel.external_id }}</td>
|
||||||
|
<td>{{ hotel.display_name }}</td>
|
||||||
|
<td>{{ hotel.name }}</td>
|
||||||
|
<td>{{ hotel.creation_date }}</td>
|
||||||
|
<td>{{ hotel.updated_at }}</td>
|
||||||
|
<td>{{ hotel.imported_to_main_db }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Здесь вы можете добавить скрытые поля или другие элементы формы, если они нужны -->
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extrahead %}
|
||||||
|
{{ block.super }}
|
||||||
|
<!-- Подключаем Bootstrap 4 -->
|
||||||
|
<link href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||||
|
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Обработчик для выбора всех чекбоксов
|
||||||
|
const selectAllCheckbox = document.getElementById('select-all');
|
||||||
|
selectAllCheckbox.addEventListener('change', function() {
|
||||||
|
const checkboxes = document.querySelectorAll(".select-row");
|
||||||
|
checkboxes.forEach(function(checkbox) {
|
||||||
|
checkbox.checked = selectAllCheckbox.checked;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Кнопка импорта
|
||||||
|
const importButton = document.getElementById('importHotelsButton');
|
||||||
|
const notificationElement = document.getElementById('notification');
|
||||||
|
|
||||||
|
importButton.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault(); // предотвращаем стандартное поведение кнопки
|
||||||
|
|
||||||
|
// Даем время DOM полностью загрузиться
|
||||||
|
setTimeout(function() {
|
||||||
|
const checkboxes = document.querySelectorAll('input[name="hotels"]:checked');
|
||||||
|
const selectedHotels = [];
|
||||||
|
|
||||||
|
console.log("Чекбоксы:", checkboxes); // Отладка: выводим все выбранные чекбоксы
|
||||||
|
|
||||||
|
checkboxes.forEach(function(checkbox) {
|
||||||
|
selectedHotels.push(checkbox.value);
|
||||||
|
console.log("Выбранный отель:", checkbox.value); // Отладка: выводим ID выбранного отеля
|
||||||
|
});
|
||||||
|
|
||||||
|
if (selectedHotels.length > 0) {
|
||||||
|
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value;
|
||||||
|
console.log("CSRF токен:", csrfToken);
|
||||||
|
|
||||||
|
importButton.disabled = true;
|
||||||
|
importButton.textContent = 'Импортируем...';
|
||||||
|
|
||||||
|
// Отправка выбранных отелей на сервер через fetch
|
||||||
|
fetch('/import_hotels/', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': csrfToken,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ hotels: selectedHotels })
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
console.log("Ответ сервера:", data);
|
||||||
|
notificationElement.classList.remove('d-none');
|
||||||
|
notificationElement.classList.add('alert-success');
|
||||||
|
notificationElement.textContent = "Отели успешно импортированы!";
|
||||||
|
checkboxes.forEach(checkbox => checkbox.checked = false); // Снимаем выделение с чекбоксов
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Ошибка при импорте:", error);
|
||||||
|
notificationElement.classList.remove('d-none');
|
||||||
|
notificationElement.classList.add('alert-danger');
|
||||||
|
notificationElement.textContent = "Произошла ошибка при импорте отелей.";
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
importButton.disabled = false;
|
||||||
|
importButton.textContent = 'Импортировать выбранные отели';
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log("Не выбраны отели");
|
||||||
|
notificationElement.classList.remove('d-none');
|
||||||
|
notificationElement.classList.add('alert-warning');
|
||||||
|
notificationElement.textContent = "Пожалуйста, выберите хотя бы один отель для импорта.";
|
||||||
|
}
|
||||||
|
}, 100); // Задержка 100ms, чтобы дождаться рендеринга всех элементов
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const checkboxes = document.querySelectorAll('input[name="hotels"]');
|
||||||
|
console.log("Чекбоксы на странице:", checkboxes); // Проверим, есть ли чекбоксы
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
60
antifroud/templates/antifroud/admin/sync_log.html
Normal file
60
antifroud/templates/antifroud/admin/sync_log.html
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
{% extends "admin/change_list.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="row mt-4">
|
||||||
|
<div class="col">
|
||||||
|
<div class="card shadow-sm mb-2 db-graph">
|
||||||
|
<div class="card-header p-2">
|
||||||
|
<h6 class="text-white m-0 font-md">Журнал синхронизации</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<!-- Форма фильтрации по отелям -->
|
||||||
|
<form method="get" class="form-inline mb-3">
|
||||||
|
<label for="hotel-filter" class="mr-2">Фильтр по отелям:</label>
|
||||||
|
<select name="hotel" id="hotel-filter" class="form-control mr-2">
|
||||||
|
<option value="">-- Все отели --</option>
|
||||||
|
{% for hotel in hotels %}
|
||||||
|
<option value="{{ hotel.id }}" {% if hotel.id|stringformat:"s" == selected_hotel %}selected{% endif %}>
|
||||||
|
{{ hotel.name }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="btn btn-primary">Применить</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Список существующих журналов синхронизации -->
|
||||||
|
<div class="table-responsive tbl-wfx mt-1 kot-table">
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead class="text-dark font-md">
|
||||||
|
<tr class="text-dark-blue">
|
||||||
|
<th>#</th>
|
||||||
|
<th>Отель</th>
|
||||||
|
<th> Дата синхронизации</th>
|
||||||
|
<th>Обработанные записи</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for log in sync_logs %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ log.id }}</td>
|
||||||
|
<td>{{ log.hotel.name }}</td>
|
||||||
|
<td>{{ log.created }}</td>
|
||||||
|
<td>{{ log.processed_records }}</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="text-center">Нет записей.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
0
antifroud/tests.py
Normal file
0
antifroud/tests.py
Normal file
11
antifroud/urls.py
Normal file
11
antifroud/urls.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# antifroud/urls.py
|
||||||
|
from django.urls import path
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
app_name = 'antifroud'
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('import_selected_hotels/', views.import_selected_hotels, name='importedhotels_import_selected_hotels'),
|
||||||
|
path('sync-log/create/', views.sync_log_create, name='sync_log_create'),
|
||||||
|
# Другие URL-адреса
|
||||||
|
]
|
||||||
122
antifroud/views.py
Normal file
122
antifroud/views.py
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import logging
|
||||||
|
from django.http import JsonResponse
|
||||||
|
from django.shortcuts import render, redirect
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from .models import ImportedHotel, SyncLog
|
||||||
|
from hotels.models import Hotel
|
||||||
|
|
||||||
|
from django.contrib.admin.views.decorators import staff_member_required
|
||||||
|
from django.utils import timezone
|
||||||
|
from .forms import SyncLogForm
|
||||||
|
# Создаем логгер
|
||||||
|
logger = logging.getLogger('antifroud')
|
||||||
|
|
||||||
|
@staff_member_required
|
||||||
|
def import_selected_hotels(request):
|
||||||
|
if request.method != 'POST':
|
||||||
|
logger.error("Invalid request method. Only POST is allowed.")
|
||||||
|
return JsonResponse({'success': False, 'error': 'Invalid request method'})
|
||||||
|
|
||||||
|
selected_hotels = request.POST.getlist('hotels')
|
||||||
|
if not selected_hotels:
|
||||||
|
logger.warning("No hotels selected for import.")
|
||||||
|
return JsonResponse({'success': False, 'error': 'No hotels selected'})
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info("Fetching selected hotels from ImportedHotel model.")
|
||||||
|
|
||||||
|
# Получаем отели, которые были выбраны для импорта
|
||||||
|
imported_hotels = ImportedHotel.objects.filter(id__in=selected_hotels)
|
||||||
|
logger.info(f"Found {imported_hotels.count()} selected hotels for import.")
|
||||||
|
|
||||||
|
# Список для хранения новых объектов отелей
|
||||||
|
hotels_to_import = []
|
||||||
|
|
||||||
|
for imported_hotel in imported_hotels:
|
||||||
|
logger.debug(f"Preparing hotel data for import: {imported_hotel.name}, {imported_hotel.city}")
|
||||||
|
|
||||||
|
# Получаем APIConfiguration (если имеется)
|
||||||
|
api_configuration = None
|
||||||
|
if imported_hotel.api:
|
||||||
|
api_configuration = imported_hotel.api
|
||||||
|
|
||||||
|
# Получаем PMSConfiguration (если имеется)
|
||||||
|
pms_configuration = None
|
||||||
|
if imported_hotel.pms:
|
||||||
|
pms_configuration = imported_hotel.pms
|
||||||
|
|
||||||
|
# Проверяем, импортирован ли отель из другого отеля (imported_from)
|
||||||
|
imported_from = None
|
||||||
|
if imported_hotel.imported_from:
|
||||||
|
imported_from = imported_hotel.imported_from
|
||||||
|
|
||||||
|
# Подготовим данные для нового отеля
|
||||||
|
hotel_data = {
|
||||||
|
'name': imported_hotel.name,
|
||||||
|
'api': api_configuration,
|
||||||
|
'pms': pms_configuration,
|
||||||
|
'imported_from': imported_from,
|
||||||
|
'imported_at': timezone.now(), # Устанавливаем дату импорта
|
||||||
|
'import_status': 'completed', # Устанавливаем статус импорта
|
||||||
|
}
|
||||||
|
|
||||||
|
# Создаем новый объект Hotel
|
||||||
|
hotel = Hotel(**hotel_data)
|
||||||
|
hotels_to_import.append(hotel)
|
||||||
|
|
||||||
|
# Массово сохраняем новые отели в таблице Hotels
|
||||||
|
logger.info(f"Importing {len(hotels_to_import)} hotels into Hotel model.")
|
||||||
|
Hotel.objects.bulk_create(hotels_to_import)
|
||||||
|
logger.info("Hotels imported successfully.")
|
||||||
|
|
||||||
|
# Обновляем статус импортированных отелей
|
||||||
|
imported_hotels.update(imported=True)
|
||||||
|
logger.info(f"Updated {imported_hotels.count()} imported hotels' status.")
|
||||||
|
|
||||||
|
return JsonResponse({'success': True})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error during hotel import: {str(e)}", exc_info=True)
|
||||||
|
return JsonResponse({'success': False, 'error': str(e)})
|
||||||
|
|
||||||
|
from django.http import JsonResponse
|
||||||
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
from .models import Hotel
|
||||||
|
from .forms import HotelImportForm
|
||||||
|
@csrf_exempt # Или используйте @login_required, если нужно ограничить доступ
|
||||||
|
def import_hotels(request):
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = HotelImportForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
# Получаем выбранные отели
|
||||||
|
selected_hotels = form.cleaned_data['hotels']
|
||||||
|
|
||||||
|
# Логика импорта отелей (например, можно их обновить или импортировать в другую базу)
|
||||||
|
# Для примера, просто устанавливаем флаг "imported" в True
|
||||||
|
for hotel in selected_hotels:
|
||||||
|
hotel.imported_to_main_db = True
|
||||||
|
hotel.save()
|
||||||
|
|
||||||
|
# Возвращаем успешный ответ
|
||||||
|
return JsonResponse({"message": "Отели успешно импортированы!"}, status=200)
|
||||||
|
else:
|
||||||
|
# Если форма невалидна
|
||||||
|
return JsonResponse({"message": "Ошибка при импорте отелей."}, status=400)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# GET-запрос, просто показываем форму
|
||||||
|
form = HotelImportForm()
|
||||||
|
|
||||||
|
return render(request, 'antifroud/admin/import_hotels.html', {'form': form})
|
||||||
|
|
||||||
|
|
||||||
|
def sync_log_create(request):
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = SyncLogForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
form.save() # Сохраняем новый SyncLog
|
||||||
|
return redirect('admin:antifroud_synclog_changelist') # Перенаправляем обратно в список
|
||||||
|
else:
|
||||||
|
form = SyncLogForm()
|
||||||
|
|
||||||
|
return render(request, 'antifroud/admin/sync_log_create.html', {'form': form})
|
||||||
0
app_settings/__init__.py
Normal file
0
app_settings/__init__.py
Normal file
26
app_settings/admin.py
Normal file
26
app_settings/admin.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# settings/admin.py
|
||||||
|
|
||||||
|
from django.contrib import admin
|
||||||
|
from .models import LocalDatabase, GlobalHotelSettings, GlobalSystemSettings, TelegramSettings, EmailSettings
|
||||||
|
|
||||||
|
@admin.register(LocalDatabase)
|
||||||
|
class LocalDatabaseAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ['name', 'host', 'port', 'user', 'database', 'is_active']
|
||||||
|
search_fields = ['name', 'host','user', 'database']
|
||||||
|
|
||||||
|
|
||||||
|
admin.site.register(GlobalHotelSettings)
|
||||||
|
class GlobalHotelSettingsAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ['checkin_time', 'checkout_time', 'global_timezone']
|
||||||
|
list_filter = ['global_timezone']
|
||||||
|
|
||||||
|
admin.site.register(GlobalSystemSettings)
|
||||||
|
class GlobalSystemSettingsAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ['system_name', 'system_version', 'server_timezone']
|
||||||
|
admin.site.register(TelegramSettings)
|
||||||
|
class TelegramSettingsAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ['bot_token', 'bot_username']
|
||||||
|
|
||||||
|
admin.site.register(EmailSettings) # Register your models here.
|
||||||
|
class EmailSettingsAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ['email_host', 'email_port', 'email_host_user', 'email_host_password']
|
||||||
24
app_settings/app_settings.py
Normal file
24
app_settings/app_settings.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
|
||||||
|
from django.apps import apps
|
||||||
|
|
||||||
|
def load_database_settings(databases):
|
||||||
|
"""
|
||||||
|
Загружает дополнительные базы данных из таблицы LocalDatabase и добавляет их в конфигурацию.
|
||||||
|
:param databases: Существующий словарь DATABASES
|
||||||
|
"""
|
||||||
|
LocalDatabase = apps.get_model('app_settings', 'LocalDatabase')
|
||||||
|
|
||||||
|
try:
|
||||||
|
local_db_settings = LocalDatabase.objects.filter(is_active=True)
|
||||||
|
for db in local_db_settings:
|
||||||
|
databases[db.name] = {
|
||||||
|
'ENGINE': db.engine, # Можно хранить тип движка в базе
|
||||||
|
'NAME': db.database,
|
||||||
|
'USER': db.user,
|
||||||
|
'PASSWORD': db.password,
|
||||||
|
'HOST': db.host,
|
||||||
|
'PORT': db.port,
|
||||||
|
'ATOMIC_REQUESTS': True, # Убедитесь, что добавляете ATOMIC_REQUESTS
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Ошибка загрузки локальных баз данных: {e}")
|
||||||
7
app_settings/apps.py
Normal file
7
app_settings/apps.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'app_settings'
|
||||||
|
verbose_name="Настройки системы"
|
||||||
86
app_settings/migrations/0001_initial.py
Normal file
86
app_settings/migrations/0001_initial.py
Normal file
File diff suppressed because one or more lines are too long
0
app_settings/migrations/__init__.py
Normal file
0
app_settings/migrations/__init__.py
Normal file
79
app_settings/models.py
Normal file
79
app_settings/models.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# settings/models.py
|
||||||
|
|
||||||
|
from django.db import models
|
||||||
|
import pytz
|
||||||
|
class LocalDatabase(models.Model):
|
||||||
|
name = models.CharField(max_length=255, verbose_name="Имя базы данных")
|
||||||
|
host = models.CharField(max_length=255, verbose_name="Хост базы данных", default="localhost")
|
||||||
|
port = models.IntegerField(default=5432, verbose_name="Порт базы данных")
|
||||||
|
user = models.CharField(max_length=255, verbose_name="Пользователь базы данных")
|
||||||
|
database = models.CharField(max_length=255, verbose_name="Название базы данных")
|
||||||
|
password = models.CharField(max_length=255, verbose_name="Пароль базы данных")
|
||||||
|
is_active = models.BooleanField(default=True, verbose_name="Активна ли база данных")
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "База данных"
|
||||||
|
verbose_name_plural = "Базы данных"
|
||||||
|
|
||||||
|
class TelegramSettings(models.Model):
|
||||||
|
bot_token = models.CharField(max_length=255, help_text="Токен вашего бота Telegram")
|
||||||
|
chat_id = models.CharField(max_length=255, help_text="ID чата для отправки сообщений")
|
||||||
|
username = models.CharField(max_length=255, help_text="Имя пользователя для бота", blank=True, null=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Telegram Bot ({self.username})"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Telegram"
|
||||||
|
verbose_name_plural = "Telegram"
|
||||||
|
|
||||||
|
|
||||||
|
class EmailSettings(models.Model):
|
||||||
|
smtp_server = models.CharField(max_length=255, help_text="SMTP сервер для отправки почты")
|
||||||
|
smtp_port = models.IntegerField(default=587, help_text="SMTP порт для почты")
|
||||||
|
smtp_user = models.CharField(max_length=255, help_text="Имя пользователя для SMTP")
|
||||||
|
smtp_password = models.CharField(max_length=255, help_text="Пароль для SMTP")
|
||||||
|
from_email = models.EmailField(help_text="Email для отправки сообщений")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "E-mail"
|
||||||
|
verbose_name_plural = "E-mails"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Email Settings for {self.from_email}"
|
||||||
|
|
||||||
|
class GlobalHotelSettings(models.Model):
|
||||||
|
check_in_time = models.TimeField(help_text="Время заезда")
|
||||||
|
check_out_time = models.TimeField(help_text="Время выезда")
|
||||||
|
currency = models.CharField(max_length=3, help_text="Валюта")
|
||||||
|
global_timezone = models.CharField(
|
||||||
|
max_length=63,
|
||||||
|
choices=[(tz, tz) for tz in pytz.all_timezones], # Список всех часовых поясов
|
||||||
|
default='UTC', # Значение по умолчанию
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return "Настройки отеля"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Настройки отеля"
|
||||||
|
verbose_name_plural = "Настройки отеля"
|
||||||
|
|
||||||
|
class GlobalSystemSettings(models.Model):
|
||||||
|
system_name = models.CharField(max_length=255, help_text="Название системы")
|
||||||
|
system_version = models.CharField(max_length=255, help_text="Версия системы")
|
||||||
|
server_timezone = models.CharField(
|
||||||
|
max_length=63,
|
||||||
|
choices=[(tz, tz) for tz in pytz.all_timezones], # Список всех часовых поясов
|
||||||
|
default='UTC', # Значение по умолчанию
|
||||||
|
)
|
||||||
|
def __str__(self):
|
||||||
|
return "Настройки системы"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Настройки системы"
|
||||||
|
verbose_name_plural = "Настройки системы"
|
||||||
|
|
||||||
3
app_settings/tests.py
Normal file
3
app_settings/tests.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
3
app_settings/views.py
Normal file
3
app_settings/views.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.shortcuts import render
|
||||||
|
|
||||||
|
# Create your views here.
|
||||||
3
bin/cli
Executable file
3
bin/cli
Executable file
@@ -0,0 +1,3 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
docker compose exec web python3 manage.py "$@"
|
||||||
3
bin/pip3
Executable file
3
bin/pip3
Executable file
@@ -0,0 +1,3 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
docker compose exec web pip3 "$@"
|
||||||
12
bin/update
Executable file
12
bin/update
Executable file
@@ -0,0 +1,12 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
cd `dirname $0`/..
|
||||||
|
|
||||||
|
git pull
|
||||||
|
|
||||||
|
docker compose down
|
||||||
|
docker compose build
|
||||||
|
docker compose up -d web
|
||||||
|
sleep 1
|
||||||
|
./bin/cli migrate
|
||||||
|
docker compose up -d
|
||||||
0
bot/__init__.py
Normal file
0
bot/__init__.py
Normal file
3
bot/admin.py
Normal file
3
bot/admin.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
# Register your models here.
|
||||||
7
bot/apps.py
Normal file
7
bot/apps.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class BotConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'bot'
|
||||||
|
verbose_name="Бот"
|
||||||
BIN
bot/fonts/DejaVuSans-Bold.cw127.pkl
Normal file
BIN
bot/fonts/DejaVuSans-Bold.cw127.pkl
Normal file
Binary file not shown.
BIN
bot/fonts/DejaVuSans-Bold.pkl
Normal file
BIN
bot/fonts/DejaVuSans-Bold.pkl
Normal file
Binary file not shown.
BIN
bot/fonts/DejaVuSans-Bold.ttf
Normal file
BIN
bot/fonts/DejaVuSans-Bold.ttf
Normal file
Binary file not shown.
BIN
bot/fonts/DejaVuSans-BoldOblique.ttf
Normal file
BIN
bot/fonts/DejaVuSans-BoldOblique.ttf
Normal file
Binary file not shown.
BIN
bot/fonts/DejaVuSans-ExtraLight.ttf
Normal file
BIN
bot/fonts/DejaVuSans-ExtraLight.ttf
Normal file
Binary file not shown.
BIN
bot/fonts/DejaVuSans-Oblique.ttf
Normal file
BIN
bot/fonts/DejaVuSans-Oblique.ttf
Normal file
Binary file not shown.
BIN
bot/fonts/DejaVuSans.cw127.pkl
Normal file
BIN
bot/fonts/DejaVuSans.cw127.pkl
Normal file
Binary file not shown.
BIN
bot/fonts/DejaVuSans.pkl
Normal file
BIN
bot/fonts/DejaVuSans.pkl
Normal file
Binary file not shown.
BIN
bot/fonts/DejaVuSans.ttf
Normal file
BIN
bot/fonts/DejaVuSans.ttf
Normal file
Binary file not shown.
BIN
bot/fonts/DejaVuSansCondensed-Bold.ttf
Normal file
BIN
bot/fonts/DejaVuSansCondensed-Bold.ttf
Normal file
Binary file not shown.
BIN
bot/fonts/DejaVuSansCondensed-BoldOblique.ttf
Normal file
BIN
bot/fonts/DejaVuSansCondensed-BoldOblique.ttf
Normal file
Binary file not shown.
BIN
bot/fonts/DejaVuSansCondensed-Oblique.ttf
Normal file
BIN
bot/fonts/DejaVuSansCondensed-Oblique.ttf
Normal file
Binary file not shown.
BIN
bot/fonts/DejaVuSansCondensed.ttf
Normal file
BIN
bot/fonts/DejaVuSansCondensed.ttf
Normal file
Binary file not shown.
BIN
bot/fonts/OpenSans-Regular.cw127.pkl
Normal file
BIN
bot/fonts/OpenSans-Regular.cw127.pkl
Normal file
Binary file not shown.
BIN
bot/fonts/OpenSans-Regular.pkl
Normal file
BIN
bot/fonts/OpenSans-Regular.pkl
Normal file
Binary file not shown.
BIN
bot/fonts/OpenSans-Regular.ttf
Normal file
BIN
bot/fonts/OpenSans-Regular.ttf
Normal file
Binary file not shown.
123
bot/handlers.py
Normal file
123
bot/handlers.py
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
from telegram import Update, InlineKeyboardMarkup, InlineKeyboardButton
|
||||||
|
from telegram.ext import ContextTypes
|
||||||
|
from bot.operations.hotels import manage_hotels, hotel_actions, delete_hotel, check_pms, setup_rooms
|
||||||
|
from bot.operations.statistics import statistics, stats_select_period, generate_statistics
|
||||||
|
from bot.operations.settings import settings_menu, toggle_telegram, toggle_email, set_notification_time, show_current_settings
|
||||||
|
from bot.operations.users import show_users
|
||||||
|
from django.core.exceptions import ObjectDoesNotExist
|
||||||
|
from pms_integration.api_client import APIClient
|
||||||
|
|
||||||
|
from users.models import User, NotificationSettings
|
||||||
|
from hotels.models import Hotel
|
||||||
|
|
||||||
|
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Обработчик команды /start."""
|
||||||
|
user_id = (
|
||||||
|
update.message.from_user.id
|
||||||
|
if update.message
|
||||||
|
else update.callback_query.from_user.id
|
||||||
|
)
|
||||||
|
print(f"Пользователь {user_id} вызвал команду /start")
|
||||||
|
|
||||||
|
keyboard = [
|
||||||
|
[InlineKeyboardButton("📊 Статистика", callback_data="stats")],
|
||||||
|
[InlineKeyboardButton("🏨 Управление отелями", callback_data="manage_hotels")],
|
||||||
|
[InlineKeyboardButton("👤 Пользователи", callback_data="manage_users")],
|
||||||
|
[InlineKeyboardButton("⚙️ Настройки", callback_data="settings")],
|
||||||
|
]
|
||||||
|
reply_markup = InlineKeyboardMarkup(keyboard)
|
||||||
|
if update.message:
|
||||||
|
await update.message.reply_text("Выберите действие:", reply_markup=reply_markup)
|
||||||
|
elif update.callback_query:
|
||||||
|
await update.callback_query.edit_message_text("Выберите действие:", reply_markup=reply_markup)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_button_click(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Обработчик всех нажатий кнопок."""
|
||||||
|
query = update.callback_query
|
||||||
|
await query.answer()
|
||||||
|
|
||||||
|
callback_data = query.data
|
||||||
|
|
||||||
|
# Сохраняем предыдущее меню для кнопки "Назад"
|
||||||
|
context.user_data["previous_menu"] = context.user_data.get("current_menu", "main_menu")
|
||||||
|
context.user_data["current_menu"] = callback_data
|
||||||
|
|
||||||
|
if callback_data == "stats":
|
||||||
|
await statistics(update, context)
|
||||||
|
elif callback_data.startswith("stats_hotel_"):
|
||||||
|
await stats_select_period(update, context)
|
||||||
|
elif callback_data.startswith("stats_period_"):
|
||||||
|
await generate_statistics(update, context)
|
||||||
|
elif callback_data == "manage_hotels":
|
||||||
|
await manage_hotels(update, context)
|
||||||
|
elif callback_data == "manage_users":
|
||||||
|
await show_users(update, context)
|
||||||
|
elif callback_data.startswith("hotel_"):
|
||||||
|
await hotel_actions(update, context)
|
||||||
|
elif callback_data.startswith("delete_hotel_"):
|
||||||
|
await delete_hotel(update, context)
|
||||||
|
elif callback_data.startswith("check_pms_"):
|
||||||
|
await check_pms(update, context)
|
||||||
|
elif callback_data.startswith("setup_rooms_"):
|
||||||
|
await setup_rooms(update, context)
|
||||||
|
elif callback_data == "settings":
|
||||||
|
await settings_menu(update, context)
|
||||||
|
elif callback_data == "toggle_telegram":
|
||||||
|
await toggle_telegram(update, context)
|
||||||
|
elif callback_data == "toggle_email":
|
||||||
|
await toggle_email(update, context)
|
||||||
|
elif callback_data == "set_notification_time":
|
||||||
|
await set_notification_time(update, context)
|
||||||
|
elif callback_data == "current_settings":
|
||||||
|
await show_current_settings(update, context)
|
||||||
|
elif callback_data == "main_menu":
|
||||||
|
await start(update, context)
|
||||||
|
elif callback_data == "back":
|
||||||
|
await navigate_back(update, context)
|
||||||
|
else:
|
||||||
|
await query.edit_message_text("Команда не распознана.")
|
||||||
|
|
||||||
|
|
||||||
|
async def navigate_back(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Обработчик кнопки 'Назад'."""
|
||||||
|
previous_menu = context.user_data.get("previous_menu", "main_menu")
|
||||||
|
context.user_data["current_menu"] = previous_menu
|
||||||
|
|
||||||
|
if previous_menu == "main_menu":
|
||||||
|
await start(update, context)
|
||||||
|
elif previous_menu == "stats":
|
||||||
|
await statistics(update, context)
|
||||||
|
elif previous_menu == "manage_hotels":
|
||||||
|
await manage_hotels(update, context)
|
||||||
|
elif previous_menu == "manage_users":
|
||||||
|
await show_users(update, context)
|
||||||
|
elif previous_menu.startswith("hotel_"):
|
||||||
|
await hotel_actions(update, context)
|
||||||
|
else:
|
||||||
|
await update.callback_query.edit_message_text("Команда не распознана.")
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_hotel_data(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Синхронизация данных отеля через API."""
|
||||||
|
user_id = update.effective_user.id
|
||||||
|
|
||||||
|
try:
|
||||||
|
user = User.objects.get(chat_id=user_id)
|
||||||
|
hotels = Hotel.objects.filter(hotel_users__user=user)
|
||||||
|
except ObjectDoesNotExist:
|
||||||
|
await update.message.reply_text("Вы не зарегистрированы.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not hotels:
|
||||||
|
await update.message.reply_text("У вас нет доступных отелей для синхронизации.")
|
||||||
|
return
|
||||||
|
|
||||||
|
for hotel in hotels:
|
||||||
|
try:
|
||||||
|
client = APIClient(hotel.pms)
|
||||||
|
client.run(hotel)
|
||||||
|
await update.message.reply_text(f"Данные отеля {hotel.name} успешно синхронизированы.")
|
||||||
|
except Exception as e:
|
||||||
|
await update.message.reply_text(f"Ошибка синхронизации для {hotel.name}: {str(e)}")
|
||||||
19
bot/keyboards.py
Normal file
19
bot/keyboards.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
|
||||||
|
|
||||||
|
def main_menu_keyboard():
|
||||||
|
"""Главное меню клавиатуры."""
|
||||||
|
return InlineKeyboardMarkup([
|
||||||
|
[InlineKeyboardButton("📊 Статистика", callback_data="stats")],
|
||||||
|
[InlineKeyboardButton("🏨 Управление отелями", callback_data="manage_hotels")],
|
||||||
|
[InlineKeyboardButton("👤 Пользователи", callback_data="manage_users")],
|
||||||
|
[InlineKeyboardButton("⚙️ Настройки", callback_data="settings")]
|
||||||
|
])
|
||||||
|
|
||||||
|
def stats_period_keyboard():
|
||||||
|
"""Клавиатура для выбора периода статистики."""
|
||||||
|
return InlineKeyboardMarkup([
|
||||||
|
[InlineKeyboardButton("Неделя", callback_data="stats_period_week")],
|
||||||
|
[InlineKeyboardButton("Месяц", callback_data="stats_period_month")],
|
||||||
|
[InlineKeyboardButton("Все время", callback_data="stats_period_all")],
|
||||||
|
[InlineKeyboardButton("🔙 Назад", callback_data="back")],
|
||||||
|
])
|
||||||
63
bot/log_processor.py
Normal file
63
bot/log_processor.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import geoip2.database
|
||||||
|
from user_agents import parse
|
||||||
|
from django.db.models import Count
|
||||||
|
from antifroud.models import UserActivityLog
|
||||||
|
|
||||||
|
# Геолокация по IP
|
||||||
|
def get_geolocation(ip):
|
||||||
|
try:
|
||||||
|
with geoip2.database.Reader('GeoLite2-City.mmdb') as reader:
|
||||||
|
response = reader.city(ip)
|
||||||
|
return {
|
||||||
|
'city': response.city.name,
|
||||||
|
'country': response.country.name,
|
||||||
|
'latitude': response.location.latitude,
|
||||||
|
'longitude': response.location.longitude,
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Анализ user-agent
|
||||||
|
def get_user_agent_details(user_agent_string):
|
||||||
|
user_agent = parse(user_agent_string)
|
||||||
|
return {
|
||||||
|
'device': user_agent.device.family,
|
||||||
|
'os': user_agent.os.family,
|
||||||
|
'browser': user_agent.browser.family,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Обработка логов
|
||||||
|
def analyze_logs():
|
||||||
|
logs = UserActivityLog.objects.all()
|
||||||
|
for log in logs:
|
||||||
|
# Геолокация
|
||||||
|
geo = get_geolocation(log.ip)
|
||||||
|
if geo:
|
||||||
|
log.location = f"{geo['city']}, {geo['country']}"
|
||||||
|
|
||||||
|
# User Agent
|
||||||
|
ua_details = get_user_agent_details(log.UAString)
|
||||||
|
log.device = ua_details['device']
|
||||||
|
log.platform = ua_details['os']
|
||||||
|
log.agent = ua_details['browser']
|
||||||
|
|
||||||
|
# Сохранение обновлённой записи
|
||||||
|
log.save()
|
||||||
|
|
||||||
|
# Генерация статистики
|
||||||
|
def generate_statistics():
|
||||||
|
statistics = {}
|
||||||
|
|
||||||
|
# Уникальные IP
|
||||||
|
unique_ips = UserActivityLog.objects.values('ip').distinct().count()
|
||||||
|
statistics['unique_ips'] = unique_ips
|
||||||
|
|
||||||
|
# Популярные страницы
|
||||||
|
popular_pages = UserActivityLog.objects.values('page_url').annotate(count=Count('page_url')).order_by('-count')[:10]
|
||||||
|
statistics['popular_pages'] = [{"url": page['page_url'], "count": page['count']} for page in popular_pages]
|
||||||
|
|
||||||
|
# Типы устройств
|
||||||
|
devices = UserActivityLog.objects.values('device').annotate(count=Count('device')).order_by('-count')
|
||||||
|
statistics['devices'] = [{"device": device['device'], "count": device['count']} for device in devices]
|
||||||
|
|
||||||
|
return statistics
|
||||||
0
bot/management/commands/__init__.py
Normal file
0
bot/management/commands/__init__.py
Normal file
BIN
bot/management/commands/fonts/OpenSans-Regular.ttf
Normal file
BIN
bot/management/commands/fonts/OpenSans-Regular.ttf
Normal file
Binary file not shown.
49
bot/management/commands/run_bot.py
Normal file
49
bot/management/commands/run_bot.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import os
|
||||||
|
import django
|
||||||
|
import asyncio
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from telegram.ext import Application
|
||||||
|
from bot.utils.bot_setup import setup_bot
|
||||||
|
from app_settings.models import TelegramSettings
|
||||||
|
from touchh.utils.log import CustomLogger
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Запуск Telegram бота"
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
# Установка Django окружения
|
||||||
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "touchh.settings")
|
||||||
|
django.setup()
|
||||||
|
|
||||||
|
# Создаем новый цикл событий
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(loop)
|
||||||
|
|
||||||
|
# Настройка Telegram бота
|
||||||
|
bot_token = TelegramSettings.objects.first().bot_token
|
||||||
|
|
||||||
|
if not bot_token:
|
||||||
|
raise ValueError("Токен бота не найден в базе данных.")
|
||||||
|
|
||||||
|
application = Application.builder().token(bot_token).build()
|
||||||
|
setup_bot(application)
|
||||||
|
|
||||||
|
# Основная асинхронная функция
|
||||||
|
async def main():
|
||||||
|
await application.initialize()
|
||||||
|
await application.start()
|
||||||
|
await application.updater.start_polling()
|
||||||
|
self.stdout.write(self.style.SUCCESS("Telegram бот успешно запущен."))
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(3600)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
await application.stop()
|
||||||
|
|
||||||
|
# Запуск асинхронной программы
|
||||||
|
try:
|
||||||
|
loop.run_until_complete(main())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
self.stdout.write(self.style.ERROR("Завершение работы Telegram бота"))
|
||||||
|
finally:
|
||||||
|
loop.close()
|
||||||
0
bot/migrations/__init__.py
Normal file
0
bot/migrations/__init__.py
Normal file
3
bot/models.py
Normal file
3
bot/models.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
# Create your models here.
|
||||||
28
bot/operations.py
Normal file
28
bot/operations.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
from asgiref.sync import sync_to_async
|
||||||
|
from hotels.models import UserHotel, Hotel, Reservation
|
||||||
|
from users.models import User
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_from_chat_id(chat_id):
|
||||||
|
"""Получение пользователя из базы по chat_id."""
|
||||||
|
return await sync_to_async(User.objects.filter(chat_id=chat_id).first)()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_hotels_for_user(user_id):
|
||||||
|
"""Получение отелей, связанных с пользователем."""
|
||||||
|
user = await sync_to_async(User.objects.filter(chat_id=user_id).first)()
|
||||||
|
if not user:
|
||||||
|
return []
|
||||||
|
return await sync_to_async(list)(
|
||||||
|
Hotel.objects.filter(userhotel__user=user).distinct()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_reservations(hotel_id, start_date=None, end_date=None):
|
||||||
|
"""Получение статистики бронирований по отелю с гостями."""
|
||||||
|
query = Reservation.objects.filter(hotel_id=hotel_id)
|
||||||
|
if start_date:
|
||||||
|
query = query.filter(check_in__gte=start_date)
|
||||||
|
if end_date:
|
||||||
|
query = query.filter(check_out__lte=end_date)
|
||||||
|
return await sync_to_async(list)(query.prefetch_related('guests'))
|
||||||
0
bot/operations/__init__.py
Normal file
0
bot/operations/__init__.py
Normal file
16
bot/operations/froud_notify.py
Normal file
16
bot/operations/froud_notify.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
from telegram import Bot
|
||||||
|
|
||||||
|
async def notify_fraud(hotel, fraud_logs):
|
||||||
|
"""
|
||||||
|
Уведомляет о FRAUD-действиях через Telegram.
|
||||||
|
:param hotel: Отель, для которого обнаружены FRAUD-действия.
|
||||||
|
:param fraud_logs: Список записей о FRAUD.
|
||||||
|
"""
|
||||||
|
bot = Bot(token="TELEGRAM_BOT_TOKEN")
|
||||||
|
admin_chat_id = "ADMIN_CHAT_ID"
|
||||||
|
|
||||||
|
message = f"🚨 FRAUD обнаружен для отеля {hotel.name}:\n"
|
||||||
|
for log in fraud_logs:
|
||||||
|
message += f"- Гость: {log.guest_name}, Дата заезда: {log.check_in_date}\n"
|
||||||
|
|
||||||
|
await bot.send_message(chat_id=admin_chat_id, text=message)
|
||||||
205
bot/operations/hotels.py
Normal file
205
bot/operations/hotels.py
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
|
||||||
|
from asgiref.sync import sync_to_async
|
||||||
|
from hotels.models import Hotel, UserHotel
|
||||||
|
from users.models import User
|
||||||
|
from pms_integration.manager import PMSIntegrationManager
|
||||||
|
from bot.utils.froud_check import detect_fraud
|
||||||
|
from touchh.utils.log import CustomLogger
|
||||||
|
|
||||||
|
logger = CustomLogger(name="BOT-hotels Manager", log_level="DEBUG").get_logger()
|
||||||
|
async def manage_hotels(update: Update, context):
|
||||||
|
"""Отображение списка отелей, связанных с пользователем."""
|
||||||
|
query = update.callback_query
|
||||||
|
await query.answer()
|
||||||
|
|
||||||
|
user_id = query.from_user.id
|
||||||
|
user = await sync_to_async(User.objects.filter(chat_id=user_id).first)()
|
||||||
|
if not user:
|
||||||
|
await query.edit_message_text("Вы не зарегистрированы в системе.")
|
||||||
|
return
|
||||||
|
|
||||||
|
user_hotels = await sync_to_async(list)(
|
||||||
|
UserHotel.objects.filter(user=user).select_related("hotel")
|
||||||
|
)
|
||||||
|
|
||||||
|
if not user_hotels:
|
||||||
|
await query.edit_message_text("У вас нет связанных отелей.")
|
||||||
|
return
|
||||||
|
|
||||||
|
keyboard = [
|
||||||
|
[InlineKeyboardButton(f"🏨 {hotel.hotel.name}", callback_data=f"hotel_{hotel.hotel.id}")]
|
||||||
|
for hotel in user_hotels
|
||||||
|
]
|
||||||
|
reply_markup = InlineKeyboardMarkup(keyboard)
|
||||||
|
await query.edit_message_text("Выберите отель:", reply_markup=reply_markup)
|
||||||
|
|
||||||
|
|
||||||
|
async def hotel_actions(update: Update, context):
|
||||||
|
"""Обработчик действий для выбранного отеля."""
|
||||||
|
query = update.callback_query
|
||||||
|
await query.answer()
|
||||||
|
|
||||||
|
hotel_id = int(query.data.split("_")[1])
|
||||||
|
print(f"Selected hotel id: {hotel_id}")
|
||||||
|
hotel = await sync_to_async(Hotel.objects.filter(id=hotel_id).first)()
|
||||||
|
if not hotel:
|
||||||
|
await query.edit_message_text("Отель не найден.")
|
||||||
|
return
|
||||||
|
|
||||||
|
keyboard = [
|
||||||
|
[InlineKeyboardButton("🗑️ Удалить отель", callback_data=f"delete_hotel_{hotel_id}")],
|
||||||
|
[InlineKeyboardButton("Проверить на FRAUD", callback_data=f"check_fraud_{hotel_id}")],
|
||||||
|
[InlineKeyboardButton("🔗 Проверить интеграцию с PMS", callback_data=f"check_pms_{hotel_id}")],
|
||||||
|
[InlineKeyboardButton("🛏️ Настроить номера", callback_data=f"setup_rooms_{hotel_id}")],
|
||||||
|
[InlineKeyboardButton("🏠 Главная", callback_data="main_menu")],
|
||||||
|
[InlineKeyboardButton("🔙 Назад", callback_data="back")],
|
||||||
|
]
|
||||||
|
reply_markup = InlineKeyboardMarkup(keyboard)
|
||||||
|
await query.edit_message_text(f"Управление отелем: {hotel.name}", reply_markup=reply_markup)
|
||||||
|
|
||||||
|
async def handle_fraud_check(update, context):
|
||||||
|
query = update.callback_query
|
||||||
|
hotel_id = int(query.data.split("_")[2])
|
||||||
|
await detect_fraud(hotel_id)
|
||||||
|
await query.edit_message_text("Проверка на FRAUD завершена. Администратор уведомлен.")
|
||||||
|
|
||||||
|
async def delete_hotel(update: Update, context):
|
||||||
|
"""Удаление отеля."""
|
||||||
|
query = update.callback_query
|
||||||
|
await query.answer()
|
||||||
|
|
||||||
|
hotel_id = int(query.data.split("_")[2])
|
||||||
|
hotel = await sync_to_async(Hotel.objects.filter(id=hotel_id).first)()
|
||||||
|
if hotel:
|
||||||
|
hotel_name = hotel.name
|
||||||
|
await sync_to_async(hotel.delete)()
|
||||||
|
await query.edit_message_text(f"Отель {hotel_name} успешно удалён.")
|
||||||
|
else:
|
||||||
|
await query.edit_message_text("Отель не найден.")
|
||||||
|
|
||||||
|
|
||||||
|
# async def check_pms(update, context):
|
||||||
|
# query = update.callback_query
|
||||||
|
|
||||||
|
# try:
|
||||||
|
# # Получение ID отеля из callback_data
|
||||||
|
# hotel_id = query.data.split("_")[2]
|
||||||
|
# logger.debug(f"Hotel ID: {hotel_id}")
|
||||||
|
# logger.debug(f"Hotel ID type : {type(hotel_id)}")
|
||||||
|
# # Получение конфигурации отеля и PMS
|
||||||
|
# hotel = await sync_to_async(Hotel.objects.select_related('pms').get)(id=hotel_id)
|
||||||
|
# pms_config = hotel.pms
|
||||||
|
|
||||||
|
# if not pms_config:
|
||||||
|
# await query.edit_message_text("PMS конфигурация не найдена.")
|
||||||
|
# return
|
||||||
|
|
||||||
|
# # Создаем экземпляр PMSIntegrationManager
|
||||||
|
# pms_manager = PMSIntegrationManager(hotel_id=hotel_id)
|
||||||
|
# await pms_manager.load_hotel()
|
||||||
|
# await sync_to_async(pms_manager.load_plugin)()
|
||||||
|
|
||||||
|
# # Проверяем, какой способ интеграции использовать
|
||||||
|
# if hasattr(pms_manager.plugin, 'fetch_data') and callable(pms_manager.plugin.fetch_data):
|
||||||
|
# # Плагин поддерживает метод fetch_data
|
||||||
|
# report = await pms_manager.plugin._fetch_data()
|
||||||
|
|
||||||
|
# else:
|
||||||
|
# await query.edit_message_text("Подходящий способ интеграции с PMS не найден.")
|
||||||
|
# return
|
||||||
|
|
||||||
|
# # Формируем сообщение о результатах
|
||||||
|
# result_message = (
|
||||||
|
# f"Интеграция PMS завершена успешно.\n"
|
||||||
|
# f"Обработано интервалов: {report['processed_intervals']}\n"
|
||||||
|
# f"Обработано записей: {report['processed_items']}\n"
|
||||||
|
# f"Ошибки: {len(report['errors'])}"
|
||||||
|
# )
|
||||||
|
# logger.info(f'Result_Message: {result_message}\n Result_meaage_type: {type(result_message)}')
|
||||||
|
# if report["errors"]:
|
||||||
|
# result_message += "\n\nСписок ошибок:\n" + "\n".join(report["errors"])
|
||||||
|
|
||||||
|
# await query.edit_message_text(result_message)
|
||||||
|
# except Exception as e:
|
||||||
|
# # Обрабатываем и логируем ошибки
|
||||||
|
# await query.edit_message_text(f"❌ Ошибка: {str(e)}")
|
||||||
|
|
||||||
|
async def check_pms(update, context):
|
||||||
|
query = update.callback_query
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Получение ID отеля из callback_data
|
||||||
|
hotel_id = query.data.split("_")[2]
|
||||||
|
logger.debug(f"Hotel ID: {hotel_id}")
|
||||||
|
logger.debug(f"Hotel ID type: {type(hotel_id)}")
|
||||||
|
|
||||||
|
# Получение объекта отеля с PMS конфигурацией
|
||||||
|
hotel = await sync_to_async(Hotel.objects.select_related('pms').get)(id=hotel_id)
|
||||||
|
if not hotel.pms:
|
||||||
|
logger.error(f"Отель {hotel.name} не имеет связанной PMS конфигурации.")
|
||||||
|
await query.edit_message_text("PMS конфигурация не найдена.")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.debug(f"Hotel PMS: {hotel.pms.name}")
|
||||||
|
|
||||||
|
# Инициализация PMSIntegrationManager с отелем
|
||||||
|
pms_manager = PMSIntegrationManager(hotel=hotel)
|
||||||
|
await sync_to_async(pms_manager.load_hotel)()
|
||||||
|
await sync_to_async(pms_manager.load_plugin)()
|
||||||
|
|
||||||
|
# Проверка наличия fetch_data и вызов плагина
|
||||||
|
if hasattr(pms_manager.plugin, 'fetch_data') and callable(pms_manager.plugin.fetch_data):
|
||||||
|
report = await pms_manager.plugin.fetch_data()
|
||||||
|
logger.debug(f"Отчет типа: {type(report)}")
|
||||||
|
else:
|
||||||
|
logger.error("Плагин не поддерживает fetch_data.")
|
||||||
|
await query.edit_message_text("Подходящий способ интеграции с PMS не найден.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Проверка корректности отчета
|
||||||
|
if not report or not isinstance(report, dict):
|
||||||
|
logger.error(f"Некорректный отчет от fetch_data: {report}")
|
||||||
|
await query.edit_message_text("Ошибка: Отчет fetch_data отсутствует или имеет некорректный формат.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Формирование сообщения о результатах
|
||||||
|
result_message = (
|
||||||
|
f"Интеграция PMS завершена успешно.\n"
|
||||||
|
f"Обработано интервалов: {report.get('processed_intervals', 0)}\n"
|
||||||
|
f"Обработано записей: {report.get('processed_items', 0)}\n"
|
||||||
|
f"Ошибки: {len(report.get('errors', []))}"
|
||||||
|
)
|
||||||
|
if report.get("errors"):
|
||||||
|
result_message += "\n\nСписок ошибок:\n" + "\n".join(report["errors"])
|
||||||
|
|
||||||
|
logger.info(f"Result_Message: {result_message}")
|
||||||
|
await query.edit_message_text(result_message)
|
||||||
|
except Hotel.DoesNotExist:
|
||||||
|
logger.error(f"Отель с ID {hotel_id} не найден.")
|
||||||
|
await query.edit_message_text("Ошибка: Отель не найден.")
|
||||||
|
except Exception as e:
|
||||||
|
# Обрабатываем и логируем ошибки
|
||||||
|
logger.error(f"Ошибка в методе check_pms: {str(e)}", exc_info=True)
|
||||||
|
await query.edit_message_text(f"❌ Ошибка: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
async def setup_rooms(update: Update, context):
|
||||||
|
"""Настроить номера отеля."""
|
||||||
|
query = update.callback_query
|
||||||
|
await query.answer()
|
||||||
|
|
||||||
|
hotel_id = int(query.data.split("_")[2])
|
||||||
|
hotel = await sync_to_async(Hotel.objects.filter(id=hotel_id).first)()
|
||||||
|
if not hotel:
|
||||||
|
await query.edit_message_text("Отель не найден.")
|
||||||
|
return
|
||||||
|
|
||||||
|
await query.edit_message_text(f"Настройка номеров для отеля: {hotel.name}")
|
||||||
|
|
||||||
|
|
||||||
|
async def get_users_for_hotel(hotel_id):
|
||||||
|
"""Получение пользователей, зарегистрированных в отеле с правами управления через бота."""
|
||||||
|
users = await sync_to_async(list)(
|
||||||
|
User.objects.filter(user_hotels__hotel_id=hotel_id, user_hotels__role__in=["admin", "manager"]).distinct()
|
||||||
|
)
|
||||||
|
return users
|
||||||
74
bot/operations/notifications.py
Normal file
74
bot/operations/notifications.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
from telegram import Bot
|
||||||
|
from django.core.mail import send_mail
|
||||||
|
from datetime import datetime
|
||||||
|
from asgiref.sync import sync_to_async
|
||||||
|
from users.models import User, NotificationSettings
|
||||||
|
|
||||||
|
|
||||||
|
async def send_telegram_notification(user, message):
|
||||||
|
"""Отправка уведомления через Telegram."""
|
||||||
|
if user.chat_id:
|
||||||
|
try:
|
||||||
|
bot = Bot(token="ВАШ_ТОКЕН")
|
||||||
|
await bot.send_message(chat_id=user.chat_id, text=message)
|
||||||
|
print(f"Telegram-уведомление отправлено пользователю {user.chat_id}: {message}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Ошибка отправки Telegram-уведомления пользователю {user.chat_id}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def send_email_notification(user, message):
|
||||||
|
"""Отправка уведомления через Email."""
|
||||||
|
if user.email:
|
||||||
|
try:
|
||||||
|
send_mail(
|
||||||
|
subject="Уведомление от системы",
|
||||||
|
message=message,
|
||||||
|
from_email="noreply@yourdomain.com",
|
||||||
|
recipient_list=[user.email],
|
||||||
|
fail_silently=False,
|
||||||
|
)
|
||||||
|
print(f"Email-уведомление отправлено на {user.email}: {message}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Ошибка отправки Email-уведомления пользователю {user.email}: {e}")
|
||||||
|
|
||||||
|
async def schedule_notifications():
|
||||||
|
"""Планировщик уведомлений."""
|
||||||
|
print("Запуск планировщика уведомлений...")
|
||||||
|
now = datetime.now().strftime("%H:%M")
|
||||||
|
|
||||||
|
# Получение всех пользователей
|
||||||
|
users = await sync_to_async(list)(User.objects.all())
|
||||||
|
for user in users:
|
||||||
|
# Получение настроек уведомлений для каждого пользователя
|
||||||
|
settings, _ = await sync_to_async(NotificationSettings.objects.get_or_create)(user=user)
|
||||||
|
|
||||||
|
# Проверка времени уведомления
|
||||||
|
if settings.notification_time == now:
|
||||||
|
message = "Это ваше уведомление от системы."
|
||||||
|
if settings.telegram_enabled:
|
||||||
|
await send_telegram_notification(user, message)
|
||||||
|
if settings.email_enabled:
|
||||||
|
send_email_notification(user, message)
|
||||||
|
|
||||||
|
async def handle_notification_time(update, context):
|
||||||
|
"""Обработка ввода времени уведомлений."""
|
||||||
|
if context.user_data.get("set_time"):
|
||||||
|
user_id = update.message.from_user.id
|
||||||
|
new_time = update.message.text
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Проверяем правильность формата времени
|
||||||
|
hour, minute = map(int, new_time.split(":"))
|
||||||
|
user = await sync_to_async(User.objects.filter(chat_id=user_id).first)()
|
||||||
|
if user:
|
||||||
|
# Обновляем настройки уведомлений
|
||||||
|
settings, _ = await sync_to_async(NotificationSettings.objects.get_or_create)(user=user)
|
||||||
|
settings.notification_time = f"{hour:02}:{minute:02}"
|
||||||
|
await sync_to_async(settings.save)()
|
||||||
|
print(f"Пользователь {user_id} установил новое время уведомлений: {new_time}")
|
||||||
|
await update.message.reply_text(f"Время уведомлений обновлено на {new_time}.")
|
||||||
|
except ValueError:
|
||||||
|
print(f"Пользователь {user_id} ввёл некорректное время: {new_time}")
|
||||||
|
await update.message.reply_text("Неверный формат. Введите время в формате HH:MM.")
|
||||||
|
finally:
|
||||||
|
context.user_data["set_time"] = False
|
||||||
108
bot/operations/settings.py
Normal file
108
bot/operations/settings.py
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
|
||||||
|
from telegram.ext import ContextTypes
|
||||||
|
from asgiref.sync import sync_to_async
|
||||||
|
from users.models import User, NotificationSettings
|
||||||
|
|
||||||
|
async def settings_menu(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Меню настроек уведомлений."""
|
||||||
|
query = update.callback_query
|
||||||
|
await query.answer()
|
||||||
|
|
||||||
|
user_id = query.from_user.id
|
||||||
|
user = await sync_to_async(User.objects.filter(chat_id=user_id).first)()
|
||||||
|
if not user:
|
||||||
|
await query.edit_message_text("Вы не зарегистрированы.")
|
||||||
|
return
|
||||||
|
|
||||||
|
settings, _ = await sync_to_async(NotificationSettings.objects.get_or_create)(user=user)
|
||||||
|
|
||||||
|
telegram_status = "✅" if settings.telegram_enabled else "❌"
|
||||||
|
email_status = "✅" if settings.email_enabled else "❌"
|
||||||
|
|
||||||
|
keyboard = [
|
||||||
|
[InlineKeyboardButton(f"{telegram_status} Уведомления в Telegram", callback_data="toggle_telegram")],
|
||||||
|
[InlineKeyboardButton(f"{email_status} Уведомления по Email", callback_data="toggle_email")],
|
||||||
|
[InlineKeyboardButton("🕒 Настроить время уведомлений", callback_data="set_notification_time")],
|
||||||
|
[InlineKeyboardButton("📋 Показать текущие настройки", callback_data="current_settings")],
|
||||||
|
[InlineKeyboardButton("🏠 Главная", callback_data="main_menu")],
|
||||||
|
[InlineKeyboardButton("🔙 Назад", callback_data="back")],
|
||||||
|
]
|
||||||
|
reply_markup = InlineKeyboardMarkup(keyboard)
|
||||||
|
await query.edit_message_text("Настройки уведомлений:", reply_markup=reply_markup)
|
||||||
|
|
||||||
|
async def toggle_telegram(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Переключение состояния Telegram-уведомлений."""
|
||||||
|
query = update.callback_query
|
||||||
|
await query.answer()
|
||||||
|
|
||||||
|
user_id = query.from_user.id
|
||||||
|
user = await sync_to_async(User.objects.filter(chat_id=user_id).first)()
|
||||||
|
if not user:
|
||||||
|
await query.edit_message_text("Вы не зарегистрированы.")
|
||||||
|
return
|
||||||
|
|
||||||
|
settings, _ = await sync_to_async(NotificationSettings.objects.get_or_create)(user=user)
|
||||||
|
settings.telegram_enabled = not settings.telegram_enabled
|
||||||
|
await sync_to_async(settings.save)()
|
||||||
|
|
||||||
|
print(f"Пользователь {user_id} переключил Telegram-уведомления: {settings.telegram_enabled}")
|
||||||
|
await settings_menu(update, context)
|
||||||
|
|
||||||
|
|
||||||
|
async def toggle_email(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Переключение состояния Email-уведомлений."""
|
||||||
|
query = update.callback_query
|
||||||
|
await query.answer()
|
||||||
|
|
||||||
|
user_id = query.from_user.id
|
||||||
|
user = await sync_to_async(User.objects.filter(chat_id=user_id).first)()
|
||||||
|
if not user:
|
||||||
|
await query.edit_message_text("Вы не зарегистрированы.")
|
||||||
|
return
|
||||||
|
|
||||||
|
settings, _ = await sync_to_async(NotificationSettings.objects.get_or_create)(user=user)
|
||||||
|
settings.email_enabled = not settings.email_enabled
|
||||||
|
await sync_to_async(settings.save)()
|
||||||
|
|
||||||
|
print(f"Пользователь {user_id} переключил Email-уведомления: {settings.email_enabled}")
|
||||||
|
await settings_menu(update, context)
|
||||||
|
|
||||||
|
|
||||||
|
async def show_current_settings(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Отображение текущих настроек уведомлений."""
|
||||||
|
query = update.callback_query
|
||||||
|
await query.answer()
|
||||||
|
|
||||||
|
user_id = query.from_user.id
|
||||||
|
user = await sync_to_async(User.objects.filter(chat_id=user_id).first)()
|
||||||
|
if not user:
|
||||||
|
await query.edit_message_text("Вы не зарегистрированы.")
|
||||||
|
return
|
||||||
|
|
||||||
|
settings, _ = await sync_to_async(NotificationSettings.objects.get_or_create)(user=user)
|
||||||
|
telegram_status = "✅ Включены" if settings.telegram_enabled else "❌ Выключены"
|
||||||
|
email_status = "✅ Включены" if settings.email_enabled else "❌ Выключены"
|
||||||
|
notification_time = settings.notification_time or "Не установлено"
|
||||||
|
|
||||||
|
message = (
|
||||||
|
f"📋 Ваши настройки уведомлений:\n"
|
||||||
|
f"🔔 Telegram: {telegram_status}\n"
|
||||||
|
f"📧 Email: {email_status}\n"
|
||||||
|
f"🕒 Время: {notification_time}"
|
||||||
|
)
|
||||||
|
await query.edit_message_text(message)
|
||||||
|
|
||||||
|
async def set_notification_time(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Настройка времени уведомлений."""
|
||||||
|
query = update.callback_query
|
||||||
|
await query.answer()
|
||||||
|
|
||||||
|
user_id = query.from_user.id
|
||||||
|
user = await sync_to_async(User.objects.filter(chat_id=user_id).first)()
|
||||||
|
if not user:
|
||||||
|
await query.edit_message_text("Вы не зарегистрированы.")
|
||||||
|
return
|
||||||
|
|
||||||
|
await query.edit_message_text("Введите новое время для уведомлений в формате HH:MM (например, 08:30):")
|
||||||
|
context.user_data["set_time"] = True
|
||||||
|
print(f"Пользователь {user_id} настраивает время уведомлений.")
|
||||||
202
bot/operations/statistics.py
Normal file
202
bot/operations/statistics.py
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
|
||||||
|
from telegram.ext import ContextTypes
|
||||||
|
from asgiref.sync import sync_to_async
|
||||||
|
from hotels.models import Reservation, Hotel
|
||||||
|
from users.models import User
|
||||||
|
import pytz
|
||||||
|
from pytz import timezone
|
||||||
|
from bot.utils.pdf_report import generate_pdf_report
|
||||||
|
from bot.utils.database import get_hotels_for_user, get_hotel_by_name
|
||||||
|
from datetime import datetime
|
||||||
|
from django.utils.timezone import make_aware, is_aware, is_naive
|
||||||
|
import os
|
||||||
|
import traceback
|
||||||
|
import logging
|
||||||
|
from touchh.utils.log import CustomLogger
|
||||||
|
from django.utils.timezone import localtime
|
||||||
|
from ..utils.date_utils import ensure_datetime
|
||||||
|
|
||||||
|
logger = CustomLogger(name="Statistics.py", log_level="DEBUG").get_logger()
|
||||||
|
|
||||||
|
async def statistics(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Вывод списка отелей для статистики."""
|
||||||
|
query = update.callback_query
|
||||||
|
user_id = query.from_user.id
|
||||||
|
await query.answer()
|
||||||
|
|
||||||
|
# Получаем пользователя
|
||||||
|
user = await sync_to_async(User.objects.filter(chat_id=user_id).first)()
|
||||||
|
if not user:
|
||||||
|
await query.edit_message_text("Вы не зарегистрированы в системе.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Получаем отели, связанные с пользователем
|
||||||
|
user_hotels = await get_hotels_for_user(user)
|
||||||
|
if not user_hotels:
|
||||||
|
await query.edit_message_text("У вас нет доступных отелей для статистики.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Формируем кнопки для выбора отеля
|
||||||
|
keyboard = [
|
||||||
|
[InlineKeyboardButton(f'🏨 {hotel.hotel.name}', callback_data=f"stats_hotel_{hotel.hotel.id}")]
|
||||||
|
for hotel in user_hotels
|
||||||
|
]
|
||||||
|
keyboard.append([InlineKeyboardButton("🏠 Главная", callback_data="main_menu")])
|
||||||
|
reply_markup = InlineKeyboardMarkup(keyboard)
|
||||||
|
await query.edit_message_text("Выберите отель:", reply_markup=reply_markup)
|
||||||
|
|
||||||
|
async def stats_select_period(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Выбор периода времени для статистики."""
|
||||||
|
query = update.callback_query
|
||||||
|
await query.answer()
|
||||||
|
|
||||||
|
hotel_id = int(query.data.split("_")[2])
|
||||||
|
context.user_data["selected_hotel"] = hotel_id
|
||||||
|
|
||||||
|
keyboard = [
|
||||||
|
[InlineKeyboardButton("Сегодня", callback_data="stats_period_today")],
|
||||||
|
[InlineKeyboardButton("Вчера", callback_data="stats_period_yesterday")],
|
||||||
|
[InlineKeyboardButton("Неделя", callback_data="stats_period_week")],
|
||||||
|
[InlineKeyboardButton("Этот месяц", callback_data="stats_period_thismonth")],
|
||||||
|
[InlineKeyboardButton("Прошлый месяц", callback_data="stats_period_lastmonth")],
|
||||||
|
[InlineKeyboardButton("Этот год", callback_data="stats_period_thisyear")],
|
||||||
|
[InlineKeyboardButton("Прошлый год", callback_data="stats_period_lastyear")],
|
||||||
|
[InlineKeyboardButton("🏠 Главная", callback_data="main_menu")],
|
||||||
|
[InlineKeyboardButton("🔙 Назад", callback_data="statistics")],
|
||||||
|
]
|
||||||
|
reply_markup = InlineKeyboardMarkup(keyboard)
|
||||||
|
await query.edit_message_text("Выберите период времени:", reply_markup=reply_markup)
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_statistics(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Генерация и отправка статистики."""
|
||||||
|
query = update.callback_query
|
||||||
|
await query.answer()
|
||||||
|
|
||||||
|
try:
|
||||||
|
hotel_id = context.user_data.get("selected_hotel")
|
||||||
|
if not hotel_id:
|
||||||
|
raise ValueError("ID отеля не найден в user_data")
|
||||||
|
|
||||||
|
period = query.data.split("_")[2]
|
||||||
|
now = ensure_datetime(datetime.utcnow())
|
||||||
|
|
||||||
|
start_date, end_date = get_period_dates(period, now)
|
||||||
|
print(type(start_date), type(end_date))
|
||||||
|
reservations = await sync_to_async(list)(
|
||||||
|
Reservation.objects.filter(
|
||||||
|
hotel_id=hotel_id,
|
||||||
|
check_in__gte=start_date,
|
||||||
|
check_in__lte=end_date
|
||||||
|
).select_related('hotel')
|
||||||
|
)
|
||||||
|
|
||||||
|
if not reservations:
|
||||||
|
await query.edit_message_text(f"Нет данных для статистики за выбранный период.{start_date} - {end_date}")
|
||||||
|
return
|
||||||
|
|
||||||
|
hotel = await sync_to_async(Hotel.objects.get)(id=hotel_id)
|
||||||
|
|
||||||
|
file_path = await generate_pdf_report(hotel.name, reservations, start_date, end_date)
|
||||||
|
|
||||||
|
with open(file_path, "rb") as file:
|
||||||
|
await query.message.reply_document(document=file, filename=f"{hotel.name}_report.pdf")
|
||||||
|
|
||||||
|
if os.path.exists(file_path):
|
||||||
|
os.remove(file_path)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Ошибка в generate_statistics: {e}", exc_info=True)
|
||||||
|
await query.edit_message_text(f"Произошла ошибка: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def get_period_dates(period, now=None):
|
||||||
|
"""
|
||||||
|
Возвращает диапазон дат (start_date, end_date) для заданного периода.
|
||||||
|
|
||||||
|
:param period: Период (строка: 'today', 'yesterday', 'last_week', 'last_month').
|
||||||
|
:param now: Текущая дата/время (опционально).
|
||||||
|
:return: Кортеж (start_date, end_date).
|
||||||
|
:raises: ValueError, если период не поддерживается.
|
||||||
|
"""
|
||||||
|
if now is None:
|
||||||
|
now = datetime.now(pytz.UTC)
|
||||||
|
else:
|
||||||
|
now = ensure_datetime(now) # Приведение now к timezone-aware
|
||||||
|
|
||||||
|
if period == "today":
|
||||||
|
start_date = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
end_date = now.replace(hour=23, minute=59, second=59, microsecond=999999)
|
||||||
|
|
||||||
|
elif period == "yesterday":
|
||||||
|
start_date = (now - timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
end_date = (now - timedelta(days=1)).replace(hour=23, minute=59, second=59, microsecond=999999)
|
||||||
|
|
||||||
|
elif period == "week":
|
||||||
|
# Последняя неделя: с понедельника предыдущей недели до воскресенья
|
||||||
|
start_date = (now - timedelta(days=now.weekday() + 7)).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
end_date = (start_date + timedelta(days=6)).replace(hour=23, minute=59, second=59, microsecond=999999)
|
||||||
|
|
||||||
|
elif period == "month":
|
||||||
|
# Текущий месяц: с первого дня месяца до текущей даты
|
||||||
|
start_date = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
end_date = now.replace(hour=23, minute=59, second=59, microsecond=999999)
|
||||||
|
|
||||||
|
elif period == "lastmonth":
|
||||||
|
# Последний месяц: с первого дня прошлого месяца до последнего дня прошлого месяца
|
||||||
|
first_day_of_current_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
last_day_of_previous_month = first_day_of_current_month - timedelta(days=1)
|
||||||
|
start_date = last_day_of_previous_month.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
end_date = last_day_of_previous_month.replace(hour=23, minute=59, second=59, microsecond=999999)
|
||||||
|
|
||||||
|
elif period == "year":
|
||||||
|
# Текущий год: с первого дня года до текущей даты
|
||||||
|
start_date = now.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
end_date = now.replace(hour=23, minute=59, second=59, microsecond=999999)
|
||||||
|
|
||||||
|
elif period == "lastyear":
|
||||||
|
# Последний год: с 1 января предыдущего года до 31 декабря предыдущего года
|
||||||
|
start_date = now.replace(year=now.year - 1, month=1, day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
end_date = now.replace(year=now.year - 1, month=12, day=31, hour=23, minute=59, second=59, microsecond=999999)
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Неподдерживаемый период: {period}")
|
||||||
|
|
||||||
|
# Приводим start_date и end_date к timezone-aware, если это не так
|
||||||
|
if start_date.tzinfo is None:
|
||||||
|
start_date = start_date.replace(tzinfo=pytz.UTC)
|
||||||
|
if end_date.tzinfo is None:
|
||||||
|
end_date = end_date.replace(tzinfo=pytz.UTC)
|
||||||
|
|
||||||
|
# Приведение дат к локальному времени
|
||||||
|
return localtime(start_date), localtime(end_date)
|
||||||
|
|
||||||
|
async def stats_back(update: Update, context):
|
||||||
|
"""Возврат к выбору отеля."""
|
||||||
|
query = update.callback_query
|
||||||
|
await query.answer()
|
||||||
|
|
||||||
|
# Получаем отели, связанные с пользователем
|
||||||
|
user_id = query.from_user.id
|
||||||
|
user = await sync_to_async(User.objects.filter(chat_id=user_id).first)()
|
||||||
|
if not user:
|
||||||
|
await query.edit_message_text("Ошибка: Пользователь не найден.")
|
||||||
|
return
|
||||||
|
|
||||||
|
hotels = await get_hotels_for_user(user)
|
||||||
|
if not hotels:
|
||||||
|
await query.edit_message_text("У вас нет доступных отелей.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Формируем кнопки для выбора отеля
|
||||||
|
keyboard = [
|
||||||
|
[InlineKeyboardButton(hotel.name, callback_data=f"stats_hotel_{hotel.id}")]
|
||||||
|
for hotel in hotels
|
||||||
|
]
|
||||||
|
keyboard.append([InlineKeyboardButton("🏠 Главная", callback_data="main_menu")])
|
||||||
|
reply_markup = InlineKeyboardMarkup(keyboard)
|
||||||
|
await query.edit_message_text("Выберите отель:", reply_markup=reply_markup)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
163
bot/operations/users.py
Normal file
163
bot/operations/users.py
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
from asgiref.sync import sync_to_async
|
||||||
|
from telegram import Update
|
||||||
|
from telegram.ext import ContextTypes
|
||||||
|
from telegram.ext import CallbackContext
|
||||||
|
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
|
||||||
|
from telegram import KeyboardButton, ReplyKeyboardMarkup
|
||||||
|
|
||||||
|
from hotels.models import Hotel, UserHotel
|
||||||
|
from users.models import User
|
||||||
|
|
||||||
|
|
||||||
|
async def edit_user(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Изменение имени пользователя."""
|
||||||
|
query = update.callback_query
|
||||||
|
await query.answer()
|
||||||
|
|
||||||
|
user_id = int(query.data.split("_")[2])
|
||||||
|
context.user_data["edit_user_id"] = user_id
|
||||||
|
|
||||||
|
await query.edit_message_text("Введите новое имя пользователя:")
|
||||||
|
|
||||||
|
async def delete_user(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Удаление пользователя."""
|
||||||
|
query = update.callback_query
|
||||||
|
await query.answer()
|
||||||
|
|
||||||
|
user_id = int(query.data.split("_")[2])
|
||||||
|
user = await sync_to_async(User.objects.get)(id=user_id)
|
||||||
|
await sync_to_async(user.delete)()
|
||||||
|
|
||||||
|
await query.edit_message_text("Пользователь успешно удален.")
|
||||||
|
|
||||||
|
async def get_users_for_hotel(hotel_id):
|
||||||
|
"""
|
||||||
|
Получение пользователей, зарегистрированных в отеле с правами управления через бота.
|
||||||
|
"""
|
||||||
|
users = await sync_to_async(list)(
|
||||||
|
User.objects.filter(user_hotels__hotel_id=hotel_id, user_hotels__role__in=["admin", "manager"]).distinct()
|
||||||
|
)
|
||||||
|
return users
|
||||||
|
|
||||||
|
|
||||||
|
async def show_users(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Показать пользователей, зарегистрированных в отеле."""
|
||||||
|
query = update.callback_query
|
||||||
|
await query.answer()
|
||||||
|
|
||||||
|
# Если callback_data не содержит ID отеля, отображаем список отелей
|
||||||
|
if not query.data.startswith("manage_users_hotel_"):
|
||||||
|
user_id = query.from_user.id
|
||||||
|
hotels = await get_hotels_for_user(user_id)
|
||||||
|
|
||||||
|
if not hotels:
|
||||||
|
await query.edit_message_text("У вас нет доступных отелей.")
|
||||||
|
return
|
||||||
|
|
||||||
|
keyboard = [
|
||||||
|
[InlineKeyboardButton(hotel.name, callback_data=f"manage_users_hotel_{hotel.id}")]
|
||||||
|
for hotel in hotels
|
||||||
|
]
|
||||||
|
keyboard.append([InlineKeyboardButton("🏠 Главная", callback_data="main_menu")])
|
||||||
|
reply_markup = InlineKeyboardMarkup(keyboard)
|
||||||
|
await query.edit_message_text("Выберите отель:", reply_markup=reply_markup)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Обработка пользователей отеля
|
||||||
|
hotel_id = int(query.data.split("_")[-1])
|
||||||
|
users = await sync_to_async(list)(
|
||||||
|
User.objects.filter(user_hotel__hotel_id=hotel_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not users:
|
||||||
|
await query.edit_message_text("В этом отеле нет пользователей.")
|
||||||
|
return
|
||||||
|
|
||||||
|
keyboard = [
|
||||||
|
[InlineKeyboardButton(f"{user.username}", callback_data=f"edit_user_{user.id}")]
|
||||||
|
for user in users
|
||||||
|
]
|
||||||
|
keyboard.append([
|
||||||
|
InlineKeyboardButton("🏠 Главная", callback_data="main_menu"),
|
||||||
|
InlineKeyboardButton("🔙 Назад", callback_data="manage_users"),
|
||||||
|
[InlineKeyboardButton("🏠 Главная", callback_data="main_menu")],
|
||||||
|
[InlineKeyboardButton("🔙 Назад", callback_data="back")],
|
||||||
|
])
|
||||||
|
reply_markup = InlineKeyboardMarkup(keyboard)
|
||||||
|
await query.edit_message_text("Выберите пользователя:", reply_markup=reply_markup)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_hotels_for_user(user):
|
||||||
|
"""Получение отелей, связанных с пользователем."""
|
||||||
|
return await sync_to_async(list)(
|
||||||
|
Hotel.objects.filter(hotel_users__user=user).distinct()
|
||||||
|
)
|
||||||
|
|
||||||
|
async def show_user_hotels(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Показ списка отелей пользователя."""
|
||||||
|
query = update.callback_query
|
||||||
|
await query.answer()
|
||||||
|
|
||||||
|
user_id = query.from_user.id
|
||||||
|
user_hotels = await get_hotels_for_user(user_id)
|
||||||
|
|
||||||
|
if not user_hotels:
|
||||||
|
await query.edit_message_text("У вас нет связанных отелей.")
|
||||||
|
return
|
||||||
|
|
||||||
|
keyboard = [
|
||||||
|
[InlineKeyboardButton(hotel.name, callback_data=f"users_hotel_{hotel.id}")]
|
||||||
|
for hotel in user_hotels
|
||||||
|
]
|
||||||
|
keyboard.append([InlineKeyboardButton("🔙 Назад", callback_data="main_menu")])
|
||||||
|
|
||||||
|
reply_markup = InlineKeyboardMarkup(keyboard)
|
||||||
|
await query.edit_message_text("Выберите отель:", reply_markup=reply_markup)
|
||||||
|
|
||||||
|
async def show_users_in_hotel(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Показ пользователей в выбранном отеле."""
|
||||||
|
query = update.callback_query
|
||||||
|
await query.answer()
|
||||||
|
|
||||||
|
hotel_id = int(query.data.split("_")[2])
|
||||||
|
users = await get_users_for_hotel(hotel_id)
|
||||||
|
|
||||||
|
if not users:
|
||||||
|
await query.edit_message_text("В этом отеле нет пользователей.")
|
||||||
|
return
|
||||||
|
|
||||||
|
keyboard = [
|
||||||
|
[InlineKeyboardButton(f"{user.first_name} {user.last_name}", callback_data=f"user_action_{user.id}")]
|
||||||
|
for user in users
|
||||||
|
]
|
||||||
|
keyboard.append([
|
||||||
|
InlineKeyboardButton("🏠 Главная", callback_data="main_menu"),
|
||||||
|
InlineKeyboardButton("🔙 Назад", callback_data="manage_users"),
|
||||||
|
])
|
||||||
|
|
||||||
|
reply_markup = InlineKeyboardMarkup(keyboard)
|
||||||
|
await query.edit_message_text("Пользователи отеля:", reply_markup=reply_markup)
|
||||||
|
|
||||||
|
async def user_action_menu(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Меню действий для пользователя."""
|
||||||
|
query = update.callback_query
|
||||||
|
await query.answer()
|
||||||
|
|
||||||
|
user_id = int(query.data.split("_")[2])
|
||||||
|
user = await sync_to_async(User.objects.get)(id=user_id)
|
||||||
|
|
||||||
|
keyboard = [
|
||||||
|
[InlineKeyboardButton("✏️ Изменить имя", callback_data=f"edit_user_{user_id}")],
|
||||||
|
[InlineKeyboardButton("🗑️ Удалить", callback_data=f"delete_user_{user_id}")],
|
||||||
|
[
|
||||||
|
InlineKeyboardButton("🏠 Главная", callback_data="main_menu"),
|
||||||
|
InlineKeyboardButton("🔙 Назад", callback_data="users_hotel"),
|
||||||
|
],
|
||||||
|
]
|
||||||
|
|
||||||
|
reply_markup = InlineKeyboardMarkup(keyboard)
|
||||||
|
await query.edit_message_text(
|
||||||
|
f"Пользователь: {user.first_name} {user.last_name}\nВыберите действие:",
|
||||||
|
reply_markup=reply_markup,
|
||||||
|
)
|
||||||
|
|
||||||
3
bot/tests.py
Normal file
3
bot/tests.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
0
bot/urls.py
Normal file
0
bot/urls.py
Normal file
0
bot/utils/__init__.py
Normal file
0
bot/utils/__init__.py
Normal file
71
bot/utils/bot_setup.py
Normal file
71
bot/utils/bot_setup.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
from telegram.ext import (
|
||||||
|
Application,
|
||||||
|
CommandHandler,
|
||||||
|
CallbackQueryHandler,
|
||||||
|
MessageHandler,
|
||||||
|
filters,
|
||||||
|
)
|
||||||
|
from bot.handlers import (
|
||||||
|
start,
|
||||||
|
handle_button_click,
|
||||||
|
manage_hotels,
|
||||||
|
hotel_actions,
|
||||||
|
delete_hotel,
|
||||||
|
check_pms,
|
||||||
|
setup_rooms,
|
||||||
|
settings_menu,
|
||||||
|
toggle_telegram,
|
||||||
|
toggle_email,
|
||||||
|
show_current_settings,
|
||||||
|
statistics,
|
||||||
|
generate_statistics,
|
||||||
|
stats_select_period,
|
||||||
|
)
|
||||||
|
|
||||||
|
from bot.operations.settings import (
|
||||||
|
settings_menu,
|
||||||
|
toggle_telegram,
|
||||||
|
toggle_email,
|
||||||
|
show_current_settings,
|
||||||
|
set_notification_time,
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
from bot.operations.notifications import (
|
||||||
|
handle_notification_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
from bot.operations.users import (
|
||||||
|
|
||||||
|
show_users,
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
def setup_bot(application: Application):
|
||||||
|
"""Настройка Telegram бота: регистрация обработчиков."""
|
||||||
|
print("Настройка Telegram приложения...")
|
||||||
|
|
||||||
|
# Регистрация обработчиков команд
|
||||||
|
application.add_handler(CommandHandler("start", start))
|
||||||
|
|
||||||
|
# Регистрация обработчиков кнопок
|
||||||
|
application.add_handler(CallbackQueryHandler(handle_button_click))
|
||||||
|
application.add_handler(CallbackQueryHandler(manage_hotels, pattern="^manage_hotels$"))
|
||||||
|
application.add_handler(CallbackQueryHandler(show_users, pattern="^manage_users$"))
|
||||||
|
application.add_handler(CallbackQueryHandler(settings_menu, pattern="^settings$"))
|
||||||
|
application.add_handler(CallbackQueryHandler(toggle_telegram, pattern="^toggle_telegram$"))
|
||||||
|
application.add_handler(CallbackQueryHandler(toggle_email, pattern="^toggle_email$"))
|
||||||
|
application.add_handler(CallbackQueryHandler(set_notification_time, pattern="^set_notification_time$"))
|
||||||
|
application.add_handler(CallbackQueryHandler(show_current_settings, pattern="^current_settings$"))
|
||||||
|
application.add_handler(CallbackQueryHandler(hotel_actions, pattern="^hotel_"))
|
||||||
|
application.add_handler(CallbackQueryHandler(delete_hotel, pattern="^delete_hotel_"))
|
||||||
|
application.add_handler(CallbackQueryHandler(check_pms, pattern="^check_pms_"))
|
||||||
|
application.add_handler(CallbackQueryHandler(setup_rooms, pattern="^setup_rooms_"))
|
||||||
|
application.add_handler(CallbackQueryHandler(statistics, pattern="^stats$"))
|
||||||
|
application.add_handler(CallbackQueryHandler(stats_select_period, pattern="^stats_hotel_"))
|
||||||
|
application.add_handler(CallbackQueryHandler(generate_statistics, pattern="^stats_period_"))
|
||||||
|
|
||||||
|
# Регистрация обработчиков текстовых сообщений
|
||||||
|
application.add_handler(MessageHandler(filters.TEXT & filters.ChatType.PRIVATE, handle_notification_time))
|
||||||
|
|
||||||
|
print("Обработчики успешно зарегистрированы.")
|
||||||
55
bot/utils/database.py
Normal file
55
bot/utils/database.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
from users.models import User
|
||||||
|
from hotels.models import Hotel, Reservation, UserHotel
|
||||||
|
from asgiref.sync import sync_to_async
|
||||||
|
from django.core.exceptions import ObjectDoesNotExist
|
||||||
|
|
||||||
|
async def get_user_from_chat_id(chat_id):
|
||||||
|
try:
|
||||||
|
return await sync_to_async(User.objects.get)(chat_id=chat_id)
|
||||||
|
except ObjectDoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_hotel_by_id(hotel_id):
|
||||||
|
try:
|
||||||
|
return await sync_to_async(Hotel.objects.get)(id=hotel_id)
|
||||||
|
except ObjectDoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_hotel_by_name(hotel_name):
|
||||||
|
try:
|
||||||
|
return await sync_to_async(Hotel.objects.get)(name=hotel_name)
|
||||||
|
except ObjectDoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_hotels_for_user(user):
|
||||||
|
"""Получение отелей, связанных с пользователем."""
|
||||||
|
return await sync_to_async(list)(
|
||||||
|
UserHotel.objects.filter(user=user).select_related('hotel')
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_reservations(hotel_id, start_date=None, end_date=None):
|
||||||
|
query = Reservation.objects.filter(hotel_id=hotel_id)
|
||||||
|
if start_date:
|
||||||
|
query = query.filter(check_in__gte=start_date)
|
||||||
|
if end_date:
|
||||||
|
query = query.filter(check_out__lte=end_date)
|
||||||
|
return await sync_to_async(list)(query.prefetch_related('guests'))
|
||||||
|
|
||||||
|
def save_reservations(data):
|
||||||
|
"""
|
||||||
|
Сохранение данных бронирований в базу данных.
|
||||||
|
:param data: Список бронирований.
|
||||||
|
"""
|
||||||
|
for booking in data:
|
||||||
|
Reservation.objects.update_or_create(
|
||||||
|
external_id=booking['id'],
|
||||||
|
defaults={
|
||||||
|
'check_in': booking['begin_date'],
|
||||||
|
'check_out': booking['end_date'],
|
||||||
|
'amount': booking['amount'],
|
||||||
|
'notes': booking.get('notes', ''),
|
||||||
|
'guest_name': booking['client']['fio'],
|
||||||
|
'guest_phone': booking['client']['phone'],
|
||||||
|
},
|
||||||
|
)
|
||||||
53
bot/utils/date_utils.py
Normal file
53
bot/utils/date_utils.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# bot/utils/date_utils.py
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import pytz
|
||||||
|
|
||||||
|
def ensure_datetime(value):
|
||||||
|
"""
|
||||||
|
Приводит значение к объекту datetime с учетом временной зоны.
|
||||||
|
|
||||||
|
:param value: Значение даты (строка или datetime).
|
||||||
|
:return: Объект datetime.
|
||||||
|
:raises: TypeError, если передан некорректный тип.
|
||||||
|
"""
|
||||||
|
if isinstance(value, str):
|
||||||
|
try:
|
||||||
|
# Если строка соответствует формату ISO 8601
|
||||||
|
return datetime.fromisoformat(value)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(f"Некорректный формат даты: {value}")
|
||||||
|
elif isinstance(value, datetime):
|
||||||
|
return value
|
||||||
|
else:
|
||||||
|
raise TypeError(f"Ожидался тип str или datetime, получено: {type(value)}")
|
||||||
|
|
||||||
|
def get_period_dates(period, now=None):
|
||||||
|
"""
|
||||||
|
Возвращает диапазон дат (start_date, end_date) для заданного периода.
|
||||||
|
|
||||||
|
:param period: Период (строка: 'today', 'yesterday', 'last_week', 'last_month').
|
||||||
|
:param now: Текущая дата/время (опционально).
|
||||||
|
:return: Кортеж (start_date, end_date).
|
||||||
|
:raises: ValueError, если период не поддерживается.
|
||||||
|
"""
|
||||||
|
if now is None:
|
||||||
|
now = datetime.now(pytz.UTC)
|
||||||
|
|
||||||
|
if period == "today":
|
||||||
|
start_date = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
end_date = now.replace(hour=23, minute=59, second=59, microsecond=999999)
|
||||||
|
elif period == "yesterday":
|
||||||
|
start_date = (now - timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
end_date = (now - timedelta(days=1)).replace(hour=23, minute=59, second=59, microsecond=999999)
|
||||||
|
elif period == "last_week":
|
||||||
|
start_date = (now - timedelta(days=now.weekday() + 7)).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
end_date = (start_date + timedelta(days=6)).replace(hour=23, minute=59, second=59, microsecond=999999)
|
||||||
|
elif period == "last_month":
|
||||||
|
first_day_of_current_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
last_day_of_previous_month = first_day_of_current_month - timedelta(days=1)
|
||||||
|
start_date = last_day_of_previous_month.replace(day=1)
|
||||||
|
end_date = last_day_of_previous_month.replace(hour=23, minute=59, second=59, microsecond=999999)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Неподдерживаемый период: {period}")
|
||||||
|
|
||||||
|
return start_date, end_date
|
||||||
54
bot/utils/froud_check.py
Normal file
54
bot/utils/froud_check.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
from asgiref.sync import sync_to_async
|
||||||
|
from django.db import connections
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from hotels.models import Reservation, Hotel, FraudLog
|
||||||
|
|
||||||
|
|
||||||
|
async def detect_fraud(hotel_id, period="day"):
|
||||||
|
"""
|
||||||
|
Сравнивает данные из PMS и QR базы для обнаружения FRAUD-действий.
|
||||||
|
:param hotel_id: ID отеля для анализа.
|
||||||
|
:param period: Период времени для анализа (day, week, month).
|
||||||
|
"""
|
||||||
|
now = datetime.now()
|
||||||
|
if period == "day":
|
||||||
|
start_date = now - timedelta(days=1)
|
||||||
|
elif period == "week":
|
||||||
|
start_date = now - timedelta(weeks=1)
|
||||||
|
elif period == "month":
|
||||||
|
start_date = now - timedelta(days=30)
|
||||||
|
else:
|
||||||
|
start_date = None
|
||||||
|
|
||||||
|
end_date = now
|
||||||
|
|
||||||
|
# Данные из PMS
|
||||||
|
reservations = await sync_to_async(list)(
|
||||||
|
Reservation.objects.filter(
|
||||||
|
hotel_id=hotel_id,
|
||||||
|
check_in__date__range=(start_date, end_date)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Данные из QR-системы
|
||||||
|
qr_checkins = []
|
||||||
|
with connections['wordpress'].cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT reservation_id, guest_name, check_in_date FROM qr_checkins WHERE check_in_date BETWEEN %s AND %s",
|
||||||
|
[start_date, end_date]
|
||||||
|
)
|
||||||
|
qr_checkins = cursor.fetchall()
|
||||||
|
|
||||||
|
qr_checkins_set = {row[0] for row in qr_checkins} # Множество reservation_id
|
||||||
|
|
||||||
|
# Сравнение данных
|
||||||
|
for res in reservations:
|
||||||
|
if res.id not in qr_checkins_set:
|
||||||
|
# Записываем FRAUD
|
||||||
|
await sync_to_async(FraudLog.objects.create)(
|
||||||
|
hotel_id=hotel_id,
|
||||||
|
reservation_id=res.id,
|
||||||
|
guest_name=res.guest_name,
|
||||||
|
check_in_date=res.check_in.date(),
|
||||||
|
message="Проверка на QR-систему провалилась."
|
||||||
|
)
|
||||||
28
bot/utils/notifications.py
Normal file
28
bot/utils/notifications.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
from telegram import Bot
|
||||||
|
from django.core.mail import send_mail
|
||||||
|
|
||||||
|
async def send_telegram_notification(user, message):
|
||||||
|
"""Отправка уведомления через Telegram."""
|
||||||
|
if user.chat_id:
|
||||||
|
try:
|
||||||
|
bot = Bot(token="ВАШ_ТОКЕН")
|
||||||
|
await bot.send_message(chat_id=user.chat_id, text=message)
|
||||||
|
print(f"Telegram-уведомление отправлено пользователю {user.chat_id}: {message}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Ошибка отправки Telegram-уведомления пользователю {user.chat_id}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def send_email_notification(user, message):
|
||||||
|
"""Отправка уведомления через Email."""
|
||||||
|
if user.email:
|
||||||
|
try:
|
||||||
|
send_mail(
|
||||||
|
subject="Уведомление от системы",
|
||||||
|
message=message,
|
||||||
|
from_email="noreply@yourdomain.com",
|
||||||
|
recipient_list=[user.email],
|
||||||
|
fail_silently=False,
|
||||||
|
)
|
||||||
|
print(f"Email-уведомление отправлено на {user.email}: {message}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Ошибка отправки Email-уведомления пользователю {user.email}: {e}")
|
||||||
119
bot/utils/pdf_report.py
Normal file
119
bot/utils/pdf_report.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
from fpdf import FPDF
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from asgiref.sync import sync_to_async
|
||||||
|
from django.utils.timezone import make_aware, is_aware, localtime
|
||||||
|
import pytz
|
||||||
|
from bot.utils.date_utils import ensure_datetime
|
||||||
|
from touchh.utils.log import CustomLogger
|
||||||
|
|
||||||
|
logger = CustomLogger(name="CustomPDF Report", log_level="DEBUG").get_logger()
|
||||||
|
|
||||||
|
# Определение абсолютного пути к папке "reports"
|
||||||
|
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
REPORTS_DIR = os.path.join(BASE_DIR, "reports")
|
||||||
|
os.makedirs(REPORTS_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
@sync_to_async
|
||||||
|
def get_reservation_data(res):
|
||||||
|
check_in = ensure_datetime(res.check_in)
|
||||||
|
check_out = ensure_datetime(res.check_out)
|
||||||
|
|
||||||
|
if not check_in or not check_out:
|
||||||
|
raise ValueError(f"Некорректные даты бронирования: check_in={res.check_in}, check_out={res.check_out}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"hotel_name": res.hotel.name,
|
||||||
|
"pms": getattr(res.hotel, 'pms', 'N/A'),
|
||||||
|
"reservation_id": res.reservation_id,
|
||||||
|
"room_number": res.room_number if res.room_number else "Не указан",
|
||||||
|
"room_type": res.room_type,
|
||||||
|
"check_in": check_in,
|
||||||
|
"check_out": check_out,
|
||||||
|
"status": res.status,
|
||||||
|
}
|
||||||
|
|
||||||
|
def sanitize_text(text):
|
||||||
|
return text.replace("\n", " ").strip() if isinstance(text, str) else text
|
||||||
|
|
||||||
|
class CustomPDF(FPDF):
|
||||||
|
def __init__(self, hotel_name, start_date, end_date, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.font_folder = "bot/fonts/"
|
||||||
|
self.add_font("DejaVuSans-Bold", "", os.path.join(self.font_folder, "DejaVuSans-Bold.ttf"), uni=True)
|
||||||
|
self.add_font("DejaVuSans", "", os.path.join(self.font_folder, "DejaVuSans.ttf"), uni=True)
|
||||||
|
self.creation_date = ensure_datetime(datetime.now(pytz.UTC))
|
||||||
|
self.hotel_name = hotel_name
|
||||||
|
self.start_date = start_date
|
||||||
|
self.end_date = end_date
|
||||||
|
|
||||||
|
def header(self):
|
||||||
|
if self.page == 1:
|
||||||
|
self.set_font("DejaVuSans-Bold", size=14)
|
||||||
|
self.cell(0, 10, f"Отчет о бронированиях отеля {self.hotel_name}", ln=1, align="C")
|
||||||
|
self.ln(5)
|
||||||
|
self.set_font("DejaVuSans", size=10)
|
||||||
|
self.cell(
|
||||||
|
0,
|
||||||
|
10,
|
||||||
|
f"за период {self.start_date.strftime('%Y-%m-%d %H:%M:%S')} - {self.end_date.strftime('%Y-%m-%d %H:%M:%S')}",
|
||||||
|
ln=1,
|
||||||
|
align="C"
|
||||||
|
)
|
||||||
|
self.ln(10)
|
||||||
|
|
||||||
|
def footer(self):
|
||||||
|
self.set_y(-15)
|
||||||
|
self.set_font("DejaVuSans", size=8)
|
||||||
|
self.cell(60, 10, f"Copyright (C) 2024 by Touchh", align="L")
|
||||||
|
self.cell(0, 10, f"Лист {self.page_no()} из {{nb}} / Дата генерации отчета: {self.creation_date}", align="C")
|
||||||
|
|
||||||
|
async def generate_pdf_report(hotel_name, reservations, start_date, end_date):
|
||||||
|
start_date = ensure_datetime(start_date)
|
||||||
|
end_date = ensure_datetime(end_date)
|
||||||
|
|
||||||
|
logger.debug(f"Start_DATE: {start_date} / TYPE: {type(start_date)}")
|
||||||
|
logger.debug(f"END_DATE: {end_date} / TYPE: {type(end_date)}")
|
||||||
|
|
||||||
|
if not start_date or not end_date:
|
||||||
|
raise ValueError("Некорректные даты для генерации отчета.")
|
||||||
|
|
||||||
|
pdf = CustomPDF(hotel_name=hotel_name, start_date=start_date, end_date=end_date, orientation="L", unit="mm", format="A4")
|
||||||
|
pdf.alias_nb_pages()
|
||||||
|
pdf.add_page()
|
||||||
|
|
||||||
|
pdf.set_font("DejaVuSans", size=8)
|
||||||
|
col_widths = [30, 30, 30, 60, 35, 35, 30]
|
||||||
|
row_height = 10
|
||||||
|
|
||||||
|
for res in reservations:
|
||||||
|
try:
|
||||||
|
res_data = await get_reservation_data(res)
|
||||||
|
res_data["check_in"] = ensure_datetime(res_data["check_in"])
|
||||||
|
res_data["check_out"] = ensure_datetime(res_data["check_out"])
|
||||||
|
|
||||||
|
row_data = [
|
||||||
|
sanitize_text(res_data["hotel_name"]),
|
||||||
|
sanitize_text(str(res_data["reservation_id"])),
|
||||||
|
sanitize_text(str(res_data["room_number"])),
|
||||||
|
sanitize_text(str(res_data["room_type"])),
|
||||||
|
res_data["check_in"].strftime('%Y-%m-%d %H:%M:%S'),
|
||||||
|
res_data["check_out"].strftime('%Y-%m-%d %H:%M:%S'),
|
||||||
|
sanitize_text(res_data["status"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
for col_width, data in zip(col_widths, row_data):
|
||||||
|
pdf.cell(col_width, row_height, data, border=1, align="C")
|
||||||
|
pdf.ln()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[ERROR] Error processing reservation {res.id}: {e}")
|
||||||
|
|
||||||
|
pdf_output_path = os.path.join(REPORTS_DIR, f"{hotel_name.replace(' ', '_')}_report_{start_date.strftime('%Y-%m-%d')}-{end_date.strftime('%Y-%m-%d')}.pdf")
|
||||||
|
logger.debug(f"PDF output path: {pdf_output_path}")
|
||||||
|
|
||||||
|
pdf.output(pdf_output_path)
|
||||||
|
|
||||||
|
if not os.path.exists(pdf_output_path):
|
||||||
|
raise RuntimeError(f"PDF file was not created at: {pdf_output_path}")
|
||||||
|
|
||||||
|
return pdf_output_path
|
||||||
3
bot/views.py
Normal file
3
bot/views.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.shortcuts import render
|
||||||
|
|
||||||
|
# Create your views here.
|
||||||
36
docker-compose.yaml
Normal file
36
docker-compose.yaml
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: mariadb:11.6
|
||||||
|
restart: on-failure
|
||||||
|
environment:
|
||||||
|
- MYSQL_RANDOM_ROOT_PASSWORD=1
|
||||||
|
- 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:
|
||||||
|
&py_service
|
||||||
|
build: .
|
||||||
|
image: touchh-py
|
||||||
|
restart: on-failure
|
||||||
|
command: ['python3', 'manage.py', 'run_bot']
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
stop_signal: SIGINT
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
scheduler:
|
||||||
|
<<: *py_service
|
||||||
|
command: ['python3', 'manage.py', 'start_scheduler']
|
||||||
|
web:
|
||||||
|
<<: *py_service
|
||||||
|
command: ['python3', 'manage.py', 'runserver', '0.0.0.0:8000']
|
||||||
|
ports:
|
||||||
|
- "${DOCKER_HTTP_BIND:-8000}:8000"
|
||||||
BIN
fonts/OpenSans-Regular.cw127.pkl
Normal file
BIN
fonts/OpenSans-Regular.cw127.pkl
Normal file
Binary file not shown.
BIN
fonts/OpenSans-Regular.pkl
Normal file
BIN
fonts/OpenSans-Regular.pkl
Normal file
Binary file not shown.
BIN
fonts/OpenSans-Regular.ttf
Normal file
BIN
fonts/OpenSans-Regular.ttf
Normal file
Binary file not shown.
0
hotels/__init__.py
Normal file
0
hotels/__init__.py
Normal file
103
hotels/admin.py
Normal file
103
hotels/admin.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
from django import forms
|
||||||
|
from django.contrib import admin
|
||||||
|
from .models import (
|
||||||
|
Hotel,
|
||||||
|
UserHotel,
|
||||||
|
APIConfiguration,
|
||||||
|
Reservation,
|
||||||
|
Room
|
||||||
|
)
|
||||||
|
from django.urls import path
|
||||||
|
from django.shortcuts import redirect
|
||||||
|
from django.utils.html import format_html
|
||||||
|
from pms_integration.api_client import APIClient
|
||||||
|
|
||||||
|
# Custom form for Hotel to filter APIConfiguration
|
||||||
|
class HotelForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Hotel
|
||||||
|
fields = "__all__"
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
# Exclude APIs already linked to another hotel
|
||||||
|
used_apis = Hotel.objects.exclude(api__isnull=True).values_list('api', flat=True)
|
||||||
|
self.fields['api'].queryset = APIConfiguration.objects.exclude(id__in=used_apis)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Hotel)
|
||||||
|
class HotelAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ['name', 'hotel_id','room_count', 'pms', 'timezone', 'description']
|
||||||
|
list_filter = ['name', 'pms', 'timezone']
|
||||||
|
list_sorting = ['name', 'pms', 'room_count', 'timezone']
|
||||||
|
def sync_button(self, obj):
|
||||||
|
return format_html(
|
||||||
|
'<a class="button" href="{}">Синхронизировать</a>',
|
||||||
|
f"/admin/hotels/sync/{obj.id}/"
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_urls(self):
|
||||||
|
urls = super().get_urls()
|
||||||
|
custom_urls = [
|
||||||
|
path('sync/<int:hotel_id>/', self.sync_hotel_data),
|
||||||
|
]
|
||||||
|
return custom_urls + urls
|
||||||
|
def room_count(self, obj):
|
||||||
|
"""
|
||||||
|
Подсчитывает количество комнат, связанных с отелем.
|
||||||
|
"""
|
||||||
|
return Room.objects.filter(hotel=obj).count()
|
||||||
|
room_count.short_description = "Количество комнат"
|
||||||
|
|
||||||
|
def sync_button(self, obj):
|
||||||
|
"""
|
||||||
|
Кнопка синхронизации данных.
|
||||||
|
"""
|
||||||
|
return format_html(
|
||||||
|
'<a class="button" href="{}">Синхронизировать</a>',
|
||||||
|
f"/admin/hotels/sync/{obj.id}/"
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_urls(self):
|
||||||
|
"""
|
||||||
|
Добавление кастомного URL для синхронизации.
|
||||||
|
"""
|
||||||
|
urls = super().get_urls()
|
||||||
|
custom_urls = [
|
||||||
|
path('sync/<int:hotel_id>/', self.sync_hotel_data),
|
||||||
|
]
|
||||||
|
return custom_urls + urls
|
||||||
|
|
||||||
|
def sync_hotel_data(self, request, hotel_id):
|
||||||
|
"""
|
||||||
|
Метод синхронизации данных отеля.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
hotel = Hotel.objects.get(id=hotel_id)
|
||||||
|
client = APIClient(hotel.pms)
|
||||||
|
client.run(hotel)
|
||||||
|
self.message_user(request, f"Данные отеля {hotel.name} успешно синхронизированы.")
|
||||||
|
except Exception as e:
|
||||||
|
self.message_user(request, f"Ошибка: {str(e)}", level="error")
|
||||||
|
return redirect("..")
|
||||||
|
|
||||||
|
@admin.register(UserHotel)
|
||||||
|
class UserHotelAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('user', 'hotel')
|
||||||
|
search_fields = ('user__username', 'hotel__name')
|
||||||
|
list_filter = ('hotel',)
|
||||||
|
ordering = ('-hotel',)
|
||||||
|
|
||||||
|
@admin.register(Reservation)
|
||||||
|
class ReservationAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('reservation_id', 'hotel', 'room_number', 'room_type', 'check_in', 'check_out', 'status', 'fraud_checked')
|
||||||
|
search_fields = ('hotel', 'room_number', 'room_type', 'check_in', 'check_out', 'status')
|
||||||
|
list_filter = ('hotel', 'room_number', 'room_type', 'check_in', 'check_out', 'status')
|
||||||
|
ordering = ('-check_in',)
|
||||||
|
|
||||||
|
@admin.register(Room)
|
||||||
|
class RoomAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('hotel', 'number', 'external_id', 'description', 'created_at', 'updated_at')
|
||||||
|
search_fields = ('hotel', 'number', 'external_id', 'description')
|
||||||
|
list_filter = ('hotel', 'number', 'external_id','description', 'created_at', 'updated_at')
|
||||||
|
ordering = ('-hotel', '-number')
|
||||||
7
hotels/apps.py
Normal file
7
hotels/apps.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class HotelsConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'hotels'
|
||||||
|
verbose_name="Отели"
|
||||||
138
hotels/migrations/0001_initial.py
Normal file
138
hotels/migrations/0001_initial.py
Normal file
File diff suppressed because one or more lines are too long
47
hotels/migrations/0002_initial.py
Normal file
47
hotels/migrations/0002_initial.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# Generated by Django 5.1.4 on 2024-12-25 04:55
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('hotels', '0001_initial'),
|
||||||
|
('pms_integration', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='hotel',
|
||||||
|
name='pms',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='pms_integration.pmsconfiguration', verbose_name='PMS система'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='fraudlog',
|
||||||
|
name='hotel',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='frauds', to='hotels.hotel'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='reservation',
|
||||||
|
name='hotel',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='hotels.hotel', verbose_name='Отель'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='guest',
|
||||||
|
name='reservation',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='guests', to='hotels.reservation', verbose_name='Бронирование'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='room',
|
||||||
|
name='hotel',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rooms', to='hotels.hotel', verbose_name='Отель'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='userhotel',
|
||||||
|
name='hotel',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='hotel_users', to='hotels.hotel', verbose_name='Отель'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Generated by Django 5.1.4 on 2024-12-25 05:10
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('hotels', '0002_initial'),
|
||||||
|
('users', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name='hotel',
|
||||||
|
old_name='external_id',
|
||||||
|
new_name='external_id_pms',
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='userhotel',
|
||||||
|
name='user',
|
||||||
|
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='user_hotels', to='users.user', verbose_name='Пользователь'),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='apirequestlog',
|
||||||
|
index=models.Index(fields=['api'], name='hotels_apir_api_id_686bb0_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='apirequestlog',
|
||||||
|
index=models.Index(fields=['request_time'], name='hotels_apir_request_f65147_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='fraudlog',
|
||||||
|
index=models.Index(fields=['reservation_id'], name='hotels_frau_reserva_5a26b7_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='fraudlog',
|
||||||
|
index=models.Index(fields=['detected_at'], name='hotels_frau_detecte_07e626_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='reservation',
|
||||||
|
index=models.Index(fields=['hotel', 'check_in', 'check_out'], name='hotels_rese_hotel_i_6c527e_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='room',
|
||||||
|
index=models.Index(fields=['hotel', 'number'], name='hotels_room_hotel_i_a7c4fc_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='room',
|
||||||
|
constraint=models.UniqueConstraint(fields=('hotel', 'number'), name='unique_hotel_room'),
|
||||||
|
),
|
||||||
|
]
|
||||||
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='Проверено на несоответствия'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 5.1.4 on 2025-02-02 00:58
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('hotels', '0004_reservation_fraud_checked'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='reservation',
|
||||||
|
name='check_in',
|
||||||
|
field=models.DateTimeField(blank=True, null=True, verbose_name='Дата заезда'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='reservation',
|
||||||
|
name='check_out',
|
||||||
|
field=models.DateTimeField(blank=True, null=True, verbose_name='Дата выезда'),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
hotels/migrations/__init__.py
Normal file
0
hotels/migrations/__init__.py
Normal file
194
hotels/models.py
Normal file
194
hotels/models.py
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
from django.db import models
|
||||||
|
from django.core.validators import MinValueValidator, MaxValueValidator, RegexValidator
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
import pytz
|
||||||
|
|
||||||
|
|
||||||
|
class APIConfiguration(models.Model):
|
||||||
|
name = models.CharField(max_length=255, verbose_name="Название API")
|
||||||
|
url = models.URLField(verbose_name="URL API")
|
||||||
|
token = models.CharField(max_length=255, blank=True, null=True, verbose_name="Токен")
|
||||||
|
username = models.CharField(max_length=255, blank=True, null=True, verbose_name="Логин")
|
||||||
|
password = models.CharField(max_length=255, blank=True, null=True, verbose_name="Пароль")
|
||||||
|
last_updated = models.DateTimeField(auto_now=True, verbose_name="Дата последнего обновления")
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Конфигурация API"
|
||||||
|
verbose_name_plural = "Конфигурации API"
|
||||||
|
|
||||||
|
|
||||||
|
class Hotel(models.Model):
|
||||||
|
name = models.CharField(max_length=255, verbose_name="Название отеля")
|
||||||
|
hotel_id = models.CharField(max_length=255, unique=True, null=True, blank=True, verbose_name="ID отеля")
|
||||||
|
external_id_pms = models.CharField(max_length=100, unique=False, null=True, blank=True, verbose_name="Внешний PMS ID")
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Создан")
|
||||||
|
phone = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Телефон",
|
||||||
|
validators=[
|
||||||
|
RegexValidator(regex=r'^\+?1?\d{9,15}$', message="Введите корректный номер телефона (до 15 цифр).")
|
||||||
|
],
|
||||||
|
)
|
||||||
|
email = models.EmailField(null=True, blank=True, verbose_name="Email")
|
||||||
|
address = models.CharField(max_length=255, null=True, blank=True, verbose_name="Адрес")
|
||||||
|
city = models.CharField(max_length=255, null=True, blank=True, verbose_name="Город")
|
||||||
|
timezone = models.CharField(
|
||||||
|
max_length=63,
|
||||||
|
choices=[(tz, tz) for tz in pytz.all_timezones],
|
||||||
|
default='UTC',
|
||||||
|
verbose_name="Часовой пояс",
|
||||||
|
)
|
||||||
|
description = models.TextField(null=True, blank=True, verbose_name="Описание")
|
||||||
|
|
||||||
|
pms = models.ForeignKey(
|
||||||
|
'pms_integration.PMSConfiguration',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="PMS система"
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Отель"
|
||||||
|
verbose_name_plural = "Отели"
|
||||||
|
|
||||||
|
|
||||||
|
class Room(models.Model):
|
||||||
|
hotel = models.ForeignKey(Hotel, on_delete=models.CASCADE, related_name="rooms", verbose_name="Отель")
|
||||||
|
number = models.CharField(max_length=50, unique=True, verbose_name="Номер комнаты")
|
||||||
|
external_id = models.CharField(max_length=255, unique=True, verbose_name="Внешний ID комнаты")
|
||||||
|
description = models.TextField(blank=True, null=True, verbose_name="Описание")
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
|
||||||
|
updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления")
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.hotel.name} - {self.number}"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Номер"
|
||||||
|
verbose_name_plural = "Номера"
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(fields=["hotel", "number"], name="unique_hotel_room")
|
||||||
|
]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["hotel", "number"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class UserHotel(models.Model):
|
||||||
|
user = models.ForeignKey(
|
||||||
|
'users.User',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="user_hotels",
|
||||||
|
verbose_name="Пользователь"
|
||||||
|
)
|
||||||
|
hotel = models.ForeignKey(
|
||||||
|
Hotel,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="hotel_users",
|
||||||
|
verbose_name="Отель"
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user.username} - {self.hotel.name}"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Пользователь отеля"
|
||||||
|
verbose_name_plural = "Пользователи отелей"
|
||||||
|
|
||||||
|
|
||||||
|
class Reservation(models.Model):
|
||||||
|
id = models.BigAutoField(primary_key=True, auto_created=True, verbose_name="ID")
|
||||||
|
hotel = models.ForeignKey(Hotel, on_delete=models.CASCADE, verbose_name="Отель")
|
||||||
|
reservation_id = models.BigIntegerField(unique=True, verbose_name="ID бронирования")
|
||||||
|
room_number = models.CharField(max_length=255, null=True, blank=True, verbose_name="Номер комнаты")
|
||||||
|
room_type = models.CharField(max_length=255, verbose_name="Тип комнаты")
|
||||||
|
check_in = models.DateTimeField(verbose_name="Дата заезда", null=True, blank=True)
|
||||||
|
check_out = models.DateTimeField(verbose_name="Дата выезда", null=True, blank=True)
|
||||||
|
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:
|
||||||
|
raise ValidationError("Дата выезда должна быть позже даты заезда.")
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Бронирование {self.reservation_id} - {self.hotel.name}"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Бронирование"
|
||||||
|
verbose_name_plural = "Бронирования"
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["hotel", "check_in", "check_out"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class Guest(models.Model):
|
||||||
|
reservation = models.ForeignKey(Reservation, on_delete=models.CASCADE, related_name="guests", verbose_name="Бронирование")
|
||||||
|
name = models.CharField(max_length=255, verbose_name="Имя гостя")
|
||||||
|
birthdate = models.DateField(null=True, blank=True, verbose_name="Дата рождения")
|
||||||
|
phone = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Телефон",
|
||||||
|
validators=[
|
||||||
|
RegexValidator(regex=r'^\+?1?\d{9,15}$', message="Введите корректный номер телефона (до 15 цифр).")
|
||||||
|
],
|
||||||
|
)
|
||||||
|
email = models.EmailField(null=True, blank=True, verbose_name="Email")
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.name} ({self.birthdate})" if self.birthdate else self.name
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Гость"
|
||||||
|
verbose_name_plural = "Гости"
|
||||||
|
|
||||||
|
|
||||||
|
class FraudLog(models.Model):
|
||||||
|
hotel = models.ForeignKey(Hotel, on_delete=models.CASCADE, related_name="frauds")
|
||||||
|
reservation_id = models.BigIntegerField(verbose_name="ID бронирования")
|
||||||
|
guest_name = models.CharField(max_length=255, null=True, blank=True)
|
||||||
|
check_in_date = models.DateField(verbose_name="Дата заезда")
|
||||||
|
detected_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата обнаружения")
|
||||||
|
message = models.TextField(verbose_name="Сообщение")
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"FRAUD: {self.guest_name} ({self.check_in_date})"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Журнал мошенничества"
|
||||||
|
verbose_name_plural = "Журналы мошенничества"
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["reservation_id"]),
|
||||||
|
models.Index(fields=["detected_at"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class APIRequestLog(models.Model):
|
||||||
|
api = models.ForeignKey(APIConfiguration, on_delete=models.CASCADE, verbose_name="API")
|
||||||
|
request_time = models.DateTimeField(auto_now_add=True, verbose_name="Время запроса")
|
||||||
|
response_status = models.IntegerField(verbose_name="HTTP статус ответа", validators=[MinValueValidator(100), MaxValueValidator(599)])
|
||||||
|
response_data = models.JSONField(verbose_name="Данные ответа", blank=True, null=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.api.name} - {self.request_time}"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Журнал запросов API"
|
||||||
|
verbose_name_plural = "Журналы запросов API"
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["api"]),
|
||||||
|
models.Index(fields=["request_time"]),
|
||||||
|
]
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user