master #2

Open
trevor wants to merge 0 commits from master into main
491 changed files with 119148 additions and 1 deletions

View File

@@ -0,0 +1,6 @@
.venv
.venv/
.log
__pycache__
.history
.vscode

30
.docker/admin/Dockerfile Normal file
View File

@@ -0,0 +1,30 @@
FROM python:3.9-alpine
WORKDIR /app
# Устанавливаем временную директорию
ENV TMPDIR=/tmp/tempdir
RUN mkdir -p $TMPDIR && chmod 1777 $TMPDIR
# Устанавливаем системные зависимости для Alpine
RUN apk add --no-cache \
gcc \
musl-dev \
mariadb-dev \
netcat-openbsd \
net-tools \
iputils
# Копируем только requirements.txt для кэширования зависимостей
COPY .docker/admin/requirements.txt /app/requirements.txt
# Устанавливаем Python-зависимости
RUN pip install --upgrade pip && pip install --no-cache-dir -r /app/requirements.txt
# Копируем весь проект
COPY . /app
RUN chmod +x .docker/admin/entrypoint.sh
ENTRYPOINT [".docker/admin/entrypoint.sh"]
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]

14
.docker/admin/entrypoint.sh Executable file
View File

@@ -0,0 +1,14 @@
#!/bin/sh
# Ожидание доступности базы данных
until nc -z -v -w30 $DB_HOST $DB_PORT; do
echo "Ожидание базы данных..."
sleep 1
done
# Выполняем миграции
python manage.py makemigrations --no-input
python manage.py migrate --no-input
# Запускаем приложение
exec "$@"

View File

@@ -0,0 +1,44 @@
ace_tools
aiohappyeyeballs
aiohttp
aiosignal
APScheduler
Django
django-environ
django_extensions
django-filter
django-health-check
django-jazzmin
django-jet
et_xmlfile
fonttools
fpdf2
geoip2
git-filter-repo
httpcore
httpx
jsonschema
jsonschema-specifications
maxminddb
multidict
PyMySQL
numpy
openpyxl
pandas
pathspec
pillow
propcache
psycopg
PyMySQL
python-dateutil
python-decouple
python-dotenv
python-telegram-bot
PyYAML
requests
sqlparse
ua-parser
ua-parser-builtins
user-agents
yarl
cryptography

View File

@@ -0,0 +1,6 @@
.venv
.venv/
.log
__pycache__
.history
.vscode

12
.docker/bot/Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM python:3.10-slim
WORKDIR /app
# Копируем весь проект в контейнер
COPY ../../ /app
# Устанавливаем зависимости только для bot
RUN pip install --upgrade pip && pip install --no-cache-dir -r .docker/bot/requirements.txt
# Команда запуска для бота
CMD ["python", "manage.py" ,"run_bot.py"]

View File

@@ -0,0 +1,44 @@
ace_tools
aiohappyeyeballs
aiohttp
aiosignal
APScheduler
Django
django-environ
django_extensions
django-filter
django-health-check
django-jazzmin
django-jet
et_xmlfile
fonttools
fpdf2
geoip2
git-filter-repo
httpcore
httpx
jsonschema
jsonschema-specifications
maxminddb
multidict
PyMySQL
numpy
openpyxl
pandas
pathspec
pillow
propcache
psycopg
PyMySQL
python-dateutil
python-decouple
python-dotenv
python-telegram-bot
PyYAML
requests
sqlparse
ua-parser
ua-parser-builtins
user-agents
yarl
cryptography

View File

@@ -0,0 +1,14 @@
FROM python:3.10-slim
WORKDIR /app
# Копируем весь проект в контейнер
COPY ../../ /app
RUN chmod +x .docker/scheduler/entrypoint.sh
ENTRYPOINT [".docker/scheduler/entrypoint.sh"]
# Устанавливаем зависимости только для scheduler
RUN pip install --upgrade pip && pip install --no-cache-dir -r .docker/scheduler/requirements.txt
# Команда запуска для планировщика
CMD ["python", "manage.py", "run_scheduler"]

View File

@@ -0,0 +1,6 @@
.venv
.venv/
.log
__pycache__
.history
.vscode

View File

@@ -0,0 +1,7 @@
#!/bin/sh
# Выполняем миграции
python manage.py migrate --no-input
# Запускаем приложение
exec "$@"

View File

@@ -0,0 +1,44 @@
ace_tools
aiohappyeyeballs
aiohttp
aiosignal
APScheduler
Django
django-environ
django-extensions
django-filter
django-health-check
django-jazzmin
django-jet
et_xmlfile
fonttools
fpdf2
geoip2
git-filter-repo
httpcore
httpx
jsonschema
jsonschema-specifications
maxminddb
multidict
PyMySQL
numpy
openpyxl
pandas
pathspec
pillow
propcache
psycopg
PyMySQL
python-dateutil
python-decouple
python-dotenv
python-telegram-bot
PyYAML
requests
sqlparse
ua-parser
ua-parser-builtins
user-agents
yarl
cryptography

1
.dockerignore Normal file
View File

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

102
.drone.yml Normal file
View 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
View 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
View 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
View File

@@ -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
View File

250
antifroud/admin.py Normal file
View 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
View 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
View 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
View 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
View 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']

View File

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

View File

@@ -0,0 +1,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': 'Журналы нарушений',
},
),
]

View 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='Отель'),
),
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

240
antifroud/models.py Normal file
View 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 = "Журналы нарушений"

View File

@@ -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 %}

View 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 %}

View File

@@ -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 %}

View 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 %}

View 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
View File

11
antifroud/urls.py Normal file
View 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
View 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
View File

26
app_settings/admin.py Normal file
View 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']

View File

@@ -0,0 +1,38 @@
from decouple import config
from django.conf import settings
from django.apps import apps
def load_database_settings(databases):
"""
Загружает дополнительные базы данных из таблицы LocalDatabase и добавляет их в конфигурацию.
:param databases: Существующий словарь DATABASES
"""
LocalDatabase = apps.get_model('app_settings', 'LocalDatabase')
local_db_settings = LocalDatabase.objects.all()
for db in local_db_settings:
# Пример добавления дополнительной базы данных
settings.DATABASES[db.name] = {
'ENGINE': 'django.db.backends.mysql',
'NAME': db.db_name,
'USER': db.username,
'PASSWORD': db.password,
'HOST': db.host,
'PORT': db.port,
}
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
View 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="Настройки системы"

File diff suppressed because one or more lines are too long

View File

79
app_settings/models.py Normal file
View 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 = "Настройки системы"

9
app_settings/signals.py Normal file
View File

@@ -0,0 +1,9 @@
from django.db.models.signals import post_save
from django.dispatch import receiver
from .models import GlobalSystemSettings
@receiver(post_save, sender=GlobalSystemSettings)
def update_system_settings(sender, instance, **kwargs):
# Безопасное использование сигнала
if instance:
print(f"Настройки системы обновлены: {instance.system_name}")

3
app_settings/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

11
app_settings/urls.py Normal file
View File

@@ -0,0 +1,11 @@
from django.urls import path
from django.http import HttpResponse
app_name = 'settings'
def placeholder_view(request):
return HttpResponse("Placeholder for settings app.")
urlpatterns = [
path('', placeholder_view, name='settings_placeholder'),
]

3
app_settings/views.py Normal file
View File

@@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

3
bin/cli Executable file
View File

@@ -0,0 +1,3 @@
#!/usr/bin/env bash
docker compose exec web python3 manage.py "$@"

3
bin/pip3 Executable file
View File

@@ -0,0 +1,3 @@
#!/usr/bin/env bash
docker compose exec web pip3 "$@"

12
bin/update Executable file
View 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

13373
bnovo_page_1.json Normal file

File diff suppressed because it is too large Load Diff

0
bot/__init__.py Normal file
View File

3
bot/admin.py Normal file
View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

7
bot/apps.py Normal file
View File

@@ -0,0 +1,7 @@
from django.apps import AppConfig
class BotConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'bot'
verbose_name="Бот"

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
bot/fonts/DejaVuSans.pkl Normal file

Binary file not shown.

BIN
bot/fonts/DejaVuSans.ttf Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

123
bot/handlers.py Normal file
View 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
View 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
View 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

View File

Binary file not shown.

View 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()

View File

3
bot/models.py Normal file
View File

@@ -0,0 +1,3 @@
from django.db import models
# Create your models here.

28
bot/operations.py Normal file
View 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'))

View File

View 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
View 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)}: {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

View 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
View 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} настраивает время уведомлений.")

View 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
View 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
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

0
bot/urls.py Normal file
View File

0
bot/utils/__init__.py Normal file
View File

71
bot/utils/bot_setup.py Normal file
View 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
View 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
View 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
View 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-систему провалилась."
)

View 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
View 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
View File

@@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

Some files were not shown because too many files have changed in this diff Show More