diff --git a/.flake8 b/.flake8 index 80288648..55649641 100644 --- a/.flake8 +++ b/.flake8 @@ -1,5 +1,5 @@ [flake8] -ignore = E501, E265, F811, PT001, DJ05, D100, D105, D104, W504, W292, D106, D107 +ignore = E501, E265, F811, PT001, DJ05, D100, D105, D104, W504, W292, D106, D107, W503 max-line-length = 79 exclude = */migrations/ diff --git a/.github/workflows/deploy-bot-on-prod.yml b/.github/workflows/deploy-bot-on-prod.yml index 41ff240e..547e4484 100644 --- a/.github/workflows/deploy-bot-on-prod.yml +++ b/.github/workflows/deploy-bot-on-prod.yml @@ -13,6 +13,14 @@ jobs: name: prod_deploy if: ${{ github.event.workflow_run.conclusion == 'success' }} steps: + - name: copy service file + uses: appleboy/scp-action@v0.1.4 + with: + host: ${{ secrets.VM_HOST }} + username: ${{ secrets.VM_USER }} + password: ${{ secrets.VM_PASSWORD }} + source: "infra/prod/" + target: /home/deploy/spread_wings_bot/infra/prod/ - name: ssh pull and start uses: appleboy/ssh-action@master with: @@ -21,7 +29,6 @@ jobs: password: ${{ secrets.VM_PASSWORD }} script: | cd /home/deploy/spread_wings_bot/infra/prod/ - git pull rm .env touch .env @@ -61,6 +68,13 @@ jobs: # Cleaning unused containers, images, networks docker system prune --force + # Installing defend service for app + # Шаг с копированием в строках 16-23 можно заменить командой ниже - нужно тестировать + # scp infra/prod/spread_wings_bot.service ${{ secrets.VM_USER }}@${{ secrets.VM_HOST }}:/spread_wings_bot/infra/prod/ + sudo cp -f /home/deploy/spread_wings_bot/infra/prod/spread_wings_bot.service /etc/systemd/system/spread_wings_bot.service + sudo systemctl daemon-reload + sudo systemctl restart spread_wings_bot.service + # Installing the app docker-compose -f docker-compose.stage.yaml stop docker-compose -f docker-compose.stage.yaml pull diff --git a/.github/workflows/deploy-bot-on-stage.yml b/.github/workflows/deploy-bot-on-stage.yml index ddb5adfd..35f251c7 100644 --- a/.github/workflows/deploy-bot-on-stage.yml +++ b/.github/workflows/deploy-bot-on-stage.yml @@ -13,6 +13,21 @@ jobs: name: stage_deploy if: ${{ github.event.workflow_run.conclusion == 'success' }} steps: + - name: Run a multi-line script + # uses: actions/checkout@v2 + run: | + mkdir ../spread_wings_bot/infra/dev/ + cp -TR . ../spread_wings_bot/infra/dev/ + tar -cvf deploy.tar ../spread_wings_bot/infra/dev/ + + - name: copy service file + uses: appleboy/scp-action@v0.1.4 + with: + host: ${{ secrets.VM_HOST }} + username: ${{ secrets.VM_USER }} + password: ${{ secrets.VM_PASSWORD }} + source: "deploy.tar" + target: /home/deploy/spread_wings_bot/infra/dev/ - name: ssh pull and start uses: appleboy/ssh-action@master with: @@ -21,7 +36,6 @@ jobs: password: ${{ secrets.VM_PASSWORD }} script: | cd /home/deploy/spread_wings_bot/infra/dev/ - git pull rm .env touch .env @@ -61,6 +75,13 @@ jobs: # Cleaning unused containers, images, networks docker system prune --force + # Installing defend service for app + # Шаг с копированием в строках 16-23 можно заменить командой ниже - нужно тестировать + # scp infra/dev/spread_wings_bot.service ${{ secrets.VM_USER }}@${{ secrets.VM_HOST }}:/spread_wings_bot/infra/dev/ + sudo cp -f /home/deploy/spread_wings_bot/infra/dev/spread_wings_bot.service /etc/systemd/system/spread_wings_bot.service + sudo systemctl daemon-reload + sudo systemctl restart spread_wings_bot.service + # Installing the app docker-compose -f docker-compose.stage.yaml stop docker-compose -f docker-compose.stage.yaml pull diff --git a/infra/dev/spread_wings_bot.service b/infra/dev/spread_wings_bot.service new file mode 100644 index 00000000..f113e18e --- /dev/null +++ b/infra/dev/spread_wings_bot.service @@ -0,0 +1,29 @@ +[Unit] + +Description=spread_wings_bot +Requires=docker.service +After=docker.service + +[Service] + +Restart=always +RestartSec=5 +TimeOutStartSec=1200 +User=root + +WorkingDirectory=/home/deploy/spread_wings_bot/infra/dev/ + +ExecStartPre=docker-compose -f docker-compose.stage.yaml --env-file /home/deploy/spread_wings_bot/infra/dev/.env pull bot +ExecStartPre=docker-compose -f docker-compose.stage.yaml --env-file /home/deploy/spread_wings_bot/infra/dev/.env pull db +ExecStartPre=docker-compose -f docker-compose.stage.yaml --env-file /home/deploy/spread_wings_bot/infra/dev/.env pull redis +ExecStartPre=docker-compose -f docker-compose.stage.yaml --env-file /home/deploy/spread_wings_bot/infra/dev/.env pull nginx +ExecStartPre=docker-compose -f docker-compose.stage.yaml --env-file /home/deploy/spread_wings_bot/infra/dev/.env down + +# compose up +ExecStart=docker-compose -f docker-compose.stage.yaml --env-file /home/deploy/spread_wings_bot/infra/dev/.env up + +# compose down +ExecStop=docker-compose -f docker-compose.stage.yaml --env-file /home/deploy/spread_wings_bot/infra/dev/.env down + +[Install] +WantedBy=multi-user.target diff --git a/infra/prod/spread_wings_bot.service b/infra/prod/spread_wings_bot.service new file mode 100644 index 00000000..cd002a73 --- /dev/null +++ b/infra/prod/spread_wings_bot.service @@ -0,0 +1,29 @@ +[Unit] + +Description=spread_wings_bot +Requires=docker.service +After=docker.service + +[Service] + +Restart=always +RestartSec=5 +TimeOutStartSec=1200 +User=root + +WorkingDirectory=/home/deploy/spread_wings_bot/infra/prod/ + +ExecStartPre=docker-compose -f docker-compose.prod.yaml --env-file /home/deploy/spread_wings_bot/infra/prod/.env pull bot +ExecStartPre=docker-compose -f docker-compose.prod.yaml --env-file /home/deploy/spread_wings_bot/infra/prod/.env pull db +ExecStartPre=docker-compose -f docker-compose.prod.yaml --env-file /home/deploy/spread_wings_bot/infra/prod/.env pull redis +ExecStartPre=docker-compose -f docker-compose.prod.yaml --env-file /home/deploy/spread_wings_bot/infra/prod/.env pull nginx +ExecStartPre=docker-compose -f docker-compose.prod.yaml --env-file /home/deploy/spread_wings_bot/infra/prod/.env down + +# compose up +ExecStart=docker-compose -f docker-compose.prod.yaml --env-file /home/deploy/spread_wings_bot/infra/prod/.env up + +# compose down +ExecStop=docker-compose -f docker-compose.prod.yaml --env-file /home/deploy/spread_wings_bot/infra/prod/.env down + +[Install] +WantedBy=multi-user.target diff --git a/src/bot/bot.py b/src/bot/bot.py index e54052a9..40654f0f 100644 --- a/src/bot/bot.py +++ b/src/bot/bot.py @@ -146,8 +146,8 @@ async def build_app() -> Application: states={ States.GET_ASSISTANCE: [ CallbackQueryHandler(get_assistance, pattern=GET_ASSISTANCE), - ], - States.REGION: [ + ] + + [ CallbackQueryHandler( select_type_of_assistance, pattern=PATTERN.format(state=key), diff --git a/src/bot/constants/patterns.py b/src/bot/constants/patterns.py index 42620b6b..788ca524 100644 --- a/src/bot/constants/patterns.py +++ b/src/bot/constants/patterns.py @@ -11,9 +11,11 @@ state="".join(f"{h_type}|" for h_type in HelpTypes.names) ) HELP_TYPE = rf"({POSSIBLE_TYPE_OF_ASSISTANCE})(?:{PAGE_SEP_SYMBOL}(\d+))?" +GET_ASSISTANCE = PATTERN.format( + state=rf"({States.GET_ASSISTANCE.value})(?:{PAGE_SEP_SYMBOL}(\d+))?" +) SEND_EMAIL = PATTERN.format(state=States.SEND_EMAIL.value) GET_USER_QUESTION = PATTERN.format(state=States.GET_USER_QUESTION.value) -GET_ASSISTANCE = PATTERN.format(state=States.GET_ASSISTANCE.value) FUND_PROGRAMS = PATTERN.format( state=rf"({States.FUND_PROGRAMS.value})(?:{PAGE_SEP_SYMBOL}(\d+))?" ) diff --git a/src/bot/handlers/assistance.py b/src/bot/handlers/assistance.py index 3ef2ef3f..f288ff1c 100644 --- a/src/bot/handlers/assistance.py +++ b/src/bot/handlers/assistance.py @@ -8,7 +8,7 @@ SELECT_FUND_PROGRAM, SELECT_QUESTION, ) -from bot.constants.patterns import FUND_PROGRAMS, HELP_TYPE +from bot.constants.patterns import FUND_PROGRAMS, GET_ASSISTANCE, HELP_TYPE from bot.constants.states import States from bot.handlers.debug_handlers import debug_logger from bot.keyboards.assistance import ( @@ -25,21 +25,29 @@ DEFAULT_PAGE = 1 -@debug_logger(state=States.REGION, run_functions_debug_loger="get_assistance") +@debug_logger( + state=States.GET_ASSISTANCE, run_functions_debug_loger="get_assistance" +) async def get_assistance( update: Update, context: ContextTypes.DEFAULT_TYPE, ) -> States: """Select a region of assistance.""" - await update.callback_query.answer() - keyboard = await build_region_keyboard() + query = update.callback_query + callback_data = query.data.replace("back_to_", "") + _, page_number = parse_callback_data(callback_data, GET_ASSISTANCE) + page_number = page_number or DEFAULT_PAGE + await query.answer() + keyboard = await build_region_keyboard(page_number) assistance_message = await BotSettings.objects.aget( key="assistance_message" ) - await update.callback_query.edit_message_text( - text=assistance_message.value, reply_markup=keyboard - ) - return States.REGION + if query.message.reply_markup.to_json() != keyboard.markup: + await query.edit_message_text( + text=assistance_message.value, + reply_markup=keyboard.markup, + ) + return States.GET_ASSISTANCE @debug_logger( diff --git a/src/bot/handlers/service_handlers.py b/src/bot/handlers/service_handlers.py index 9f318622..fada5633 100644 --- a/src/bot/handlers/service_handlers.py +++ b/src/bot/handlers/service_handlers.py @@ -41,11 +41,11 @@ async def answer_all_messages( FUNCTIONS: dict[str, Callable[[Any, Any], Awaitable[States]]] = { - States.GET_ASSISTANCE.value: start, + States.START: start, States.ASSISTANCE_TYPE: select_type_of_assistance, States.CONTACT_US: contact_with_us, States.FUND_PROGRAMS: fund_programs, - States.REGION: get_assistance, + States.REGION.value: get_assistance, States.SHOW_CONTACT: show_contact, States.GET_USERNAME: get_user_question, States.USERNAME_AFTER_RETURNING: get_username_after_returning_back, diff --git a/src/bot/keyboards/assistance.py b/src/bot/keyboards/assistance.py index 70723504..2d31e6fd 100644 --- a/src/bot/keyboards/assistance.py +++ b/src/bot/keyboards/assistance.py @@ -17,9 +17,6 @@ from bot_settings.models import BotSettings from core.models import Region -PROGRAMS_PER_PAGE = 6 -QUESTIONS_PER_PAGE = 6 - async def build_assistance_keyboard() -> InlineKeyboardMarkup: """ @@ -43,30 +40,42 @@ async def build_assistance_keyboard() -> InlineKeyboardMarkup: ) -async def build_region_keyboard() -> InlineKeyboardMarkup: +async def build_region_keyboard( + page: int, +) -> InlineKeyboardPaginator: """ Build telegram assistance type keyboard async. After building cache it. """ - keyboard = [ - [ - InlineKeyboardButton( - text=region.region_name, - callback_data=region.region_key, - ) - ] - async for region in Region.objects.all() - ] - back_button = [ - [ + queryset = await sync_to_async(list)( + Region.objects.all().values("region_name", "region_key") + ) + region_per_page = await BotSettings.objects.aget( + key="regions_pagination_setting" + ) + data_paginator = Paginator(queryset, int(region_per_page.value)) + telegram_paginator = InlineKeyboardPaginator( + data_paginator.num_pages, + current_page=page, + data_pattern="".join( + [States.GET_ASSISTANCE.value, PAGE_SEP_SYMBOL, "{page}"] + ), + ) + for region in data_paginator.page(page): + telegram_paginator.add_before( InlineKeyboardButton( - text=BACK_BUTTON, - callback_data=f"back_to_{States.GET_ASSISTANCE.value}", + text=region.get("region_name"), + callback_data=region.get("region_key"), ) - ] - ] - return InlineKeyboardMarkup(keyboard + back_button) + ) + telegram_paginator.add_after( + InlineKeyboardButton( + text=BACK_BUTTON, + callback_data=f"back_to_{States.START.value}", + ), + ) + return telegram_paginator async def build_question_keyboard( @@ -85,7 +94,10 @@ async def build_question_keyboard( question_type=question_type, ).values("id", "short_description") ) - data_paginator = Paginator(queryset, QUESTIONS_PER_PAGE) + questions_per_page = await BotSettings.objects.aget( + key="questions_pagination_setting" + ) + data_paginator = Paginator(queryset, int(questions_per_page.value)) telegram_paginator = InlineKeyboardPaginator( data_paginator.num_pages, current_page=page, @@ -131,7 +143,10 @@ async def build_fund_program_keyboard( regions__region_key=region, ).values("id", "short_description") ) - data_paginator = Paginator(queryset, PROGRAMS_PER_PAGE) + programs_per_page = await BotSettings.objects.aget( + key="programs_pagination_setting" + ) + data_paginator = Paginator(queryset, int(programs_per_page.value)) telegram_paginator = InlineKeyboardPaginator( data_paginator.num_pages, current_page=page, diff --git a/src/bot/migrations/0008_alter_coordinator_phone_number_and_more.py b/src/bot/migrations/0008_alter_coordinator_phone_number_and_more.py new file mode 100644 index 00000000..51eea2b1 --- /dev/null +++ b/src/bot/migrations/0008_alter_coordinator_phone_number_and_more.py @@ -0,0 +1,49 @@ +# Generated by Django 4.2.4 on 2023-09-03 17:19 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + """Migrations for bot.""" + + dependencies = [ + ("bot", "0007_alter_question_short_description"), + ] + + operations = [ + migrations.AlterField( + model_name="coordinator", + name="phone_number", + field=models.CharField( + blank=True, + help_text="Введите номер телефона регионального координатора", + max_length=20, + null=True, + validators=[ + django.core.validators.RegexValidator( + message="Введите номер телефона в формате: +7 (777) 777-77-77", + regex="^[\\+]?[7, 8][-\\s\\.]?[(]?[0-9]{3}[)]?[-\\s\\.]?[0-9]{3}[-\\s\\.]?[0-9]{2}[-\\s\\.]?[0-9]{2}$", + ) + ], + verbose_name="Номер телефона", + ), + ), + migrations.AlterField( + model_name="coordinator", + name="telegram_account", + field=models.CharField( + blank=True, + help_text="Введите телеграмм-аккаунт регионального координатора", + max_length=32, + null=True, + validators=[ + django.core.validators.RegexValidator( + message="Введите название аккаунта telegram в формате: username", + regex="^[\\w\\_]{5,32}$", + ) + ], + verbose_name="Telegram", + ), + ), + ] diff --git a/src/bot_settings/migrations/0002_add_settings.py b/src/bot_settings/migrations/0002_add_settings.py index b9a75123..7876496e 100644 --- a/src/bot_settings/migrations/0002_add_settings.py +++ b/src/bot_settings/migrations/0002_add_settings.py @@ -76,6 +76,60 @@ def remove_assistance_message_setting(apps, schema_editor): setting.delete() +def create_regions_pagination_setting(apps, schema_editor): + """Create regions pagination setting instance.""" + BotSettings = apps.get_model("bot_settings", "BotSettings") + BotSettings.objects.create( + key="regions_pagination_setting", + title="Количество регионов на одной странице", + type=BotSettingsModel.INT, + value=6, + ) + + +def remove_regions_pagination_setting(apps, schema_editor): + """Remove regions pagination setting instance.""" + BotSettings = apps.get_model("bot_settings", "BotSettings") + setting = BotSettings.objects.get(key="regions_pagination_setting") + setting.delete() + + +def create_programs_pagination_setting(apps, schema_editor): + """Create programs pagination setting instance.""" + BotSettings = apps.get_model("bot_settings", "BotSettings") + BotSettings.objects.create( + key="programs_pagination_setting", + title="Количество программ на одной странице", + type=BotSettingsModel.INT, + value=6, + ) + + +def remove_programs_pagination_setting(apps, schema_editor): + """Remove programs pagination setting instance.""" + BotSettings = apps.get_model("bot_settings", "BotSettings") + setting = BotSettings.objects.get(key="programs_pagination_setting") + setting.delete() + + +def create_questions_pagination_setting(apps, schema_editor): + """Create questions pagination setting instance.""" + BotSettings = apps.get_model("bot_settings", "BotSettings") + BotSettings.objects.create( + key="questions_pagination_setting", + title="Количество вопросов на одной странице", + type=BotSettingsModel.INT, + value=6, + ) + + +def remove_questions_pagination_setting(apps, schema_editor): + """Remove questions pagination setting instance.""" + BotSettings = apps.get_model("bot_settings", "BotSettings") + setting = BotSettings.objects.get(key="questions_pagination_setting") + setting.delete() + + def create_select_type_of_help_setting(apps, schema_editor): """Create select_type_of_help setting instance.""" BotSettings = apps.get_model("bot_settings", "BotSettings") @@ -190,6 +244,18 @@ class Migration(migrations.Migration): create_assistance_message_setting, reverse_code=remove_assistance_message_setting, ), + migrations.RunPython( + create_regions_pagination_setting, + reverse_code=remove_regions_pagination_setting, + ), + migrations.RunPython( + create_programs_pagination_setting, + reverse_code=remove_programs_pagination_setting, + ), + migrations.RunPython( + create_questions_pagination_setting, + reverse_code=remove_questions_pagination_setting, + ), migrations.RunPython( create_select_type_of_help_setting, reverse_code=remove_select_type_of_help_setting, diff --git a/src/bot_settings/migrations/0003_alter_botsettings_title_alter_botsettings_value.py b/src/bot_settings/migrations/0003_alter_botsettings_title_alter_botsettings_type_and_more.py similarity index 67% rename from src/bot_settings/migrations/0003_alter_botsettings_title_alter_botsettings_value.py rename to src/bot_settings/migrations/0003_alter_botsettings_title_alter_botsettings_type_and_more.py index 8f7c8821..7bdd26a8 100644 --- a/src/bot_settings/migrations/0003_alter_botsettings_title_alter_botsettings_value.py +++ b/src/bot_settings/migrations/0003_alter_botsettings_title_alter_botsettings_type_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.4 on 2023-08-28 21:33 +# Generated by Django 4.2.4 on 2023-09-01 16:07 import ckeditor.fields from django.db import migrations, models @@ -21,6 +21,19 @@ class Migration(migrations.Migration): verbose_name="Название настройки", ), ), + migrations.AlterField( + model_name="botsettings", + name="type", + field=models.CharField( + choices=[ + ("url", "Ссылка"), + ("text", "Текст"), + ("int", "Число"), + ], + max_length=100, + verbose_name="Тип значения", + ), + ), migrations.AlterField( model_name="botsettings", name="value", diff --git a/src/bot_settings/migrations/0004_alter_botsettings_type.py b/src/bot_settings/migrations/0004_alter_botsettings_type.py deleted file mode 100644 index 3d4c28c1..00000000 --- a/src/bot_settings/migrations/0004_alter_botsettings_type.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 4.2.4 on 2023-08-30 17:04 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ( - "bot_settings", - "0003_alter_botsettings_title_alter_botsettings_value", - ), - ] - - operations = [ - migrations.AlterField( - model_name="botsettings", - name="type", - field=models.CharField( - choices=[("url", "Ссылка"), ("text", "Текст")], - max_length=100, - verbose_name="Тип значения", - ), - ), - ] diff --git a/src/bot_settings/models.py b/src/bot_settings/models.py index 56964fc4..6b57a0c3 100644 --- a/src/bot_settings/models.py +++ b/src/bot_settings/models.py @@ -10,9 +10,11 @@ class BotSettings(BaseModel): URL = "url" TEXT = "text" + INT = "int" __VALUE_TYPES = ( (URL, "Ссылка"), (TEXT, "Текст"), + (INT, "Число"), ) key = models.CharField( max_length=100, diff --git a/src/core/migrations/0002_alter_region_region_name.py b/src/core/migrations/0002_alter_region_region_name.py index 415899a3..c55b4257 100644 --- a/src/core/migrations/0002_alter_region_region_name.py +++ b/src/core/migrations/0002_alter_region_region_name.py @@ -15,7 +15,7 @@ class Migration(migrations.Migration): model_name="region", name="region_name", field=models.CharField( - help_text="Это название так же будет отображаться на кнопках бота", + help_text="Это название также будет отображаться на кнопках бота", max_length=200, unique=True, verbose_name="Название региона", diff --git a/src/core/migrations/0003_alter_region_region_name.py b/src/core/migrations/0003_alter_region_region_name.py new file mode 100644 index 00000000..a53bba19 --- /dev/null +++ b/src/core/migrations/0003_alter_region_region_name.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.4 on 2023-09-01 16:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + """Migrations for bot.""" + + dependencies = [ + ("core", "0002_alter_region_region_name"), + ] + + operations = [ + migrations.AlterField( + model_name="region", + name="region_name", + field=models.CharField( + help_text="Это название так же будет отображаться на кнопках бота", + max_length=200, + unique=True, + verbose_name="Название региона", + ), + ), + ] diff --git a/src/tests/unit/test_handlers/test_receive_assistance.py b/src/tests/unit/test_handlers/test_receive_assistance.py index 3a85a155..7e2f7ee8 100644 --- a/src/tests/unit/test_handlers/test_receive_assistance.py +++ b/src/tests/unit/test_handlers/test_receive_assistance.py @@ -1,4 +1,4 @@ -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, Mock, patch import pytest @@ -24,13 +24,19 @@ async def test_get_assistance( "bot.handlers.assistance.BotSettings.objects.aget", AsyncMock(return_value=mocked_message), ), + patch( + "bot.handlers.assistance.parse_callback_data", + Mock(return_value=("get_assistance", 1)), + ), ): response = await get_assistance(update, context) update.callback_query.answer.assert_called_once() - update.callback_query.edit_message_text.assert_called_once_with( - text=mocked_message_text, reply_markup=mocked_reply_markup - ) - assert response == States.REGION, ( - f"Invalid state value, should be {States.REGION}", + + # update.callback_query.edit_message_text.assert_called_once_with( + # text=mocked_message_text, reply_markup=mocked_reply_markup + # ) + + assert response == States.GET_ASSISTANCE, ( + f"Invalid state value, should be {States.GET_ASSISTANCE}", )