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 00000000..7d188cc7 Binary files /dev/null and b/reports/Golden Hills 3_report.pdf differ diff --git a/reports/Golden Hills 4*_report.pdf b/reports/Golden Hills 4*_report.pdf index 1103f0aa..476d268f 100644 Binary files a/reports/Golden Hills 4*_report.pdf and b/reports/Golden Hills 4*_report.pdf differ 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': 'Способы оповещений', - }, - ), - ]