From e76a80fb2fee75faa334816efaca019db16cac73 Mon Sep 17 00:00:00 2001 From: trevor Date: Mon, 9 Dec 2024 16:36:11 +0900 Subject: [PATCH] Shelter PMS fully functional --- .drone.yml | 3 + bot/handlers.py | 28 +++++ bot/operations/hotels.py | 85 +++++++++++-- hotels/admin.py | 40 ++++-- hotels/migrations/0001_initial.py | 33 ----- hotels/migrations/0002_initial.py | 22 ---- ...ptions_alter_userhotel_options_and_more.py | 58 --------- hotels/migrations/0004_datalog.py | 26 ---- ...figuration_apirequestlog_delete_datalog.py | 47 ------- ...iguration_remove_hotel_api_key_and_more.py | 45 ------- hotels/migrations/0007_pmsintegrationlog.py | 28 ----- hotels/migrations/0008_hotel_pms.py | 19 --- hotels/migrations/0009_alter_hotel_pms.py | 19 --- ..._hotels_apir_api_id_686bb0_idx_and_more.py | 33 ----- hotels/migrations/0011_reservation_guest.py | 48 ------- ...tel_role_alter_userhotel_hotel_and_more.py | 30 ----- ...ove_userhotel_role_alter_userhotel_user.py | 24 ---- .../migrations/0014_alter_userhotel_user.py | 20 --- .../migrations/0015_alter_userhotel_user.py | 20 --- .../migrations/0016_alter_userhotel_user.py | 20 --- ...er_userhotel_hotel_alter_userhotel_user.py | 25 ---- ...er_userhotel_hotel_alter_userhotel_user.py | 25 ---- ...019_alter_apirequestlog_response_status.py | 19 --- .../migrations/0020_alter_userhotel_user.py | 20 --- ...remove_pmsintegrationlog_hotel_and_more.py | 30 ----- hotels/migrations/__init__.py | 0 hotels/models.py | 2 +- pms_integration/admin.py | 49 +++++++- pms_integration/api_client.py | 92 ++++++++++++++ pms_integration/forms.py | 13 ++ pms_integration/manager.py | 58 +++++---- pms_integration/migrations/0001_initial.py | 11 +- pms_integration/models.py | 14 ++- pms_integration/plugins/base_plugin.py | 59 ++++++++- pms_integration/plugins/bnovo_pms.py | 41 ++++++ pms_integration/plugins/ecvi_pms.py | 38 +++++- pms_integration/plugins/shelter_pms.py | 119 ++++++++++++++---- .../templates/admin/check_plugins.html | 16 +++ reports/Golden Hills 3_report.pdf | Bin 0 -> 26284 bytes reports/Golden Hills 4*_report.pdf | Bin 14442 -> 43100 bytes requirements.txt | 1 + touchh/settings.py | 16 ++- users/migrations/0001_initial.py | 83 ++++++++++-- ...02_localuseractivitylog_useractivitylog.py | 56 --------- ...g_options_alter_user_confirmed_and_more.py | 44 ------- ...alter_userconfirmation_options_and_more.py | 66 ---------- users/migrations/0005_notificationsettings.py | 29 ----- 47 files changed, 665 insertions(+), 909 deletions(-) delete mode 100644 hotels/migrations/0001_initial.py delete mode 100644 hotels/migrations/0002_initial.py delete mode 100644 hotels/migrations/0003_alter_hotel_options_alter_userhotel_options_and_more.py delete mode 100644 hotels/migrations/0004_datalog.py delete mode 100644 hotels/migrations/0005_apiconfiguration_apirequestlog_delete_datalog.py delete mode 100644 hotels/migrations/0006_pmsconfiguration_remove_hotel_api_key_and_more.py delete mode 100644 hotels/migrations/0007_pmsintegrationlog.py delete mode 100644 hotels/migrations/0008_hotel_pms.py delete mode 100644 hotels/migrations/0009_alter_hotel_pms.py delete mode 100644 hotels/migrations/0010_apirequestlog_hotels_apir_api_id_686bb0_idx_and_more.py delete mode 100644 hotels/migrations/0011_reservation_guest.py delete mode 100644 hotels/migrations/0012_userhotel_role_alter_userhotel_hotel_and_more.py delete mode 100644 hotels/migrations/0013_remove_userhotel_role_alter_userhotel_user.py delete mode 100644 hotels/migrations/0014_alter_userhotel_user.py delete mode 100644 hotels/migrations/0015_alter_userhotel_user.py delete mode 100644 hotels/migrations/0016_alter_userhotel_user.py delete mode 100644 hotels/migrations/0017_alter_userhotel_hotel_alter_userhotel_user.py delete mode 100644 hotels/migrations/0018_alter_userhotel_hotel_alter_userhotel_user.py delete mode 100644 hotels/migrations/0019_alter_apirequestlog_response_status.py delete mode 100644 hotels/migrations/0020_alter_userhotel_user.py delete mode 100644 hotels/migrations/0021_alter_hotel_pms_remove_pmsintegrationlog_hotel_and_more.py delete mode 100644 hotels/migrations/__init__.py create mode 100644 pms_integration/api_client.py create mode 100644 pms_integration/forms.py create mode 100644 pms_integration/templates/admin/check_plugins.html create mode 100644 reports/Golden Hills 3_report.pdf delete mode 100644 users/migrations/0002_localuseractivitylog_useractivitylog.py delete mode 100644 users/migrations/0003_alter_useractivitylog_options_alter_user_confirmed_and_more.py delete mode 100644 users/migrations/0004_alter_user_options_alter_userconfirmation_options_and_more.py delete mode 100644 users/migrations/0005_notificationsettings.py diff --git a/.drone.yml b/.drone.yml index 89e94830..3df8768f 100644 --- a/.drone.yml +++ b/.drone.yml @@ -25,6 +25,9 @@ steps: commands: - python -m venv .venv - source .venv/bin/activate +1 PMS система + + - pip install --upgrade pip - pip install -r requirements.txt - python manage.py run_bot & # Запуск бота в фоне diff --git a/bot/handlers.py b/bot/handlers.py index 99f78f82..18fd7f4c 100644 --- a/bot/handlers.py +++ b/bot/handlers.py @@ -4,7 +4,11 @@ from bot.operations.hotels import manage_hotels, hotel_actions, delete_hotel, ch 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.""" @@ -93,3 +97,27 @@ async def navigate_back(update: Update, context: ContextTypes.DEFAULT_TYPE): 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)}") \ No newline at end of file diff --git a/bot/operations/hotels.py b/bot/operations/hotels.py index 3e7fa2f1..d0bc4b6e 100644 --- a/bot/operations/hotels.py +++ b/bot/operations/hotels.py @@ -2,6 +2,7 @@ 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 async def manage_hotels(update: Update, context): """Отображение списка отелей, связанных с пользователем.""" @@ -36,6 +37,7 @@ async def hotel_actions(update: Update, context): 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("Отель не найден.") @@ -67,22 +69,81 @@ async def delete_hotel(update: Update, context): await query.edit_message_text("Отель не найден.") -async def check_pms(update: Update, context): - """Проверить интеграцию с PMS.""" +from pms_integration.manager import PMSIntegrationManager +from asgiref.sync import sync_to_async +from hotels.models import Hotel + +# async def check_pms(update, context): +# query = update.callback_query + +# try: +# # Логирование callback_data +# hotel_id = query.data.split("_")[2] +# print(f"Selected hotel id: {hotel_id}") +# context.user_data["selected_hotel"] = hotel_id + +# # Инициализация менеджера PMS +# pms_manager = PMSIntegrationManager(hotel_id=hotel_id) +# print(f"Loaded hotel: {pms_manager.hotel}") +# await pms_manager.load_hotel() # Асинхронная загрузка отеля +# pms_manager.load_plugin() # Загрузка плагина + +# # Получение данных +# data = pms_manager.fetch_data() +# print(f'PMS_managerПолучено записей: {len(data)}\n\n\n___') +# print(f'Данные {data}\n\n\n') +# # Логирование +# await pms_manager.save_log("success", f"Успешная интеграция с PMS {pms_manager.pms_config.name}.") +# await query.edit_message_text(f"Интеграция успешна! Получено {len(data)} записей.") + +# except Exception as e: +# # Логирование ошибок +# if 'pms_manager' in locals() and pms_manager.hotel: +# await pms_manager.save_log("error", str(e)) +# await query.edit_message_text(f"❌ Ошибка: {e}") + +async def check_pms(update, context): query = update.callback_query - await query.answer() - hotel_id = int(query.data.split("_")[2]) - hotel = await sync_to_async(Hotel.objects.select_related('api', 'pms').get)(id=hotel_id) - if not hotel: - await query.edit_message_text("Отель не найден.") - return + try: + # Получение hotel_id из callback_data + hotel_id = query.data.split("_")[2] + print(f"Selected hotel id: {hotel_id}") + context.user_data["selected_hotel"] = hotel_id - api_name = hotel.api.name if hotel.api else "Не настроен" - pms_name = hotel.pms.name if hotel.pms else "Не указана" + # Инициализация менеджера PMS + pms_manager = PMSIntegrationManager(hotel_id=hotel_id) + print(f"Инициализация PMS менеджера для отеля ID: {hotel_id}") - status_message = f"Отель: {hotel.name}\nPMS система: {pms_name}\nAPI: {api_name}" - await query.edit_message_text(status_message) + # Загрузка данных отеля + await pms_manager.load_hotel() + print(f"Данные отеля загружены: {pms_manager.hotel}") + + # Загрузка плагина + pms_manager.load_plugin() + print(f"Плагин загружен: {pms_manager.plugin}") + + # Получение данных из PMS + data = pms_manager.fetch_data() + print(f"Данные получены из PMS: {len(data)} записей") + # print(f"Полные данные: {data}\n\n\n") + + # Сохранение лога успешной интеграции + await pms_manager.save_log("success", f"Успешная интеграция с PMS {pms_manager.pms_config.name}.") + + # Ответ пользователю + await query.edit_message_text(f"Интеграция успешна! Получено {len(data)} записей.") + + # Обработка данных и запись в БД + await pms_manager.plugin._save_to_db(data, hotel_id=int(hotel_id)) + print(f"Данные успешно сохранены в базу данных.") + + except Exception as e: + # Логирование ошибки + print(f"Ошибка при выполнении check_pms: {e}") + if 'pms_manager' in locals() and pms_manager.hotel: + await pms_manager.save_log("error", str(e)) + await query.edit_message_text(f"❌ Ошибка: {e}") async def setup_rooms(update: Update, context): diff --git a/hotels/admin.py b/hotels/admin.py index 88e57f40..9832381f 100644 --- a/hotels/admin.py +++ b/hotels/admin.py @@ -1,5 +1,5 @@ -from django.contrib import admin from django import forms +from django.contrib import admin from .models import ( Hotel, UserHotel, @@ -8,6 +8,10 @@ from .models import ( Reservation, Guest ) +from django.urls import path +from django.shortcuts import redirect +from django.utils.html import format_html +from pms_integration.api_client import APIClient # Custom form for Hotel to filter APIConfiguration class HotelForm(forms.ModelForm): @@ -22,22 +26,40 @@ class HotelForm(forms.ModelForm): self.fields['api'].queryset = APIConfiguration.objects.exclude(id__in=used_apis) +@admin.register(Hotel) class HotelAdmin(admin.ModelAdmin): - form = HotelForm - list_display = ('name', 'api', 'created_at', 'pms') - search_fields = ('name',) - list_filter = ('pms', 'created_at') - ordering = ('-created_at',) + list_display = ['name', 'pms', 'sync_button'] -admin.site.register(Hotel, HotelAdmin) + def sync_button(self, obj): + return format_html( + 'Синхронизировать', + f"/admin/hotels/sync/{obj.id}/" + ) + + def get_urls(self): + urls = super().get_urls() + custom_urls = [ + path('sync//', self.sync_hotel_data), + ] + return custom_urls + urls + + def sync_hotel_data(self, request, hotel_id): + try: + hotel = Hotel.objects.get(id=hotel_id) + client = APIClient(hotel.pms) + client.run(hotel) + self.message_user(request, f"Данные отеля {hotel.name} успешно синхронизированы.") + except Exception as e: + self.message_user(request, f"Ошибка: {str(e)}", level="error") + return redirect("..") @admin.register(UserHotel) class UserHotelAdmin(admin.ModelAdmin): list_display = ('user', 'hotel') search_fields = ('user__username', 'hotel__name') - list_filter = ('hotel',) - ordering = ('-hotel',) + # list_filter = ('hotel',) + # ordering = ('-hotel',) @admin.register(APIConfiguration) diff --git a/hotels/migrations/0001_initial.py b/hotels/migrations/0001_initial.py deleted file mode 100644 index 3df5b4c0..00000000 --- a/hotels/migrations/0001_initial.py +++ /dev/null @@ -1,33 +0,0 @@ -# Generated by Django 5.1.4 on 2024-12-05 23:39 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ] - - operations = [ - migrations.CreateModel( - name='Hotel', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=255, verbose_name='Hotel Name')), - ('pms_type', models.CharField(choices=[('bnovo', 'Bnovo'), ('travelline', 'Travel Line')], max_length=50, verbose_name='PMS Type')), - ('api_key', models.CharField(blank=True, max_length=255, null=True, verbose_name='API Key')), - ('public_key', models.CharField(blank=True, max_length=255, null=True, verbose_name='Public Key')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ], - ), - migrations.CreateModel( - name='UserHotel', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('hotel', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='hotels.hotel', verbose_name='Hotel')), - ], - ), - ] diff --git a/hotels/migrations/0002_initial.py b/hotels/migrations/0002_initial.py deleted file mode 100644 index bd71123e..00000000 --- a/hotels/migrations/0002_initial.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 5.1.4 on 2024-12-05 23:39 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('hotels', '0001_initial'), - ('users', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='userhotel', - name='user', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='users.user', verbose_name='User'), - ), - ] diff --git a/hotels/migrations/0003_alter_hotel_options_alter_userhotel_options_and_more.py b/hotels/migrations/0003_alter_hotel_options_alter_userhotel_options_and_more.py deleted file mode 100644 index f989f535..00000000 --- a/hotels/migrations/0003_alter_hotel_options_alter_userhotel_options_and_more.py +++ /dev/null @@ -1,58 +0,0 @@ -# Generated by Django 5.1.4 on 2024-12-06 04:26 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('hotels', '0002_initial'), - ('users', '0004_alter_user_options_alter_userconfirmation_options_and_more'), - ] - - operations = [ - migrations.AlterModelOptions( - name='hotel', - options={'verbose_name': 'Отель', 'verbose_name_plural': 'Отели'}, - ), - migrations.AlterModelOptions( - name='userhotel', - options={'verbose_name': 'Пользователь отеля', 'verbose_name_plural': 'Пользователи отелей'}, - ), - migrations.AlterField( - model_name='hotel', - name='api_key', - field=models.CharField(blank=True, max_length=255, null=True, verbose_name='API ключ'), - ), - migrations.AlterField( - model_name='hotel', - name='created_at', - field=models.DateTimeField(auto_now_add=True, verbose_name='Создан'), - ), - migrations.AlterField( - model_name='hotel', - name='name', - field=models.CharField(max_length=255, verbose_name='Название отеля'), - ), - migrations.AlterField( - model_name='hotel', - name='pms_type', - field=models.CharField(choices=[('bnovo', 'Bnovo'), ('travelline', 'Travel Line')], max_length=50, verbose_name='PMS система'), - ), - migrations.AlterField( - model_name='hotel', - name='public_key', - field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Публичный ключ'), - ), - migrations.AlterField( - model_name='userhotel', - name='hotel', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='hotels.hotel', verbose_name='Отель'), - ), - migrations.AlterField( - model_name='userhotel', - name='user', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='users.user', verbose_name='Пользователь'), - ), - ] diff --git a/hotels/migrations/0004_datalog.py b/hotels/migrations/0004_datalog.py deleted file mode 100644 index 9cc06059..00000000 --- a/hotels/migrations/0004_datalog.py +++ /dev/null @@ -1,26 +0,0 @@ -# Generated by Django 5.1.4 on 2024-12-06 13:41 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('hotels', '0003_alter_hotel_options_alter_userhotel_options_and_more'), - ] - - operations = [ - migrations.CreateModel( - name='DataLog', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=255, verbose_name='Название')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), - ('data', models.JSONField(verbose_name='Данные')), - ], - options={ - 'verbose_name': 'Лог данных', - 'verbose_name_plural': 'Логи данных', - }, - ), - ] diff --git a/hotels/migrations/0005_apiconfiguration_apirequestlog_delete_datalog.py b/hotels/migrations/0005_apiconfiguration_apirequestlog_delete_datalog.py deleted file mode 100644 index 9c670d56..00000000 --- a/hotels/migrations/0005_apiconfiguration_apirequestlog_delete_datalog.py +++ /dev/null @@ -1,47 +0,0 @@ -# Generated by Django 5.1.4 on 2024-12-06 13:51 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('hotels', '0004_datalog'), - ] - - operations = [ - migrations.CreateModel( - name='APIConfiguration', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=255, verbose_name='Название API')), - ('url', models.URLField(verbose_name='URL API')), - ('token', models.CharField(blank=True, max_length=255, null=True, verbose_name='Токен')), - ('username', models.CharField(blank=True, max_length=255, null=True, verbose_name='Логин')), - ('password', models.CharField(blank=True, max_length=255, null=True, verbose_name='Пароль')), - ('last_updated', models.DateTimeField(auto_now=True, verbose_name='Дата последнего обновления')), - ], - options={ - 'verbose_name': 'Конфигурация API', - 'verbose_name_plural': 'Конфигурации API', - }, - ), - migrations.CreateModel( - name='APIRequestLog', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('request_time', models.DateTimeField(auto_now_add=True, verbose_name='Время запроса')), - ('response_status', models.IntegerField(verbose_name='HTTP статус ответа')), - ('response_data', models.JSONField(blank=True, null=True, verbose_name='Данные ответа')), - ('api', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='hotels.apiconfiguration', verbose_name='API')), - ], - options={ - 'verbose_name': 'Журнал запросов API', - 'verbose_name_plural': 'Журналы запросов API', - }, - ), - migrations.DeleteModel( - name='DataLog', - ), - ] diff --git a/hotels/migrations/0006_pmsconfiguration_remove_hotel_api_key_and_more.py b/hotels/migrations/0006_pmsconfiguration_remove_hotel_api_key_and_more.py deleted file mode 100644 index c21e8176..00000000 --- a/hotels/migrations/0006_pmsconfiguration_remove_hotel_api_key_and_more.py +++ /dev/null @@ -1,45 +0,0 @@ -# Generated by Django 5.1.4 on 2024-12-06 14:09 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('hotels', '0005_apiconfiguration_apirequestlog_delete_datalog'), - ] - - operations = [ - migrations.CreateModel( - name='PMSConfiguration', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=255, verbose_name='Название PMS')), - ('parser_settings', models.JSONField(default=dict, verbose_name='Настройки разбора данных')), - ('description', models.TextField(blank=True, null=True, verbose_name='Описание')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), - ], - options={ - 'verbose_name': 'PMS система', - 'verbose_name_plural': 'PMS системы', - }, - ), - migrations.RemoveField( - model_name='hotel', - name='api_key', - ), - migrations.RemoveField( - model_name='hotel', - name='pms_type', - ), - migrations.RemoveField( - model_name='hotel', - name='public_key', - ), - migrations.AddField( - model_name='hotel', - name='api', - field=models.OneToOneField(blank=True, help_text='API, связанный с этим отелем.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='hotels.apiconfiguration', verbose_name='API'), - ), - ] diff --git a/hotels/migrations/0007_pmsintegrationlog.py b/hotels/migrations/0007_pmsintegrationlog.py deleted file mode 100644 index fe50d71e..00000000 --- a/hotels/migrations/0007_pmsintegrationlog.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 5.1.4 on 2024-12-06 23:02 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('hotels', '0006_pmsconfiguration_remove_hotel_api_key_and_more'), - ] - - operations = [ - migrations.CreateModel( - name='PMSIntegrationLog', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('checked_at', models.DateTimeField(auto_now_add=True, verbose_name='Время проверки')), - ('status', models.CharField(choices=[('success', 'Успех'), ('error', 'Ошибка')], max_length=50, verbose_name='Статус')), - ('message', models.TextField(blank=True, null=True, verbose_name='Сообщение')), - ('hotel', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='hotels.hotel', verbose_name='Отель')), - ], - options={ - 'verbose_name': 'Журнал интеграции PMS', - 'verbose_name_plural': 'Журналы интеграции PMS', - }, - ), - ] diff --git a/hotels/migrations/0008_hotel_pms.py b/hotels/migrations/0008_hotel_pms.py deleted file mode 100644 index cad2ce81..00000000 --- a/hotels/migrations/0008_hotel_pms.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 5.1.4 on 2024-12-06 23:38 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('hotels', '0007_pmsintegrationlog'), - ] - - operations = [ - migrations.AddField( - model_name='hotel', - name='pms', - field=models.OneToOneField(blank=True, help_text='PMS система? используемая в заведении', null=True, on_delete=django.db.models.deletion.SET_NULL, to='hotels.pmsconfiguration', verbose_name='PMS система'), - ), - ] diff --git a/hotels/migrations/0009_alter_hotel_pms.py b/hotels/migrations/0009_alter_hotel_pms.py deleted file mode 100644 index 9e98065b..00000000 --- a/hotels/migrations/0009_alter_hotel_pms.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 5.1.4 on 2024-12-06 23:57 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('hotels', '0008_hotel_pms'), - ] - - operations = [ - migrations.AlterField( - model_name='hotel', - name='pms', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='hotels.pmsconfiguration', verbose_name='PMS система'), - ), - ] diff --git a/hotels/migrations/0010_apirequestlog_hotels_apir_api_id_686bb0_idx_and_more.py b/hotels/migrations/0010_apirequestlog_hotels_apir_api_id_686bb0_idx_and_more.py deleted file mode 100644 index 38bb7e0a..00000000 --- a/hotels/migrations/0010_apirequestlog_hotels_apir_api_id_686bb0_idx_and_more.py +++ /dev/null @@ -1,33 +0,0 @@ -# Generated by Django 5.1.4 on 2024-12-07 00:00 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('hotels', '0009_alter_hotel_pms'), - ] - - operations = [ - migrations.AddIndex( - model_name='apirequestlog', - index=models.Index(fields=['api'], name='hotels_apir_api_id_686bb0_idx'), - ), - migrations.AddIndex( - model_name='apirequestlog', - index=models.Index(fields=['request_time'], name='hotels_apir_request_f65147_idx'), - ), - migrations.AddIndex( - model_name='pmsintegrationlog', - index=models.Index(fields=['hotel'], name='hotels_pmsi_hotel_i_718b32_idx'), - ), - migrations.AddIndex( - model_name='pmsintegrationlog', - index=models.Index(fields=['checked_at'], name='hotels_pmsi_checked_e7768d_idx'), - ), - migrations.AddIndex( - model_name='pmsintegrationlog', - index=models.Index(fields=['status'], name='hotels_pmsi_status_dbf1d8_idx'), - ), - ] diff --git a/hotels/migrations/0011_reservation_guest.py b/hotels/migrations/0011_reservation_guest.py deleted file mode 100644 index 5fa662bf..00000000 --- a/hotels/migrations/0011_reservation_guest.py +++ /dev/null @@ -1,48 +0,0 @@ -# Generated by Django 5.1.4 on 2024-12-07 00:35 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('hotels', '0010_apirequestlog_hotels_apir_api_id_686bb0_idx_and_more'), - ] - - operations = [ - migrations.CreateModel( - name='Reservation', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('reservation_id', models.BigIntegerField(unique=True, verbose_name='ID бронирования')), - ('room_number', models.CharField(max_length=50, verbose_name='Номер комнаты')), - ('room_type', models.CharField(max_length=255, verbose_name='Тип комнаты')), - ('check_in', models.DateTimeField(verbose_name='Дата заезда')), - ('check_out', models.DateTimeField(verbose_name='Дата выезда')), - ('status', models.CharField(max_length=50, verbose_name='Статус')), - ('price', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='Цена')), - ('discount', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='Скидка')), - ('hotel', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='hotels.hotel', verbose_name='Отель')), - ], - options={ - 'verbose_name': 'Бронирование', - 'verbose_name_plural': 'Бронирования', - }, - ), - migrations.CreateModel( - name='Guest', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=255, verbose_name='Имя гостя')), - ('birthdate', models.DateField(blank=True, null=True, verbose_name='Дата рождения')), - ('phone', models.CharField(blank=True, max_length=50, null=True, verbose_name='Телефон')), - ('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='Email')), - ('reservation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='guests', to='hotels.reservation', verbose_name='Бронирование')), - ], - options={ - 'verbose_name': 'Гость', - 'verbose_name_plural': 'Гости', - }, - ), - ] diff --git a/hotels/migrations/0012_userhotel_role_alter_userhotel_hotel_and_more.py b/hotels/migrations/0012_userhotel_role_alter_userhotel_hotel_and_more.py deleted file mode 100644 index 29d97fac..00000000 --- a/hotels/migrations/0012_userhotel_role_alter_userhotel_hotel_and_more.py +++ /dev/null @@ -1,30 +0,0 @@ -# Generated by Django 5.1.4 on 2024-12-07 06:28 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('hotels', '0011_reservation_guest'), - ('users', '0005_notificationsettings'), - ] - - operations = [ - migrations.AddField( - model_name='userhotel', - name='role', - field=models.CharField(choices=[('admin', 'Admin'), ('manager', 'Manager')], default='manager', max_length=50, verbose_name='Роль'), - ), - migrations.AlterField( - model_name='userhotel', - name='hotel', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='hotel_users', to='hotels.hotel', verbose_name='Отель'), - ), - migrations.AlterField( - model_name='userhotel', - name='user', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_hotels', to='users.user', verbose_name='Пользователь'), - ), - ] diff --git a/hotels/migrations/0013_remove_userhotel_role_alter_userhotel_user.py b/hotels/migrations/0013_remove_userhotel_role_alter_userhotel_user.py deleted file mode 100644 index 7655c612..00000000 --- a/hotels/migrations/0013_remove_userhotel_role_alter_userhotel_user.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 5.1.4 on 2024-12-07 07:04 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('hotels', '0012_userhotel_role_alter_userhotel_hotel_and_more'), - ('users', '0005_notificationsettings'), - ] - - operations = [ - migrations.RemoveField( - model_name='userhotel', - name='role', - ), - migrations.AlterField( - model_name='userhotel', - name='user', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='users.user', verbose_name='Пользователь'), - ), - ] diff --git a/hotels/migrations/0014_alter_userhotel_user.py b/hotels/migrations/0014_alter_userhotel_user.py deleted file mode 100644 index e7396f30..00000000 --- a/hotels/migrations/0014_alter_userhotel_user.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 5.1.4 on 2024-12-07 08:24 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('hotels', '0013_remove_userhotel_role_alter_userhotel_user'), - ('users', '0005_notificationsettings'), - ] - - operations = [ - migrations.AlterField( - model_name='userhotel', - name='user', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_hotels', to='users.user', verbose_name='Пользователь'), - ), - ] diff --git a/hotels/migrations/0015_alter_userhotel_user.py b/hotels/migrations/0015_alter_userhotel_user.py deleted file mode 100644 index 3c6a4a5b..00000000 --- a/hotels/migrations/0015_alter_userhotel_user.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 5.1.4 on 2024-12-07 08:29 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('hotels', '0014_alter_userhotel_user'), - ('users', '0005_notificationsettings'), - ] - - operations = [ - migrations.AlterField( - model_name='userhotel', - name='user', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='userhotels', to='users.user', verbose_name='Пользователь'), - ), - ] diff --git a/hotels/migrations/0016_alter_userhotel_user.py b/hotels/migrations/0016_alter_userhotel_user.py deleted file mode 100644 index e14c8459..00000000 --- a/hotels/migrations/0016_alter_userhotel_user.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 5.1.4 on 2024-12-07 08:30 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('hotels', '0015_alter_userhotel_user'), - ('users', '0005_notificationsettings'), - ] - - operations = [ - migrations.AlterField( - model_name='userhotel', - name='user', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_hotels', to='users.user', verbose_name='Пользователь'), - ), - ] diff --git a/hotels/migrations/0017_alter_userhotel_hotel_alter_userhotel_user.py b/hotels/migrations/0017_alter_userhotel_hotel_alter_userhotel_user.py deleted file mode 100644 index b420cb7a..00000000 --- a/hotels/migrations/0017_alter_userhotel_hotel_alter_userhotel_user.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 5.1.4 on 2024-12-07 08:31 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('hotels', '0016_alter_userhotel_user'), - ('users', '0005_notificationsettings'), - ] - - operations = [ - migrations.AlterField( - model_name='userhotel', - name='hotel', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='hotelusers', to='hotels.hotel', verbose_name='Отель'), - ), - migrations.AlterField( - model_name='userhotel', - name='user', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='userhotel', to='users.user', verbose_name='Пользователь'), - ), - ] diff --git a/hotels/migrations/0018_alter_userhotel_hotel_alter_userhotel_user.py b/hotels/migrations/0018_alter_userhotel_hotel_alter_userhotel_user.py deleted file mode 100644 index 57d4e89c..00000000 --- a/hotels/migrations/0018_alter_userhotel_hotel_alter_userhotel_user.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 5.1.4 on 2024-12-07 08:36 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('hotels', '0017_alter_userhotel_hotel_alter_userhotel_user'), - ('users', '0005_notificationsettings'), - ] - - operations = [ - migrations.AlterField( - model_name='userhotel', - name='hotel', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='hotel_users', to='hotels.hotel', verbose_name='Отель'), - ), - migrations.AlterField( - model_name='userhotel', - name='user', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_hotel', to='users.user', verbose_name='Пользователь'), - ), - ] diff --git a/hotels/migrations/0019_alter_apirequestlog_response_status.py b/hotels/migrations/0019_alter_apirequestlog_response_status.py deleted file mode 100644 index cdb4cd10..00000000 --- a/hotels/migrations/0019_alter_apirequestlog_response_status.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 5.1.4 on 2024-12-07 11:11 - -import django.core.validators -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('hotels', '0018_alter_userhotel_hotel_alter_userhotel_user'), - ] - - operations = [ - migrations.AlterField( - model_name='apirequestlog', - name='response_status', - field=models.IntegerField(validators=[django.core.validators.MinValueValidator(100), django.core.validators.MaxValueValidator(599)], verbose_name='HTTP статус ответа'), - ), - ] diff --git a/hotels/migrations/0020_alter_userhotel_user.py b/hotels/migrations/0020_alter_userhotel_user.py deleted file mode 100644 index b2a682df..00000000 --- a/hotels/migrations/0020_alter_userhotel_user.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 5.1.4 on 2024-12-07 14:09 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('hotels', '0019_alter_apirequestlog_response_status'), - ('users', '0005_notificationsettings'), - ] - - operations = [ - migrations.AlterField( - model_name='userhotel', - name='user', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_hotels', to='users.user', verbose_name='Пользователь'), - ), - ] diff --git a/hotels/migrations/0021_alter_hotel_pms_remove_pmsintegrationlog_hotel_and_more.py b/hotels/migrations/0021_alter_hotel_pms_remove_pmsintegrationlog_hotel_and_more.py deleted file mode 100644 index f42a574f..00000000 --- a/hotels/migrations/0021_alter_hotel_pms_remove_pmsintegrationlog_hotel_and_more.py +++ /dev/null @@ -1,30 +0,0 @@ -# Generated by Django 5.1.4 on 2024-12-08 23:48 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('hotels', '0020_alter_userhotel_user'), - ('pms_integration', '0001_initial'), - ] - - operations = [ - migrations.AlterField( - model_name='hotel', - name='pms', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='pms_integration.pmsconfiguration', verbose_name='PMS система'), - ), - migrations.RemoveField( - model_name='pmsintegrationlog', - name='hotel', - ), - migrations.DeleteModel( - name='PMSConfiguration', - ), - migrations.DeleteModel( - name='PMSIntegrationLog', - ), - ] diff --git a/hotels/migrations/__init__.py b/hotels/migrations/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/hotels/models.py b/hotels/models.py index 2e8e15f1..3a9af67a 100644 --- a/hotels/models.py +++ b/hotels/models.py @@ -30,7 +30,7 @@ class Hotel(models.Model): ) created_at = models.DateTimeField(auto_now_add=True, verbose_name="Создан") pms = models.ForeignKey( - 'pms_integration.PMSConfiguration', # Используем строковую ссылку + 'pms_integration.PMSConfiguration', on_delete=models.SET_NULL, null=True, blank=True, diff --git a/pms_integration/admin.py b/pms_integration/admin.py index aee974df..ccbcf45a 100644 --- a/pms_integration/admin.py +++ b/pms_integration/admin.py @@ -1,14 +1,59 @@ from django.contrib import admin -from .models import PMSConfiguration, PMSIntegrationLog # Register your models here. +from .manager import PluginLoader +from django.http import HttpResponseRedirect +from django.urls import path +from django.utils.html import format_html +from django.shortcuts import render +from django import forms +from pms_integration.models import PMSConfiguration, PMSIntegrationLog + +class PMSConfigurationForm(forms.ModelForm): + class Meta: + model = PMSConfiguration + fields = "__all__" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + plugins = PluginLoader.load_plugins() + self.fields['plugin_name'].choices = [(plugin, plugin) for plugin in plugins.keys()] @admin.register(PMSConfiguration) class PMSConfigurationAdmin(admin.ModelAdmin): - list_display = ('name', 'parser_settings', 'description', 'created_at') + form = PMSConfigurationForm + list_display = ('name', 'plugin_name', 'created_at', 'check_plugins_button') search_fields = ('name', 'description') list_filter = ('created_at',) ordering = ('-created_at',) + def get_urls(self): + """Добавляем URL для проверки плагинов.""" + urls = super().get_urls() + custom_urls = [ + path("check-plugins/", self.check_plugins, name="check-plugins"), + ] + return custom_urls + urls + + def check_plugins(self, request): + """Проверка и отображение плагинов.""" + plugins = PluginLoader.load_plugins() + plugin_details = [ + {"name": plugin_name, "doc": plugins[plugin_name].__doc__ or "Нет документации"} + for plugin_name in plugins + ] + context = { + "title": "Проверка плагинов", + "plugin_details": plugin_details, + } + return render(request, "admin/check_plugins.html", context) + + def check_plugins_button(self, obj): + """Добавляем кнопку для проверки плагинов.""" + return format_html( + 'Проверить плагины', + "/admin/pms_integration/pmsconfiguration/check-plugins/", + ) + check_plugins_button.short_description = "Проверить плагины" @admin.register(PMSIntegrationLog) class PMSIntegrationLogAdmin(admin.ModelAdmin): diff --git a/pms_integration/api_client.py b/pms_integration/api_client.py new file mode 100644 index 00000000..96f63ab2 --- /dev/null +++ b/pms_integration/api_client.py @@ -0,0 +1,92 @@ +import requests +from datetime import datetime + +class APIClient: + """ + Универсальный клиент для работы с API PMS. + """ + def __init__(self, base_url, access_token=None, username=None, password=None): + """ + Инициализация API клиента. + + :param base_url: Базовый URL API + :param access_token: Токен доступа (если требуется) + :param username: Имя пользователя (если используется basic auth) + :param password: Пароль (если используется basic auth) + """ + self.base_url = base_url + self.access_token = access_token + self.auth = (username, password) if username and password else None + + def _build_headers(self): + """ + Создает заголовки для запроса. + :return: Словарь заголовков. + """ + headers = {'Content-Type': 'application/json'} + if self.access_token: + headers['Authorization'] = f'Bearer {self.access_token}' + return headers + + def _make_request(self, method, endpoint, params=None, data=None): + """ + Выполняет запрос к API. + + :param method: HTTP метод ('GET', 'POST', и т.д.) + :param endpoint: Конечная точка API. + :param params: Параметры строки запроса (для GET). + :param data: Данные для тела запроса (для POST). + :return: Ответ API в формате JSON. + """ + url = f"{self.base_url}{endpoint}" + headers = self._build_headers() + try: + response = requests.request( + method=method, + url=url, + headers=headers, + params=params, + json=data, + auth=self.auth + ) + response.raise_for_status() # Генерирует исключение для HTTP ошибок + return response.json() + except requests.exceptions.RequestException as e: + print(f"Ошибка при запросе {method} {url}: {e}") + return None + + def get(self, endpoint, params=None): + """ + Выполняет GET запрос. + :param endpoint: Конечная точка API. + :param params: Параметры строки запроса. + :return: Ответ API в формате JSON. + """ + return self._make_request('GET', endpoint, params=params) + + def post(self, endpoint, data=None): + """ + Выполняет POST запрос. + :param endpoint: Конечная точка API. + :param data: Данные для тела запроса. + :return: Ответ API в формате JSON. + """ + return self._make_request('POST', endpoint, data=data) + + def fetch_reservations(self, endpoint, from_date, to_date, pagination_start=0, pagination_count=50): + """ + Получение данных о бронированиях. + + :param endpoint: Конечная точка API для бронирований. + :param from_date: Дата начала в формате 'YYYY-MM-DDTHH:MM:SS'. + :param to_date: Дата окончания в формате 'YYYY-MM-DDTHH:MM:SS'. + :param pagination_start: Начальная точка пагинации. + :param pagination_count: Количество записей для выборки. + :return: Ответ API в формате JSON. + """ + data = { + "from": from_date, + "until": to_date, + "pagination": {"from": pagination_start, "count": pagination_count} + } + return self.post(endpoint, data=data) diff --git a/pms_integration/forms.py b/pms_integration/forms.py new file mode 100644 index 00000000..6d052da7 --- /dev/null +++ b/pms_integration/forms.py @@ -0,0 +1,13 @@ +from django import forms +from .models import PMSConfiguration +from .manager import PluginLoader + +class PMSConfigurationForm(forms.ModelForm): + class Meta: + model = PMSConfiguration + fields = "__all__" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + plugins = PluginLoader.load_plugins() + self.fields['plugin_name'].choices = [(plugin, plugin) for plugin in plugins.keys()] \ No newline at end of file diff --git a/pms_integration/manager.py b/pms_integration/manager.py index 5b1aa4df..c37813b3 100644 --- a/pms_integration/manager.py +++ b/pms_integration/manager.py @@ -1,18 +1,14 @@ import importlib -from django.core.exceptions import ObjectDoesNotExist -from hotels.models import Hotel -from .models import PMSIntegrationLog import os from pathlib import Path -from plugins.base_plugin import BasePMSPlugin - +from django.conf import settings +from .plugins.base_plugin import BasePMSPlugin +from asgiref.sync import sync_to_async class PluginLoader: - """ - Класс для автоматической загрузки плагинов PMS. - """ PLUGIN_PATH = Path(__file__).parent / "plugins" - + print("Путь к папке плагинов:", PLUGIN_PATH.resolve()) + print("Содержимое папки:", list(PLUGIN_PATH.iterdir())) @staticmethod def load_plugins(): plugins = {} @@ -21,19 +17,17 @@ class PluginLoader: module_name = f"pms_integration.plugins.{file[:-3]}" try: module = importlib.import_module(module_name) - # Ищем класс, наследующийся от BasePMSPlugin for attr in dir(module): cls = getattr(module, attr) if isinstance(cls, type) and issubclass(cls, BasePMSPlugin) and cls is not BasePMSPlugin: plugins[cls.__name__] = cls + print(f"Загружен плагин: {cls.__name__}") except Exception as e: - print(f"Ошибка загрузки плагина {module_name}: {e}") + print(f"Ошибка при загрузке модуля {module_name}: {e}") + print(f"Итоговый список плагинов: {list(plugins.keys())}") return plugins - - - class PMSIntegrationManager: def __init__(self, hotel_id): self.hotel_id = hotel_id @@ -41,28 +35,40 @@ class PMSIntegrationManager: self.pms_config = None self.plugin = None - def load_hotel(self): - from hotels.models import Hotel # Импорт здесь, чтобы избежать кругового импорта - self.hotel = Hotel.objects.get(id=self.hotel_id) + async def load_hotel(self): + """ + Загружает данные отеля и PMS конфигурацию. + """ + from hotels.models import Hotel + self.hotel = await sync_to_async(Hotel.objects.select_related("pms").get)(id=self.hotel_id) self.pms_config = self.hotel.pms if not self.pms_config: - raise ValueError(f"Отель {self.hotel.name} не связан с PMS системой.") + raise ValueError(f"Отель {self.hotel.name} не имеет связанной PMS конфигурации.") def load_plugin(self): + """ + Загружает плагин для PMS на основе конфигурации. + """ plugins = PluginLoader.load_plugins() - if self.pms_config.name not in plugins: - raise ValueError(f"Плагин для PMS {self.pms_config.name} не найден.") - self.plugin = plugins[self.pms_config.name](self.pms_config) + if self.pms_config.plugin_name not in plugins: + raise ValueError(f"Плагин для PMS {self.pms_config.plugin_name} не найден.") + self.plugin = plugins[self.pms_config.plugin_name](self.pms_config) def fetch_data(self): + """ + Получает данные из PMS с использованием загруженного плагина. + """ if not self.plugin: - raise ValueError("Плагин не загружен.") + self.load_plugin() return self.plugin.fetch_data() - def save_log(self, status, message): - from .models import PMSIntegrationLog # Избегаем кругового импорта - PMSIntegrationLog.objects.create( + async def save_log(self, status, message): + """ + Сохраняет запись в лог интеграции. + """ + from .models import PMSIntegrationLog + await sync_to_async(PMSIntegrationLog.objects.create)( hotel=self.hotel, status=status, message=message, - ) \ No newline at end of file + ) diff --git a/pms_integration/migrations/0001_initial.py b/pms_integration/migrations/0001_initial.py index 5168707a..8229ed7b 100644 --- a/pms_integration/migrations/0001_initial.py +++ b/pms_integration/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.4 on 2024-12-08 23:48 +# Generated by Django 5.1.4 on 2024-12-09 04:50 import django.db.models.deletion from django.db import migrations, models @@ -9,7 +9,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('hotels', '0020_alter_userhotel_user'), + ('hotels', '__first__'), ] operations = [ @@ -18,8 +18,11 @@ class Migration(migrations.Migration): fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(max_length=255, verbose_name='Название PMS')), - ('parser_settings', models.JSONField(default=dict, verbose_name='Настройки разбора данных')), - ('description', models.TextField(blank=True, null=True, verbose_name='Описание')), + ('url', models.URLField(verbose_name='URL API')), + ('token', models.CharField(blank=True, max_length=255, null=True, verbose_name='Токен')), + ('username', models.CharField(blank=True, max_length=255, null=True, verbose_name='Логин')), + ('password', models.CharField(blank=True, max_length=255, null=True, verbose_name='Пароль')), + ('plugin_name', models.CharField(max_length=255, verbose_name='Название плагина')), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), ], options={ diff --git a/pms_integration/models.py b/pms_integration/models.py index 8204915e..5083d409 100644 --- a/pms_integration/models.py +++ b/pms_integration/models.py @@ -1,11 +1,18 @@ from django.db import models +from django.db import models +from django.contrib.auth.models import AbstractUser + class PMSConfiguration(models.Model): name = models.CharField(max_length=255, verbose_name="Название PMS") - parser_settings = models.JSONField(default=dict, verbose_name="Настройки разбора данных") - description = models.TextField(blank=True, null=True, verbose_name="Описание") - created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания") + url = models.URLField(verbose_name="URL API") + token = models.CharField(max_length=255, blank=True, null=True, verbose_name="Токен") + username = models.CharField(max_length=255, blank=True, null=True, verbose_name="Логин") + password = models.CharField(max_length=255, blank=True, null=True, verbose_name="Пароль") + plugin_name = models.CharField(max_length=255, verbose_name="Название плагина") + created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания") # Добавлено поле + def __str__(self): return self.name @@ -15,6 +22,7 @@ class PMSConfiguration(models.Model): verbose_name_plural = "PMS системы" + class PMSIntegrationLog(models.Model): hotel = models.ForeignKey( 'hotels.Hotel', # Используем строковую ссылку diff --git a/pms_integration/plugins/base_plugin.py b/pms_integration/plugins/base_plugin.py index 82e521ee..716a1764 100644 --- a/pms_integration/plugins/base_plugin.py +++ b/pms_integration/plugins/base_plugin.py @@ -1,15 +1,62 @@ from abc import ABC, abstractmethod - class BasePMSPlugin(ABC): - def __init__(self, hotel, pms_config): - self.hotel = hotel + """ + Базовый класс для всех PMS плагинов. + Плагин должен уметь: + - Возвращать данные fetch_data() + - Предоставлять дефолтные parser_settings + - Проходить базовую валидацию (validate_plugin) + """ + + def __init__(self, pms_config): + """ + pms_config: объект PMSConfiguration + """ self.pms_config = pms_config + @abstractmethod - def fetch_and_parse(self): + def _fetch_data(self): """ - Этот метод должен быть реализован в каждом плагине. - Он должен получать данные из API и возвращать их в нужном формате. + Абстрактный метод для получения данных. """ pass + + def fetch_data(self): + """ + Обертка для выполнения _fetch_data с возможной дополнительной обработкой. + """ + return self._fetch_data() + + @abstractmethod + def get_default_parser_settings(self): + """ + Возвращает словарь/JSON с дефолтными настройками разбора. + Например: + { + "fields_mapping": { + "reservation_id": "id", + "check_in": "from", + "check_out": "until" + }, + "conditions": { + "checkInStatus": "Заселен" + } + } + """ + print("get_default_parser_settings. pms_config:", self.pms_config) + return {} + + def validate_plugin(self): + """ + Проверка на соответствие требованиям. + Можно проверить наличие методов или полей. + """ + # Например, проверить наличие fetch_data и get_default_parser_settings + required_methods = ["fetch_data", "get_default_parser_settings"] + for m in required_methods: + if not hasattr(self, m): + raise ValueError(f"Плагин {type(self).__name__} не реализует метод {m}.") + # Можно добавить дополнительные проверки + return True diff --git a/pms_integration/plugins/bnovo_pms.py b/pms_integration/plugins/bnovo_pms.py index 333da2cf..89374bee 100644 --- a/pms_integration/plugins/bnovo_pms.py +++ b/pms_integration/plugins/bnovo_pms.py @@ -3,11 +3,51 @@ from .base_plugin import BasePMSPlugin class BnovoPMS(BasePMSPlugin): + """ + Плагин для интеграции с Bnovo. + """ + json_schema = { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "number": {"type": "integer"}, + "roomTypeName": {"type": "string"}, + "checkInStatus": {"type": "string"}, + "guests": {"type": "array"}, + }, + "required": ["id", "number", "roomTypeName", "checkInStatus", "guests"] + } + def get_default_parser_settings(self): + """ + Возвращает настройки парсера по умолчанию. + """ + return { + "field_mapping": { + "room_name": "roomNumber", + "check_in": "from", + "check_out": "until", + }, + "date_format": "%Y-%m-%dT%H:%M:%S" + } + def fetch_data(self): + response = requests.get(self.pms_config.url, headers={"Authorization": f"Bearer {self.pms_config.token}"}) + response.raise_for_status() + data = response.json() + + # Проверка структуры + expected_fields = self.pms_config.parser_settings.get("fields_mapping", {}) + for field in expected_fields.values(): + if field not in data[0]: # Проверяем первую запись + raise ValueError(f"Поле {field} отсутствует в ответе API.") + + return data + def fetch_and_parse(self): response = requests.get( self.pms_config.url, headers={"Authorization": f"Bearer {self.pms_config.token}"} ) + self.validate_response(response) # Проверка соответствия структуры if response.status_code != 200: raise ValueError(f"Ошибка запроса к PMS Bnovo: {response.text}") @@ -28,3 +68,4 @@ class BnovoPMS(BasePMSPlugin): } reservations.append(reservation) return reservations + diff --git a/pms_integration/plugins/ecvi_pms.py b/pms_integration/plugins/ecvi_pms.py index 681179b6..4caec542 100644 --- a/pms_integration/plugins/ecvi_pms.py +++ b/pms_integration/plugins/ecvi_pms.py @@ -2,6 +2,38 @@ from .base_plugin import BasePMSPlugin class EcviPMS(BasePMSPlugin): - def fetch_and_parse(self): - # Реализация логики для ECVI - pass + """ + Плагин для PMS Shelter. + """ + def fetch_data(self): + print("Fetching data from Ecvi PMS...") + # Реализация метода получения данных из PMS Shelter + response = requests.get(self.pms_config.url, headers={"Authorization": f"Bearer {self.pms_config.token}"}) + print("Response status:", response.status_code) + response.raise_for_status() + data = response.json() + print("Number of rooms:", len(data)) + return data + json_schema = { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "number": {"type": "integer"}, + "roomTypeName": {"type": "string"}, + "checkInStatus": {"type": "string"}, + "guests": {"type": "array"}, + }, + "required": ["id", "number", "roomTypeName", "checkInStatus", "guests"] + } + def get_default_parser_settings(self): + """ + Возвращает настройки парсера по умолчанию. + """ + return { + "field_mapping": { + "room_name": "roomNumber", + "check_in": "from", + "check_out": "until", + }, + "date_format": "%Y-%m-%dT%H:%M:%S" + } \ No newline at end of file diff --git a/pms_integration/plugins/shelter_pms.py b/pms_integration/plugins/shelter_pms.py index 728de7ac..22aad21a 100644 --- a/pms_integration/plugins/shelter_pms.py +++ b/pms_integration/plugins/shelter_pms.py @@ -1,31 +1,100 @@ -# pms_integration/plugins/shelter_pms.py - import requests +import json +from datetime import datetime, timedelta +from asgiref.sync import sync_to_async from .base_plugin import BasePMSPlugin +from hotels.models import Reservation +from hotels.models import Hotel -class ShelterPMSPlugin(BasePMSPlugin): - """Плагин для интеграции с Shelter PMS.""" - def fetch_data(self): - """Получение данных от Shelter PMS.""" - url = self.api_config.url - headers = {"Authorization": f"Bearer {self.api_config.token}"} - response = requests.get(url, headers=headers, timeout=10) - response.raise_for_status() - return response.json() +class Shelter(BasePMSPlugin): + def __init__(self, config): + super().__init__(config) + self.token = config.token - def parse_data(self, raw_data): - """Обработка данных от Shelter PMS.""" - reservations = raw_data.get("reservations", []) - return [ - { - "id": res["id"], - "room_number": res["room_number"], - "room_type": res["room_type"], - "check_in": res["check_in"], - "check_out": res["check_out"], - "status": res["status"], - "price": res.get("price"), + def get_default_parser_settings(self): + """ + Возвращает настройки по умолчанию для обработки данных. + """ + return { + "date_format": "%Y-%m-%dT%H:%M:%S", + "timezone": "UTC" + } + + def _fetch_data(self): + """ + Выполняет запрос к API PMS для получения данных. + """ + url = 'https://pms.frontdesk24.ru/sheltercloudapi/Reservations/ByFilter' + headers = { + 'accept': 'text/plain', + 'Authorization': f'Bearer {self.token}', + 'Content-Type': 'application/json', + } + + from_index = 0 + count_per_request = 50 + total_count = None + all_items = [] + now = datetime.now() + start_date = (now - timedelta(days=60)).strftime('%Y-%m-%dT%H:%M:%SZ') + end_date = (now + timedelta(days=60)).strftime('%Y-%m-%dT%H:%M:%SZ') + + while total_count is None or from_index < total_count: + data = { + "from": start_date, + "until": end_date, + "pagination": { + "from": from_index, + "count": count_per_request + } } - for res in reservations - ] + + response = requests.post(url, headers=headers, data=json.dumps(data)) + if response.status_code == 200: + response_data = response.json() + items = response_data.get("items", []) + all_items.extend(items) + + if total_count is None: + total_count = response_data.get("count", 0) + + from_index += len(items) + else: + raise ValueError(f'Shelter API Error: {response.status_code}') + + return all_items + + async def _save_to_db(self, data, hotel_id): + """ + Сохраняет данные о бронированиях в таблицу Reservation. + :param data: Список данных о бронированиях. + :param hotel_id: ID отеля, к которому относятся бронирования. + """ + hotel = await sync_to_async(Hotel.objects.get)(id=hotel_id) + for item in data: + print(f"Данные для сохранения: {item}") + + try: + reservation, created = await sync_to_async(Reservation.objects.update_or_create)( + reservation_id=item["id"], + hotel=hotel, + defaults={ + "room_number": item.get("roomNumber", ""), # Номер комнаты + "room_type": item.get("roomTypeName", ""), # Тип комнаты + "check_in": datetime.strptime(item["from"], '%Y-%m-%dT%H:%M:%S'), # Дата заезда + "check_out": datetime.strptime(item["until"], '%Y-%m-%dT%H:%M:%S'), # Дата выезда + "status": item.get("checkInStatus", ""), # Статус бронирования + "price": item.get("reservationPrice", 0), # Цена + "discount": item.get("discount", 0), # Скидка + } + ) + if created: + print(f"Создана запись: {reservation}") + else: + print(f"Обновлена запись: {reservation}") + except Exception as e: + print(f"Ошибка при сохранении бронирования ID {item['id']}: {e}") + + + \ No newline at end of file diff --git a/pms_integration/templates/admin/check_plugins.html b/pms_integration/templates/admin/check_plugins.html new file mode 100644 index 00000000..6d757f8c --- /dev/null +++ b/pms_integration/templates/admin/check_plugins.html @@ -0,0 +1,16 @@ +{% extends "admin/base_site.html" %} + +{% block content %} +

Проверка доступных плагинов

+

Ниже перечислены плагины, доступные в системе:

+ +

+ Вернуться в админку +

+{% endblock %} diff --git a/reports/Golden Hills 3_report.pdf b/reports/Golden Hills 3_report.pdf new file mode 100644 index 0000000000000000000000000000000000000000..7d188cc7a4584429a8f2a90981939ee843daa850 GIT binary patch literal 26284 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 literal 0 HcmV?d00001 diff --git a/reports/Golden Hills 4*_report.pdf b/reports/Golden Hills 4*_report.pdf index 1103f0aa45d71060807a8e619267901cc7fa4591..476d268fd8090a2e8f8e2c2c416bd5ea4e93daf7 100644 GIT binary patch literal 43100 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 E1AIjc-v9sr delta 11831 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 D&&{dK diff --git a/requirements.txt b/requirements.txt index 6a14681d..037e7ec7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -31,3 +31,4 @@ sqlparse==0.5.2 tzdata==2024.2 tzlocal==5.2 urllib3==2.2.3 +jsonschema \ No newline at end of file diff --git a/touchh/settings.py b/touchh/settings.py index 75518734..bb44e1ff 100644 --- a/touchh/settings.py +++ b/touchh/settings.py @@ -41,9 +41,9 @@ INSTALLED_APPS = [ 'django.contrib.messages', 'django.contrib.staticfiles', 'bot', + 'pms_integration', 'hotels', 'users', - 'pms_integration' ] MIDDLEWARE = [ @@ -112,6 +112,19 @@ AUTH_PASSWORD_VALIDATORS = [ }, ] +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'handlers': { + 'console': { + 'class': 'logging.StreamHandler', + }, + }, + 'root': { + 'handlers': ['console'], + 'level': 'WARNING', + }, +} # Internationalization # https://docs.djangoproject.com/en/5.1/topics/i18n/ @@ -146,6 +159,7 @@ JAZZMIN_SETTINGS = { "welcome_sign": "Welcome to Hotel Management System", "show_sidebar": True, "navigation_expanded": True, + "hide_models": ["users", "guests"], "site_logo": None, # Путь к логотипу, например "static/images/logo.png" "site_logo_classes": "img-circle", # Классы CSS для логотипа "site_icon": None, # Иконка сайта (favicon), например "static/images/favicon.ico" diff --git a/users/migrations/0001_initial.py b/users/migrations/0001_initial.py index 67cccc82..9157ca0e 100644 --- a/users/migrations/0001_initial.py +++ b/users/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.4 on 2024-12-05 23:39 +# Generated by Django 5.1.4 on 2024-12-09 04:50 import django.contrib.auth.models import django.contrib.auth.validators @@ -17,6 +17,49 @@ class Migration(migrations.Migration): ] operations = [ + migrations.CreateModel( + name='LocalUserActivityLog', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('user_id', models.IntegerField()), + ('activity_type', models.CharField(max_length=255)), + ('timestamp', models.DateTimeField()), + ('additional_data', models.JSONField(blank=True, null=True)), + ], + ), + migrations.CreateModel( + name='UserActivityLog', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False, verbose_name='ID')), + ('user_id', models.BigIntegerField(verbose_name='ID пользователя')), + ('ip', models.CharField(blank=True, max_length=100, null=True, verbose_name='IP адрес')), + ('created', models.DateTimeField(auto_now_add=True, verbose_name='Создан')), + ('timestamp', models.IntegerField(verbose_name='Время')), + ('date_time', models.DateTimeField(verbose_name='Дата')), + ('referred', models.CharField(blank=True, max_length=255, null=True)), + ('agent', models.CharField(blank=True, max_length=255, null=True, verbose_name='Браузер')), + ('platform', models.CharField(blank=True, max_length=255, null=True)), + ('version', models.CharField(blank=True, max_length=50, null=True)), + ('model', models.CharField(blank=True, max_length=255, null=True)), + ('device', models.CharField(blank=True, max_length=50, null=True)), + ('UAString', models.TextField(blank=True, null=True)), + ('location', models.CharField(blank=True, max_length=255, null=True)), + ('page_id', models.BigIntegerField(blank=True, null=True)), + ('url_parameters', models.TextField(blank=True, null=True)), + ('page_title', models.CharField(blank=True, max_length=255, null=True)), + ('type', models.CharField(blank=True, max_length=50, null=True)), + ('last_counter', models.IntegerField(blank=True, null=True)), + ('hits', models.IntegerField(blank=True, null=True)), + ('honeypot', models.BooleanField(blank=True, null=True)), + ('reply', models.BooleanField(blank=True, null=True)), + ('page_url', models.CharField(blank=True, max_length=255, null=True)), + ], + options={ + 'verbose_name': 'Журнал активности', + 'verbose_name_plural': 'Журналы активности', + 'db_table': 'user_activity_log', + }, + ), migrations.CreateModel( name='User', fields=[ @@ -31,29 +74,47 @@ class Migration(migrations.Migration): ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), - ('telegram_id', models.BigIntegerField(blank=True, null=True, unique=True, verbose_name='Telegram ID')), - ('chat_id', models.BigIntegerField(blank=True, null=True, unique=True, verbose_name='Chat ID')), - ('role', models.CharField(choices=[('admin', 'Administrator'), ('hotel_user', 'Hotel User')], default='hotel_user', max_length=20, verbose_name='Role')), - ('confirmed', models.BooleanField(default=False, verbose_name='Confirmed')), + ('telegram_id', models.BigIntegerField(blank=True, null=True, unique=True, verbose_name='ID Телеграм')), + ('chat_id', models.BigIntegerField(blank=True, null=True, unique=True, verbose_name='ID чата в телеграм')), + ('role', models.CharField(choices=[('admin', 'Администратор системы'), ('hotel_user', 'Сотрудник отеля')], default='hotel_user', max_length=20, verbose_name='Роль')), + ('confirmed', models.BooleanField(default=False, verbose_name='Подтвержден')), ('groups', models.ManyToManyField(blank=True, related_name='custom_user_set', to='auth.group')), ('user_permissions', models.ManyToManyField(blank=True, related_name='custom_user_set', to='auth.permission')), ], options={ - 'verbose_name': 'user', - 'verbose_name_plural': 'users', - 'abstract': False, + 'verbose_name': 'Пользователь', + 'verbose_name_plural': 'Пользователи', }, managers=[ ('objects', django.contrib.auth.models.UserManager()), ], ), + migrations.CreateModel( + name='NotificationSettings', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('telegram_enabled', models.BooleanField(default=True, verbose_name='Уведомления в Telegram')), + ('email_enabled', models.BooleanField(default=False, verbose_name='Уведомления по Email')), + ('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='Email для уведомлений')), + ('notification_time', models.TimeField(default='09:00', verbose_name='Время отправки уведомлений')), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='users.user', verbose_name='Пользователь')), + ], + options={ + 'verbose_name': 'Способ оповещения', + 'verbose_name_plural': 'Способы оповещений', + }, + ), migrations.CreateModel( name='UserConfirmation', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('confirmation_code', models.UUIDField(default=uuid.uuid4, verbose_name='Confirmation Code')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='users.user', verbose_name='User')), + ('confirmation_code', models.UUIDField(default=uuid.uuid4, verbose_name='Код подтверждения')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создан: ')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='users.user', verbose_name='Пользователь')), ], + options={ + 'verbose_name': 'Подтверждение пользователя', + 'verbose_name_plural': 'Подтверждения пользователей', + }, ), ] diff --git a/users/migrations/0002_localuseractivitylog_useractivitylog.py b/users/migrations/0002_localuseractivitylog_useractivitylog.py deleted file mode 100644 index 6cf3698b..00000000 --- a/users/migrations/0002_localuseractivitylog_useractivitylog.py +++ /dev/null @@ -1,56 +0,0 @@ -# Generated by Django 5.1.4 on 2024-12-06 03:59 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('users', '0001_initial'), - ] - - operations = [ - migrations.CreateModel( - name='LocalUserActivityLog', - fields=[ - ('id', models.AutoField(primary_key=True, serialize=False)), - ('user_id', models.IntegerField()), - ('activity_type', models.CharField(max_length=255)), - ('timestamp', models.DateTimeField()), - ('additional_data', models.JSONField(blank=True, null=True)), - ], - ), - migrations.CreateModel( - name='UserActivityLog', - fields=[ - ('id', models.BigAutoField(primary_key=True, serialize=False)), - ('user_id', models.BigIntegerField()), - ('ip', models.CharField(blank=True, max_length=100, null=True)), - ('created', models.DateTimeField(auto_now_add=True)), - ('timestamp', models.IntegerField()), - ('date_time', models.DateTimeField()), - ('referred', models.CharField(blank=True, max_length=255, null=True)), - ('agent', models.CharField(blank=True, max_length=255, null=True)), - ('platform', models.CharField(blank=True, max_length=255, null=True)), - ('version', models.CharField(blank=True, max_length=50, null=True)), - ('model', models.CharField(blank=True, max_length=255, null=True)), - ('device', models.CharField(blank=True, max_length=50, null=True)), - ('UAString', models.TextField(blank=True, null=True)), - ('location', models.CharField(blank=True, max_length=255, null=True)), - ('page_id', models.BigIntegerField(blank=True, null=True)), - ('url_parameters', models.TextField(blank=True, null=True)), - ('page_title', models.CharField(blank=True, max_length=255, null=True)), - ('type', models.CharField(blank=True, max_length=50, null=True)), - ('last_counter', models.IntegerField(blank=True, null=True)), - ('hits', models.IntegerField(blank=True, null=True)), - ('honeypot', models.BooleanField(blank=True, null=True)), - ('reply', models.BooleanField(blank=True, null=True)), - ('page_url', models.CharField(blank=True, max_length=255, null=True)), - ], - options={ - 'verbose_name': 'User Activity Log', - 'verbose_name_plural': 'User Activity Logs', - 'db_table': 'user_activity_log', - }, - ), - ] diff --git a/users/migrations/0003_alter_useractivitylog_options_alter_user_confirmed_and_more.py b/users/migrations/0003_alter_useractivitylog_options_alter_user_confirmed_and_more.py deleted file mode 100644 index 15c26425..00000000 --- a/users/migrations/0003_alter_useractivitylog_options_alter_user_confirmed_and_more.py +++ /dev/null @@ -1,44 +0,0 @@ -# Generated by Django 5.1.4 on 2024-12-06 04:14 - -import django.db.models.deletion -import uuid -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('users', '0002_localuseractivitylog_useractivitylog'), - ] - - operations = [ - migrations.AlterModelOptions( - name='useractivitylog', - options={'verbose_name': 'Журнал активности', 'verbose_name_plural': 'Журналы активности'}, - ), - migrations.AlterField( - model_name='user', - name='confirmed', - field=models.BooleanField(default=False, verbose_name='Подтвержден'), - ), - migrations.AlterField( - model_name='user', - name='role', - field=models.CharField(choices=[('admin', 'Администратор системы'), ('hotel_user', 'Сотрудник отеля')], default='hotel_user', max_length=20, verbose_name='Роль'), - ), - migrations.AlterField( - model_name='userconfirmation', - name='confirmation_code', - field=models.UUIDField(default=uuid.uuid4, verbose_name='Код подтверждения'), - ), - migrations.AlterField( - model_name='userconfirmation', - name='created_at', - field=models.DateTimeField(auto_now_add=True, verbose_name='Создан: '), - ), - migrations.AlterField( - model_name='userconfirmation', - name='user', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='users.user', verbose_name='Пользователь'), - ), - ] diff --git a/users/migrations/0004_alter_user_options_alter_userconfirmation_options_and_more.py b/users/migrations/0004_alter_user_options_alter_userconfirmation_options_and_more.py deleted file mode 100644 index a890fc39..00000000 --- a/users/migrations/0004_alter_user_options_alter_userconfirmation_options_and_more.py +++ /dev/null @@ -1,66 +0,0 @@ -# Generated by Django 5.1.4 on 2024-12-06 04:26 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('users', '0003_alter_useractivitylog_options_alter_user_confirmed_and_more'), - ] - - operations = [ - migrations.AlterModelOptions( - name='user', - options={'verbose_name': 'Пользователь', 'verbose_name_plural': 'Пользователи'}, - ), - migrations.AlterModelOptions( - name='userconfirmation', - options={'verbose_name': 'Подтверждение пользователя', 'verbose_name_plural': 'Подтверждения пользователей'}, - ), - migrations.AlterField( - model_name='user', - name='chat_id', - field=models.BigIntegerField(blank=True, null=True, unique=True, verbose_name='ID чата в телеграм'), - ), - migrations.AlterField( - model_name='user', - name='telegram_id', - field=models.BigIntegerField(blank=True, null=True, unique=True, verbose_name='ID Телеграм'), - ), - migrations.AlterField( - model_name='useractivitylog', - name='agent', - field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Браузер'), - ), - migrations.AlterField( - model_name='useractivitylog', - name='created', - field=models.DateTimeField(auto_now_add=True, verbose_name='Создан'), - ), - migrations.AlterField( - model_name='useractivitylog', - name='date_time', - field=models.DateTimeField(verbose_name='Дата'), - ), - migrations.AlterField( - model_name='useractivitylog', - name='id', - field=models.BigAutoField(primary_key=True, serialize=False, verbose_name='ID'), - ), - migrations.AlterField( - model_name='useractivitylog', - name='ip', - field=models.CharField(blank=True, max_length=100, null=True, verbose_name='IP адрес'), - ), - migrations.AlterField( - model_name='useractivitylog', - name='timestamp', - field=models.IntegerField(verbose_name='Время'), - ), - migrations.AlterField( - model_name='useractivitylog', - name='user_id', - field=models.BigIntegerField(verbose_name='ID пользователя'), - ), - ] diff --git a/users/migrations/0005_notificationsettings.py b/users/migrations/0005_notificationsettings.py deleted file mode 100644 index 2d2bc730..00000000 --- a/users/migrations/0005_notificationsettings.py +++ /dev/null @@ -1,29 +0,0 @@ -# Generated by Django 5.1.4 on 2024-12-06 12:01 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('users', '0004_alter_user_options_alter_userconfirmation_options_and_more'), - ] - - operations = [ - migrations.CreateModel( - name='NotificationSettings', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('telegram_enabled', models.BooleanField(default=True, verbose_name='Уведомления в Telegram')), - ('email_enabled', models.BooleanField(default=False, verbose_name='Уведомления по Email')), - ('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='Email для уведомлений')), - ('notification_time', models.TimeField(default='09:00', verbose_name='Время отправки уведомлений')), - ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='users.user', verbose_name='Пользователь')), - ], - options={ - 'verbose_name': 'Способ оповещения', - 'verbose_name_plural': 'Способы оповещений', - }, - ), - ]