From 4e60d28292e3f90c82aa14b15f2058306086e61d Mon Sep 17 00:00:00 2001 From: Sergey Natalenko Date: Thu, 4 Jan 2024 22:16:33 +0300 Subject: [PATCH] Complete --- .github/workflows/check.yml | 13 +- .gitignore | 3 +- .pre-commit-config.yaml | 53 +---- Makefile | 8 +- README.md | 18 +- docker-compose.yaml | 18 +- {inclusive_dance_bot => idb}/__init__.py | 0 {inclusive_dance_bot => idb}/__main__.py | 13 +- {inclusive_dance_bot => idb}/arguments.py | 9 +- {inclusive_dance_bot => idb}/bot/__init__.py | 0 .../read => idb/bot/dialogs}/__init__.py | 0 .../bot/dialogs/admins}/__init__.py | 0 .../bot/dialogs/admins/feedbacks}/__init__.py | 0 .../admins/feedbacks/answer}/__init__.py | 0 .../dialogs/admins/feedbacks/answer/dialog.py | 13 ++ .../feedbacks/answer/windows}/__init__.py | 0 .../feedbacks/answer/windows/confirm.py | 44 ++++ .../feedbacks/answer/windows/input_message.py | 27 +++ .../dialogs/admins/feedbacks/read/__init__.py | 15 ++ idb/bot/dialogs/admins/feedbacks/read/item.py | 83 +++++++ idb/bot/dialogs/admins/feedbacks/read/menu.py | 36 +++ idb/bot/dialogs/admins/feedbacks/read/new.py | 64 +++++ .../dialogs/admins/feedbacks/read/viewed.py | 67 ++++++ idb/bot/dialogs/admins/feedbacks/router.py | 12 + .../bot/dialogs/admins/mailings/__init__.py | 2 +- .../admins/mailings/cancel}/__init__.py | 2 +- .../dialogs/admins/mailings/cancel/confirm.py | 10 +- .../admins/mailings/create/__init__.py | 2 +- .../mailings/create/choose_user_types.py | 12 +- .../dialogs/admins/mailings/create/confirm.py | 14 +- .../admins/mailings/create/input_content.py | 6 +- .../admins/mailings/create/input_date.py | 6 +- .../mailings/create/input_is_immediately.py | 4 +- .../admins/mailings/create/input_time.py | 6 +- .../admins/mailings/create/input_title.py | 6 +- .../dialogs/admins/mailings/read/__init__.py | 2 +- .../bot/dialogs/admins/mailings/read/item.py | 10 +- .../bot/dialogs/admins/mailings/read/items.py | 8 +- .../bot/dialogs/admins/mailings/read/menu.py | 4 +- .../bot/dialogs/admins/main_menu}/__init__.py | 0 idb/bot/dialogs/admins/main_menu/dialog.py | 5 + .../admins/main_menu/windows}/__init__.py | 0 .../dialogs/admins/main_menu/windows/menu.py | 76 ++++++ .../dialogs/admins/manage_admins/__init__.py | 2 +- .../admins/manage_admins/add/__init__.py | 7 + .../manage_admins/add/input_username.py | 12 +- .../admins/manage_admins/delete/__init__.py | 5 + .../admins/manage_admins/delete/confirm.py | 10 +- .../admins/manage_admins/read/__init__.py | 7 + .../admins/manage_admins/read/items.py | 9 +- idb/bot/dialogs/admins/router.py | 24 ++ .../bot/dialogs/admins/states.py | 6 +- .../bot/dialogs/admins/submenu}/__init__.py | 2 +- .../dialogs/admins/submenu/create/__init__.py | 2 +- .../dialogs/admins/submenu/create/confirm.py | 14 +- .../submenu/create/input_button_text.py | 6 +- .../admins/submenu/create/input_message.py | 8 +- .../admins/submenu/create/input_type.py | 10 +- .../admins/submenu/create/input_weight.py | 8 +- .../dialogs/admins/submenu/delete/__init__.py | 2 +- .../dialogs/admins/submenu/delete/confirm.py | 16 +- .../dialogs/admins/submenu/read/__init__.py | 2 +- .../bot/dialogs/admins/submenu/read/item.py | 32 ++- .../bot/dialogs/admins/submenu/read/items.py | 14 +- .../dialogs/admins/submenu/update/__init__.py | 2 +- .../submenu/update/change_button_text.py | 18 +- .../admins/submenu/update/change_message.py | 24 +- .../admins/submenu/update/change_type.py | 20 +- .../admins/submenu/update/change_weight.py | 16 +- .../bot/dialogs/admins/url}/__init__.py | 2 +- .../bot/dialogs/admins/url/create/__init__.py | 2 +- .../bot/dialogs/admins/url/create/confirm.py | 14 +- .../dialogs/admins/url/create/input_slug.py | 12 +- .../dialogs/admins/url/create/input_value.py | 4 +- .../dialogs/admins/url/delete}/__init__.py | 2 +- .../bot/dialogs/admins/url/delete/confirm.py | 16 +- .../bot/dialogs/admins/url/read/__init__.py | 2 +- .../bot/dialogs/admins/url/read/item.py | 17 +- .../bot/dialogs/admins/url/read/items.py | 16 +- .../bot/dialogs/admins/url/update/__init__.py | 2 +- .../dialogs/admins/url/update/change_slug.py | 22 +- .../dialogs/admins/url/update/change_value.py | 16 +- idb/bot/dialogs/commands.py | 11 + .../bot/dialogs/messages.py | 17 +- idb/bot/dialogs/router.py | 18 ++ .../bot/dialogs/users}/__init__.py | 0 .../bot/dialogs/users/feedback/__init__.py | 4 +- .../bot/dialogs/users/feedback/confirm.py | 21 +- .../bot/dialogs/users/feedback/input_text.py | 9 +- .../bot/dialogs/users/feedback/input_title.py | 6 +- idb/bot/dialogs/users/main_menu/__init__.py | 10 + .../bot/dialogs/users/main_menu/menu.py | 14 +- .../dialogs/users/registration/__init__.py | 2 +- .../users/registration/choose_user_types.py | 14 +- .../bot/dialogs/users/registration/confirm.py | 21 +- .../dialogs/users/registration/input_name.py | 10 +- .../dialogs/users/registration/input_phone.py | 6 +- .../users/registration/input_region.py | 6 +- .../bot/dialogs/users/router.py | 2 +- .../bot/dialogs/users/states.py | 0 .../bot/dialogs/users/submenu/__init__.py | 7 +- .../bot/dialogs/users/submenu/submenu_list.py | 18 +- .../bot/dialogs/users/windows}/__init__.py | 0 idb/bot/dialogs/utils/__init__.py | 4 + .../bot/dialogs/utils/buttons.py | 4 +- idb/bot/dialogs/utils/getters.py | 21 ++ .../bot/dialogs/utils/input_form_field.py | 4 +- .../bot/dialogs/utils/start_with_data.py | 2 +- .../bot/dialogs/utils/submenu_window.py | 14 +- .../bot/dialogs/utils/sync_scroll.py | 9 +- .../bot/dialogs/utils/validators.py | 0 {inclusive_dance_bot => idb}/bot/factory.py | 7 +- idb/bot/handlers.py | 31 +++ .../bot/middlewares}/__init__.py | 0 .../bot/middlewares/cache.py | 10 +- .../bot/middlewares/uow.py | 10 +- idb/bot/middlewares/user.py | 49 ++++ .../bot/services}/__init__.py | 0 idb/bot/services/bot.py | 73 ++++++ idb/bot/services/periodic.py | 21 ++ .../bot/ui_commands.py | 0 idb/bot/utils.py | 41 ++++ .../test_mailing => idb/db}/__init__.py | 0 {inclusive_dance_bot => idb}/db/__main__.py | 8 +- {inclusive_dance_bot => idb}/db/alembic.ini | 0 {inclusive_dance_bot => idb}/db/base.py | 0 .../db/migrations/env.py | 2 +- .../db/migrations/script.py.mako | 0 .../versions/2024_01_03_41c796b69ba4_.py | 62 ++++- .../db/migrations/versions}/__init__.py | 0 {inclusive_dance_bot => idb}/db/models.py | 42 +++- .../db/repositories}/__init__.py | 0 idb/db/repositories/answer.py | 59 +++++ .../db/repositories/base.py | 4 +- idb/db/repositories/feedback.py | 92 ++++++++ .../db/repositories/mailing.py | 76 +++--- idb/db/repositories/submenu.py | 121 ++++++++++ idb/db/repositories/url.py | 81 +++++++ idb/db/repositories/user.py | 131 +++++++++++ idb/db/repositories/user_type.py | 64 +++++ .../db/repositories/user_type_user.py | 6 +- idb/db/uow.py | 44 ++++ {inclusive_dance_bot => idb}/db/utils.py | 6 +- idb/deps.py | 44 ++++ .../test_process_new_mailing.py => idb/dto.py | 0 .../exceptions/__init__.py | 10 +- .../exceptions/base.py | 0 idb/exceptions/mailing.py | 5 + .../exceptions/submenu.py | 2 +- .../exceptions/url.py | 2 +- .../exceptions/user.py | 2 +- .../generals/__init__.py | 0 .../generals}/enums.py | 13 +- .../generals/models/__init__.py | 0 idb/generals/models/answer.py | 15 ++ idb/generals/models/feedback.py | 21 ++ idb/generals/models/mailing.py | 22 ++ idb/generals/models/submenu.py | 13 ++ idb/generals/models/url.py | 12 + idb/generals/models/user.py | 48 ++++ idb/generals/models/user_type.py | 8 + idb/logic/__init__.py | 0 idb/logic/answer.py | 24 ++ idb/logic/feedback.py | 56 +++++ {inclusive_dance_bot => idb}/logic/mailing.py | 14 +- {inclusive_dance_bot => idb}/logic/submenu.py | 28 +-- idb/logic/url.py | 47 ++++ idb/logic/users.py | 51 ++++ idb/utils/__init__.py | 0 idb/utils/cache.py | 220 ++++++++++++++++++ .../utils.py => idb/utils/urls.py | 4 +- inclusive_dance_bot/bot/dialogs/__init__.py | 13 -- .../bot/dialogs/admins/__init__.py | 20 -- .../bot/dialogs/admins/feedbacks/__init__.py | 9 - .../admins/feedbacks/answer/__init__.py | 7 - .../admins/feedbacks/answer/input_message.py | 7 - .../admins/feedbacks/items/__init__.py | 8 - .../dialogs/admins/feedbacks/items/archive.py | 7 - .../bot/dialogs/admins/feedbacks/items/new.py | 7 - .../bot/dialogs/admins/main_menu/__init__.py | 5 - .../bot/dialogs/admins/main_menu/read/menu.py | 53 ----- .../admins/manage_admins/add/__init__.py | 7 - .../admins/manage_admins/delete/__init__.py | 5 - .../admins/manage_admins/read/__init__.py | 7 - inclusive_dance_bot/bot/dialogs/commands.py | 25 -- .../bot/dialogs/users/main_menu/__init__.py | 10 - .../bot/dialogs/utils/__init__.py | 4 - .../bot/dialogs/utils/getters.py | 21 -- inclusive_dance_bot/bot/middlewares/user.py | 31 --- .../db/repositories/feedback.py | 43 ---- .../db/repositories/submenu.py | 82 ------- inclusive_dance_bot/db/repositories/url.py | 60 ----- inclusive_dance_bot/db/repositories/user.py | 92 -------- .../db/repositories/user_type.py | 44 ---- inclusive_dance_bot/db/uow/base.py | 24 -- inclusive_dance_bot/db/uow/main.py | 46 ---- inclusive_dance_bot/deps.py | 35 --- inclusive_dance_bot/dto.py | 148 ------------ inclusive_dance_bot/exceptions/mailing.py | 5 - inclusive_dance_bot/init_data.py | 130 ----------- inclusive_dance_bot/logic/feedback.py | 20 -- inclusive_dance_bot/logic/storage.py | 86 ------- inclusive_dance_bot/logic/url.py | 43 ---- inclusive_dance_bot/logic/user.py | 75 ------ inclusive_dance_bot/services/bot.py | 59 ----- inclusive_dance_bot/services/periodic.py | 20 -- inutils/init_data.py | 139 +++++++++++ inutils/init_data.yaml | 180 ++++++++++++++ poetry.lock | 189 +++++++++------ pyproject.toml | 49 +++- tests/conftest.py | 112 +-------- tests/factories.py | 32 ++- tests/plugins/__init__.py | 0 tests/plugins/cache.py | 27 +++ tests/plugins/database.py | 117 ++++++++++ .../test_migrations_up_to_date.py | 2 +- tests/test_logic/__init__.py | 0 tests/test_logic/test_feedback/__init__.py | 0 .../test_feedback/test_create_feedback.py | 10 +- tests/test_logic/test_mailing/__init__.py | 0 .../test_mailing/test_process_new_mailing.py | 0 .../test_mailing/test_save_mailing.py | 0 .../test_mailing/test_send_mailngs.py | 0 tests/test_logic/test_url/__init__.py | 0 tests/test_logic/test_url/test_create_url.py | 33 +++ .../test_url/test_delete_url_by_slug.py | 20 ++ .../test_url/test_update_url_by_slug.py | 47 ++++ tests/test_logic/test_user/__init__.py | 0 .../test_logic/test_user/test_create_user.py | 0 tests/test_unit/conftest.py | 17 -- tests/test_unit/test_cache/__init__.py | 0 tests/test_unit/test_cache/test_memory.py | 0 tests/test_unit/test_cache/test_redis.py | 0 tests/test_unit/test_init_data.py | 52 ++++- tests/test_unit/test_repositories/conftest.py | 12 +- .../test_repositories/test_feedback.py | 14 +- .../test_repositories/test_submenu.py | 35 +-- tests/test_unit/test_repositories/test_url.py | 20 +- .../test_unit/test_repositories/test_user.py | 55 +++-- .../test_repositories/test_user_type.py | 20 +- .../test_repositories/test_user_type_user.py | 6 +- tests/test_unit/test_services/test_storage.py | 73 ------ .../test_services/test_url/test_create_url.py | 30 --- .../test_url/test_delete_url_by_slug.py | 20 -- .../test_url/test_update_url_by_slug.py | 43 ---- .../test_user/test_create_user.py | 68 ------ 246 files changed, 3410 insertions(+), 2301 deletions(-) rename {inclusive_dance_bot => idb}/__init__.py (100%) rename {inclusive_dance_bot => idb}/__main__.py (72%) rename {inclusive_dance_bot => idb}/arguments.py (87%) rename {inclusive_dance_bot => idb}/bot/__init__.py (100%) rename {inclusive_dance_bot/bot/dialogs/admins/main_menu/read => idb/bot/dialogs}/__init__.py (100%) rename {inclusive_dance_bot/bot/dialogs/users/windows => idb/bot/dialogs/admins}/__init__.py (100%) rename {inclusive_dance_bot/bot/middlewares => idb/bot/dialogs/admins/feedbacks}/__init__.py (100%) rename {inclusive_dance_bot/db => idb/bot/dialogs/admins/feedbacks/answer}/__init__.py (100%) create mode 100644 idb/bot/dialogs/admins/feedbacks/answer/dialog.py rename {inclusive_dance_bot/db/migrations/versions => idb/bot/dialogs/admins/feedbacks/answer/windows}/__init__.py (100%) create mode 100644 idb/bot/dialogs/admins/feedbacks/answer/windows/confirm.py create mode 100644 idb/bot/dialogs/admins/feedbacks/answer/windows/input_message.py create mode 100644 idb/bot/dialogs/admins/feedbacks/read/__init__.py create mode 100644 idb/bot/dialogs/admins/feedbacks/read/item.py create mode 100644 idb/bot/dialogs/admins/feedbacks/read/menu.py create mode 100644 idb/bot/dialogs/admins/feedbacks/read/new.py create mode 100644 idb/bot/dialogs/admins/feedbacks/read/viewed.py create mode 100644 idb/bot/dialogs/admins/feedbacks/router.py rename {inclusive_dance_bot => idb}/bot/dialogs/admins/mailings/__init__.py (61%) rename {inclusive_dance_bot/bot/dialogs/admins/url/delete => idb/bot/dialogs/admins/mailings/cancel}/__init__.py (51%) rename {inclusive_dance_bot => idb}/bot/dialogs/admins/mailings/cancel/confirm.py (74%) rename {inclusive_dance_bot => idb}/bot/dialogs/admins/mailings/create/__init__.py (84%) rename {inclusive_dance_bot => idb}/bot/dialogs/admins/mailings/create/choose_user_types.py (77%) rename {inclusive_dance_bot => idb}/bot/dialogs/admins/mailings/create/confirm.py (86%) rename {inclusive_dance_bot => idb}/bot/dialogs/admins/mailings/create/input_content.py (78%) rename {inclusive_dance_bot => idb}/bot/dialogs/admins/mailings/create/input_date.py (78%) rename {inclusive_dance_bot => idb}/bot/dialogs/admins/mailings/create/input_is_immediately.py (76%) rename {inclusive_dance_bot => idb}/bot/dialogs/admins/mailings/create/input_time.py (79%) rename {inclusive_dance_bot => idb}/bot/dialogs/admins/mailings/create/input_title.py (78%) rename {inclusive_dance_bot => idb}/bot/dialogs/admins/mailings/read/__init__.py (56%) rename {inclusive_dance_bot => idb}/bot/dialogs/admins/mailings/read/item.py (84%) rename {inclusive_dance_bot => idb}/bot/dialogs/admins/mailings/read/items.py (87%) rename {inclusive_dance_bot => idb}/bot/dialogs/admins/mailings/read/menu.py (87%) rename {inclusive_dance_bot/db/repositories => idb/bot/dialogs/admins/main_menu}/__init__.py (100%) create mode 100644 idb/bot/dialogs/admins/main_menu/dialog.py rename {inclusive_dance_bot/db/uow => idb/bot/dialogs/admins/main_menu/windows}/__init__.py (100%) create mode 100644 idb/bot/dialogs/admins/main_menu/windows/menu.py rename {inclusive_dance_bot => idb}/bot/dialogs/admins/manage_admins/__init__.py (60%) create mode 100644 idb/bot/dialogs/admins/manage_admins/add/__init__.py rename {inclusive_dance_bot => idb}/bot/dialogs/admins/manage_admins/add/input_username.py (77%) create mode 100644 idb/bot/dialogs/admins/manage_admins/delete/__init__.py rename {inclusive_dance_bot => idb}/bot/dialogs/admins/manage_admins/delete/confirm.py (81%) create mode 100644 idb/bot/dialogs/admins/manage_admins/read/__init__.py rename {inclusive_dance_bot => idb}/bot/dialogs/admins/manage_admins/read/items.py (84%) create mode 100644 idb/bot/dialogs/admins/router.py rename {inclusive_dance_bot => idb}/bot/dialogs/admins/states.py (93%) rename {inclusive_dance_bot/bot/dialogs/admins/url => idb/bot/dialogs/admins/submenu}/__init__.py (63%) rename {inclusive_dance_bot => idb}/bot/dialogs/admins/submenu/create/__init__.py (79%) rename {inclusive_dance_bot => idb}/bot/dialogs/admins/submenu/create/confirm.py (78%) rename {inclusive_dance_bot => idb}/bot/dialogs/admins/submenu/create/input_button_text.py (77%) rename {inclusive_dance_bot => idb}/bot/dialogs/admins/submenu/create/input_message.py (72%) rename {inclusive_dance_bot => idb}/bot/dialogs/admins/submenu/create/input_type.py (77%) rename {inclusive_dance_bot => idb}/bot/dialogs/admins/submenu/create/input_weight.py (76%) rename {inclusive_dance_bot => idb}/bot/dialogs/admins/submenu/delete/__init__.py (50%) rename {inclusive_dance_bot => idb}/bot/dialogs/admins/submenu/delete/confirm.py (66%) rename {inclusive_dance_bot => idb}/bot/dialogs/admins/submenu/read/__init__.py (54%) rename {inclusive_dance_bot => idb}/bot/dialogs/admins/submenu/read/item.py (63%) rename {inclusive_dance_bot => idb}/bot/dialogs/admins/submenu/read/items.py (78%) rename {inclusive_dance_bot => idb}/bot/dialogs/admins/submenu/update/__init__.py (78%) rename {inclusive_dance_bot => idb}/bot/dialogs/admins/submenu/update/change_button_text.py (63%) rename {inclusive_dance_bot => idb}/bot/dialogs/admins/submenu/update/change_message.py (50%) rename {inclusive_dance_bot => idb}/bot/dialogs/admins/submenu/update/change_type.py (69%) rename {inclusive_dance_bot => idb}/bot/dialogs/admins/submenu/update/change_weight.py (64%) rename {inclusive_dance_bot/bot/dialogs/admins/submenu => idb/bot/dialogs/admins/url}/__init__.py (62%) rename {inclusive_dance_bot => idb}/bot/dialogs/admins/url/create/__init__.py (72%) rename {inclusive_dance_bot => idb}/bot/dialogs/admins/url/create/confirm.py (71%) rename {inclusive_dance_bot => idb}/bot/dialogs/admins/url/create/input_slug.py (73%) rename {inclusive_dance_bot => idb}/bot/dialogs/admins/url/create/input_value.py (82%) rename {inclusive_dance_bot/bot/dialogs/admins/mailings/cancel => idb/bot/dialogs/admins/url/delete}/__init__.py (50%) rename {inclusive_dance_bot => idb}/bot/dialogs/admins/url/delete/confirm.py (64%) rename {inclusive_dance_bot => idb}/bot/dialogs/admins/url/read/__init__.py (83%) rename {inclusive_dance_bot => idb}/bot/dialogs/admins/url/read/item.py (63%) rename {inclusive_dance_bot => idb}/bot/dialogs/admins/url/read/items.py (72%) rename {inclusive_dance_bot => idb}/bot/dialogs/admins/url/update/__init__.py (54%) rename {inclusive_dance_bot => idb}/bot/dialogs/admins/url/update/change_slug.py (65%) rename {inclusive_dance_bot => idb}/bot/dialogs/admins/url/update/change_value.py (59%) create mode 100644 idb/bot/dialogs/commands.py rename {inclusive_dance_bot => idb}/bot/dialogs/messages.py (89%) create mode 100644 idb/bot/dialogs/router.py rename {inclusive_dance_bot/logic => idb/bot/dialogs/users}/__init__.py (100%) rename {inclusive_dance_bot => idb}/bot/dialogs/users/feedback/__init__.py (77%) rename {inclusive_dance_bot => idb}/bot/dialogs/users/feedback/confirm.py (70%) rename {inclusive_dance_bot => idb}/bot/dialogs/users/feedback/input_text.py (68%) rename {inclusive_dance_bot => idb}/bot/dialogs/users/feedback/input_title.py (76%) create mode 100644 idb/bot/dialogs/users/main_menu/__init__.py rename {inclusive_dance_bot => idb}/bot/dialogs/users/main_menu/menu.py (87%) rename {inclusive_dance_bot => idb}/bot/dialogs/users/registration/__init__.py (80%) rename {inclusive_dance_bot => idb}/bot/dialogs/users/registration/choose_user_types.py (74%) rename {inclusive_dance_bot => idb}/bot/dialogs/users/registration/confirm.py (78%) rename {inclusive_dance_bot => idb}/bot/dialogs/users/registration/input_name.py (66%) rename {inclusive_dance_bot => idb}/bot/dialogs/users/registration/input_phone.py (72%) rename {inclusive_dance_bot => idb}/bot/dialogs/users/registration/input_region.py (73%) rename inclusive_dance_bot/bot/dialogs/users/__init__.py => idb/bot/dialogs/users/router.py (83%) rename {inclusive_dance_bot => idb}/bot/dialogs/users/states.py (100%) rename {inclusive_dance_bot => idb}/bot/dialogs/users/submenu/__init__.py (63%) rename {inclusive_dance_bot => idb}/bot/dialogs/users/submenu/submenu_list.py (67%) rename {inclusive_dance_bot/services => idb/bot/dialogs/users/windows}/__init__.py (100%) create mode 100644 idb/bot/dialogs/utils/__init__.py rename {inclusive_dance_bot => idb}/bot/dialogs/utils/buttons.py (51%) create mode 100644 idb/bot/dialogs/utils/getters.py rename {inclusive_dance_bot => idb}/bot/dialogs/utils/input_form_field.py (90%) rename {inclusive_dance_bot => idb}/bot/dialogs/utils/start_with_data.py (90%) rename {inclusive_dance_bot => idb}/bot/dialogs/utils/submenu_window.py (64%) rename {inclusive_dance_bot => idb}/bot/dialogs/utils/sync_scroll.py (70%) rename {inclusive_dance_bot => idb}/bot/dialogs/utils/validators.py (100%) rename {inclusive_dance_bot => idb}/bot/factory.py (74%) create mode 100644 idb/bot/handlers.py rename {tests/test_unit/test_services => idb/bot/middlewares}/__init__.py (100%) rename inclusive_dance_bot/bot/middlewares/storage.py => idb/bot/middlewares/cache.py (67%) rename {inclusive_dance_bot => idb}/bot/middlewares/uow.py (63%) create mode 100644 idb/bot/middlewares/user.py rename {tests/test_unit/test_services/test_feedback => idb/bot/services}/__init__.py (100%) create mode 100644 idb/bot/services/bot.py create mode 100644 idb/bot/services/periodic.py rename {inclusive_dance_bot => idb}/bot/ui_commands.py (100%) create mode 100644 idb/bot/utils.py rename {tests/test_unit/test_services/test_mailing => idb/db}/__init__.py (100%) rename {inclusive_dance_bot => idb}/db/__main__.py (72%) rename {inclusive_dance_bot => idb}/db/alembic.ini (100%) rename {inclusive_dance_bot => idb}/db/base.py (100%) rename {inclusive_dance_bot => idb}/db/migrations/env.py (97%) rename {inclusive_dance_bot => idb}/db/migrations/script.py.mako (100%) rename inclusive_dance_bot/db/migrations/versions/2023_11_14_0228a02c3bb6_initial_commit.py => idb/db/migrations/versions/2024_01_03_41c796b69ba4_.py (76%) rename {tests/test_unit/test_services/test_url => idb/db/migrations/versions}/__init__.py (100%) rename {inclusive_dance_bot => idb}/db/models.py (78%) rename {tests/test_unit/test_services/test_user => idb/db/repositories}/__init__.py (100%) create mode 100644 idb/db/repositories/answer.py rename {inclusive_dance_bot => idb}/db/repositories/base.py (91%) create mode 100644 idb/db/repositories/feedback.py rename {inclusive_dance_bot => idb}/db/repositories/mailing.py (58%) create mode 100644 idb/db/repositories/submenu.py create mode 100644 idb/db/repositories/url.py create mode 100644 idb/db/repositories/user.py create mode 100644 idb/db/repositories/user_type.py rename {inclusive_dance_bot => idb}/db/repositories/user_type_user.py (90%) create mode 100644 idb/db/uow.py rename {inclusive_dance_bot => idb}/db/utils.py (87%) create mode 100644 idb/deps.py rename tests/test_unit/test_services/test_mailing/test_process_new_mailing.py => idb/dto.py (100%) rename {inclusive_dance_bot => idb}/exceptions/__init__.py (75%) rename {inclusive_dance_bot => idb}/exceptions/base.py (100%) create mode 100644 idb/exceptions/mailing.py rename {inclusive_dance_bot => idb}/exceptions/submenu.py (78%) rename {inclusive_dance_bot => idb}/exceptions/url.py (83%) rename {inclusive_dance_bot => idb}/exceptions/user.py (90%) rename tests/test_unit/test_services/test_mailing/test_save_mailing.py => idb/generals/__init__.py (100%) rename {inclusive_dance_bot => idb/generals}/enums.py (76%) rename tests/test_unit/test_services/test_mailing/test_send_mailngs.py => idb/generals/models/__init__.py (100%) create mode 100644 idb/generals/models/answer.py create mode 100644 idb/generals/models/feedback.py create mode 100644 idb/generals/models/mailing.py create mode 100644 idb/generals/models/submenu.py create mode 100644 idb/generals/models/url.py create mode 100644 idb/generals/models/user.py create mode 100644 idb/generals/models/user_type.py create mode 100644 idb/logic/__init__.py create mode 100644 idb/logic/answer.py create mode 100644 idb/logic/feedback.py rename {inclusive_dance_bot => idb}/logic/mailing.py (87%) rename {inclusive_dance_bot => idb}/logic/submenu.py (70%) create mode 100644 idb/logic/url.py create mode 100644 idb/logic/users.py create mode 100644 idb/utils/__init__.py create mode 100644 idb/utils/cache.py rename inclusive_dance_bot/utils.py => idb/utils/urls.py (57%) delete mode 100644 inclusive_dance_bot/bot/dialogs/__init__.py delete mode 100644 inclusive_dance_bot/bot/dialogs/admins/__init__.py delete mode 100644 inclusive_dance_bot/bot/dialogs/admins/feedbacks/__init__.py delete mode 100644 inclusive_dance_bot/bot/dialogs/admins/feedbacks/answer/__init__.py delete mode 100644 inclusive_dance_bot/bot/dialogs/admins/feedbacks/answer/input_message.py delete mode 100644 inclusive_dance_bot/bot/dialogs/admins/feedbacks/items/__init__.py delete mode 100644 inclusive_dance_bot/bot/dialogs/admins/feedbacks/items/archive.py delete mode 100644 inclusive_dance_bot/bot/dialogs/admins/feedbacks/items/new.py delete mode 100644 inclusive_dance_bot/bot/dialogs/admins/main_menu/__init__.py delete mode 100644 inclusive_dance_bot/bot/dialogs/admins/main_menu/read/menu.py delete mode 100644 inclusive_dance_bot/bot/dialogs/admins/manage_admins/add/__init__.py delete mode 100644 inclusive_dance_bot/bot/dialogs/admins/manage_admins/delete/__init__.py delete mode 100644 inclusive_dance_bot/bot/dialogs/admins/manage_admins/read/__init__.py delete mode 100644 inclusive_dance_bot/bot/dialogs/commands.py delete mode 100644 inclusive_dance_bot/bot/dialogs/users/main_menu/__init__.py delete mode 100644 inclusive_dance_bot/bot/dialogs/utils/__init__.py delete mode 100644 inclusive_dance_bot/bot/dialogs/utils/getters.py delete mode 100644 inclusive_dance_bot/bot/middlewares/user.py delete mode 100644 inclusive_dance_bot/db/repositories/feedback.py delete mode 100644 inclusive_dance_bot/db/repositories/submenu.py delete mode 100644 inclusive_dance_bot/db/repositories/url.py delete mode 100644 inclusive_dance_bot/db/repositories/user.py delete mode 100644 inclusive_dance_bot/db/repositories/user_type.py delete mode 100644 inclusive_dance_bot/db/uow/base.py delete mode 100644 inclusive_dance_bot/db/uow/main.py delete mode 100644 inclusive_dance_bot/deps.py delete mode 100644 inclusive_dance_bot/dto.py delete mode 100644 inclusive_dance_bot/exceptions/mailing.py delete mode 100644 inclusive_dance_bot/init_data.py delete mode 100644 inclusive_dance_bot/logic/feedback.py delete mode 100644 inclusive_dance_bot/logic/storage.py delete mode 100644 inclusive_dance_bot/logic/url.py delete mode 100644 inclusive_dance_bot/logic/user.py delete mode 100644 inclusive_dance_bot/services/bot.py delete mode 100644 inclusive_dance_bot/services/periodic.py create mode 100644 inutils/init_data.py create mode 100644 inutils/init_data.yaml create mode 100644 tests/plugins/__init__.py create mode 100644 tests/plugins/cache.py create mode 100644 tests/plugins/database.py create mode 100644 tests/test_logic/__init__.py create mode 100644 tests/test_logic/test_feedback/__init__.py rename tests/{test_unit/test_services => test_logic}/test_feedback/test_create_feedback.py (78%) create mode 100644 tests/test_logic/test_mailing/__init__.py create mode 100644 tests/test_logic/test_mailing/test_process_new_mailing.py create mode 100644 tests/test_logic/test_mailing/test_save_mailing.py create mode 100644 tests/test_logic/test_mailing/test_send_mailngs.py create mode 100644 tests/test_logic/test_url/__init__.py create mode 100644 tests/test_logic/test_url/test_create_url.py create mode 100644 tests/test_logic/test_url/test_delete_url_by_slug.py create mode 100644 tests/test_logic/test_url/test_update_url_by_slug.py create mode 100644 tests/test_logic/test_user/__init__.py create mode 100644 tests/test_logic/test_user/test_create_user.py delete mode 100644 tests/test_unit/conftest.py create mode 100644 tests/test_unit/test_cache/__init__.py create mode 100644 tests/test_unit/test_cache/test_memory.py create mode 100644 tests/test_unit/test_cache/test_redis.py delete mode 100644 tests/test_unit/test_services/test_storage.py delete mode 100644 tests/test_unit/test_services/test_url/test_create_url.py delete mode 100644 tests/test_unit/test_services/test_url/test_delete_url_by_slug.py delete mode 100644 tests/test_unit/test_services/test_url/test_update_url_by_slug.py delete mode 100644 tests/test_unit/test_services/test_user/test_create_user.py diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 820f6c5..1dc4877 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -30,17 +30,8 @@ jobs: - name: Install dependencies run: make develop - - name: Run flake8 - run: make flake - - - name: Run black - run: make black - - - name: Run bandit - run: make bandit - - - name: Run mypy - run: make mypy + - name: Run CI linters + run: make lint-ci test: name: Run service tests with pytest diff --git a/.gitignore b/.gitignore index 68bc17f..195ec50 100644 --- a/.gitignore +++ b/.gitignore @@ -157,4 +157,5 @@ cython_debug/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +.idea/ +.vscode \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2eeff45..e8cacea 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -22,59 +22,18 @@ repos: types: [python] - id: trailing-whitespace - - repo: https://github.com/pre-commit/pygrep-hooks - rev: v1.10.0 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.1.7 hooks: - - id: python-check-blanket-noqa - - id: python-check-mock-methods - - id: python-no-eval - - id: python-no-log-warn - - id: python-use-type-annotations - - id: text-unicode-replacement-char - - - repo: https://github.com/asottile/pyupgrade - rev: v3.15.0 - hooks: - - id: pyupgrade - args: [--py311-plus] - - - repo: https://github.com/pycqa/autoflake - rev: v2.2.1 - hooks: - - id: autoflake - args: - - --in-place - - --remove-all-unused-imports - - --remove-unused-variables - - --ignore-init-module-imports - - - repo: https://github.com/psf/black - rev: 23.10.1 - hooks: - - id: black - language_version: python3.11 - - - repo: https://github.com/pycqa/bandit - rev: 1.7.5 - hooks: - - id: bandit - args: - - --aggregate=file - - -iii - - -ll - require_serial: true - - repo: https://github.com/pycqa/isort - rev: 5.12.0 - hooks: - - id: isort - name: isort (python) - args: [--profile black] + - id: ruff + args: [--fix] + - id: ruff-format - repo: local hooks: - id: mypy name: mypy - entry: mypy ./inclusive_dance_bot --config-file ./pyproject.toml + entry: mypy ./idb --config-file ./pyproject.toml language: python language_version: python3.11 require_serial: true diff --git a/Makefile b/Makefile index 5c385a9..90e8a8b 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -PROJECT_PATH = ./inclusive_dance_bot/ +PROJECT_PATH = ./idb/ TEST_PATH = ./tests/ HELP_FUN = \ @@ -11,13 +11,13 @@ help: ##@Help Show this help @echo -e "Usage: make [target] ...\n" @perl -e '$(HELP_FUN)' $(MAKEFILE_LIST) -lint-ci: flake black bandit mypy ##@Linting Run all linters in CI +lint-ci: flake ruff bandit mypy ##@Linting Run all linters in CI flake: ##@Linting Run flake8 .venv/bin/flake8 --max-line-length 88 --format=default $(PROJECT_PATH) 2>&1 | tee flake8.txt -black: ##@Linting Run black - .venv/bin/black $(PROJECT_PATH) --check +ruff: ##@Linting Run ruff + .venv/bin/ruff check $(PROJECT_PATH) bandit: ##@Linting Run bandit .venv/bin/bandit -r -ll -iii $(PROJECT_PATH) -f json -o ./bandit.json diff --git a/README.md b/README.md index 2de33dd..7dcb494 100644 --- a/README.md +++ b/README.md @@ -29,21 +29,13 @@ Environment variables are used to configure the bot for connecting to Telegram, ```bash -TELEGRAM_BOT_TOKEN # your bot token -TELEGRAM_BOT_ADMIN_IDS # Superadmin IDs, who can appoint other admins +APP_TELEGRAM_BOT_TOKEN # your bot token +APP_TELEGRAM_BOT_ADMIN_IDS # Superadmin IDs, who can appoint other admins -DEBUG # Flag for debugging (using in sqlalchemy engine for echo) +APP_DEBUG # Flag for debugging (using in sqlalchemy engine for echo) -POSTGRES_HOST # database host -POSTGRES_PORT # database port -POSTGRES_USER # database user -POSTGRES_PASSWORD # database password -POSTGRES_DB # database name - -REDIS_HOST # redis host -REDIS_PORT # redis port -REDIS_PASSWORD # redis password -REDIS_DB # redis db +APP_PG_DSN # DSN of your postgresql database +APP_REDIS_DSN # DSN of your redis storage ``` ### Docker diff --git a/docker-compose.yaml b/docker-compose.yaml index 14a61fe..0ee75a2 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -15,27 +15,17 @@ services: redis: image: redis restart: unless-stopped - command: redis-server --requirepass ${REDIS_PASSWORD} + command: redis-server --requirepass $REDIS_PASSWORD bot: image: andytakker/inclusive_dance_bot:latest restart: unless-stopped command: /wait-for-it.sh -t 15 -h db -p 5432 -- /app/start.sh environment: - POSTGRES_HOST: db - POSTGRES_PORT: 5432 - POSTGRES_USER: $POSTGRES_USER - POSTGRES_PASSWORD: $POSTGRES_PASSWORD - POSTGRES_DB: $POSTGRES_DB - - PG_URL: postgresql+asyncpg://$POSTGRES_USER:$POSTGRES_PASSWORD@db:5432/$POSTGRES_DB - - REDIS_HOST: redis - REDIS_PORT: 6379 - REDIS_PASSWORD: $REDIS_PASSWORD - REDIS_DB: $REDIS_DB + APP_REDIS_DSN: redis://:$REDIS_PASSWORD@redis:6379/1 + APP_PG_DSN: postgresql+asyncpg://$POSTGRES_USER:$POSTGRES_PASSWORD@postgres:5432/$POSTGRES_DB - TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN} + APP_TELEGRAM_BOT_TOKEN: $TELEGRAM_BOT_TOKEN volumes: postgres_data: diff --git a/inclusive_dance_bot/__init__.py b/idb/__init__.py similarity index 100% rename from inclusive_dance_bot/__init__.py rename to idb/__init__.py diff --git a/inclusive_dance_bot/__main__.py b/idb/__main__.py similarity index 72% rename from inclusive_dance_bot/__main__.py rename to idb/__main__.py index f87e1e5..e484f15 100644 --- a/inclusive_dance_bot/__main__.py +++ b/idb/__main__.py @@ -3,10 +3,10 @@ from aiomisc import Service, entrypoint from aiomisc_log import basic_config -from inclusive_dance_bot.arguments import get_parser -from inclusive_dance_bot.deps import config_deps -from inclusive_dance_bot.services.bot import AiogramBotService -from inclusive_dance_bot.services.periodic import PeriodicMailingService +from idb.arguments import get_parser +from idb.bot.services.bot import AiogramBotService +from idb.bot.services.periodic import PeriodicMailingService +from idb.deps import config_deps log = logging.getLogger(__name__) @@ -14,9 +14,10 @@ def main() -> None: parser = get_parser() arguments = parser.parse_args() + basic_config( - log_format=arguments.log_level, - level=arguments.log_format, + log_format=arguments.log_format, + level=arguments.log_level, ) config_deps(arguments=arguments) services: list[Service] = [ diff --git a/inclusive_dance_bot/arguments.py b/idb/arguments.py similarity index 87% rename from inclusive_dance_bot/arguments.py rename to idb/arguments.py index 8db0fd3..bc73b51 100644 --- a/inclusive_dance_bot/arguments.py +++ b/idb/arguments.py @@ -1,6 +1,7 @@ import argparse import json +from aiogram.enums import ParseMode from aiomisc_log import LogFormat, LogLevel from configargparse import ArgumentParser @@ -13,7 +14,7 @@ def int_list(s: str) -> list[int]: if not all(map(lambda x: isinstance(x, int), value)): raise ValueError return value - except Exception: + except (json.JSONDecodeError, ValueError): raise ValueError("This is not list of integers") @@ -42,6 +43,12 @@ def get_parser() -> ArgumentParser: group = parser.add_argument_group("Telegram Bot options") group.add_argument("--telegram-bot-token", required=True) group.add_argument("--telegram-bot-admin-ids", type=int_list, default=[]) + group.add_argument( + "--telegram-parse-mode", + type=ParseMode, + choices=tuple(ParseMode._member_names_), + default=ParseMode.HTML, + ) group.add_argument("--telegram-periodic-interval", type=int, default=5 * 60) group.add_argument("--telegram-mailing-gap", type=int, default=2 * 60) diff --git a/inclusive_dance_bot/bot/__init__.py b/idb/bot/__init__.py similarity index 100% rename from inclusive_dance_bot/bot/__init__.py rename to idb/bot/__init__.py diff --git a/inclusive_dance_bot/bot/dialogs/admins/main_menu/read/__init__.py b/idb/bot/dialogs/__init__.py similarity index 100% rename from inclusive_dance_bot/bot/dialogs/admins/main_menu/read/__init__.py rename to idb/bot/dialogs/__init__.py diff --git a/inclusive_dance_bot/bot/dialogs/users/windows/__init__.py b/idb/bot/dialogs/admins/__init__.py similarity index 100% rename from inclusive_dance_bot/bot/dialogs/users/windows/__init__.py rename to idb/bot/dialogs/admins/__init__.py diff --git a/inclusive_dance_bot/bot/middlewares/__init__.py b/idb/bot/dialogs/admins/feedbacks/__init__.py similarity index 100% rename from inclusive_dance_bot/bot/middlewares/__init__.py rename to idb/bot/dialogs/admins/feedbacks/__init__.py diff --git a/inclusive_dance_bot/db/__init__.py b/idb/bot/dialogs/admins/feedbacks/answer/__init__.py similarity index 100% rename from inclusive_dance_bot/db/__init__.py rename to idb/bot/dialogs/admins/feedbacks/answer/__init__.py diff --git a/idb/bot/dialogs/admins/feedbacks/answer/dialog.py b/idb/bot/dialogs/admins/feedbacks/answer/dialog.py new file mode 100644 index 0000000..6f8df0a --- /dev/null +++ b/idb/bot/dialogs/admins/feedbacks/answer/dialog.py @@ -0,0 +1,13 @@ +from aiogram_dialog import Dialog + +from idb.bot.dialogs.admins.feedbacks.answer.windows.confirm import ( + window as confirm_window, +) +from idb.bot.dialogs.admins.feedbacks.answer.windows.input_message import ( + window as input_message_window, +) + +dialog = Dialog( + input_message_window, + confirm_window, +) diff --git a/inclusive_dance_bot/db/migrations/versions/__init__.py b/idb/bot/dialogs/admins/feedbacks/answer/windows/__init__.py similarity index 100% rename from inclusive_dance_bot/db/migrations/versions/__init__.py rename to idb/bot/dialogs/admins/feedbacks/answer/windows/__init__.py diff --git a/idb/bot/dialogs/admins/feedbacks/answer/windows/confirm.py b/idb/bot/dialogs/admins/feedbacks/answer/windows/confirm.py new file mode 100644 index 0000000..883003a --- /dev/null +++ b/idb/bot/dialogs/admins/feedbacks/answer/windows/confirm.py @@ -0,0 +1,44 @@ +from datetime import UTC, datetime + +from aiogram.types import CallbackQuery +from aiogram_dialog import DialogManager, Window +from aiogram_dialog.widgets.kbd import Button, Row +from aiogram_dialog.widgets.text import Const + +from idb.bot.dialogs.admins.states import FeedbackAnswerSG +from idb.bot.dialogs.utils.buttons import CANCEL +from idb.db.uow import UnitOfWork +from idb.logic.answer import create_feedback_answer +from idb.logic.feedback import update_answered_feedback + + +async def on_click( + c: CallbackQuery, button: Button, dialog_manager: DialogManager +) -> None: + uow: UnitOfWork = dialog_manager.middleware_data["uow"] + feedback_id = int(dialog_manager.start_data["feedback_id"]) + text = str(dialog_manager.current_context().widget_data["input_answer"]) + answer = await create_feedback_answer( + uow=uow, + feedback_id=feedback_id, + text=text, + from_user_id=dialog_manager.event.from_user.id, # type: ignore[union-attr] + ) + + await c.bot.send_message( # type: ignore[union-attr] + chat_id=answer.to_user_id, + text=answer.text, + ) + await update_answered_feedback( + uow=uow, + feedback_id=feedback_id, + dt=datetime.now(UTC), + ) + await dialog_manager.done() + + +window = Window( + Const("Отправить сообщение?"), + Row(CANCEL, Button(text=Const("📨 Да"), id="send_message", on_click=on_click)), + state=FeedbackAnswerSG.confirm, +) diff --git a/idb/bot/dialogs/admins/feedbacks/answer/windows/input_message.py b/idb/bot/dialogs/admins/feedbacks/answer/windows/input_message.py new file mode 100644 index 0000000..569fe76 --- /dev/null +++ b/idb/bot/dialogs/admins/feedbacks/answer/windows/input_message.py @@ -0,0 +1,27 @@ +from aiogram.types import Message +from aiogram_dialog import DialogManager, Window +from aiogram_dialog.widgets.input import ManagedTextInput, TextInput +from aiogram_dialog.widgets.text import Const + +from idb.bot.dialogs.admins.states import FeedbackAnswerSG +from idb.bot.dialogs.utils.buttons import CANCEL + + +async def on_success_next( + message: Message, + widget: ManagedTextInput[str], + dialog_manager: DialogManager, + value: str, +) -> None: + await dialog_manager.next() + + +window = Window( + Const("Введите сообщение"), + TextInput( + id="input_answer", + on_success=on_success_next, # type: ignore[arg-type] + ), + CANCEL, + state=FeedbackAnswerSG.input_message, +) diff --git a/idb/bot/dialogs/admins/feedbacks/read/__init__.py b/idb/bot/dialogs/admins/feedbacks/read/__init__.py new file mode 100644 index 0000000..be8aed0 --- /dev/null +++ b/idb/bot/dialogs/admins/feedbacks/read/__init__.py @@ -0,0 +1,15 @@ +from aiogram_dialog import Dialog + +from idb.bot.dialogs.admins.feedbacks.read import ( + item, + menu, + new, + viewed, +) + +dialog = Dialog( + menu.window, + new.window, + viewed.window, + item.window, +) diff --git a/idb/bot/dialogs/admins/feedbacks/read/item.py b/idb/bot/dialogs/admins/feedbacks/read/item.py new file mode 100644 index 0000000..e797b28 --- /dev/null +++ b/idb/bot/dialogs/admins/feedbacks/read/item.py @@ -0,0 +1,83 @@ +from typing import Any + +from aiogram.types import CallbackQuery +from aiogram_dialog import DialogManager, Window +from aiogram_dialog.widgets.kbd import Button +from aiogram_dialog.widgets.text import Const, Jinja + +from idb.bot.dialogs.admins.states import ( + FeedbackAnswerSG, + FeedbackItemsSG, +) +from idb.bot.dialogs.utils.start_with_data import start_with_data +from idb.db.uow import UnitOfWork +from idb.generals.enums import FEEDBACK_TYPE_MAPPING, FeedbackStatus + +FEEDBACK_TEMPLATE = """ +Тема: {{ feedback.title }} +Тип: {{ feedback_type }} +Отправлено: {{ feedback.created_at|as_local_fmt }} +Просмотрено: {{ feedback.viewed_at|as_local_fmt }} + +{{ feedback.text }} + +{% if answers %} + +Ответы: + +{% for answer in answers %} +Отправлено: {{ answer.created_at|as_local_fmt }} +{{ answer.text }} +======= + +{% endfor %} + +{% endif %} +""" + + +async def get_feedback_data( + uow: UnitOfWork, dialog_manager: DialogManager, **kwargs: Any +) -> dict[str, Any]: + feedback_id = dialog_manager.dialog_data["feedback_id"] + feedback = await uow.feedbacks.read_by_id(feedback_id) + return { + "feedback": feedback, + "answers": await uow.answer.history(feedback_id=feedback_id), + "feedback_type": FEEDBACK_TYPE_MAPPING[feedback.type], + } + + +async def back( + c: CallbackQuery, + widget: Button, + dialog_manager: DialogManager, +) -> None: + if dialog_manager.dialog_data["back"] == FeedbackStatus.NEW: + await dialog_manager.switch_to(FeedbackItemsSG.new) + elif dialog_manager.dialog_data["back"] == FeedbackStatus.ARCHIVED: + await dialog_manager.switch_to(FeedbackItemsSG.archived) + + +def _when(data: dict, widget: Button, dialog_manager: DialogManager) -> bool: + feedback = data.get("feedback") + if feedback is None: + return False + return feedback.is_answered + + +window = Window( + Const("Обратная связь"), + Jinja(FEEDBACK_TEMPLATE), + Button( + Const("✏️ Написать пользователю"), + id="start_answer", + on_click=start_with_data( + state=FeedbackAnswerSG.input_message, + field="feedback_id", + ), + ), + Button(Const("⬅️ Назад"), id="back", on_click=back), + state=FeedbackItemsSG.item, + getter=get_feedback_data, +) diff --git a/idb/bot/dialogs/admins/feedbacks/read/menu.py b/idb/bot/dialogs/admins/feedbacks/read/menu.py new file mode 100644 index 0000000..0e52a6e --- /dev/null +++ b/idb/bot/dialogs/admins/feedbacks/read/menu.py @@ -0,0 +1,36 @@ +from typing import Any + +from aiogram_dialog import Window +from aiogram_dialog.widgets.kbd import SwitchTo +from aiogram_dialog.widgets.text import Const, Format + +from idb.bot.dialogs.admins.states import FeedbackItemsSG +from idb.bot.dialogs.utils.buttons import CANCEL +from idb.db.uow import UnitOfWork + + +async def get_data(uow: UnitOfWork, **kwargs: Any) -> dict[str, Any]: + archive_feedback_count = await uow.feedbacks.archive_count() + new_feedback_count = await uow.feedbacks.new_count() + return { + "archive_feedback_count": archive_feedback_count, + "new_feedback_count": new_feedback_count, + } + + +window = Window( + Const("Обратная связь"), + SwitchTo( + text=Format("🆕 Новые сообщения ({new_feedback_count})"), + state=FeedbackItemsSG.new, + id="new_feedbacks", + ), + SwitchTo( + text=Format("👁 Просмотренные сообщения ({archive_feedback_count})"), + state=FeedbackItemsSG.archived, + id="archive_feedbacks", + ), + CANCEL, + state=FeedbackItemsSG.menu, + getter=get_data, +) diff --git a/idb/bot/dialogs/admins/feedbacks/read/new.py b/idb/bot/dialogs/admins/feedbacks/read/new.py new file mode 100644 index 0000000..0f2b846 --- /dev/null +++ b/idb/bot/dialogs/admins/feedbacks/read/new.py @@ -0,0 +1,64 @@ +from datetime import UTC, datetime +from typing import Any + +from aiogram.types import CallbackQuery +from aiogram_dialog import DialogManager, Window +from aiogram_dialog.widgets.kbd import Button, ScrollingGroup, Select, SwitchTo +from aiogram_dialog.widgets.text import Const, Format, Jinja, List + +from idb.bot.dialogs.admins.states import FeedbackItemsSG +from idb.bot.dialogs.utils.sync_scroll import sync_scroll +from idb.db.uow import UnitOfWork +from idb.generals.enums import FeedbackStatus +from idb.logic.feedback import set_feedback_as_viewed + +SCROLL_KBD_ID = "feedback_scroll_id" +SCROLL_MESSAGE_ID = "feedback_message_scroll_id" + + +async def get_feedbacks_list_data(uow: UnitOfWork, **kwargs: Any) -> dict[str, Any]: + return {"feedbacks": await uow.feedbacks.new_items()} + + +async def on_click( + c: CallbackQuery, + widget: Button, + dialog_manager: DialogManager, + feedback_id: int, +) -> None: + dialog_manager.dialog_data["feedback_id"] = feedback_id + dialog_manager.dialog_data["back"] = FeedbackStatus.NEW + uow: UnitOfWork = dialog_manager.middleware_data["uow"] + dt = datetime.now(tz=UTC) + await set_feedback_as_viewed(uow=uow, feedback_id=feedback_id, dt=dt) + await c.answer("Сообщение отмечено прочитанным", show_alert=True) + await dialog_manager.switch_to(FeedbackItemsSG.item) + + +window = Window( + Const("Новые сообщения\n"), + List( + Jinja("[{{pos}}] {{ item.created_at|as_local_fmt }} {{item.title}}"), + items="feedbacks", + id=SCROLL_MESSAGE_ID, + page_size=10, + ), + ScrollingGroup( + Select( + Format("{pos}"), + id="s_feedback", + item_id_getter=lambda x: x.id, + items="feedbacks", + on_click=on_click, # type: ignore[arg-type] + type_factory=int, + ), + id=SCROLL_KBD_ID, + width=5, + height=2, + hide_on_single_page=True, + on_page_changed=sync_scroll(SCROLL_MESSAGE_ID), + ), + SwitchTo(Const("⬅️ Назад"), id="back", state=FeedbackItemsSG.menu), + state=FeedbackItemsSG.new, + getter=get_feedbacks_list_data, +) diff --git a/idb/bot/dialogs/admins/feedbacks/read/viewed.py b/idb/bot/dialogs/admins/feedbacks/read/viewed.py new file mode 100644 index 0000000..c90059f --- /dev/null +++ b/idb/bot/dialogs/admins/feedbacks/read/viewed.py @@ -0,0 +1,67 @@ +from typing import Any + +from aiogram.types import CallbackQuery +from aiogram_dialog import DialogManager, Window +from aiogram_dialog.widgets.kbd import Button, ScrollingGroup, Select, SwitchTo +from aiogram_dialog.widgets.text import Const, Format, Jinja, List + +from idb.bot.dialogs.admins.states import FeedbackItemsSG +from idb.bot.dialogs.utils.sync_scroll import sync_scroll +from idb.db.uow import UnitOfWork +from idb.generals.enums import FeedbackStatus + +SCROLL_KBD_ID = "feedback_scroll_id" +SCROLL_MESSAGE_ID = "feedback_message_scroll_id" + + +async def get_feedbacks_list_data(uow: UnitOfWork, **kwargs: Any) -> dict[str, Any]: + return {"feedbacks": await uow.feedbacks.viewed_items()} + + +async def open_feedback( + c: CallbackQuery, + widget: Button, + dialog_manager: DialogManager, + feedback_id: int, +) -> None: + dialog_manager.dialog_data["feedback_id"] = feedback_id + dialog_manager.dialog_data["back"] = FeedbackStatus.ARCHIVED + await dialog_manager.switch_to(FeedbackItemsSG.item) + + +HEAD_MESSAGE = """ +Просмотренные сообщения +❌ - не ответили +✅ - ответили +""" + +window = Window( + Const(HEAD_MESSAGE), + List( + Jinja( + "[{{ pos }}] {{ item.created_at|as_local_fmt }} {{ item.title }} " + "{% if item.is_answered %}✅{% else %}❌{% endif %}" + ), + items="feedbacks", + id=SCROLL_MESSAGE_ID, + page_size=10, + ), + ScrollingGroup( + Select( + Format("{pos}"), + id="s_feedback", + item_id_getter=lambda x: x.id, + items="feedbacks", + on_click=open_feedback, # type: ignore[arg-type] + type_factory=int, + ), + id=SCROLL_KBD_ID, + width=5, + height=2, + hide_on_single_page=True, + on_page_changed=sync_scroll(SCROLL_MESSAGE_ID), + ), + SwitchTo(Const("⬅️ Назад"), id="back", state=FeedbackItemsSG.menu), + state=FeedbackItemsSG.archived, + getter=get_feedbacks_list_data, +) diff --git a/idb/bot/dialogs/admins/feedbacks/router.py b/idb/bot/dialogs/admins/feedbacks/router.py new file mode 100644 index 0000000..e2b7f75 --- /dev/null +++ b/idb/bot/dialogs/admins/feedbacks/router.py @@ -0,0 +1,12 @@ +from aiogram import Router + +from idb.bot.dialogs.admins.feedbacks import read +from idb.bot.dialogs.admins.feedbacks.answer.dialog import ( + dialog as answer_dialog, +) + +router = Router() +router.include_routers( + read.dialog, + answer_dialog, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/mailings/__init__.py b/idb/bot/dialogs/admins/mailings/__init__.py similarity index 61% rename from inclusive_dance_bot/bot/dialogs/admins/mailings/__init__.py rename to idb/bot/dialogs/admins/mailings/__init__.py index 1cc5667..3f622cb 100644 --- a/inclusive_dance_bot/bot/dialogs/admins/mailings/__init__.py +++ b/idb/bot/dialogs/admins/mailings/__init__.py @@ -1,6 +1,6 @@ from aiogram import Router -from inclusive_dance_bot.bot.dialogs.admins.mailings import cancel, create, read +from idb.bot.dialogs.admins.mailings import cancel, create, read router = Router() router.include_routers( diff --git a/inclusive_dance_bot/bot/dialogs/admins/url/delete/__init__.py b/idb/bot/dialogs/admins/mailings/cancel/__init__.py similarity index 51% rename from inclusive_dance_bot/bot/dialogs/admins/url/delete/__init__.py rename to idb/bot/dialogs/admins/mailings/cancel/__init__.py index b041188..5e4e159 100644 --- a/inclusive_dance_bot/bot/dialogs/admins/url/delete/__init__.py +++ b/idb/bot/dialogs/admins/mailings/cancel/__init__.py @@ -1,6 +1,6 @@ from aiogram_dialog import Dialog -from inclusive_dance_bot.bot.dialogs.admins.url.delete import confirm +from idb.bot.dialogs.admins.mailings.cancel import confirm dialog = Dialog( confirm.window, diff --git a/inclusive_dance_bot/bot/dialogs/admins/mailings/cancel/confirm.py b/idb/bot/dialogs/admins/mailings/cancel/confirm.py similarity index 74% rename from inclusive_dance_bot/bot/dialogs/admins/mailings/cancel/confirm.py rename to idb/bot/dialogs/admins/mailings/cancel/confirm.py index 5ef84a7..446adf3 100644 --- a/inclusive_dance_bot/bot/dialogs/admins/mailings/cancel/confirm.py +++ b/idb/bot/dialogs/admins/mailings/cancel/confirm.py @@ -3,11 +3,11 @@ from aiogram_dialog.widgets.kbd import Button, Row from aiogram_dialog.widgets.text import Const -from inclusive_dance_bot.bot.dialogs.admins.states import CancelMailingSG -from inclusive_dance_bot.bot.dialogs.utils.buttons import CANCEL -from inclusive_dance_bot.db.uow.main import UnitOfWork -from inclusive_dance_bot.enums import MailingStatus -from inclusive_dance_bot.logic.mailing import update_mailing_by_id +from idb.bot.dialogs.admins.states import CancelMailingSG +from idb.bot.dialogs.utils.buttons import CANCEL +from idb.db.uow import UnitOfWork +from idb.generals.enums import MailingStatus +from idb.logic.mailing import update_mailing_by_id async def on_click( diff --git a/inclusive_dance_bot/bot/dialogs/admins/mailings/create/__init__.py b/idb/bot/dialogs/admins/mailings/create/__init__.py similarity index 84% rename from inclusive_dance_bot/bot/dialogs/admins/mailings/create/__init__.py rename to idb/bot/dialogs/admins/mailings/create/__init__.py index 7d75c13..f9138a1 100644 --- a/inclusive_dance_bot/bot/dialogs/admins/mailings/create/__init__.py +++ b/idb/bot/dialogs/admins/mailings/create/__init__.py @@ -1,6 +1,6 @@ from aiogram_dialog import Dialog -from inclusive_dance_bot.bot.dialogs.admins.mailings.create import ( +from idb.bot.dialogs.admins.mailings.create import ( choose_user_types, confirm, input_content, diff --git a/inclusive_dance_bot/bot/dialogs/admins/mailings/create/choose_user_types.py b/idb/bot/dialogs/admins/mailings/create/choose_user_types.py similarity index 77% rename from inclusive_dance_bot/bot/dialogs/admins/mailings/create/choose_user_types.py rename to idb/bot/dialogs/admins/mailings/create/choose_user_types.py index f742a3f..49b078d 100644 --- a/inclusive_dance_bot/bot/dialogs/admins/mailings/create/choose_user_types.py +++ b/idb/bot/dialogs/admins/mailings/create/choose_user_types.py @@ -5,15 +5,13 @@ from aiogram_dialog.widgets.kbd import Button, Multiselect, Next, ScrollingGroup from aiogram_dialog.widgets.text import Const, Format -from inclusive_dance_bot.bot.dialogs.admins.states import CreateMailingSG -from inclusive_dance_bot.bot.dialogs.utils.buttons import BACK -from inclusive_dance_bot.logic.storage import Storage +from idb.bot.dialogs.admins.states import CreateMailingSG +from idb.bot.dialogs.utils.buttons import BACK +from idb.utils.cache import AbstractBotCache -async def get_user_types_data( - dialog_manager: DialogManager, storage: Storage, **kwargs: Any -) -> dict[str, Any]: - user_types = await storage.get_user_types() +async def get_user_types_data(cache: AbstractBotCache, **kwargs: Any) -> dict[str, Any]: + user_types = await cache.get_user_types() return { "user_types": user_types.values(), } diff --git a/inclusive_dance_bot/bot/dialogs/admins/mailings/create/confirm.py b/idb/bot/dialogs/admins/mailings/create/confirm.py similarity index 86% rename from inclusive_dance_bot/bot/dialogs/admins/mailings/create/confirm.py rename to idb/bot/dialogs/admins/mailings/create/confirm.py index 7f17dd6..0b4ffcf 100644 --- a/inclusive_dance_bot/bot/dialogs/admins/mailings/create/confirm.py +++ b/idb/bot/dialogs/admins/mailings/create/confirm.py @@ -7,11 +7,11 @@ from aiogram_dialog.widgets.kbd import Button, Row from aiogram_dialog.widgets.text import Const, Format -from inclusive_dance_bot.bot.dialogs.admins.states import CreateMailingSG -from inclusive_dance_bot.bot.dialogs.utils.buttons import BACK -from inclusive_dance_bot.db.uow.main import UnitOfWork -from inclusive_dance_bot.logic.mailing import save_mailing -from inclusive_dance_bot.logic.storage import Storage +from idb.bot.dialogs.admins.states import CreateMailingSG +from idb.bot.dialogs.utils.buttons import BACK +from idb.db.uow import UnitOfWork +from idb.logic.mailing import save_mailing +from idb.utils.cache import AbstractBotCache def parse_dt( @@ -48,11 +48,11 @@ async def on_click( async def get_mailing_data( - dialog_manager: DialogManager, storage: Storage, **kwargs: Any + dialog_manager: DialogManager, cache: AbstractBotCache, **kwargs: Any ) -> dict[str, Any]: user_types = filter( lambda ut: ut.id in dialog_manager.dialog_data["user_types"], - (await storage.get_user_types()).values(), + (await cache.get_user_types()).values(), ) t = dialog_manager.dialog_data.get("time") d = dialog_manager.dialog_data.get("date") diff --git a/inclusive_dance_bot/bot/dialogs/admins/mailings/create/input_content.py b/idb/bot/dialogs/admins/mailings/create/input_content.py similarity index 78% rename from inclusive_dance_bot/bot/dialogs/admins/mailings/create/input_content.py rename to idb/bot/dialogs/admins/mailings/create/input_content.py index 534e2f2..aeea263 100644 --- a/inclusive_dance_bot/bot/dialogs/admins/mailings/create/input_content.py +++ b/idb/bot/dialogs/admins/mailings/create/input_content.py @@ -3,9 +3,9 @@ from aiogram_dialog.widgets.input import ManagedTextInput, TextInput from aiogram_dialog.widgets.text import Const -from inclusive_dance_bot.bot.dialogs.admins.states import CreateMailingSG -from inclusive_dance_bot.bot.dialogs.utils.buttons import BACK -from inclusive_dance_bot.bot.dialogs.utils.validators import validate_length +from idb.bot.dialogs.admins.states import CreateMailingSG +from idb.bot.dialogs.utils.buttons import BACK +from idb.bot.dialogs.utils.validators import validate_length MESSAGE = "Введите сообщение\n\nОграничение - 3072 символов" diff --git a/inclusive_dance_bot/bot/dialogs/admins/mailings/create/input_date.py b/idb/bot/dialogs/admins/mailings/create/input_date.py similarity index 78% rename from inclusive_dance_bot/bot/dialogs/admins/mailings/create/input_date.py rename to idb/bot/dialogs/admins/mailings/create/input_date.py index c2f3696..b1f9105 100644 --- a/inclusive_dance_bot/bot/dialogs/admins/mailings/create/input_date.py +++ b/idb/bot/dialogs/admins/mailings/create/input_date.py @@ -5,9 +5,9 @@ from aiogram_dialog.widgets.input import ManagedTextInput, TextInput from aiogram_dialog.widgets.text import Const -from inclusive_dance_bot.bot.dialogs.admins.states import CreateMailingSG -from inclusive_dance_bot.bot.dialogs.utils.buttons import BACK -from inclusive_dance_bot.bot.dialogs.utils.validators import validate_date +from idb.bot.dialogs.admins.states import CreateMailingSG +from idb.bot.dialogs.utils.buttons import BACK +from idb.bot.dialogs.utils.validators import validate_date MESSAGE = "Введите дату отправки сообщения\n\nФормат: ДД.ММ.ГГГГ" diff --git a/inclusive_dance_bot/bot/dialogs/admins/mailings/create/input_is_immediately.py b/idb/bot/dialogs/admins/mailings/create/input_is_immediately.py similarity index 76% rename from inclusive_dance_bot/bot/dialogs/admins/mailings/create/input_is_immediately.py rename to idb/bot/dialogs/admins/mailings/create/input_is_immediately.py index ee0f3ca..526e233 100644 --- a/inclusive_dance_bot/bot/dialogs/admins/mailings/create/input_is_immediately.py +++ b/idb/bot/dialogs/admins/mailings/create/input_is_immediately.py @@ -2,8 +2,8 @@ from aiogram_dialog.widgets.kbd import Next, Row, SwitchTo from aiogram_dialog.widgets.text import Const -from inclusive_dance_bot.bot.dialogs.admins.states import CreateMailingSG -from inclusive_dance_bot.bot.dialogs.utils.buttons import BACK +from idb.bot.dialogs.admins.states import CreateMailingSG +from idb.bot.dialogs.utils.buttons import BACK window = Window( Const("Когда отправить?"), diff --git a/inclusive_dance_bot/bot/dialogs/admins/mailings/create/input_time.py b/idb/bot/dialogs/admins/mailings/create/input_time.py similarity index 79% rename from inclusive_dance_bot/bot/dialogs/admins/mailings/create/input_time.py rename to idb/bot/dialogs/admins/mailings/create/input_time.py index ffa1838..837abf4 100644 --- a/inclusive_dance_bot/bot/dialogs/admins/mailings/create/input_time.py +++ b/idb/bot/dialogs/admins/mailings/create/input_time.py @@ -5,9 +5,9 @@ from aiogram_dialog.widgets.input import ManagedTextInput, TextInput from aiogram_dialog.widgets.text import Const -from inclusive_dance_bot.bot.dialogs.admins.states import CreateMailingSG -from inclusive_dance_bot.bot.dialogs.utils.buttons import BACK -from inclusive_dance_bot.bot.dialogs.utils.validators import validate_time +from idb.bot.dialogs.admins.states import CreateMailingSG +from idb.bot.dialogs.utils.buttons import BACK +from idb.bot.dialogs.utils.validators import validate_time MESSAGE = "Введите время отправки сообщения по Москве\n\nФормат: ЧЧ:ММ" diff --git a/inclusive_dance_bot/bot/dialogs/admins/mailings/create/input_title.py b/idb/bot/dialogs/admins/mailings/create/input_title.py similarity index 78% rename from inclusive_dance_bot/bot/dialogs/admins/mailings/create/input_title.py rename to idb/bot/dialogs/admins/mailings/create/input_title.py index 6eb152b..5321c41 100644 --- a/inclusive_dance_bot/bot/dialogs/admins/mailings/create/input_title.py +++ b/idb/bot/dialogs/admins/mailings/create/input_title.py @@ -3,9 +3,9 @@ from aiogram_dialog.widgets.input import ManagedTextInput, TextInput from aiogram_dialog.widgets.text import Const -from inclusive_dance_bot.bot.dialogs.admins.states import CreateMailingSG -from inclusive_dance_bot.bot.dialogs.utils.buttons import CANCEL -from inclusive_dance_bot.bot.dialogs.utils.validators import validate_length +from idb.bot.dialogs.admins.states import CreateMailingSG +from idb.bot.dialogs.utils.buttons import CANCEL +from idb.bot.dialogs.utils.validators import validate_length MESSAGE = "Введите тему сообщения.\n\nОграничение - 512 символов" diff --git a/inclusive_dance_bot/bot/dialogs/admins/mailings/read/__init__.py b/idb/bot/dialogs/admins/mailings/read/__init__.py similarity index 56% rename from inclusive_dance_bot/bot/dialogs/admins/mailings/read/__init__.py rename to idb/bot/dialogs/admins/mailings/read/__init__.py index f4ad2ac..123863d 100644 --- a/inclusive_dance_bot/bot/dialogs/admins/mailings/read/__init__.py +++ b/idb/bot/dialogs/admins/mailings/read/__init__.py @@ -1,6 +1,6 @@ from aiogram_dialog import Dialog -from inclusive_dance_bot.bot.dialogs.admins.mailings.read import item, items, menu +from idb.bot.dialogs.admins.mailings.read import item, items, menu dialog = Dialog( menu.window, diff --git a/inclusive_dance_bot/bot/dialogs/admins/mailings/read/item.py b/idb/bot/dialogs/admins/mailings/read/item.py similarity index 84% rename from inclusive_dance_bot/bot/dialogs/admins/mailings/read/item.py rename to idb/bot/dialogs/admins/mailings/read/item.py index 4509b22..acd7389 100644 --- a/inclusive_dance_bot/bot/dialogs/admins/mailings/read/item.py +++ b/idb/bot/dialogs/admins/mailings/read/item.py @@ -5,11 +5,11 @@ from aiogram_dialog.widgets.kbd import Button from aiogram_dialog.widgets.text import Const, Format -from inclusive_dance_bot.bot.dialogs.admins.states import CancelMailingSG, MailingsSG -from inclusive_dance_bot.bot.dialogs.utils import start_with_data -from inclusive_dance_bot.bot.dialogs.utils.buttons import BACK -from inclusive_dance_bot.db.uow.main import UnitOfWork -from inclusive_dance_bot.enums import MailingStatus +from idb.bot.dialogs.admins.states import CancelMailingSG, MailingsSG +from idb.bot.dialogs.utils import start_with_data +from idb.bot.dialogs.utils.buttons import BACK +from idb.db.uow import UnitOfWork +from idb.generals.enums import MailingStatus TEMPLATE_MESSAGE = """ Рассылка diff --git a/inclusive_dance_bot/bot/dialogs/admins/mailings/read/items.py b/idb/bot/dialogs/admins/mailings/read/items.py similarity index 87% rename from inclusive_dance_bot/bot/dialogs/admins/mailings/read/items.py rename to idb/bot/dialogs/admins/mailings/read/items.py index bd5aa16..0efadd9 100644 --- a/inclusive_dance_bot/bot/dialogs/admins/mailings/read/items.py +++ b/idb/bot/dialogs/admins/mailings/read/items.py @@ -6,10 +6,10 @@ from aiogram_dialog.widgets.kbd import Button, ScrollingGroup, Select from aiogram_dialog.widgets.text import Const, Format, List -from inclusive_dance_bot.bot.dialogs.admins.states import MailingsSG -from inclusive_dance_bot.bot.dialogs.utils import sync_scroll -from inclusive_dance_bot.bot.dialogs.utils.buttons import BACK -from inclusive_dance_bot.db.uow.main import UnitOfWork +from idb.bot.dialogs.admins.states import MailingsSG +from idb.bot.dialogs.utils import sync_scroll +from idb.bot.dialogs.utils.buttons import BACK +from idb.db.uow import UnitOfWork MAILING_TEMPLATE = """\ [{pos}] {item.title} diff --git a/inclusive_dance_bot/bot/dialogs/admins/mailings/read/menu.py b/idb/bot/dialogs/admins/mailings/read/menu.py similarity index 87% rename from inclusive_dance_bot/bot/dialogs/admins/mailings/read/menu.py rename to idb/bot/dialogs/admins/mailings/read/menu.py index 8fbbf64..a5a078d 100644 --- a/inclusive_dance_bot/bot/dialogs/admins/mailings/read/menu.py +++ b/idb/bot/dialogs/admins/mailings/read/menu.py @@ -6,8 +6,8 @@ from aiogram_dialog.widgets.kbd import Button, Next, Start from aiogram_dialog.widgets.text import Const -from inclusive_dance_bot.bot.dialogs.admins.states import CreateMailingSG, MailingsSG -from inclusive_dance_bot.bot.dialogs.utils.buttons import CANCEL +from idb.bot.dialogs.admins.states import CreateMailingSG, MailingsSG +from idb.bot.dialogs.utils.buttons import CANCEL def next_with_data(data: dict[str, Any]) -> Callable: diff --git a/inclusive_dance_bot/db/repositories/__init__.py b/idb/bot/dialogs/admins/main_menu/__init__.py similarity index 100% rename from inclusive_dance_bot/db/repositories/__init__.py rename to idb/bot/dialogs/admins/main_menu/__init__.py diff --git a/idb/bot/dialogs/admins/main_menu/dialog.py b/idb/bot/dialogs/admins/main_menu/dialog.py new file mode 100644 index 0000000..a846cb2 --- /dev/null +++ b/idb/bot/dialogs/admins/main_menu/dialog.py @@ -0,0 +1,5 @@ +from aiogram_dialog import Dialog + +from idb.bot.dialogs.admins.main_menu.windows import menu + +dialog = Dialog(menu.window) diff --git a/inclusive_dance_bot/db/uow/__init__.py b/idb/bot/dialogs/admins/main_menu/windows/__init__.py similarity index 100% rename from inclusive_dance_bot/db/uow/__init__.py rename to idb/bot/dialogs/admins/main_menu/windows/__init__.py diff --git a/idb/bot/dialogs/admins/main_menu/windows/menu.py b/idb/bot/dialogs/admins/main_menu/windows/menu.py new file mode 100644 index 0000000..41ff67f --- /dev/null +++ b/idb/bot/dialogs/admins/main_menu/windows/menu.py @@ -0,0 +1,76 @@ +from typing import Any + +from aiogram_dialog import DialogManager, Window +from aiogram_dialog.widgets.kbd import Start +from aiogram_dialog.widgets.text import Const, Format + +from idb.bot.dialogs.admins.states import ( + AdminMainMenuSG, + AdminSubmenuSG, + FeedbackItemsSG, + MailingsSG, + ManageAdminSG, + ReadUrlSG, +) +from idb.db.uow import UnitOfWork +from idb.generals.models.user import BotUser + +MESSAGE_TEMPLATE = """ +В боте зарегистрировано {total_users_count} пользователей. + +Получено всего {total_feedbacks_count} ОС. + +Запланировано/отправлено всего {total_mailings_count} рассылок. +""" + + +def when_(data: dict, widget: Any, dialog_manager: DialogManager) -> bool: + user: BotUser = data["middleware_data"]["user"] + return user.is_superuser + + +async def get_user_stat( + dialog_manager: DialogManager, uow: UnitOfWork, **kwargs: Any +) -> dict[str, Any]: + total_users_count = await uow.users.total_count() + total_feedbacks_count = await uow.feedbacks.total_count() + total_mailings_count = await uow.mailings.total_count() + return { + "total_users_count": total_users_count, + "total_feedbacks_count": total_feedbacks_count, + "total_mailings_count": total_mailings_count, + } + + +window = Window( + Const("Меню администратора"), + Format(MESSAGE_TEMPLATE), + Start( + id="feedbacks", + text=Const("🖋 Обратная связь от пользователей"), + state=FeedbackItemsSG.menu, + ), + Start( + id="mailings", + text=Const("📬 Рассылки"), + state=MailingsSG.menu, + ), + Start( + id="manage_submenu_id", + text=Const("📑 Управление подменю"), + state=AdminSubmenuSG.items, + ), + Start( + id="manage_url_id", + text=Const("🔗 Управление ссылками"), + state=ReadUrlSG.items, + ), + Start( + id="manage_admin_id", + text=Const("👮‍♀️ Управление администраторами"), + state=ManageAdminSG.items, + when=when_, + ), + state=AdminMainMenuSG.menu, + getter=get_user_stat, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/manage_admins/__init__.py b/idb/bot/dialogs/admins/manage_admins/__init__.py similarity index 60% rename from inclusive_dance_bot/bot/dialogs/admins/manage_admins/__init__.py rename to idb/bot/dialogs/admins/manage_admins/__init__.py index 5dd57a1..94b0a71 100644 --- a/inclusive_dance_bot/bot/dialogs/admins/manage_admins/__init__.py +++ b/idb/bot/dialogs/admins/manage_admins/__init__.py @@ -1,6 +1,6 @@ from aiogram import Router -from inclusive_dance_bot.bot.dialogs.admins.manage_admins import add, delete, read +from idb.bot.dialogs.admins.manage_admins import add, delete, read router = Router() router.include_routers( diff --git a/idb/bot/dialogs/admins/manage_admins/add/__init__.py b/idb/bot/dialogs/admins/manage_admins/add/__init__.py new file mode 100644 index 0000000..82f6df0 --- /dev/null +++ b/idb/bot/dialogs/admins/manage_admins/add/__init__.py @@ -0,0 +1,7 @@ +from aiogram_dialog import Dialog + +from idb.bot.dialogs.admins.manage_admins.add import input_username + +dialog = Dialog( + input_username.window, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/manage_admins/add/input_username.py b/idb/bot/dialogs/admins/manage_admins/add/input_username.py similarity index 77% rename from inclusive_dance_bot/bot/dialogs/admins/manage_admins/add/input_username.py rename to idb/bot/dialogs/admins/manage_admins/add/input_username.py index 43a3626..834f95e 100644 --- a/inclusive_dance_bot/bot/dialogs/admins/manage_admins/add/input_username.py +++ b/idb/bot/dialogs/admins/manage_admins/add/input_username.py @@ -3,12 +3,12 @@ from aiogram_dialog.widgets.input import TextInput from aiogram_dialog.widgets.text import Const -from inclusive_dance_bot.bot.dialogs.admins.states import AddAdminSG -from inclusive_dance_bot.bot.dialogs.messages import ADD_ADMIN_MESSAGE -from inclusive_dance_bot.bot.dialogs.utils.buttons import CANCEL -from inclusive_dance_bot.db.uow.main import UnitOfWork -from inclusive_dance_bot.exceptions import UserNotFoundError -from inclusive_dance_bot.logic.user import add_user_to_admins +from idb.bot.dialogs.admins.states import AddAdminSG +from idb.bot.dialogs.messages import ADD_ADMIN_MESSAGE +from idb.bot.dialogs.utils.buttons import CANCEL +from idb.db.uow import UnitOfWork +from idb.exceptions import UserNotFoundError +from idb.logic.users import add_user_to_admins async def on_success( diff --git a/idb/bot/dialogs/admins/manage_admins/delete/__init__.py b/idb/bot/dialogs/admins/manage_admins/delete/__init__.py new file mode 100644 index 0000000..425d341 --- /dev/null +++ b/idb/bot/dialogs/admins/manage_admins/delete/__init__.py @@ -0,0 +1,5 @@ +from aiogram_dialog import Dialog + +from idb.bot.dialogs.admins.manage_admins.delete import confirm + +dialog = Dialog(confirm.window) diff --git a/inclusive_dance_bot/bot/dialogs/admins/manage_admins/delete/confirm.py b/idb/bot/dialogs/admins/manage_admins/delete/confirm.py similarity index 81% rename from inclusive_dance_bot/bot/dialogs/admins/manage_admins/delete/confirm.py rename to idb/bot/dialogs/admins/manage_admins/delete/confirm.py index 01fb1e0..3d8f4b3 100644 --- a/inclusive_dance_bot/bot/dialogs/admins/manage_admins/delete/confirm.py +++ b/idb/bot/dialogs/admins/manage_admins/delete/confirm.py @@ -5,11 +5,11 @@ from aiogram_dialog.widgets.kbd import Button, Row from aiogram_dialog.widgets.text import Const, Format -from inclusive_dance_bot.bot.dialogs.admins.states import DeleteAdminSG -from inclusive_dance_bot.bot.dialogs.users.states import MainMenuSG -from inclusive_dance_bot.bot.dialogs.utils.buttons import CANCEL -from inclusive_dance_bot.db.uow.main import UnitOfWork -from inclusive_dance_bot.logic.user import delete_from_admins +from idb.bot.dialogs.admins.states import DeleteAdminSG +from idb.bot.dialogs.users.states import MainMenuSG +from idb.bot.dialogs.utils.buttons import CANCEL +from idb.db.uow import UnitOfWork +from idb.logic.users import delete_from_admins async def on_click( diff --git a/idb/bot/dialogs/admins/manage_admins/read/__init__.py b/idb/bot/dialogs/admins/manage_admins/read/__init__.py new file mode 100644 index 0000000..afc55fe --- /dev/null +++ b/idb/bot/dialogs/admins/manage_admins/read/__init__.py @@ -0,0 +1,7 @@ +from aiogram_dialog import Dialog + +from idb.bot.dialogs.admins.manage_admins.read import items + +dialog = Dialog( + items.window, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/manage_admins/read/items.py b/idb/bot/dialogs/admins/manage_admins/read/items.py similarity index 84% rename from inclusive_dance_bot/bot/dialogs/admins/manage_admins/read/items.py rename to idb/bot/dialogs/admins/manage_admins/read/items.py index 09ce1db..d2fba28 100644 --- a/inclusive_dance_bot/bot/dialogs/admins/manage_admins/read/items.py +++ b/idb/bot/dialogs/admins/manage_admins/read/items.py @@ -5,17 +5,18 @@ from aiogram_dialog.widgets.kbd import Button, ScrollingGroup, Select, Start from aiogram_dialog.widgets.text import Const, Format -from inclusive_dance_bot.bot.dialogs.admins.states import ( +from idb.bot.dialogs.admins.states import ( AddAdminSG, DeleteAdminSG, ManageAdminSG, ) -from inclusive_dance_bot.bot.dialogs.utils.buttons import CANCEL -from inclusive_dance_bot.db.uow.main import UnitOfWork +from idb.bot.dialogs.utils.buttons import CANCEL +from idb.db.uow import UnitOfWork async def get_admins(uow: UnitOfWork, **kwargs: Any) -> dict[str, Any]: - admins = await uow.users.get_admin_list() + admins = await uow.users.get_admin_list(include_superusers=False) + return {"admins": admins} diff --git a/idb/bot/dialogs/admins/router.py b/idb/bot/dialogs/admins/router.py new file mode 100644 index 0000000..b23924e --- /dev/null +++ b/idb/bot/dialogs/admins/router.py @@ -0,0 +1,24 @@ +from aiogram import Router + +from idb.bot.dialogs.admins import ( + mailings, + manage_admins, + submenu, + url, +) +from idb.bot.dialogs.admins.feedbacks.router import ( + router as feedbacks_router, +) +from idb.bot.dialogs.admins.main_menu.dialog import ( + dialog as main_menu_dialog, +) + +dialog_router = Router(name="admin_router") +dialog_router.include_routers( + feedbacks_router, + main_menu_dialog, + submenu.router, + url.router, + manage_admins.router, + mailings.router, +) diff --git a/inclusive_dance_bot/bot/dialogs/admins/states.py b/idb/bot/dialogs/admins/states.py similarity index 93% rename from inclusive_dance_bot/bot/dialogs/admins/states.py rename to idb/bot/dialogs/admins/states.py index 12aa3c2..bb30311 100644 --- a/inclusive_dance_bot/bot/dialogs/admins/states.py +++ b/idb/bot/dialogs/admins/states.py @@ -75,14 +75,16 @@ class CancelMailingSG(StatesGroup): confirm = State() -class FeedbackAsnwerSG(StatesGroup): +class FeedbackAnswerSG(StatesGroup): input_message = State() confirm = State() class FeedbackItemsSG(StatesGroup): + menu = State() new = State() - archive = State() + archived = State() + item = State() class CreateMailingSG(StatesGroup): diff --git a/inclusive_dance_bot/bot/dialogs/admins/url/__init__.py b/idb/bot/dialogs/admins/submenu/__init__.py similarity index 63% rename from inclusive_dance_bot/bot/dialogs/admins/url/__init__.py rename to idb/bot/dialogs/admins/submenu/__init__.py index c375d5a..4491a79 100644 --- a/inclusive_dance_bot/bot/dialogs/admins/url/__init__.py +++ b/idb/bot/dialogs/admins/submenu/__init__.py @@ -1,6 +1,6 @@ from aiogram import Router -from inclusive_dance_bot.bot.dialogs.admins.url import create, delete, read, update +from idb.bot.dialogs.admins.submenu import create, delete, read, update router = Router() router.include_routers( diff --git a/inclusive_dance_bot/bot/dialogs/admins/submenu/create/__init__.py b/idb/bot/dialogs/admins/submenu/create/__init__.py similarity index 79% rename from inclusive_dance_bot/bot/dialogs/admins/submenu/create/__init__.py rename to idb/bot/dialogs/admins/submenu/create/__init__.py index 7c2a0ab..e1a725a 100644 --- a/inclusive_dance_bot/bot/dialogs/admins/submenu/create/__init__.py +++ b/idb/bot/dialogs/admins/submenu/create/__init__.py @@ -1,6 +1,6 @@ from aiogram_dialog import Dialog -from inclusive_dance_bot.bot.dialogs.admins.submenu.create import ( +from idb.bot.dialogs.admins.submenu.create import ( confirm, input_button_text, input_message, diff --git a/inclusive_dance_bot/bot/dialogs/admins/submenu/create/confirm.py b/idb/bot/dialogs/admins/submenu/create/confirm.py similarity index 78% rename from inclusive_dance_bot/bot/dialogs/admins/submenu/create/confirm.py rename to idb/bot/dialogs/admins/submenu/create/confirm.py index 68294fa..1264b96 100644 --- a/inclusive_dance_bot/bot/dialogs/admins/submenu/create/confirm.py +++ b/idb/bot/dialogs/admins/submenu/create/confirm.py @@ -3,24 +3,24 @@ from aiogram_dialog.widgets.kbd import Button, Row from aiogram_dialog.widgets.text import Const, Format -from inclusive_dance_bot.bot.dialogs.admins.states import ( +from idb.bot.dialogs.admins.states import ( AdminMainMenuSG, CreateSubmenuSG, ) -from inclusive_dance_bot.bot.dialogs.utils.buttons import BACK -from inclusive_dance_bot.db.uow.main import UnitOfWork -from inclusive_dance_bot.logic.storage import Storage -from inclusive_dance_bot.logic.submenu import create_submenu +from idb.bot.dialogs.utils.buttons import BACK +from idb.db.uow import UnitOfWork +from idb.logic.submenu import create_submenu +from idb.utils.cache import AbstractBotCache async def on_click( c: CallbackQuery, button: Button, dialog_manager: DialogManager ) -> None: - storage: Storage = dialog_manager.middleware_data["storage"] + cache: AbstractBotCache = dialog_manager.middleware_data["cache"] uow: UnitOfWork = dialog_manager.middleware_data["uow"] await create_submenu( uow=uow, - storage=storage, + cache=cache, type=dialog_manager.dialog_data["type"], weight=dialog_manager.dialog_data["weight"], message=dialog_manager.dialog_data["message"], diff --git a/inclusive_dance_bot/bot/dialogs/admins/submenu/create/input_button_text.py b/idb/bot/dialogs/admins/submenu/create/input_button_text.py similarity index 77% rename from inclusive_dance_bot/bot/dialogs/admins/submenu/create/input_button_text.py rename to idb/bot/dialogs/admins/submenu/create/input_button_text.py index 703b3cf..7be0e23 100644 --- a/inclusive_dance_bot/bot/dialogs/admins/submenu/create/input_button_text.py +++ b/idb/bot/dialogs/admins/submenu/create/input_button_text.py @@ -3,9 +3,9 @@ from aiogram_dialog.widgets.input import TextInput from aiogram_dialog.widgets.text import Const -from inclusive_dance_bot.bot.dialogs.admins.states import CreateSubmenuSG -from inclusive_dance_bot.bot.dialogs.utils.buttons import BACK -from inclusive_dance_bot.bot.dialogs.utils.validators import validate_length +from idb.bot.dialogs.admins.states import CreateSubmenuSG +from idb.bot.dialogs.utils.buttons import BACK +from idb.bot.dialogs.utils.validators import validate_length MESSAGE = "Введите текст кнопки.\n\nОграничение - 64 символа" diff --git a/inclusive_dance_bot/bot/dialogs/admins/submenu/create/input_message.py b/idb/bot/dialogs/admins/submenu/create/input_message.py similarity index 72% rename from inclusive_dance_bot/bot/dialogs/admins/submenu/create/input_message.py rename to idb/bot/dialogs/admins/submenu/create/input_message.py index 4383e21..3375483 100644 --- a/inclusive_dance_bot/bot/dialogs/admins/submenu/create/input_message.py +++ b/idb/bot/dialogs/admins/submenu/create/input_message.py @@ -1,10 +1,10 @@ from aiogram.types import Message from aiogram_dialog import DialogManager, Window from aiogram_dialog.widgets.input import TextInput -from aiogram_dialog.widgets.text import Format +from aiogram_dialog.widgets.text import Const -from inclusive_dance_bot.bot.dialogs.admins.states import CreateSubmenuSG -from inclusive_dance_bot.bot.dialogs.utils.buttons import BACK +from idb.bot.dialogs.admins.states import CreateSubmenuSG +from idb.bot.dialogs.utils.buttons import BACK TEMPLATE_MESSAGE = "Введите шаблон сообщения" @@ -17,7 +17,7 @@ async def on_success( window = Window( - Format(TEMPLATE_MESSAGE), + Const(TEMPLATE_MESSAGE), TextInput(id="input_message", on_success=on_success), # type: ignore[arg-type] BACK, state=CreateSubmenuSG.message, diff --git a/inclusive_dance_bot/bot/dialogs/admins/submenu/create/input_type.py b/idb/bot/dialogs/admins/submenu/create/input_type.py similarity index 77% rename from inclusive_dance_bot/bot/dialogs/admins/submenu/create/input_type.py rename to idb/bot/dialogs/admins/submenu/create/input_type.py index 95ba905..b2cbdab 100644 --- a/inclusive_dance_bot/bot/dialogs/admins/submenu/create/input_type.py +++ b/idb/bot/dialogs/admins/submenu/create/input_type.py @@ -3,11 +3,11 @@ from aiogram.types import CallbackQuery from aiogram_dialog import DialogManager, Window from aiogram_dialog.widgets.kbd import Button, Column, Select -from aiogram_dialog.widgets.text import Format +from aiogram_dialog.widgets.text import Const, Format -from inclusive_dance_bot.bot.dialogs.admins.states import CreateSubmenuSG -from inclusive_dance_bot.bot.dialogs.utils.buttons import CANCEL -from inclusive_dance_bot.enums import SubmenuType +from idb.bot.dialogs.admins.states import CreateSubmenuSG +from idb.bot.dialogs.utils.buttons import CANCEL +from idb.generals.enums import SubmenuType async def get_submenu_data(**kwargs: Any) -> dict[str, Any]: @@ -29,7 +29,7 @@ async def on_click( TEMPLATE_MESSAGE = "Выберите тип подменю" window = Window( - Format(TEMPLATE_MESSAGE), + Const(TEMPLATE_MESSAGE), Column( Select( id="submenu_types", diff --git a/inclusive_dance_bot/bot/dialogs/admins/submenu/create/input_weight.py b/idb/bot/dialogs/admins/submenu/create/input_weight.py similarity index 76% rename from inclusive_dance_bot/bot/dialogs/admins/submenu/create/input_weight.py rename to idb/bot/dialogs/admins/submenu/create/input_weight.py index ccb3070..8e73de9 100644 --- a/inclusive_dance_bot/bot/dialogs/admins/submenu/create/input_weight.py +++ b/idb/bot/dialogs/admins/submenu/create/input_weight.py @@ -1,10 +1,10 @@ from aiogram.types import Message from aiogram_dialog import DialogManager, Window from aiogram_dialog.widgets.input import TextInput -from aiogram_dialog.widgets.text import Format +from aiogram_dialog.widgets.text import Const -from inclusive_dance_bot.bot.dialogs.admins.states import CreateSubmenuSG -from inclusive_dance_bot.bot.dialogs.utils.buttons import BACK +from idb.bot.dialogs.admins.states import CreateSubmenuSG +from idb.bot.dialogs.utils.buttons import BACK TEMPLATE_MESSAGE = ( "Введите вес подменю.\n\n" @@ -20,7 +20,7 @@ async def on_success( window = Window( - Format(TEMPLATE_MESSAGE), + Const(TEMPLATE_MESSAGE), TextInput(id="input_weight", on_success=on_success, type_factory=int), # type: ignore[arg-type] BACK, state=CreateSubmenuSG.weight, diff --git a/inclusive_dance_bot/bot/dialogs/admins/submenu/delete/__init__.py b/idb/bot/dialogs/admins/submenu/delete/__init__.py similarity index 50% rename from inclusive_dance_bot/bot/dialogs/admins/submenu/delete/__init__.py rename to idb/bot/dialogs/admins/submenu/delete/__init__.py index 0201ded..70f5c65 100644 --- a/inclusive_dance_bot/bot/dialogs/admins/submenu/delete/__init__.py +++ b/idb/bot/dialogs/admins/submenu/delete/__init__.py @@ -1,6 +1,6 @@ from aiogram_dialog import Dialog -from inclusive_dance_bot.bot.dialogs.admins.submenu.delete import confirm +from idb.bot.dialogs.admins.submenu.delete import confirm dialog = Dialog( confirm.window, diff --git a/inclusive_dance_bot/bot/dialogs/admins/submenu/delete/confirm.py b/idb/bot/dialogs/admins/submenu/delete/confirm.py similarity index 66% rename from inclusive_dance_bot/bot/dialogs/admins/submenu/delete/confirm.py rename to idb/bot/dialogs/admins/submenu/delete/confirm.py index def7fea..709e869 100644 --- a/inclusive_dance_bot/bot/dialogs/admins/submenu/delete/confirm.py +++ b/idb/bot/dialogs/admins/submenu/delete/confirm.py @@ -3,24 +3,24 @@ from aiogram_dialog.widgets.kbd import Button, Row from aiogram_dialog.widgets.text import Const, Format -from inclusive_dance_bot.bot.dialogs.admins.states import ( +from idb.bot.dialogs.admins.states import ( AdminMainMenuSG, DeleteSubmenuSG, ) -from inclusive_dance_bot.bot.dialogs.utils.buttons import CANCEL -from inclusive_dance_bot.bot.dialogs.utils.getters import get_submenu_data -from inclusive_dance_bot.db.uow.main import UnitOfWork -from inclusive_dance_bot.logic.storage import Storage -from inclusive_dance_bot.logic.submenu import delete_submenu_by_id +from idb.bot.dialogs.utils.buttons import CANCEL +from idb.bot.dialogs.utils.getters import get_submenu_data +from idb.db.uow import UnitOfWork +from idb.logic.submenu import delete_submenu_by_id +from idb.utils.cache import AbstractBotCache async def on_click( c: CallbackQuery, button: Button, dialog_manager: DialogManager ) -> None: - storage: Storage = dialog_manager.middleware_data["storage"] + cache: AbstractBotCache = dialog_manager.middleware_data["cache"] uow: UnitOfWork = dialog_manager.middleware_data["uow"] submenu_id = dialog_manager.start_data["submenu_id"] - await delete_submenu_by_id(uow=uow, storage=storage, submenu_id=submenu_id) + await delete_submenu_by_id(uow=uow, cache=cache, submenu_id=submenu_id) dialog_manager.show_mode = ShowMode.SEND await c.bot.send_message(c.from_user.id, text="Подменю было удалено") # type: ignore[union-attr] await dialog_manager.start(state=AdminMainMenuSG.menu, mode=StartMode.RESET_STACK) diff --git a/inclusive_dance_bot/bot/dialogs/admins/submenu/read/__init__.py b/idb/bot/dialogs/admins/submenu/read/__init__.py similarity index 54% rename from inclusive_dance_bot/bot/dialogs/admins/submenu/read/__init__.py rename to idb/bot/dialogs/admins/submenu/read/__init__.py index ee4827b..5a79e6d 100644 --- a/inclusive_dance_bot/bot/dialogs/admins/submenu/read/__init__.py +++ b/idb/bot/dialogs/admins/submenu/read/__init__.py @@ -1,6 +1,6 @@ from aiogram_dialog import Dialog -from inclusive_dance_bot.bot.dialogs.admins.submenu.read import item, items +from idb.bot.dialogs.admins.submenu.read import item, items dialog = Dialog( items.window, diff --git a/inclusive_dance_bot/bot/dialogs/admins/submenu/read/item.py b/idb/bot/dialogs/admins/submenu/read/item.py similarity index 63% rename from inclusive_dance_bot/bot/dialogs/admins/submenu/read/item.py rename to idb/bot/dialogs/admins/submenu/read/item.py index 53f9e78..c3bd8b6 100644 --- a/inclusive_dance_bot/bot/dialogs/admins/submenu/read/item.py +++ b/idb/bot/dialogs/admins/submenu/read/item.py @@ -1,30 +1,44 @@ from typing import Any from aiogram_dialog import DialogManager, Window -from aiogram_dialog.widgets.kbd import Back, Button -from aiogram_dialog.widgets.text import Const, Format +from aiogram_dialog.widgets.kbd import Button +from aiogram_dialog.widgets.text import Const, Format, Jinja -from inclusive_dance_bot.bot.dialogs.admins.states import ( +from idb.bot.dialogs.admins.states import ( AdminSubmenuSG, ChangeSubmenuSG, DeleteSubmenuSG, ) -from inclusive_dance_bot.bot.dialogs.messages import SUBMENU_TEMPLATE -from inclusive_dance_bot.bot.dialogs.utils import start_with_data -from inclusive_dance_bot.logic.storage import Storage +from idb.bot.dialogs.utils import start_with_data +from idb.bot.dialogs.utils.buttons import BACK +from idb.utils.cache import AbstractBotCache SUBMENU_ID = "submenu_id" +SUBMENU_TEMPLATE = """\ +Подменю {submenu.id} + +Тип: {submenu.type} +Текст кнопки: {submenu.button_text} +Вес: {submenu.weight} +""" +SUBMENU_JINJA = """\ +Значение: + +{{ submenu.message }} +""" + async def get_data( - storage: Storage, dialog_manager: DialogManager, **kwargs: Any + cache: AbstractBotCache, dialog_manager: DialogManager, **kwargs: Any ) -> dict[str, Any]: - s = await storage.get_submenu_by_id(dialog_manager.dialog_data[SUBMENU_ID]) + s = await cache.get_submenu_by_id(dialog_manager.dialog_data[SUBMENU_ID]) return {"submenu": s} window = Window( Format(SUBMENU_TEMPLATE), + Jinja(SUBMENU_JINJA), Button( Const("Изменить тип"), id="change_submenu_type", @@ -50,7 +64,7 @@ async def get_data( id="delete_submenu", on_click=start_with_data(state=DeleteSubmenuSG.confirm, field=SUBMENU_ID), ), - Back(Const("Назад")), + BACK, state=AdminSubmenuSG.info, getter=get_data, ) diff --git a/inclusive_dance_bot/bot/dialogs/admins/submenu/read/items.py b/idb/bot/dialogs/admins/submenu/read/items.py similarity index 78% rename from inclusive_dance_bot/bot/dialogs/admins/submenu/read/items.py rename to idb/bot/dialogs/admins/submenu/read/items.py index 102aa2d..1b2a75a 100644 --- a/inclusive_dance_bot/bot/dialogs/admins/submenu/read/items.py +++ b/idb/bot/dialogs/admins/submenu/read/items.py @@ -5,20 +5,22 @@ from aiogram_dialog.widgets.kbd import Button, ScrollingGroup, Select, Start from aiogram_dialog.widgets.text import Const, Format, List -from inclusive_dance_bot.bot.dialogs.admins.states import ( +from idb.bot.dialogs.admins.states import ( AdminSubmenuSG, CreateSubmenuSG, ) -from inclusive_dance_bot.bot.dialogs.utils import sync_scroll -from inclusive_dance_bot.bot.dialogs.utils.buttons import CANCEL -from inclusive_dance_bot.logic.storage import Storage +from idb.bot.dialogs.utils import sync_scroll +from idb.bot.dialogs.utils.buttons import CANCEL +from idb.utils.cache import AbstractBotCache SCROLL_KBD_ID = "submenu_scroll_id" SCROLL_MESSAGE_ID = "submenu_message_scroll_id" -async def get_submenu_list_data(storage: Storage, **kwargs: Any) -> dict[str, Any]: - return {"submenus": list((await storage.get_submenus()).values())} +async def get_submenu_list_data( + cache: AbstractBotCache, **kwargs: Any +) -> dict[str, Any]: + return {"submenus": list((await cache.get_submenus()).values())} async def open_submenu( diff --git a/inclusive_dance_bot/bot/dialogs/admins/submenu/update/__init__.py b/idb/bot/dialogs/admins/submenu/update/__init__.py similarity index 78% rename from inclusive_dance_bot/bot/dialogs/admins/submenu/update/__init__.py rename to idb/bot/dialogs/admins/submenu/update/__init__.py index e0009a4..9a96a24 100644 --- a/inclusive_dance_bot/bot/dialogs/admins/submenu/update/__init__.py +++ b/idb/bot/dialogs/admins/submenu/update/__init__.py @@ -1,6 +1,6 @@ from aiogram_dialog import Dialog -from inclusive_dance_bot.bot.dialogs.admins.submenu.update import ( +from idb.bot.dialogs.admins.submenu.update import ( change_button_text, change_message, change_type, diff --git a/inclusive_dance_bot/bot/dialogs/admins/submenu/update/change_button_text.py b/idb/bot/dialogs/admins/submenu/update/change_button_text.py similarity index 63% rename from inclusive_dance_bot/bot/dialogs/admins/submenu/update/change_button_text.py rename to idb/bot/dialogs/admins/submenu/update/change_button_text.py index e3c3f37..c2aa140 100644 --- a/inclusive_dance_bot/bot/dialogs/admins/submenu/update/change_button_text.py +++ b/idb/bot/dialogs/admins/submenu/update/change_button_text.py @@ -3,13 +3,13 @@ from aiogram_dialog.widgets.input import TextInput from aiogram_dialog.widgets.text import Format -from inclusive_dance_bot.bot.dialogs.admins.states import ChangeSubmenuSG -from inclusive_dance_bot.bot.dialogs.utils.buttons import CANCEL -from inclusive_dance_bot.bot.dialogs.utils.getters import get_submenu_data -from inclusive_dance_bot.bot.dialogs.utils.validators import validate_length -from inclusive_dance_bot.db.uow.main import UnitOfWork -from inclusive_dance_bot.logic.storage import Storage -from inclusive_dance_bot.logic.submenu import update_submenu_by_id +from idb.bot.dialogs.admins.states import ChangeSubmenuSG +from idb.bot.dialogs.utils.buttons import CANCEL +from idb.bot.dialogs.utils.getters import get_submenu_data +from idb.bot.dialogs.utils.validators import validate_length +from idb.db.uow import UnitOfWork +from idb.logic.submenu import update_submenu_by_id +from idb.utils.cache import AbstractBotCache TEMPLATE_MESSAGE = "Введите новый текст кнопки\n\nТекущее: {submenu.button_text}" @@ -19,10 +19,10 @@ async def on_success( ) -> None: submenu_id = dialog_manager.start_data["submenu_id"] uow: UnitOfWork = dialog_manager.middleware_data["uow"] - storage: Storage = dialog_manager.middleware_data["storage"] + cache: AbstractBotCache = dialog_manager.middleware_data["cache"] await update_submenu_by_id( uow=uow, - storage=storage, + cache=cache, submenu_id=submenu_id, button_text=value, ) diff --git a/inclusive_dance_bot/bot/dialogs/admins/submenu/update/change_message.py b/idb/bot/dialogs/admins/submenu/update/change_message.py similarity index 50% rename from inclusive_dance_bot/bot/dialogs/admins/submenu/update/change_message.py rename to idb/bot/dialogs/admins/submenu/update/change_message.py index 46843c1..985fc0c 100644 --- a/inclusive_dance_bot/bot/dialogs/admins/submenu/update/change_message.py +++ b/idb/bot/dialogs/admins/submenu/update/change_message.py @@ -1,16 +1,18 @@ from aiogram.types import Message from aiogram_dialog import DialogManager, Window from aiogram_dialog.widgets.input import TextInput -from aiogram_dialog.widgets.text import Format +from aiogram_dialog.widgets.text import Jinja -from inclusive_dance_bot.bot.dialogs.admins.states import ChangeSubmenuSG -from inclusive_dance_bot.bot.dialogs.utils.buttons import CANCEL -from inclusive_dance_bot.bot.dialogs.utils.getters import get_submenu_data -from inclusive_dance_bot.db.uow.main import UnitOfWork -from inclusive_dance_bot.logic.storage import Storage -from inclusive_dance_bot.logic.submenu import update_submenu_by_id +from idb.bot.dialogs.admins.states import ChangeSubmenuSG +from idb.bot.dialogs.utils.buttons import CANCEL +from idb.bot.dialogs.utils.getters import get_submenu_data +from idb.db.uow import UnitOfWork +from idb.logic.submenu import update_submenu_by_id +from idb.utils.cache import AbstractBotCache -TEMPLATE_MESSAGE = "Введите новый шаблон сообщения\n\nТекущее: {submenu.message}" +TEMPLATE_MESSAGE = ( + "Введите новый шаблон сообщения\nТекущее:\n\n{{submenu.message}}" +) async def on_success( @@ -18,10 +20,10 @@ async def on_success( ) -> None: submenu_id = dialog_manager.start_data["submenu_id"] uow: UnitOfWork = dialog_manager.middleware_data["uow"] - storage: Storage = dialog_manager.middleware_data["storage"] + cache: AbstractBotCache = dialog_manager.middleware_data["cache"] await update_submenu_by_id( uow=uow, - storage=storage, + cache=cache, submenu_id=submenu_id, message=value, ) @@ -29,7 +31,7 @@ async def on_success( window = Window( - Format(TEMPLATE_MESSAGE), + Jinja(TEMPLATE_MESSAGE), TextInput(id="input_message", on_success=on_success), # type: ignore[arg-type] CANCEL, state=ChangeSubmenuSG.message, diff --git a/inclusive_dance_bot/bot/dialogs/admins/submenu/update/change_type.py b/idb/bot/dialogs/admins/submenu/update/change_type.py similarity index 69% rename from inclusive_dance_bot/bot/dialogs/admins/submenu/update/change_type.py rename to idb/bot/dialogs/admins/submenu/update/change_type.py index a44dde8..4e3cd0e 100644 --- a/inclusive_dance_bot/bot/dialogs/admins/submenu/update/change_type.py +++ b/idb/bot/dialogs/admins/submenu/update/change_type.py @@ -5,19 +5,19 @@ from aiogram_dialog.widgets.kbd import Button, Column, Select from aiogram_dialog.widgets.text import Format -from inclusive_dance_bot.bot.dialogs.admins.states import ChangeSubmenuSG -from inclusive_dance_bot.bot.dialogs.utils.buttons import CANCEL -from inclusive_dance_bot.db.uow.main import UnitOfWork -from inclusive_dance_bot.enums import SubmenuType -from inclusive_dance_bot.logic.storage import Storage -from inclusive_dance_bot.logic.submenu import update_submenu_by_id +from idb.bot.dialogs.admins.states import ChangeSubmenuSG +from idb.bot.dialogs.utils.buttons import CANCEL +from idb.db.uow import UnitOfWork +from idb.generals.enums import SubmenuType +from idb.logic.submenu import update_submenu_by_id +from idb.utils.cache import AbstractBotCache async def get_submenu_data( - storage: Storage, dialog_manager: DialogManager, **kwargs: Any + cache: AbstractBotCache, dialog_manager: DialogManager, **kwargs: Any ) -> dict[str, Any]: return { - "submenu": await storage.get_submenu_by_id( + "submenu": await cache.get_submenu_by_id( dialog_manager.start_data["submenu_id"] ), "submenu_types": list(SubmenuType), @@ -32,10 +32,10 @@ async def on_click( ) -> None: submenu_id = dialog_manager.start_data["submenu_id"] uow: UnitOfWork = dialog_manager.middleware_data["uow"] - storage: Storage = dialog_manager.middleware_data["storage"] + cache: AbstractBotCache = dialog_manager.middleware_data["cache"] await update_submenu_by_id( uow=uow, - storage=storage, + cache=cache, submenu_id=submenu_id, type_=SubmenuType(type_), ) diff --git a/inclusive_dance_bot/bot/dialogs/admins/submenu/update/change_weight.py b/idb/bot/dialogs/admins/submenu/update/change_weight.py similarity index 64% rename from inclusive_dance_bot/bot/dialogs/admins/submenu/update/change_weight.py rename to idb/bot/dialogs/admins/submenu/update/change_weight.py index 45fc423..c60c2e9 100644 --- a/inclusive_dance_bot/bot/dialogs/admins/submenu/update/change_weight.py +++ b/idb/bot/dialogs/admins/submenu/update/change_weight.py @@ -3,12 +3,12 @@ from aiogram_dialog.widgets.input import TextInput from aiogram_dialog.widgets.text import Format -from inclusive_dance_bot.bot.dialogs.admins.states import ChangeSubmenuSG -from inclusive_dance_bot.bot.dialogs.utils.buttons import CANCEL -from inclusive_dance_bot.bot.dialogs.utils.getters import get_submenu_data -from inclusive_dance_bot.db.uow.main import UnitOfWork -from inclusive_dance_bot.logic.storage import Storage -from inclusive_dance_bot.logic.submenu import update_submenu_by_id +from idb.bot.dialogs.admins.states import ChangeSubmenuSG +from idb.bot.dialogs.utils.buttons import CANCEL +from idb.bot.dialogs.utils.getters import get_submenu_data +from idb.db.uow import UnitOfWork +from idb.logic.submenu import update_submenu_by_id +from idb.utils.cache import AbstractBotCache TEMPLATE_MESSAGE = ( "Введите новое значение веса. " @@ -22,9 +22,9 @@ async def on_success( ) -> None: submenu_id = dialog_manager.start_data["submenu_id"] uow: UnitOfWork = dialog_manager.middleware_data["uow"] - storage: Storage = dialog_manager.middleware_data["storage"] + cache: AbstractBotCache = dialog_manager.middleware_data["cache"] await update_submenu_by_id( - uow=uow, storage=storage, submenu_id=submenu_id, weight=weight + uow=uow, cache=cache, submenu_id=submenu_id, weight=weight ) await dialog_manager.done() diff --git a/inclusive_dance_bot/bot/dialogs/admins/submenu/__init__.py b/idb/bot/dialogs/admins/url/__init__.py similarity index 62% rename from inclusive_dance_bot/bot/dialogs/admins/submenu/__init__.py rename to idb/bot/dialogs/admins/url/__init__.py index f876ed3..e6ac2e3 100644 --- a/inclusive_dance_bot/bot/dialogs/admins/submenu/__init__.py +++ b/idb/bot/dialogs/admins/url/__init__.py @@ -1,6 +1,6 @@ from aiogram import Router -from inclusive_dance_bot.bot.dialogs.admins.submenu import create, delete, read, update +from idb.bot.dialogs.admins.url import create, delete, read, update router = Router() router.include_routers( diff --git a/inclusive_dance_bot/bot/dialogs/admins/url/create/__init__.py b/idb/bot/dialogs/admins/url/create/__init__.py similarity index 72% rename from inclusive_dance_bot/bot/dialogs/admins/url/create/__init__.py rename to idb/bot/dialogs/admins/url/create/__init__.py index 09ea351..dbae942 100644 --- a/inclusive_dance_bot/bot/dialogs/admins/url/create/__init__.py +++ b/idb/bot/dialogs/admins/url/create/__init__.py @@ -1,6 +1,6 @@ from aiogram_dialog import Dialog -from inclusive_dance_bot.bot.dialogs.admins.url.create import ( +from idb.bot.dialogs.admins.url.create import ( confirm, input_slug, input_value, diff --git a/inclusive_dance_bot/bot/dialogs/admins/url/create/confirm.py b/idb/bot/dialogs/admins/url/create/confirm.py similarity index 71% rename from inclusive_dance_bot/bot/dialogs/admins/url/create/confirm.py rename to idb/bot/dialogs/admins/url/create/confirm.py index 10e73b2..1087b5c 100644 --- a/inclusive_dance_bot/bot/dialogs/admins/url/create/confirm.py +++ b/idb/bot/dialogs/admins/url/create/confirm.py @@ -3,21 +3,21 @@ from aiogram_dialog.widgets.kbd import Button, Row from aiogram_dialog.widgets.text import Const, Format -from inclusive_dance_bot.bot.dialogs.admins.states import AdminMainMenuSG, CreateUrlSG -from inclusive_dance_bot.bot.dialogs.utils.buttons import BACK -from inclusive_dance_bot.db.uow.main import UnitOfWork -from inclusive_dance_bot.logic.storage import Storage -from inclusive_dance_bot.logic.url import create_url +from idb.bot.dialogs.admins.states import AdminMainMenuSG, CreateUrlSG +from idb.bot.dialogs.utils.buttons import BACK +from idb.db.uow import UnitOfWork +from idb.logic.url import create_url +from idb.utils.cache import AbstractBotCache async def on_click( c: CallbackQuery, button: Button, dialog_manager: DialogManager ) -> None: - storage: Storage = dialog_manager.middleware_data["storage"] + cache: AbstractBotCache = dialog_manager.middleware_data["cache"] uow: UnitOfWork = dialog_manager.middleware_data["uow"] await create_url( uow=uow, - storage=storage, + cache=cache, slug=dialog_manager.dialog_data["slug"], value=dialog_manager.dialog_data["value"], ) diff --git a/inclusive_dance_bot/bot/dialogs/admins/url/create/input_slug.py b/idb/bot/dialogs/admins/url/create/input_slug.py similarity index 73% rename from inclusive_dance_bot/bot/dialogs/admins/url/create/input_slug.py rename to idb/bot/dialogs/admins/url/create/input_slug.py index 0f11e41..8f63a8c 100644 --- a/inclusive_dance_bot/bot/dialogs/admins/url/create/input_slug.py +++ b/idb/bot/dialogs/admins/url/create/input_slug.py @@ -3,10 +3,10 @@ from aiogram_dialog.widgets.input import TextInput from aiogram_dialog.widgets.text import Const -from inclusive_dance_bot.bot.dialogs.admins.states import CreateUrlSG -from inclusive_dance_bot.bot.dialogs.utils.buttons import CANCEL -from inclusive_dance_bot.logic.storage import Storage -from inclusive_dance_bot.utils import check_slug +from idb.bot.dialogs.admins.states import CreateUrlSG +from idb.bot.dialogs.utils.buttons import CANCEL +from idb.utils.cache import AbstractBotCache +from idb.utils.urls import check_slug async def on_success( @@ -17,10 +17,10 @@ async def on_success( await message.answer(text="Некорректный слаг") return - storage: Storage = dialog_manager.middleware_data["storage"] + cache: AbstractBotCache = dialog_manager.middleware_data["cache"] try: - await storage.get_url_by_slug(slug) + await cache.get_url_by_slug(slug) except KeyError: dialog_manager.dialog_data["slug"] = slug await dialog_manager.next() diff --git a/inclusive_dance_bot/bot/dialogs/admins/url/create/input_value.py b/idb/bot/dialogs/admins/url/create/input_value.py similarity index 82% rename from inclusive_dance_bot/bot/dialogs/admins/url/create/input_value.py rename to idb/bot/dialogs/admins/url/create/input_value.py index dd0812e..fba09ed 100644 --- a/inclusive_dance_bot/bot/dialogs/admins/url/create/input_value.py +++ b/idb/bot/dialogs/admins/url/create/input_value.py @@ -3,8 +3,8 @@ from aiogram_dialog.widgets.input import TextInput from aiogram_dialog.widgets.text import Const -from inclusive_dance_bot.bot.dialogs.admins.states import CreateUrlSG -from inclusive_dance_bot.bot.dialogs.utils.buttons import BACK +from idb.bot.dialogs.admins.states import CreateUrlSG +from idb.bot.dialogs.utils.buttons import BACK async def on_success( diff --git a/inclusive_dance_bot/bot/dialogs/admins/mailings/cancel/__init__.py b/idb/bot/dialogs/admins/url/delete/__init__.py similarity index 50% rename from inclusive_dance_bot/bot/dialogs/admins/mailings/cancel/__init__.py rename to idb/bot/dialogs/admins/url/delete/__init__.py index c157dbb..5108585 100644 --- a/inclusive_dance_bot/bot/dialogs/admins/mailings/cancel/__init__.py +++ b/idb/bot/dialogs/admins/url/delete/__init__.py @@ -1,6 +1,6 @@ from aiogram_dialog import Dialog -from inclusive_dance_bot.bot.dialogs.admins.mailings.cancel import confirm +from idb.bot.dialogs.admins.url.delete import confirm dialog = Dialog( confirm.window, diff --git a/inclusive_dance_bot/bot/dialogs/admins/url/delete/confirm.py b/idb/bot/dialogs/admins/url/delete/confirm.py similarity index 64% rename from inclusive_dance_bot/bot/dialogs/admins/url/delete/confirm.py rename to idb/bot/dialogs/admins/url/delete/confirm.py index 33ba0a4..6820f4b 100644 --- a/inclusive_dance_bot/bot/dialogs/admins/url/delete/confirm.py +++ b/idb/bot/dialogs/admins/url/delete/confirm.py @@ -3,21 +3,21 @@ from aiogram_dialog.widgets.kbd import Button, Row from aiogram_dialog.widgets.text import Const, Format -from inclusive_dance_bot.bot.dialogs.admins.states import AdminMainMenuSG, DeleteUrlSG -from inclusive_dance_bot.bot.dialogs.utils.buttons import CANCEL -from inclusive_dance_bot.bot.dialogs.utils.getters import get_url_data -from inclusive_dance_bot.db.uow.main import UnitOfWork -from inclusive_dance_bot.logic.storage import Storage -from inclusive_dance_bot.logic.url import delete_url_by_slug +from idb.bot.dialogs.admins.states import AdminMainMenuSG, DeleteUrlSG +from idb.bot.dialogs.utils.buttons import CANCEL +from idb.bot.dialogs.utils.getters import get_url_data +from idb.db.uow import UnitOfWork +from idb.logic.url import delete_url_by_slug +from idb.utils.cache import AbstractBotCache async def on_click( c: CallbackQuery, button: Button, dialog_manager: DialogManager ) -> None: - storage: Storage = dialog_manager.middleware_data["storage"] + cache: AbstractBotCache = dialog_manager.middleware_data["cache"] uow: UnitOfWork = dialog_manager.middleware_data["uow"] url_slug = dialog_manager.start_data["url_slug"] - await delete_url_by_slug(uow=uow, storage=storage, url_slug=url_slug) + await delete_url_by_slug(uow=uow, cache=cache, url_slug=url_slug) dialog_manager.show_mode = ShowMode.SEND await c.bot.send_message(c.from_user.id, text="Ссылка была удалена") # type: ignore[union-attr] await dialog_manager.start(state=AdminMainMenuSG.menu, mode=StartMode.RESET_STACK) diff --git a/inclusive_dance_bot/bot/dialogs/admins/url/read/__init__.py b/idb/bot/dialogs/admins/url/read/__init__.py similarity index 83% rename from inclusive_dance_bot/bot/dialogs/admins/url/read/__init__.py rename to idb/bot/dialogs/admins/url/read/__init__.py index 19f95ac..d58985a 100644 --- a/inclusive_dance_bot/bot/dialogs/admins/url/read/__init__.py +++ b/idb/bot/dialogs/admins/url/read/__init__.py @@ -2,7 +2,7 @@ from aiogram_dialog import Data, Dialog, DialogManager -from inclusive_dance_bot.bot.dialogs.admins.url.read import item, items +from idb.bot.dialogs.admins.url.read import item, items async def on_process_result( diff --git a/inclusive_dance_bot/bot/dialogs/admins/url/read/item.py b/idb/bot/dialogs/admins/url/read/item.py similarity index 63% rename from inclusive_dance_bot/bot/dialogs/admins/url/read/item.py rename to idb/bot/dialogs/admins/url/read/item.py index e978705..7d6f5db 100644 --- a/inclusive_dance_bot/bot/dialogs/admins/url/read/item.py +++ b/idb/bot/dialogs/admins/url/read/item.py @@ -1,25 +1,26 @@ from typing import Any from aiogram_dialog import DialogManager, Window -from aiogram_dialog.widgets.kbd import Back, Button +from aiogram_dialog.widgets.kbd import Button from aiogram_dialog.widgets.text import Const, Format -from inclusive_dance_bot.bot.dialogs.admins.states import ( +from idb.bot.dialogs.admins.states import ( ChangeUrlSG, DeleteUrlSG, ReadUrlSG, ) -from inclusive_dance_bot.bot.dialogs.messages import URL_TEMPLATE -from inclusive_dance_bot.bot.dialogs.utils import start_with_data -from inclusive_dance_bot.logic.storage import Storage +from idb.bot.dialogs.messages import URL_TEMPLATE +from idb.bot.dialogs.utils import start_with_data +from idb.bot.dialogs.utils.buttons import BACK +from idb.utils.cache import AbstractBotCache URL_ID = "url_slug" async def get_data( - storage: Storage, dialog_manager: DialogManager, **kwargs: Any + cache: AbstractBotCache, dialog_manager: DialogManager, **kwargs: Any ) -> dict[str, Any]: - return {"url": await storage.get_url_by_slug(dialog_manager.dialog_data[URL_ID])} + return {"url": await cache.get_url_by_slug(dialog_manager.dialog_data[URL_ID])} window = Window( @@ -39,7 +40,7 @@ async def get_data( id="delete_url", on_click=start_with_data(state=DeleteUrlSG.confirm, field=URL_ID), ), - Back(Const("Назад")), + BACK, state=ReadUrlSG.item, getter=get_data, ) diff --git a/inclusive_dance_bot/bot/dialogs/admins/url/read/items.py b/idb/bot/dialogs/admins/url/read/items.py similarity index 72% rename from inclusive_dance_bot/bot/dialogs/admins/url/read/items.py rename to idb/bot/dialogs/admins/url/read/items.py index 3a15c2b..6f562fd 100644 --- a/inclusive_dance_bot/bot/dialogs/admins/url/read/items.py +++ b/idb/bot/dialogs/admins/url/read/items.py @@ -5,21 +5,21 @@ from aiogram_dialog.widgets.kbd import Button, ScrollingGroup, Select, Start from aiogram_dialog.widgets.text import Const, Format, List -from inclusive_dance_bot.bot.dialogs.admins.states import CreateUrlSG, ReadUrlSG -from inclusive_dance_bot.bot.dialogs.utils import sync_scroll -from inclusive_dance_bot.bot.dialogs.utils.buttons import CANCEL -from inclusive_dance_bot.logic.storage import Storage +from idb.bot.dialogs.admins.states import CreateUrlSG, ReadUrlSG +from idb.bot.dialogs.utils import sync_scroll +from idb.bot.dialogs.utils.buttons import CANCEL +from idb.utils.cache import AbstractBotCache SCROLL_KBD_ID = "url_scroll_id" SCROLL_MESSAGE_ID = "url_message_scroll_id" -async def get_urls_list_data(storage: Storage, **kwargs: Any) -> dict[str, Any]: - return {"urls": list((await storage.get_urls()).values())} +async def get_urls_list_data(cache: AbstractBotCache, **kwargs: Any) -> dict[str, Any]: + return {"urls": list((await cache.get_urls()).values())} async def open_url( - c: CallbackQuery, widget: Button, dialog_manager: DialogManager, url_slug: int + c: CallbackQuery, widget: Button, dialog_manager: DialogManager, url_slug: str ) -> None: dialog_manager.dialog_data["url_slug"] = url_slug await dialog_manager.next() @@ -28,7 +28,7 @@ async def open_url( window = Window( Const("Ссылки\n"), List( - Format("[{pos}] {item.slug}"), + Format("[{pos}] {item.slug}"), items="urls", id=SCROLL_MESSAGE_ID, page_size=10, diff --git a/inclusive_dance_bot/bot/dialogs/admins/url/update/__init__.py b/idb/bot/dialogs/admins/url/update/__init__.py similarity index 54% rename from inclusive_dance_bot/bot/dialogs/admins/url/update/__init__.py rename to idb/bot/dialogs/admins/url/update/__init__.py index 964a164..50ae1ce 100644 --- a/inclusive_dance_bot/bot/dialogs/admins/url/update/__init__.py +++ b/idb/bot/dialogs/admins/url/update/__init__.py @@ -1,6 +1,6 @@ from aiogram_dialog import Dialog -from inclusive_dance_bot.bot.dialogs.admins.url.update import change_slug, change_value +from idb.bot.dialogs.admins.url.update import change_slug, change_value dialog = Dialog( change_slug.window, diff --git a/inclusive_dance_bot/bot/dialogs/admins/url/update/change_slug.py b/idb/bot/dialogs/admins/url/update/change_slug.py similarity index 65% rename from inclusive_dance_bot/bot/dialogs/admins/url/update/change_slug.py rename to idb/bot/dialogs/admins/url/update/change_slug.py index 280fb9e..381ccf0 100644 --- a/inclusive_dance_bot/bot/dialogs/admins/url/update/change_slug.py +++ b/idb/bot/dialogs/admins/url/update/change_slug.py @@ -3,14 +3,14 @@ from aiogram_dialog.widgets.input import TextInput from aiogram_dialog.widgets.text import Format -from inclusive_dance_bot.bot.dialogs.admins.states import ChangeUrlSG -from inclusive_dance_bot.bot.dialogs.utils.buttons import CANCEL -from inclusive_dance_bot.bot.dialogs.utils.getters import get_url_data -from inclusive_dance_bot.db.uow.main import UnitOfWork -from inclusive_dance_bot.exceptions.url import UrlSlugAlreadyExistsError -from inclusive_dance_bot.logic.storage import Storage -from inclusive_dance_bot.logic.url import update_url_by_slug -from inclusive_dance_bot.utils import check_slug +from idb.bot.dialogs.admins.states import ChangeUrlSG +from idb.bot.dialogs.utils.buttons import CANCEL +from idb.bot.dialogs.utils.getters import get_url_data +from idb.db.uow import UnitOfWork +from idb.exceptions.url import UrlSlugAlreadyExistsError +from idb.logic.url import update_url_by_slug +from idb.utils.cache import AbstractBotCache +from idb.utils.urls import check_slug TEMPLATE_MESSAGE = ( "Введите новый слаг\n(слаг может состоять только из латинских букв" @@ -27,11 +27,9 @@ async def on_success( return url_slug = dialog_manager.start_data["url_slug"] uow: UnitOfWork = dialog_manager.middleware_data["uow"] - storage: Storage = dialog_manager.middleware_data["storage"] + cache: AbstractBotCache = dialog_manager.middleware_data["cache"] try: - await update_url_by_slug( - uow=uow, storage=storage, url_slug=url_slug, slug=value - ) + await update_url_by_slug(uow=uow, cache=cache, url_slug=url_slug, slug=value) except UrlSlugAlreadyExistsError: dialog_manager.show_mode = ShowMode.SEND await message.answer(text="Такой слаг уже занят. Придумайте другой") diff --git a/inclusive_dance_bot/bot/dialogs/admins/url/update/change_value.py b/idb/bot/dialogs/admins/url/update/change_value.py similarity index 59% rename from inclusive_dance_bot/bot/dialogs/admins/url/update/change_value.py rename to idb/bot/dialogs/admins/url/update/change_value.py index b8f1b43..500b7cc 100644 --- a/inclusive_dance_bot/bot/dialogs/admins/url/update/change_value.py +++ b/idb/bot/dialogs/admins/url/update/change_value.py @@ -3,12 +3,12 @@ from aiogram_dialog.widgets.input import TextInput from aiogram_dialog.widgets.text import Format -from inclusive_dance_bot.bot.dialogs.admins.states import ChangeUrlSG -from inclusive_dance_bot.bot.dialogs.utils.buttons import CANCEL -from inclusive_dance_bot.bot.dialogs.utils.getters import get_url_data -from inclusive_dance_bot.db.uow.main import UnitOfWork -from inclusive_dance_bot.logic.storage import Storage -from inclusive_dance_bot.logic.url import update_url_by_slug +from idb.bot.dialogs.admins.states import ChangeUrlSG +from idb.bot.dialogs.utils.buttons import CANCEL +from idb.bot.dialogs.utils.getters import get_url_data +from idb.db.uow import UnitOfWork +from idb.logic.url import update_url_by_slug +from idb.utils.cache import AbstractBotCache TEMPLATE_MESSAGE = "Введите новое значение ссылки\n\nТекущее: {url.value}" @@ -18,8 +18,8 @@ async def on_success( ) -> None: url_slug = dialog_manager.start_data["url_slug"] uow: UnitOfWork = dialog_manager.middleware_data["uow"] - storage: Storage = dialog_manager.middleware_data["storage"] - await update_url_by_slug(uow=uow, storage=storage, url_slug=url_slug, value=value) + cache: AbstractBotCache = dialog_manager.middleware_data["cache"] + await update_url_by_slug(uow=uow, cache=cache, url_slug=url_slug, value=value) await dialog_manager.done(result={"url_slug": url_slug}) diff --git a/idb/bot/dialogs/commands.py b/idb/bot/dialogs/commands.py new file mode 100644 index 0000000..4285b0e --- /dev/null +++ b/idb/bot/dialogs/commands.py @@ -0,0 +1,11 @@ +from aiogram.types import Message +from aiogram_dialog import DialogManager + +from idb.bot.utils import start_new_dialog + + +async def start_command( + message: Message, + dialog_manager: DialogManager, +) -> None: + await start_new_dialog(dialog_manager=dialog_manager) diff --git a/inclusive_dance_bot/bot/dialogs/messages.py b/idb/bot/dialogs/messages.py similarity index 89% rename from inclusive_dance_bot/bot/dialogs/messages.py rename to idb/bot/dialogs/messages.py index 4637c05..1ed7644 100644 --- a/inclusive_dance_bot/bot/dialogs/messages.py +++ b/idb/bot/dialogs/messages.py @@ -4,9 +4,7 @@ В этом чате вы можете получать актуальную информацию о событиях международного движения Inclusive Dance, регистрироваться на мероприятия, задавать все интересующие вас вопросы и многое другое. -""".replace( - "\n", " " -) +""".replace("\n", " ") INPUT_NAME_MESSAGE = """ А теперь я хочу с вами познакомиться. @@ -60,18 +58,13 @@ Значение: {url.value} """ -SUBMENU_TEMPLATE = """\ -Подменю {submenu.id} - -Тип: {submenu.type} -Текст кнопки: {submenu.button_text} -Вес: {submenu.weight} -Значение: {submenu.message!r} -""" - ADD_ADMIN_MESSAGE = """\ Введите ник пользователя в Телеграме для добавления в группу адмиинистраторов. Пользователь обязательно должен быть зарегистрирован в боте. """ + +GOT_NEW_FEEDBACK_MESSAGE = """\ +Получена обратная связь от пользователя +""" diff --git a/idb/bot/dialogs/router.py b/idb/bot/dialogs/router.py new file mode 100644 index 0000000..a6afb99 --- /dev/null +++ b/idb/bot/dialogs/router.py @@ -0,0 +1,18 @@ +from aiogram import Router +from aiogram.filters import Command + +from idb.bot.dialogs.admins.router import ( + dialog_router as admin_dialog_router, +) +from idb.bot.dialogs.commands import start_command +from idb.bot.dialogs.users.router import ( + dialog_router as user_dialog_router, +) +from idb.bot.ui_commands import Commands + + +def register_dialogs(root_router: Router) -> None: + dialog_router = Router() + dialog_router.include_routers(admin_dialog_router, user_dialog_router) + dialog_router.message(Command(Commands.START))(start_command) + root_router.include_router(dialog_router) diff --git a/inclusive_dance_bot/logic/__init__.py b/idb/bot/dialogs/users/__init__.py similarity index 100% rename from inclusive_dance_bot/logic/__init__.py rename to idb/bot/dialogs/users/__init__.py diff --git a/inclusive_dance_bot/bot/dialogs/users/feedback/__init__.py b/idb/bot/dialogs/users/feedback/__init__.py similarity index 77% rename from inclusive_dance_bot/bot/dialogs/users/feedback/__init__.py rename to idb/bot/dialogs/users/feedback/__init__.py index b8b91be..013fdcf 100644 --- a/inclusive_dance_bot/bot/dialogs/users/feedback/__init__.py +++ b/idb/bot/dialogs/users/feedback/__init__.py @@ -2,12 +2,12 @@ from aiogram_dialog import Dialog, DialogManager -from inclusive_dance_bot.bot.dialogs.users.feedback import ( +from idb.bot.dialogs.users.feedback import ( confirm, input_text, input_title, ) -from inclusive_dance_bot.enums import FeedbackField +from idb.generals.enums import FeedbackField async def on_start(data: dict[str, Any], dialog_manager: DialogManager) -> None: diff --git a/inclusive_dance_bot/bot/dialogs/users/feedback/confirm.py b/idb/bot/dialogs/users/feedback/confirm.py similarity index 70% rename from inclusive_dance_bot/bot/dialogs/users/feedback/confirm.py rename to idb/bot/dialogs/users/feedback/confirm.py index 17bdd3d..5d135dc 100644 --- a/inclusive_dance_bot/bot/dialogs/users/feedback/confirm.py +++ b/idb/bot/dialogs/users/feedback/confirm.py @@ -5,15 +5,16 @@ from aiogram_dialog.widgets.kbd import Button, Row from aiogram_dialog.widgets.text import Const, Format -from inclusive_dance_bot.bot.dialogs.messages import ( +from idb.bot.dialogs.messages import ( ANSWER_ON_FEEDBACK_MESSAGE, FEEDBACK_CONFIRM_TEMPLATE, + GOT_NEW_FEEDBACK_MESSAGE, ) -from inclusive_dance_bot.bot.dialogs.users.states import FeedbackSG -from inclusive_dance_bot.bot.dialogs.utils.buttons import BACK -from inclusive_dance_bot.db.uow.main import UnitOfWork -from inclusive_dance_bot.enums import FeedbackField -from inclusive_dance_bot.logic.feedback import create_feedback +from idb.bot.dialogs.users.states import FeedbackSG +from idb.bot.dialogs.utils.buttons import BACK +from idb.db.uow import UnitOfWork +from idb.generals.enums import FeedbackField +from idb.logic.feedback import create_feedback async def on_click( @@ -28,10 +29,16 @@ async def on_click( text=dialog_manager.dialog_data[FeedbackField.TEXT], ) dialog_manager.show_mode = ShowMode.SEND - await c.bot.send_message( # type:ignore[union-attr] + await c.bot.send_message( # type: ignore[union-attr] chat_id=c.from_user.id, text=ANSWER_ON_FEEDBACK_MESSAGE, ) + admins = await uow.users.get_admin_list(include_superusers=True) + for admin in admins: + await c.bot.send_message( # type: ignore[union-attr] + chat_id=admin.id, + text=GOT_NEW_FEEDBACK_MESSAGE, + ) await dialog_manager.done() diff --git a/inclusive_dance_bot/bot/dialogs/users/feedback/input_text.py b/idb/bot/dialogs/users/feedback/input_text.py similarity index 68% rename from inclusive_dance_bot/bot/dialogs/users/feedback/input_text.py rename to idb/bot/dialogs/users/feedback/input_text.py index ed167db..3590ea4 100644 --- a/inclusive_dance_bot/bot/dialogs/users/feedback/input_text.py +++ b/idb/bot/dialogs/users/feedback/input_text.py @@ -3,9 +3,9 @@ from aiogram_dialog.widgets.input import TextInput from aiogram_dialog.widgets.text import Const -from inclusive_dance_bot.bot.dialogs.users.states import FeedbackSG -from inclusive_dance_bot.bot.dialogs.utils.buttons import BACK -from inclusive_dance_bot.enums import FeedbackField +from idb.bot.dialogs.users.states import FeedbackSG +from idb.bot.dialogs.utils.buttons import BACK +from idb.generals.enums import FeedbackField async def on_success( @@ -17,7 +17,8 @@ async def on_success( window = Window( Const( - "Опишите Вашу проблему или предложение. Администраторы обязательно его рассмотрят" + "Опишите Вашу проблему или предложение. " + "Администраторы обязательно его рассмотрят" ), TextInput("input_message_id", on_success=on_success), # type: ignore[arg-type] BACK, diff --git a/inclusive_dance_bot/bot/dialogs/users/feedback/input_title.py b/idb/bot/dialogs/users/feedback/input_title.py similarity index 76% rename from inclusive_dance_bot/bot/dialogs/users/feedback/input_title.py rename to idb/bot/dialogs/users/feedback/input_title.py index 5ee7d02..52f876d 100644 --- a/inclusive_dance_bot/bot/dialogs/users/feedback/input_title.py +++ b/idb/bot/dialogs/users/feedback/input_title.py @@ -3,9 +3,9 @@ from aiogram_dialog.widgets.input import TextInput from aiogram_dialog.widgets.text import Const -from inclusive_dance_bot.bot.dialogs.users.states import FeedbackSG -from inclusive_dance_bot.bot.dialogs.utils.buttons import CANCEL -from inclusive_dance_bot.enums import FeedbackField +from idb.bot.dialogs.users.states import FeedbackSG +from idb.bot.dialogs.utils.buttons import CANCEL +from idb.generals.enums import FeedbackField async def on_success( diff --git a/idb/bot/dialogs/users/main_menu/__init__.py b/idb/bot/dialogs/users/main_menu/__init__.py new file mode 100644 index 0000000..de7dfab --- /dev/null +++ b/idb/bot/dialogs/users/main_menu/__init__.py @@ -0,0 +1,10 @@ +from aiogram_dialog import Dialog + +from idb.bot.dialogs.users.main_menu import menu +from idb.bot.dialogs.users.states import MainMenuSG +from idb.bot.dialogs.utils.submenu_window import SubmenuWindow + +dialog = Dialog( + menu.window, + SubmenuWindow(state=MainMenuSG.message), +) diff --git a/inclusive_dance_bot/bot/dialogs/users/main_menu/menu.py b/idb/bot/dialogs/users/main_menu/menu.py similarity index 87% rename from inclusive_dance_bot/bot/dialogs/users/main_menu/menu.py rename to idb/bot/dialogs/users/main_menu/menu.py index 54bcaf5..921fdd4 100644 --- a/inclusive_dance_bot/bot/dialogs/users/main_menu/menu.py +++ b/idb/bot/dialogs/users/main_menu/menu.py @@ -5,17 +5,17 @@ from aiogram_dialog.widgets.kbd import Button, Column, Select, Start from aiogram_dialog.widgets.text import Const, Format -from inclusive_dance_bot.bot.dialogs.users.states import ( +from idb.bot.dialogs.users.states import ( FeedbackSG, MainMenuSG, SubmenuSG, ) -from inclusive_dance_bot.enums import FeedbackType, SubmenuType -from inclusive_dance_bot.logic.storage import Storage +from idb.generals.enums import FeedbackType, SubmenuType +from idb.utils.cache import AbstractBotCache -async def get_submenus_data(storage: Storage, **kwargs: Any) -> dict[str, Any]: - submenus = await storage.get_submenus() +async def get_submenus_data(cache: AbstractBotCache, **kwargs: Any) -> dict[str, Any]: + submenus = await cache.get_submenus() return { "submenus": list( filter(lambda x: x.type == SubmenuType.OTHER, submenus.values()) @@ -29,8 +29,8 @@ async def open_message( dialog_manager: DialogManager, submenu_id: int, ) -> None: - storage: Storage = dialog_manager.middleware_data["storage"] - submenus = await storage.get_submenus() + cache: AbstractBotCache = dialog_manager.middleware_data["cache"] + submenus = await cache.get_submenus() submenu = submenus[submenu_id] scrolling_text = dialog_manager.find("scroll_text") scrolling_text.widget.text = Format(submenu.message) # type: ignore[union-attr] diff --git a/inclusive_dance_bot/bot/dialogs/users/registration/__init__.py b/idb/bot/dialogs/users/registration/__init__.py similarity index 80% rename from inclusive_dance_bot/bot/dialogs/users/registration/__init__.py rename to idb/bot/dialogs/users/registration/__init__.py index 19908ce..9baba73 100644 --- a/inclusive_dance_bot/bot/dialogs/users/registration/__init__.py +++ b/idb/bot/dialogs/users/registration/__init__.py @@ -1,6 +1,6 @@ from aiogram_dialog import Dialog -from inclusive_dance_bot.bot.dialogs.users.registration import ( +from idb.bot.dialogs.users.registration import ( choose_user_types, confirm, input_name, diff --git a/inclusive_dance_bot/bot/dialogs/users/registration/choose_user_types.py b/idb/bot/dialogs/users/registration/choose_user_types.py similarity index 74% rename from inclusive_dance_bot/bot/dialogs/users/registration/choose_user_types.py rename to idb/bot/dialogs/users/registration/choose_user_types.py index 6ee52a5..ec92ea0 100644 --- a/inclusive_dance_bot/bot/dialogs/users/registration/choose_user_types.py +++ b/idb/bot/dialogs/users/registration/choose_user_types.py @@ -5,17 +5,17 @@ from aiogram_dialog.widgets.kbd import Button, Multiselect, Next, ScrollingGroup from aiogram_dialog.widgets.text import Const, Format -from inclusive_dance_bot.bot.dialogs.messages import CHOOSE_USER_TYPE_MESSAGE -from inclusive_dance_bot.bot.dialogs.users.states import RegistrationSG -from inclusive_dance_bot.bot.dialogs.utils.buttons import BACK -from inclusive_dance_bot.enums import RegistrationField -from inclusive_dance_bot.logic.storage import Storage +from idb.bot.dialogs.messages import CHOOSE_USER_TYPE_MESSAGE +from idb.bot.dialogs.users.states import RegistrationSG +from idb.bot.dialogs.utils.buttons import BACK +from idb.generals.enums import RegistrationField +from idb.utils.cache import AbstractBotCache async def get_user_types_data( - dialog_manager: DialogManager, storage: Storage, **kwargs: Any + dialog_manager: DialogManager, cache: AbstractBotCache, **kwargs: Any ) -> dict[str, Any]: - user_types = await storage.get_user_types() + user_types = await cache.get_user_types() return { "user_types": user_types.values(), RegistrationField.NAME: dialog_manager.dialog_data[RegistrationField.NAME], diff --git a/inclusive_dance_bot/bot/dialogs/users/registration/confirm.py b/idb/bot/dialogs/users/registration/confirm.py similarity index 78% rename from inclusive_dance_bot/bot/dialogs/users/registration/confirm.py rename to idb/bot/dialogs/users/registration/confirm.py index 492e842..b254f3a 100644 --- a/inclusive_dance_bot/bot/dialogs/users/registration/confirm.py +++ b/idb/bot/dialogs/users/registration/confirm.py @@ -5,16 +5,16 @@ from aiogram_dialog.widgets.kbd import Button, Row from aiogram_dialog.widgets.text import Const, Format -from inclusive_dance_bot.bot.dialogs.messages import ( +from idb.bot.dialogs.messages import ( CONFIRM_REGISTRATION_MESSAGE_TEMPLATE, THANK_FOR_REGISTRATION_MESSAGE, ) -from inclusive_dance_bot.bot.dialogs.users.states import MainMenuSG, RegistrationSG -from inclusive_dance_bot.bot.dialogs.utils.buttons import BACK -from inclusive_dance_bot.db.uow.main import UnitOfWork -from inclusive_dance_bot.enums import RegistrationField -from inclusive_dance_bot.logic.storage import Storage -from inclusive_dance_bot.logic.user import create_user +from idb.bot.dialogs.users.states import MainMenuSG, RegistrationSG +from idb.bot.dialogs.utils.buttons import BACK +from idb.db.uow import UnitOfWork +from idb.generals.enums import RegistrationField +from idb.logic.users import save_profile_user +from idb.utils.cache import AbstractBotCache async def on_click( @@ -30,10 +30,9 @@ async def on_click( ) return uow: UnitOfWork = dialog_manager.middleware_data["uow"] - await create_user( + await save_profile_user( uow=uow, user_id=c.from_user.id, - username=username, name=dialog_manager.dialog_data[RegistrationField.NAME], region=dialog_manager.dialog_data[RegistrationField.REGION], phone_number=dialog_manager.dialog_data[RegistrationField.PHONE], @@ -48,11 +47,11 @@ async def on_click( async def get_user_data( - dialog_manager: DialogManager, storage: Storage, **kwargs: Any + dialog_manager: DialogManager, cache: AbstractBotCache, **kwargs: Any ) -> dict[str, Any]: user_types = filter( lambda ut: ut.id in dialog_manager.dialog_data[RegistrationField.USER_TYPE_IDS], - (await storage.get_user_types()).values(), + (await cache.get_user_types()).values(), ) return { "name": dialog_manager.dialog_data[RegistrationField.NAME], diff --git a/inclusive_dance_bot/bot/dialogs/users/registration/input_name.py b/idb/bot/dialogs/users/registration/input_name.py similarity index 66% rename from inclusive_dance_bot/bot/dialogs/users/registration/input_name.py rename to idb/bot/dialogs/users/registration/input_name.py index d3120dd..bfc6de6 100644 --- a/inclusive_dance_bot/bot/dialogs/users/registration/input_name.py +++ b/idb/bot/dialogs/users/registration/input_name.py @@ -1,12 +1,12 @@ from aiogram.types import Message from aiogram_dialog import DialogManager, Window from aiogram_dialog.widgets.input import TextInput -from aiogram_dialog.widgets.kbd import Cancel from aiogram_dialog.widgets.text import Const -from inclusive_dance_bot.bot.dialogs.messages import INPUT_NAME_MESSAGE -from inclusive_dance_bot.bot.dialogs.users.states import RegistrationSG -from inclusive_dance_bot.enums import RegistrationField +from idb.bot.dialogs.messages import INPUT_NAME_MESSAGE +from idb.bot.dialogs.users.states import RegistrationSG +from idb.bot.dialogs.utils.buttons import CANCEL +from idb.generals.enums import RegistrationField async def on_success( @@ -19,6 +19,6 @@ async def on_success( window = Window( Const(INPUT_NAME_MESSAGE), TextInput("input_name_id", on_success=on_success), # type: ignore[arg-type] - Cancel(text=Const("Отмена")), + CANCEL, state=RegistrationSG.input_name, ) diff --git a/inclusive_dance_bot/bot/dialogs/users/registration/input_phone.py b/idb/bot/dialogs/users/registration/input_phone.py similarity index 72% rename from inclusive_dance_bot/bot/dialogs/users/registration/input_phone.py rename to idb/bot/dialogs/users/registration/input_phone.py index b60e77e..e9c9e60 100644 --- a/inclusive_dance_bot/bot/dialogs/users/registration/input_phone.py +++ b/idb/bot/dialogs/users/registration/input_phone.py @@ -3,9 +3,9 @@ from aiogram_dialog.widgets.input import TextInput from aiogram_dialog.widgets.text import Const -from inclusive_dance_bot.bot.dialogs.messages import INPUT_PHONE_MESSAGE -from inclusive_dance_bot.bot.dialogs.users.states import RegistrationSG -from inclusive_dance_bot.bot.dialogs.utils.buttons import BACK +from idb.bot.dialogs.messages import INPUT_PHONE_MESSAGE +from idb.bot.dialogs.users.states import RegistrationSG +from idb.bot.dialogs.utils.buttons import BACK async def on_success( diff --git a/inclusive_dance_bot/bot/dialogs/users/registration/input_region.py b/idb/bot/dialogs/users/registration/input_region.py similarity index 73% rename from inclusive_dance_bot/bot/dialogs/users/registration/input_region.py rename to idb/bot/dialogs/users/registration/input_region.py index 3a0228e..798aea1 100644 --- a/inclusive_dance_bot/bot/dialogs/users/registration/input_region.py +++ b/idb/bot/dialogs/users/registration/input_region.py @@ -3,9 +3,9 @@ from aiogram_dialog.widgets.input import TextInput from aiogram_dialog.widgets.text import Const -from inclusive_dance_bot.bot.dialogs.messages import INPUT_REGION_MESSAGE -from inclusive_dance_bot.bot.dialogs.users.states import RegistrationSG -from inclusive_dance_bot.bot.dialogs.utils.buttons import BACK +from idb.bot.dialogs.messages import INPUT_REGION_MESSAGE +from idb.bot.dialogs.users.states import RegistrationSG +from idb.bot.dialogs.utils.buttons import BACK async def on_success( diff --git a/inclusive_dance_bot/bot/dialogs/users/__init__.py b/idb/bot/dialogs/users/router.py similarity index 83% rename from inclusive_dance_bot/bot/dialogs/users/__init__.py rename to idb/bot/dialogs/users/router.py index 5438687..f90a22e 100644 --- a/inclusive_dance_bot/bot/dialogs/users/__init__.py +++ b/idb/bot/dialogs/users/router.py @@ -1,6 +1,6 @@ from aiogram import Router -from inclusive_dance_bot.bot.dialogs.users import ( +from idb.bot.dialogs.users import ( feedback, main_menu, registration, diff --git a/inclusive_dance_bot/bot/dialogs/users/states.py b/idb/bot/dialogs/users/states.py similarity index 100% rename from inclusive_dance_bot/bot/dialogs/users/states.py rename to idb/bot/dialogs/users/states.py diff --git a/inclusive_dance_bot/bot/dialogs/users/submenu/__init__.py b/idb/bot/dialogs/users/submenu/__init__.py similarity index 63% rename from inclusive_dance_bot/bot/dialogs/users/submenu/__init__.py rename to idb/bot/dialogs/users/submenu/__init__.py index 022d811..0a975d2 100644 --- a/inclusive_dance_bot/bot/dialogs/users/submenu/__init__.py +++ b/idb/bot/dialogs/users/submenu/__init__.py @@ -2,9 +2,9 @@ from aiogram_dialog import Dialog, DialogManager -from inclusive_dance_bot.bot.dialogs.users.states import SubmenuSG -from inclusive_dance_bot.bot.dialogs.users.submenu import submenu_list -from inclusive_dance_bot.bot.dialogs.utils.submenu_window import SubmenuWindow +from idb.bot.dialogs.users.states import SubmenuSG +from idb.bot.dialogs.users.submenu import submenu_list +from idb.bot.dialogs.utils.submenu_window import SubmenuWindow async def on_start(data: dict[str, Any], dialog_manager: DialogManager) -> None: @@ -17,3 +17,4 @@ async def on_start(data: dict[str, Any], dialog_manager: DialogManager) -> None: SubmenuWindow(SubmenuSG.submenu), on_start=on_start, ) +# 292990139, diff --git a/inclusive_dance_bot/bot/dialogs/users/submenu/submenu_list.py b/idb/bot/dialogs/users/submenu/submenu_list.py similarity index 67% rename from inclusive_dance_bot/bot/dialogs/users/submenu/submenu_list.py rename to idb/bot/dialogs/users/submenu/submenu_list.py index 26684d6..ac4c846 100644 --- a/inclusive_dance_bot/bot/dialogs/users/submenu/submenu_list.py +++ b/idb/bot/dialogs/users/submenu/submenu_list.py @@ -3,18 +3,18 @@ from aiogram.types import CallbackQuery from aiogram_dialog import DialogManager, Window from aiogram_dialog.widgets.kbd import Button, Column, Select -from aiogram_dialog.widgets.text import Format +from aiogram_dialog.widgets.text import Format, Jinja -from inclusive_dance_bot.bot.dialogs.users.states import SubmenuSG -from inclusive_dance_bot.bot.dialogs.utils.buttons import CANCEL -from inclusive_dance_bot.logic.storage import Storage +from idb.bot.dialogs.users.states import SubmenuSG +from idb.bot.dialogs.utils.buttons import CANCEL +from idb.utils.cache import AbstractBotCache async def get_submenu_data( - dialog_manager: DialogManager, storage: Storage, **kwargs: Any + dialog_manager: DialogManager, cache: AbstractBotCache, **kwargs: Any ) -> dict[str, Any]: submenu_type = dialog_manager.dialog_data["type"] - submenus = await storage.get_submenus() + submenus = await cache.get_submenus() return { "submenus": list(filter(lambda x: x.type == submenu_type, submenus.values())), "message": dialog_manager.dialog_data["message"], @@ -27,11 +27,11 @@ async def open_message( dialog_manager: DialogManager, submenu_id: int, ) -> None: - storage: Storage = dialog_manager.middleware_data["storage"] - submenus = await storage.get_submenus() + cache: AbstractBotCache = dialog_manager.middleware_data["cache"] + submenus = await cache.get_submenus() submenu = submenus[submenu_id] scrolling_text = dialog_manager.find("scroll_text") - scrolling_text.widget.text = Format(submenu.message) # type: ignore[union-attr] + scrolling_text.widget.text = Jinja(submenu.message) # type: ignore[union-attr] await dialog_manager.next() diff --git a/inclusive_dance_bot/services/__init__.py b/idb/bot/dialogs/users/windows/__init__.py similarity index 100% rename from inclusive_dance_bot/services/__init__.py rename to idb/bot/dialogs/users/windows/__init__.py diff --git a/idb/bot/dialogs/utils/__init__.py b/idb/bot/dialogs/utils/__init__.py new file mode 100644 index 0000000..0b112d8 --- /dev/null +++ b/idb/bot/dialogs/utils/__init__.py @@ -0,0 +1,4 @@ +__all__ = ["sync_scroll", "start_with_data"] + +from idb.bot.dialogs.utils.start_with_data import start_with_data +from idb.bot.dialogs.utils.sync_scroll import sync_scroll diff --git a/inclusive_dance_bot/bot/dialogs/utils/buttons.py b/idb/bot/dialogs/utils/buttons.py similarity index 51% rename from inclusive_dance_bot/bot/dialogs/utils/buttons.py rename to idb/bot/dialogs/utils/buttons.py index 575a322..1c33697 100644 --- a/inclusive_dance_bot/bot/dialogs/utils/buttons.py +++ b/idb/bot/dialogs/utils/buttons.py @@ -1,5 +1,5 @@ from aiogram_dialog.widgets.kbd import Back, Cancel from aiogram_dialog.widgets.text import Const -BACK = Back(text=Const("Назад")) -CANCEL = Cancel(text=Const("Назад")) +BACK = Back(text=Const("⬅️ Назад")) +CANCEL = Cancel(text=Const("⬅️ Назад")) diff --git a/idb/bot/dialogs/utils/getters.py b/idb/bot/dialogs/utils/getters.py new file mode 100644 index 0000000..a404c43 --- /dev/null +++ b/idb/bot/dialogs/utils/getters.py @@ -0,0 +1,21 @@ +from typing import Any + +from aiogram_dialog import DialogManager + +from idb.utils.cache import AbstractBotCache + + +async def get_url_data( + cache: AbstractBotCache, dialog_manager: DialogManager, **kwargs: Any +) -> dict[str, Any]: + return {"url": await cache.get_url_by_slug(dialog_manager.start_data["url_slug"])} + + +async def get_submenu_data( + cache: AbstractBotCache, dialog_manager: DialogManager, **kwargs: Any +) -> dict[str, Any]: + return { + "submenu": await cache.get_submenu_by_id( + dialog_manager.start_data["submenu_id"] + ) + } diff --git a/inclusive_dance_bot/bot/dialogs/utils/input_form_field.py b/idb/bot/dialogs/utils/input_form_field.py similarity index 90% rename from inclusive_dance_bot/bot/dialogs/utils/input_form_field.py rename to idb/bot/dialogs/utils/input_form_field.py index 0b6ea90..492d9e8 100644 --- a/inclusive_dance_bot/bot/dialogs/utils/input_form_field.py +++ b/idb/bot/dialogs/utils/input_form_field.py @@ -30,8 +30,8 @@ def __init__( on_success=self.on_success, # type: ignore[arg-type] type_factory=type_factory, ), - Cancel(text=Const("Отмена"), when=F["is_first"]), - Back(text=Const("Назад"), when=~F["is_first"]), + Cancel(text=Const("⬅️ Отмена"), when=F["is_first"]), + Back(text=Const("⬅️ Назад"), when=~F["is_first"]), state=state, getter=self.get_data, ) diff --git a/inclusive_dance_bot/bot/dialogs/utils/start_with_data.py b/idb/bot/dialogs/utils/start_with_data.py similarity index 90% rename from inclusive_dance_bot/bot/dialogs/utils/start_with_data.py rename to idb/bot/dialogs/utils/start_with_data.py index eb272aa..2393d93 100644 --- a/inclusive_dance_bot/bot/dialogs/utils/start_with_data.py +++ b/idb/bot/dialogs/utils/start_with_data.py @@ -14,6 +14,6 @@ async def on_click( callback: CallbackQuery, button: Button, manager: DialogManager ) -> None: data = {field: manager.dialog_data.get(field)} - await manager.start(state, data, mode=StartMode.NORMAL) + await manager.start(state, data, mode=mode) return on_click diff --git a/inclusive_dance_bot/bot/dialogs/utils/submenu_window.py b/idb/bot/dialogs/utils/submenu_window.py similarity index 64% rename from inclusive_dance_bot/bot/dialogs/utils/submenu_window.py rename to idb/bot/dialogs/utils/submenu_window.py index 498c919..7d6e7af 100644 --- a/inclusive_dance_bot/bot/dialogs/utils/submenu_window.py +++ b/idb/bot/dialogs/utils/submenu_window.py @@ -1,3 +1,4 @@ +from collections.abc import Mapping from typing import Any from aiogram.fsm.state import State @@ -5,8 +6,9 @@ from aiogram_dialog.widgets.kbd import NumberedPager from aiogram_dialog.widgets.text import Format, ScrollingText -from inclusive_dance_bot.bot.dialogs.utils.buttons import BACK -from inclusive_dance_bot.logic.storage import Storage +from idb.bot.dialogs.utils.buttons import BACK +from idb.generals.models.url import Url +from idb.utils.cache import AbstractBotCache class SubmenuWindow(Window): @@ -16,11 +18,13 @@ def __init__(self, state: State) -> None: NumberedPager(scroll="scroll_text", when=when_), # type: ignore[arg-type] BACK, state=state, - getter=self.get_urls, + getter=self.get_urls, # type: ignore[arg-type] ) - async def get_urls(self, storage: Storage, **kwargs: Any) -> dict[str, Any]: - return await storage.get_urls() + async def get_urls( + self, cache: AbstractBotCache, **kwargs: Any + ) -> Mapping[str, Url]: + return await cache.get_urls() def when_(data: dict, widget: NumberedPager, dialog_manager: DialogManager) -> bool: diff --git a/inclusive_dance_bot/bot/dialogs/utils/sync_scroll.py b/idb/bot/dialogs/utils/sync_scroll.py similarity index 70% rename from inclusive_dance_bot/bot/dialogs/utils/sync_scroll.py rename to idb/bot/dialogs/utils/sync_scroll.py index fdd1c50..068c84e 100644 --- a/inclusive_dance_bot/bot/dialogs/utils/sync_scroll.py +++ b/idb/bot/dialogs/utils/sync_scroll.py @@ -6,16 +6,17 @@ def sync_scroll( scroll_id: str, -) -> Callable[[ChatEvent, ManagedScroll, DialogManager], Awaitable[None],]: +) -> Callable[ + [ChatEvent, ManagedScroll, DialogManager], + Awaitable[None], +]: async def on_page_changed( event: ChatEvent, widget: ManagedScroll, dialog_manager: DialogManager, ) -> None: page = await widget.get_page() - other_scroll: ManagedScroll = dialog_manager.find( - scroll_id - ) # type: ignore[assignment] + other_scroll: ManagedScroll = dialog_manager.find(scroll_id) # type: ignore[assignment] await other_scroll.set_page(page=page) return on_page_changed diff --git a/inclusive_dance_bot/bot/dialogs/utils/validators.py b/idb/bot/dialogs/utils/validators.py similarity index 100% rename from inclusive_dance_bot/bot/dialogs/utils/validators.py rename to idb/bot/dialogs/utils/validators.py diff --git a/inclusive_dance_bot/bot/factory.py b/idb/bot/factory.py similarity index 74% rename from inclusive_dance_bot/bot/factory.py rename to idb/bot/factory.py index c3bd55b..fbbd9aa 100644 --- a/inclusive_dance_bot/bot/factory.py +++ b/idb/bot/factory.py @@ -1,3 +1,4 @@ +import ujson from aiogram import Bot from aiogram.enums import ParseMode from aiogram.fsm.storage.base import BaseStorage @@ -5,10 +6,10 @@ from aiogram.fsm.storage.redis import DefaultKeyBuilder, RedisStorage -def get_bot(telegram_bot_token: str) -> Bot: +def get_bot(telegram_bot_token: str, parse_mode: ParseMode) -> Bot: return Bot( token=telegram_bot_token, - parse_mode=ParseMode.HTML, + parse_mode=parse_mode, ) @@ -18,4 +19,6 @@ def get_storage(debug: bool, redis_dsn: str) -> BaseStorage: return RedisStorage.from_url( url=redis_dsn, key_builder=DefaultKeyBuilder(with_destiny=True), + json_loads=ujson.loads, + json_dumps=ujson.dumps, ) diff --git a/idb/bot/handlers.py b/idb/bot/handlers.py new file mode 100644 index 0000000..a795352 --- /dev/null +++ b/idb/bot/handlers.py @@ -0,0 +1,31 @@ +import logging + +from aiogram.exceptions import TelegramBadRequest +from aiogram.types import ErrorEvent +from aiogram_dialog import DialogManager + +from idb.bot.utils import start_new_dialog + +log = logging.getLogger(__name__) + + +async def on_unknown_intent(event: ErrorEvent, dialog_manager: DialogManager) -> None: + """Example of handling UnknownIntent Error and starting new dialog.""" + log.error("Restarting dialog: %s", event.exception) + if event.update.callback_query: + await event.update.callback_query.answer( + "Бот был перезапущен для тех. обслуживания.\n" + "Вы будете перенаправлены в главное меню.", + ) + if event.update.callback_query.message: + try: + await event.update.callback_query.message.delete() + except TelegramBadRequest: + pass # whatever + await start_new_dialog(dialog_manager=dialog_manager) + + +async def on_unknown_state(event: ErrorEvent, dialog_manager: DialogManager) -> None: + """Example of handling UnknownState Error and starting new dialog.""" + log.error("Restarting dialog: %s", event.exception) + await start_new_dialog(dialog_manager=dialog_manager) diff --git a/tests/test_unit/test_services/__init__.py b/idb/bot/middlewares/__init__.py similarity index 100% rename from tests/test_unit/test_services/__init__.py rename to idb/bot/middlewares/__init__.py diff --git a/inclusive_dance_bot/bot/middlewares/storage.py b/idb/bot/middlewares/cache.py similarity index 67% rename from inclusive_dance_bot/bot/middlewares/storage.py rename to idb/bot/middlewares/cache.py index b802215..d382c40 100644 --- a/inclusive_dance_bot/bot/middlewares/storage.py +++ b/idb/bot/middlewares/cache.py @@ -4,13 +4,13 @@ from aiogram import BaseMiddleware from aiogram.types import TelegramObject, Update -from inclusive_dance_bot.logic.storage import Storage +from idb.utils.cache import AbstractBotCache -class StorageMiddleware(BaseMiddleware): - def __init__(self, storage: Storage) -> None: +class CacheMiddleware(BaseMiddleware): + def __init__(self, cache: AbstractBotCache) -> None: super().__init__() - self.storage = storage + self._cache = cache async def __call__( self, @@ -18,5 +18,5 @@ async def __call__( event: Update, # type: ignore[override] data: dict[str, Any], ) -> Any: - data["storage"] = self.storage + data["cache"] = self._cache return await handler(event, data) diff --git a/inclusive_dance_bot/bot/middlewares/uow.py b/idb/bot/middlewares/uow.py similarity index 63% rename from inclusive_dance_bot/bot/middlewares/uow.py rename to idb/bot/middlewares/uow.py index 54bbdf2..78daeac 100644 --- a/inclusive_dance_bot/bot/middlewares/uow.py +++ b/idb/bot/middlewares/uow.py @@ -3,14 +3,14 @@ from aiogram import BaseMiddleware from aiogram.types import TelegramObject, Update +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker -from inclusive_dance_bot.db.uow.main import UnitOfWork +from idb.db.uow import uow_context class UowMiddleware(BaseMiddleware): - def __init__(self, uow: UnitOfWork) -> None: - super().__init__() - self.uow = uow + def __init__(self, sessionmaker: async_sessionmaker[AsyncSession]) -> None: + self.sessionmaker = sessionmaker async def __call__( self, @@ -18,6 +18,6 @@ async def __call__( event: Update, # type: ignore[override] data: dict[str, Any], ) -> Any: - async with self.uow as uow: + async with uow_context(self.sessionmaker) as uow: data["uow"] = uow return await handler(event, data) diff --git a/idb/bot/middlewares/user.py b/idb/bot/middlewares/user.py new file mode 100644 index 0000000..7c10351 --- /dev/null +++ b/idb/bot/middlewares/user.py @@ -0,0 +1,49 @@ +from collections.abc import Awaitable, Callable +from typing import Any + +from aiogram import BaseMiddleware +from aiogram.types import TelegramObject, Update +from aiogram.types import User as TelegramUser + +from idb.db.uow import UnitOfWork +from idb.generals.models.user import BotUser, User + + +class UserMiddleware(BaseMiddleware): + _telegram_bot_admin_ids: list[int] + + def __init__(self, telegram_bot_admin_ids: list[int]) -> None: + self._telegram_bot_admin_ids = telegram_bot_admin_ids + + async def __call__( + self, + handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]], + event: Update, # type: ignore[override] + data: dict[str, Any], + ) -> Any: + telegram_user: TelegramUser = data["event_from_user"] + uow: UnitOfWork = data["uow"] + user = await self._get_or_create_user(uow=uow, telegram_user=telegram_user) + data["user"] = BotUser( + telegram_user=telegram_user, + user=user, + ) + return await handler(event, data) + + async def _get_or_create_user( + self, + uow: UnitOfWork, + telegram_user: TelegramUser, + ) -> User: + user = await uow.users.get_by_id_or_none(telegram_user.id) + if user is None: + is_superuser = telegram_user.id in self._telegram_bot_admin_ids + user = await uow.users.create( + id=telegram_user.id, + username=telegram_user.username, + is_admin=is_superuser, + is_superuser=is_superuser, + profile=dict(), + ) + await uow.commit() + return user diff --git a/tests/test_unit/test_services/test_feedback/__init__.py b/idb/bot/services/__init__.py similarity index 100% rename from tests/test_unit/test_services/test_feedback/__init__.py rename to idb/bot/services/__init__.py diff --git a/idb/bot/services/bot.py b/idb/bot/services/bot.py new file mode 100644 index 0000000..2fb7d3c --- /dev/null +++ b/idb/bot/services/bot.py @@ -0,0 +1,73 @@ +import logging + +from aiogram import Bot, Dispatcher +from aiogram.filters import ExceptionTypeFilter +from aiogram.fsm.storage.memory import SimpleEventIsolation +from aiogram_dialog import setup_dialogs +from aiogram_dialog.api.exceptions import UnknownIntent, UnknownState +from aiogram_dialog.widgets.text.jinja import setup_jinja +from aiomisc import Service +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from idb.bot.dialogs.router import register_dialogs +from idb.bot.factory import get_storage +from idb.bot.handlers import on_unknown_intent, on_unknown_state +from idb.bot.middlewares.cache import CacheMiddleware +from idb.bot.middlewares.uow import UowMiddleware +from idb.bot.middlewares.user import UserMiddleware +from idb.bot.ui_commands import set_ui_commands +from idb.bot.utils import as_local_fmt +from idb.utils.cache import AbstractBotCache + +log = logging.getLogger(__name__) + + +class AiogramBotService(Service): + __required__ = ("debug", "redis_dsn", "telegram_bot_admin_ids") + + __dependencies__ = ("sessionmaker", "bot", "cache") + + debug: bool + redis_dsn: str + telegram_bot_admin_ids: list[int] + + sessionmaker: async_sessionmaker[AsyncSession] + bot: Bot + cache: AbstractBotCache + + async def start(self) -> None: + log.info("Initialize bot") + await set_ui_commands(self.bot) + await self.bot.delete_webhook(drop_pending_updates=True) + + dp = Dispatcher( + storage=get_storage( + debug=self.debug, + redis_dsn=self.redis_dsn, + ), + events_isolation=SimpleEventIsolation(), + ) + dp.update.outer_middleware(UowMiddleware(sessionmaker=self.sessionmaker)) + dp.update.outer_middleware(CacheMiddleware(cache=self.cache)) + dp.update.outer_middleware( + UserMiddleware(telegram_bot_admin_ids=self.telegram_bot_admin_ids) + ) + register_dialogs(dp) + setup_dialogs(dp) + dp.errors.register( + on_unknown_intent, + ExceptionTypeFilter(UnknownIntent), + ) + dp.errors.register( + on_unknown_state, + ExceptionTypeFilter(UnknownState), + ) + setup_jinja( + dp=self.bot, + filters={ + "as_local_fmt": as_local_fmt, + }, + ) + self.start_event.set() + log.info("Start polling") + await dp.start_polling(self.bot) diff --git a/idb/bot/services/periodic.py b/idb/bot/services/periodic.py new file mode 100644 index 0000000..59460a4 --- /dev/null +++ b/idb/bot/services/periodic.py @@ -0,0 +1,21 @@ +from typing import Any + +from aiogram import Bot +from aiomisc.service.periodic import PeriodicService +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from idb.db.uow import uow_context +from idb.logic.mailing import send_mailings + + +class PeriodicMailingService(PeriodicService): + __required__ = ("gap",) + __dependencies__ = ("bot", "sessionmaker") + + bot: Bot + sessionmaker: async_sessionmaker[AsyncSession] + gap: int + + async def callback(self) -> Any: + async with uow_context(self.sessionmaker) as uow: + await send_mailings(uow=uow, bot=self.bot, gap=self.gap) diff --git a/inclusive_dance_bot/bot/ui_commands.py b/idb/bot/ui_commands.py similarity index 100% rename from inclusive_dance_bot/bot/ui_commands.py rename to idb/bot/ui_commands.py diff --git a/idb/bot/utils.py b/idb/bot/utils.py new file mode 100644 index 0000000..9e4e9ce --- /dev/null +++ b/idb/bot/utils.py @@ -0,0 +1,41 @@ +from datetime import datetime + +import pytz +from aiogram_dialog import DialogManager, ShowMode, StartMode + +from idb.bot.dialogs.admins.states import AdminMainMenuSG +from idb.bot.dialogs.messages import START_MESSAGE +from idb.bot.dialogs.users.states import MainMenuSG, RegistrationSG +from idb.generals.models.user import BotUser + +LOCAL_TZ = pytz.timezone("Europe/Moscow") + + +async def start_new_dialog(dialog_manager: DialogManager) -> None: + user: BotUser = dialog_manager.middleware_data["user"] + if user.is_admin: + await dialog_manager.start( + AdminMainMenuSG.menu, mode=StartMode.RESET_STACK, show_mode=ShowMode.SEND + ) + elif user.is_anonymous: + chat_id = dialog_manager.event.from_user.id # type: ignore[union-attr] + await dialog_manager.event.bot.send_message( # type: ignore[union-attr] + chat_id=chat_id, + text=START_MESSAGE, + ) + await dialog_manager.start( + RegistrationSG.input_name, + mode=StartMode.RESET_STACK, + ) + else: + await dialog_manager.start( + MainMenuSG.menu, mode=StartMode.RESET_STACK, show_mode=ShowMode.SEND + ) + + +def as_local_dt(dt: datetime) -> datetime: + return dt.astimezone(LOCAL_TZ) + + +def as_local_fmt(dt: datetime) -> str: + return as_local_dt(dt).strftime("%H:%M:%S %d.%m.%Y") diff --git a/tests/test_unit/test_services/test_mailing/__init__.py b/idb/db/__init__.py similarity index 100% rename from tests/test_unit/test_services/test_mailing/__init__.py rename to idb/db/__init__.py diff --git a/inclusive_dance_bot/db/__main__.py b/idb/db/__main__.py similarity index 72% rename from inclusive_dance_bot/db/__main__.py rename to idb/db/__main__.py index 8a80e8a..ffff942 100644 --- a/inclusive_dance_bot/db/__main__.py +++ b/idb/db/__main__.py @@ -4,9 +4,9 @@ from alembic.config import CommandLine -from inclusive_dance_bot.db.utils import make_alembic_config +from idb.db.utils import make_alembic_config -DEFAULT_PG_URL = "postgresql://user:secret@localhost/inclusive_dance_bot" +DEFAULT_PG_DSN = "postgresql+asyncpg://pguser:pguser@localhost/pgdb" def main() -> None: @@ -16,8 +16,8 @@ def main() -> None: alembic.parser.formatter_class = argparse.ArgumentDefaultsHelpFormatter alembic.parser.add_argument( "--pg-url", - default=os.getenv("PG_URL", DEFAULT_PG_URL), - help="Database URL [env var: PG_URL]", + default=os.getenv("APP_PG_DSN", DEFAULT_PG_DSN), + help="Database URL [env var: APP_PG_DSN]", ) options = alembic.parser.parse_args() diff --git a/inclusive_dance_bot/db/alembic.ini b/idb/db/alembic.ini similarity index 100% rename from inclusive_dance_bot/db/alembic.ini rename to idb/db/alembic.ini diff --git a/inclusive_dance_bot/db/base.py b/idb/db/base.py similarity index 100% rename from inclusive_dance_bot/db/base.py rename to idb/db/base.py diff --git a/inclusive_dance_bot/db/migrations/env.py b/idb/db/migrations/env.py similarity index 97% rename from inclusive_dance_bot/db/migrations/env.py rename to idb/db/migrations/env.py index c1ef47f..7cfd564 100644 --- a/inclusive_dance_bot/db/migrations/env.py +++ b/idb/db/migrations/env.py @@ -6,7 +6,7 @@ from sqlalchemy.engine import Connection from sqlalchemy.ext.asyncio import async_engine_from_config -from inclusive_dance_bot.db.models import Base +from idb.db.models import Base config = context.config diff --git a/inclusive_dance_bot/db/migrations/script.py.mako b/idb/db/migrations/script.py.mako similarity index 100% rename from inclusive_dance_bot/db/migrations/script.py.mako rename to idb/db/migrations/script.py.mako diff --git a/inclusive_dance_bot/db/migrations/versions/2023_11_14_0228a02c3bb6_initial_commit.py b/idb/db/migrations/versions/2024_01_03_41c796b69ba4_.py similarity index 76% rename from inclusive_dance_bot/db/migrations/versions/2023_11_14_0228a02c3bb6_initial_commit.py rename to idb/db/migrations/versions/2024_01_03_41c796b69ba4_.py index 4348467..d7c0e64 100644 --- a/inclusive_dance_bot/db/migrations/versions/2023_11_14_0228a02c3bb6_initial_commit.py +++ b/idb/db/migrations/versions/2024_01_03_41c796b69ba4_.py @@ -1,15 +1,16 @@ -"""Initial commit +"""empty message -Revision ID: 0228a02c3bb6 +Revision ID: 41c796b69ba4 Revises: -Create Date: 2023-11-14 18:36:06.158969 +Create Date: 2024-01-03 22:29:54.480730 """ import sqlalchemy as sa from alembic import op +from sqlalchemy.dialects import postgresql # revision identifiers, used by Alembic. -revision = "0228a02c3bb6" +revision = "41c796b69ba4" down_revision = None branch_labels = None depends_on = None @@ -94,11 +95,10 @@ def upgrade() -> None: op.create_table( "users", sa.Column("id", sa.BigInteger(), nullable=False), - sa.Column("username", sa.String(length=256), nullable=False), - sa.Column("name", sa.String(length=256), nullable=False), - sa.Column("region", sa.String(length=256), nullable=False), - sa.Column("phone_number", sa.String(length=16), nullable=False), + sa.Column("username", sa.String(length=256), nullable=True), sa.Column("is_admin", sa.Boolean(), nullable=False), + sa.Column("is_superuser", sa.Boolean(), nullable=False), + sa.Column("profile", postgresql.JSONB(astext_type=sa.Text()), nullable=False), sa.Column( "created_at", sa.DateTime(timezone=True), @@ -112,7 +112,6 @@ def upgrade() -> None: nullable=False, ), sa.PrimaryKeyConstraint("id", name=op.f("pk__users")), - sa.UniqueConstraint("username", name=op.f("uq__users__username")), ) op.create_table( "feedback", @@ -179,11 +178,56 @@ def upgrade() -> None: "user_id", "user_type_id", name=op.f("pk__user_type_user") ), ) + op.create_table( + "answer", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("feedback_id", sa.Integer(), nullable=False), + sa.Column("from_user_id", sa.BigInteger(), nullable=False), + sa.Column("to_user_id", sa.BigInteger(), nullable=False), + sa.Column("text", sa.String(length=4096), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("TIMEZONE('utc', now())"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("TIMEZONE('utc', now())"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["feedback_id"], + ["feedback.id"], + name=op.f("fk__answer__feedback_id__feedback"), + ), + sa.ForeignKeyConstraint( + ["from_user_id"], ["users.id"], name=op.f("fk__answer__from_user_id__users") + ), + sa.ForeignKeyConstraint( + ["to_user_id"], ["users.id"], name=op.f("fk__answer__to_user_id__users") + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk__answer")), + ) + op.create_index( + op.f("ix__answer__feedback_id"), "answer", ["feedback_id"], unique=False + ) + op.create_index( + op.f("ix__answer__from_user_id"), "answer", ["from_user_id"], unique=False + ) + op.create_index( + op.f("ix__answer__to_user_id"), "answer", ["to_user_id"], unique=False + ) # ### end Alembic commands ### def downgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f("ix__answer__to_user_id"), table_name="answer") + op.drop_index(op.f("ix__answer__from_user_id"), table_name="answer") + op.drop_index(op.f("ix__answer__feedback_id"), table_name="answer") + op.drop_table("answer") op.drop_table("user_type_user") op.drop_table("mailing_user_type") op.drop_index(op.f("ix__feedback__user_id"), table_name="feedback") diff --git a/tests/test_unit/test_services/test_url/__init__.py b/idb/db/migrations/versions/__init__.py similarity index 100% rename from tests/test_unit/test_services/test_url/__init__.py rename to idb/db/migrations/versions/__init__.py diff --git a/inclusive_dance_bot/db/models.py b/idb/db/models.py similarity index 78% rename from inclusive_dance_bot/db/models.py rename to idb/db/models.py index 09d05e2..44f9de3 100644 --- a/inclusive_dance_bot/db/models.py +++ b/idb/db/models.py @@ -1,24 +1,25 @@ from datetime import datetime +from typing import Any from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Integer, String +from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy_utils import ChoiceType -from inclusive_dance_bot.db.base import Base, TimestampMixin -from inclusive_dance_bot.enums import FeedbackType, MailingStatus, SubmenuType +from idb.db.base import Base, TimestampMixin +from idb.generals.enums import FeedbackType, MailingStatus, SubmenuType class User(TimestampMixin, Base): __tablename__ = "users" # type: ignore[assignment] id: Mapped[int] = mapped_column(BigInteger, primary_key=True) - username: Mapped[str] = mapped_column(String(256), nullable=False, unique=True) - name: Mapped[str] = mapped_column(String(256), nullable=False, default="") - region: Mapped[str] = mapped_column(String(256), nullable=False, default="") - phone_number: Mapped[str] = mapped_column(String(16), nullable=False, default="") + username: Mapped[str] = mapped_column(String(256), nullable=True) is_admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) - - user_types: Mapped[list["UserType"]] = relationship( - "UserType", secondary="user_type_user" + is_superuser: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + profile: Mapped[dict[str, Any]] = mapped_column( + JSONB, + nullable=False, + default={}, ) @@ -76,6 +77,29 @@ class Feedback(TimestampMixin, Base): ) +class Answer(TimestampMixin, Base): + id: Mapped[int] = mapped_column(Integer, primary_key=True) + feedback_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("feedback.id"), + nullable=False, + index=True, + ) + from_user_id: Mapped[int] = mapped_column( + BigInteger, + ForeignKey("users.id"), + nullable=False, + index=True, + ) + to_user_id: Mapped[int] = mapped_column( + BigInteger, + ForeignKey("users.id"), + nullable=False, + index=True, + ) + text: Mapped[str] = mapped_column(String(4096), nullable=False) + + class Mailing(TimestampMixin, Base): id: Mapped[int] = mapped_column(Integer, primary_key=True) scheduled_at: Mapped[datetime | None] = mapped_column( diff --git a/tests/test_unit/test_services/test_user/__init__.py b/idb/db/repositories/__init__.py similarity index 100% rename from tests/test_unit/test_services/test_user/__init__.py rename to idb/db/repositories/__init__.py diff --git a/idb/db/repositories/answer.py b/idb/db/repositories/answer.py new file mode 100644 index 0000000..be50c71 --- /dev/null +++ b/idb/db/repositories/answer.py @@ -0,0 +1,59 @@ +from collections.abc import Sequence +from typing import NoReturn + +from sqlalchemy import insert, select +from sqlalchemy.exc import DBAPIError, IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from idb.db.models import Answer as AnswerDb +from idb.db.repositories.base import Repository +from idb.exceptions.base import InclusiveDanceError +from idb.exceptions.user import InvalidUserIDError +from idb.generals.models.answer import Answer + + +class AnswerRepository(Repository[AnswerDb]): + def __init__(self, session: AsyncSession) -> None: + super().__init__(model=AnswerDb, session=session) + + async def create( + self, + *, + feedback_id: int, + from_user_id: int, + to_user_id: int, + text: str, + ) -> Answer: + query = ( + insert(AnswerDb) + .values( + feedback_id=feedback_id, + from_user_id=from_user_id, + to_user_id=to_user_id, + text=text, + ) + .returning(AnswerDb) + ) + try: + obj = (await self._session.scalars(query)).one() + except IntegrityError as e: + self._raise_error(e) + await self._session.flush() + return Answer.model_validate(obj) + + async def history(self, feedback_id: int) -> Sequence[Answer]: + query = ( + select(AnswerDb) + .where(AnswerDb.feedback_id == feedback_id) + .order_by(AnswerDb.created_at) + ) + objs = (await self._session.scalars(query)).all() + return [Answer.model_validate(obj) for obj in objs] + + def _raise_error(self, e: DBAPIError) -> NoReturn: + constraint = e.__cause__.__cause__.constraint_name # type: ignore[union-attr] + if constraint == "fk__answer__to_user_id__users": + raise InvalidUserIDError from e + if constraint == "fk__answer__from_user_id__users": + raise InvalidUserIDError from e + raise InclusiveDanceError from e diff --git a/inclusive_dance_bot/db/repositories/base.py b/idb/db/repositories/base.py similarity index 91% rename from inclusive_dance_bot/db/repositories/base.py rename to idb/db/repositories/base.py index 23b73e7..1b77e98 100644 --- a/inclusive_dance_bot/db/repositories/base.py +++ b/idb/db/repositories/base.py @@ -5,8 +5,8 @@ from sqlalchemy.exc import NoResultFound from sqlalchemy.ext.asyncio import AsyncSession -from inclusive_dance_bot.db.base import Base -from inclusive_dance_bot.exceptions import EntityNotFoundError +from idb.db.base import Base +from idb.exceptions import EntityNotFoundError Model = TypeVar("Model", bound=Base) diff --git a/idb/db/repositories/feedback.py b/idb/db/repositories/feedback.py new file mode 100644 index 0000000..2936e49 --- /dev/null +++ b/idb/db/repositories/feedback.py @@ -0,0 +1,92 @@ +from collections.abc import Sequence +from typing import Any, NoReturn + +from sqlalchemy import desc, func, insert, select +from sqlalchemy.exc import DBAPIError, IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from idb.db.models import Feedback as FeedbackDb +from idb.db.repositories.base import Repository +from idb.exceptions import InclusiveDanceError, InvalidUserIDError +from idb.generals.enums import FeedbackType +from idb.generals.models.feedback import Feedback + + +class FeedbackRepository(Repository[FeedbackDb]): + def __init__(self, session: AsyncSession) -> None: + super().__init__(model=FeedbackDb, session=session) + + async def read_by_id(self, feedback_id: int) -> Feedback: + obj = await self._get_by_id(feedback_id) + return Feedback.model_validate(obj) + + async def create( + self, *, user_id: int, type: FeedbackType, title: str, text: str + ) -> Feedback: + query = ( + insert(FeedbackDb) + .values( + user_id=user_id, + type=type, + title=title, + text=text, + ) + .returning(FeedbackDb) + ) + try: + obj = (await self._session.scalars(query)).one() + except IntegrityError as e: + self._raise_error(e) + await self._session.flush() + return Feedback.model_validate(obj) + + async def total_count(self) -> int: + return ( + await self._session.execute(select(func.count("*")).select_from(FeedbackDb)) + ).scalar_one() + + async def new_count(self) -> int: + return ( + await self._session.execute( + select(func.count("*")) + .select_from(FeedbackDb) + .where(FeedbackDb.is_viewed.is_(False)) + ) + ).scalar_one() + + async def new_items(self) -> Sequence[Feedback]: + query = ( + select(FeedbackDb) + .where(FeedbackDb.is_viewed.is_(False)) + .order_by(desc(FeedbackDb.created_at)) + ) + objs = (await self._session.scalars(query)).all() + return [Feedback.model_validate(obj) for obj in objs] + + async def viewed_items(self) -> Sequence[Feedback]: + query = ( + select(FeedbackDb) + .where(FeedbackDb.is_viewed.is_(True)) + .order_by(desc(FeedbackDb.created_at)) + ) + objs = (await self._session.scalars(query)).all() + return [Feedback.model_validate(obj) for obj in objs] + + async def archive_count(self) -> int: + return ( + await self._session.execute( + select(func.count("*")) + .select_from(FeedbackDb) + .where(FeedbackDb.is_viewed.is_(True)) + ) + ).scalar_one() + + async def update_by_id(self, feedback_id: int, **kwargs: Any) -> Feedback: + obj = await self._update(FeedbackDb.id == feedback_id, **kwargs) + return Feedback.model_validate(obj) + + def _raise_error(self, e: DBAPIError) -> NoReturn: + constraint = e.__cause__.__cause__.constraint_name # type: ignore[union-attr] + if constraint == "fk__feedback__user_id__users": + raise InvalidUserIDError from e + raise InclusiveDanceError from e diff --git a/inclusive_dance_bot/db/repositories/mailing.py b/idb/db/repositories/mailing.py similarity index 58% rename from inclusive_dance_bot/db/repositories/mailing.py rename to idb/db/repositories/mailing.py index 8d93b9d..66ebc45 100644 --- a/inclusive_dance_bot/db/repositories/mailing.py +++ b/idb/db/repositories/mailing.py @@ -1,32 +1,33 @@ from datetime import datetime, timedelta from typing import Any, NoReturn -from sqlalchemy import insert, select, update +from sqlalchemy import func, insert, select, update from sqlalchemy.exc import DBAPIError, IntegrityError, NoResultFound from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from inclusive_dance_bot.db.models import Mailing, MailingUserType -from inclusive_dance_bot.db.repositories.base import Repository -from inclusive_dance_bot.dto import MailingDto -from inclusive_dance_bot.enums import MailingStatus -from inclusive_dance_bot.exceptions import ( +from idb.db.models import Mailing as MailingDb +from idb.db.models import MailingUserType as MailingUserTypeDb +from idb.db.repositories.base import Repository +from idb.exceptions import ( EntityNotFoundError, InclusiveDanceError, MailingNotFoundError, ) +from idb.generals.enums import MailingStatus +from idb.generals.models.mailing import Mailing -class MailingRepository(Repository[Mailing]): +class MailingRepository(Repository[MailingDb]): def __init__(self, session: AsyncSession) -> None: - super().__init__(model=Mailing, session=session) + super().__init__(model=MailingDb, session=session) - async def get_by_id(self, mailing_id: int) -> MailingDto: + async def get_by_id(self, mailing_id: int) -> Mailing: try: obj = await self._session.get_one( - Mailing, mailing_id, options=(selectinload(Mailing.user_types),) + MailingDb, mailing_id, options=(selectinload(MailingDb.user_types),) ) - return MailingDto.from_orm(obj) + return Mailing.model_validate(obj) except NoResultFound as e: raise MailingNotFoundError from e @@ -38,9 +39,9 @@ async def create( scheduled_at: datetime | None, status: MailingStatus, sent_at: datetime | None, - ) -> MailingDto: + ) -> Mailing: stmt = ( - insert(Mailing) + insert(MailingDb) .values( title=title, content=content, @@ -48,24 +49,24 @@ async def create( status=status, sent_at=sent_at, ) - .returning(Mailing) - .options(selectinload(Mailing.user_types)) + .returning(MailingDb) + .options(selectinload(MailingDb.user_types)) ) try: - result = await self._session.scalars(stmt) + obj = (await self._session.scalars(stmt)).one() except IntegrityError as e: self._raise_error(e) else: await self._session.flush() - return MailingDto.from_orm(result.one()) + return Mailing.model_validate(obj) async def create_mailing_user_type( self, *, mailing_id: int, user_type_id: int - ) -> MailingUserType: + ) -> MailingUserTypeDb: stmt = ( - insert(MailingUserType) + insert(MailingUserTypeDb) .values(mailing_id=mailing_id, user_type_id=user_type_id) - .returning(MailingUserType) + .returning(MailingUserTypeDb) ) try: result = await self._session.scalars(stmt) @@ -77,42 +78,42 @@ async def create_mailing_user_type( async def get_new_mailings( self, now: datetime | None = None, gap: int | None = None - ) -> list[MailingDto]: + ) -> list[Mailing]: return await self.get_mailings( - now, gap, Mailing.status == MailingStatus.SCHEDULED + now, gap, MailingDb.status == MailingStatus.SCHEDULED ) - async def get_archive_mailings(self) -> list[MailingDto]: + async def get_archive_mailings(self) -> list[Mailing]: return await self.get_mailings( - None, None, Mailing.status != MailingStatus.SCHEDULED + None, None, MailingDb.status != MailingStatus.SCHEDULED ) async def get_mailings( self, now: datetime | None = None, gap: int | None = None, *args: Any - ) -> list[MailingDto]: + ) -> list[Mailing]: stmt = ( - select(Mailing) - .options(selectinload(Mailing.user_types)) - .order_by(Mailing.created_at) + select(MailingDb) + .options(selectinload(MailingDb.user_types)) + .order_by(MailingDb.created_at) ) for arg in args: stmt = stmt.where(arg) if gap is not None and now is not None: - stmt = stmt.where(Mailing.scheduled_at < now + timedelta(seconds=gap)) + stmt = stmt.where(MailingDb.scheduled_at < now + timedelta(seconds=gap)) result = await self._session.scalars(stmt) - return [MailingDto.from_orm(obj) for obj in result] + return [Mailing.model_validate(obj) for obj in result] - async def update_by_id(self, mailing_id: int, **kwargs: Any) -> MailingDto: - obj = await self._update(Mailing.id == mailing_id, **kwargs) - return MailingDto.from_orm(obj) + async def update_by_id(self, mailing_id: int, **kwargs: Any) -> Mailing: + obj = await self._update(MailingDb.id == mailing_id, **kwargs) + return Mailing.model_validate(obj) - async def _update(self, *args: Any, **kwargs: Any) -> Mailing: + async def _update(self, *args: Any, **kwargs: Any) -> MailingDb: query = update(self._model).where(*args).values(**kwargs).returning(self._model) result = await self._session.scalars( select(self._model) .from_statement(query) - .options(selectinload(Mailing.user_types)) + .options(selectinload(MailingDb.user_types)) ) try: obj = result.one() @@ -122,5 +123,10 @@ async def _update(self, *args: Any, **kwargs: Any) -> Mailing: await self._session.refresh(obj) return obj + async def total_count(self) -> int: + return ( + await self._session.execute(select(func.count("*")).select_from(MailingDb)) + ).scalar_one() + def _raise_error(self, e: DBAPIError) -> NoReturn: raise InclusiveDanceError from e diff --git a/idb/db/repositories/submenu.py b/idb/db/repositories/submenu.py new file mode 100644 index 0000000..51207b8 --- /dev/null +++ b/idb/db/repositories/submenu.py @@ -0,0 +1,121 @@ +from collections.abc import Sequence +from typing import Any, NoReturn + +from sqlalchemy import ScalarResult, delete, desc, select +from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.exc import DBAPIError, IntegrityError, NoResultFound +from sqlalchemy.ext.asyncio import AsyncSession + +from idb.db.models import Submenu as SubmenuDb +from idb.db.repositories.base import Repository +from idb.exceptions import ( + InclusiveDanceError, + SubmenuAlreadyExistsError, + SubmenuNotFoundError, +) +from idb.exceptions.base import EntityNotFoundError +from idb.generals.enums import SubmenuType +from idb.generals.models.submenu import Submenu + + +class SubmenuRepository(Repository[SubmenuDb]): + def __init__(self, session: AsyncSession) -> None: + super().__init__(model=SubmenuDb, session=session) + + async def create( + self, + type: SubmenuType, + button_text: str, + message: str, + weight: int = 0, + id: int | None = None, + ) -> Submenu: + data = dict(type=type, button_text=button_text, message=message, weight=weight) + if id is not None: + data["id"] = id + stmt = insert(SubmenuDb).values(**data).returning(SubmenuDb) + try: + result: ScalarResult[SubmenuDb] = await self._session.scalars(stmt) + except IntegrityError as e: + self._raise_error(e) + + await self._session.flush() + return Submenu.model_validate(result.one()) + + async def upsert( + self, + *, + id: int, + type: SubmenuType, + weight: int, + button_text: str, + message: str, + ) -> Submenu: + stmt = ( + insert(SubmenuDb) + .values( + id=id, + type=type, + weight=weight, + button_text=button_text, + message=message, + ) + .on_conflict_do_update( + index_elements=[SubmenuDb.id], + set_={ + "id": id, + "type": type, + "weight": weight, + "button_text": button_text, + "message": message, + }, + ) + .returning(SubmenuDb) + ) + try: + result: ScalarResult[SubmenuDb] = await self._session.scalars(stmt) + except IntegrityError as e: + self._raise_error(e) + await self._session.flush() + return Submenu.model_validate(result.one()) + + async def get_by_id(self, submenu_id: int) -> Submenu: + try: + obj = await self._get_by_id(submenu_id) + except NoResultFound as e: + raise SubmenuNotFoundError from e + return Submenu.model_validate(obj) + + async def update_by_id(self, submenu_id: int, **kwargs: Any) -> Submenu: + try: + obj = await self._update(SubmenuDb.id == submenu_id, **kwargs) + except EntityNotFoundError as e: + raise SubmenuNotFoundError from e + except IntegrityError as e: + self._raise_error(e) + return Submenu.model_validate(obj) + + async def delete_by_id(self, submenu_id: int) -> None: + stmt = delete(SubmenuDb).where(SubmenuDb.id == submenu_id) + await self._session.execute(stmt) + + async def list(self) -> tuple[Submenu, ...]: + query = select(SubmenuDb).order_by(desc(SubmenuDb.weight), SubmenuDb.id) + objs = (await self._session.scalars(query)).all() + return tuple(Submenu.model_validate(obj) for obj in objs) + + async def get_list_by_type(self, submenu_type: SubmenuType) -> Sequence[Submenu]: + stmt = ( + select(SubmenuDb) + .where(SubmenuDb.type == submenu_type) + .order_by(desc(SubmenuDb.weight), SubmenuDb.id) + ) + + objs = (await self._session.scalars(stmt)).all() + return [Submenu.model_validate(obj) for obj in objs] + + def _raise_error(self, e: DBAPIError) -> NoReturn: + constraint = e.__cause__.__cause__.constraint_name # type: ignore[union-attr] + if constraint == "pk__submenu": + raise SubmenuAlreadyExistsError from e + raise InclusiveDanceError from e diff --git a/idb/db/repositories/url.py b/idb/db/repositories/url.py new file mode 100644 index 0000000..5e1c437 --- /dev/null +++ b/idb/db/repositories/url.py @@ -0,0 +1,81 @@ +from typing import Any, NoReturn + +from sqlalchemy import ScalarResult, delete, select +from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.exc import DBAPIError, IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from idb.db.models import Url as UrlDb +from idb.db.repositories.base import Repository +from idb.exceptions import ( + InclusiveDanceError, + UrlAlreadyExistsError, + UrlSlugAlreadyExistsError, +) +from idb.exceptions.base import EntityNotFoundError +from idb.exceptions.url import UrlNotFoundError +from idb.generals.models.url import Url + + +class UrlRepository(Repository[UrlDb]): + def __init__(self, session: AsyncSession) -> None: + super().__init__(model=UrlDb, session=session) + + async def create(self, slug: str, value: str, id: int | None = None) -> Url: + data: dict[str, str | int] = dict(slug=slug, value=value) + if id is not None: + data["id"] = id + stmt = insert(UrlDb).values(**data).returning(UrlDb) + try: + result: ScalarResult[UrlDb] = await self._session.scalars(stmt) + except IntegrityError as e: + self._raise_error(e) + await self._session.flush() + return Url.model_validate(result.one()) + + async def update_by_slug(self, url_slug: str, **kwargs: Any) -> Url: + try: + url = await self._update(UrlDb.slug == url_slug, **kwargs) + except EntityNotFoundError as e: + raise UrlNotFoundError from e + except IntegrityError as e: + self._raise_error(e) + return Url.model_validate(url) + + async def delete_by_slug(self, url_slug: str) -> None: + stmt = delete(UrlDb).where(UrlDb.slug == url_slug) + await self._session.execute(stmt) + + async def list(self) -> tuple[Url, ...]: + stmt = select(UrlDb).order_by(UrlDb.id) + objs = (await self._session.scalars(stmt)).all() + return tuple(Url.model_validate(obj) for obj in objs) + + async def upsert(self, *, id: int, slug: str, value: str) -> Url: + stmt = ( + insert(UrlDb) + .values(id=id, slug=slug, value=value) + .on_conflict_do_update( + index_elements=[UrlDb.slug], + set_={ + "id": id, + "slug": slug, + "value": value, + }, + ) + .returning(UrlDb) + ) + try: + result: ScalarResult[UrlDb] = await self._session.scalars(stmt) + except IntegrityError as e: + self._raise_error(e) + await self._session.flush() + return Url.model_validate(result.one()) + + def _raise_error(self, e: DBAPIError) -> NoReturn: + constraint = e.__cause__.__cause__.constraint_name # type: ignore[union-attr] + if constraint == "pk__url": + raise UrlAlreadyExistsError from e + if constraint == "uq__url__slug": + raise UrlSlugAlreadyExistsError from e + raise InclusiveDanceError from e diff --git a/idb/db/repositories/user.py b/idb/db/repositories/user.py new file mode 100644 index 0000000..cb9d9e3 --- /dev/null +++ b/idb/db/repositories/user.py @@ -0,0 +1,131 @@ +from collections.abc import Mapping, Sequence +from typing import Any, NoReturn + +from sqlalchemy import ScalarResult, func, insert, select +from sqlalchemy.exc import DBAPIError, IntegrityError, NoResultFound +from sqlalchemy.ext.asyncio import AsyncSession + +from idb.db.models import ( + User as UserDb, +) +from idb.db.models import ( + UserType as UserTypeDb, +) +from idb.db.models import ( + UserTypeUser as UserTypeUserDb, +) +from idb.db.repositories.base import Repository +from idb.exceptions import ( + EntityNotFoundError, + InclusiveDanceError, + UserAlreadyExistsError, + UserNotFoundError, +) +from idb.generals.models.user import User +from idb.generals.models.user_type import UserType + + +class UserRepository(Repository[UserDb]): + def __init__(self, session: AsyncSession) -> None: + super().__init__(model=UserDb, session=session) + + async def create( + self, + *, + id: int, + username: str | None, + is_admin: bool, + is_superuser: bool, + profile: Mapping[str, Any], + ) -> User: + stmt = ( + insert(UserDb) + .values( + id=id, + username=username, + is_admin=is_admin, + is_superuser=is_superuser, + profile=profile, + ) + .returning(UserDb) + ) + try: + result: ScalarResult[UserDb] = await self._session.scalars(stmt) + except IntegrityError as e: + self._raise_error(e) + await self._session.flush() + return User.model_validate(result.one()) + + async def update_by_id(self, user_id: int, **kwargs: Any) -> User: + obj = await self._update(UserDb.id == user_id, **kwargs) + return User.model_validate(obj) + + async def get_by_id(self, user_id: int) -> User: + try: + obj = await self._get_by_id(obj_id=user_id) + except NoResultFound as e: + raise UserNotFoundError from e + return User.model_validate(obj) + + async def get_by_id_or_none(self, user_id: int) -> User | None: + obj = await self._get_by_id_or_none(user_id) + return User.model_validate(obj) if obj else None + + async def get_admin_list(self, include_superusers: bool) -> list[User]: + stmt = select(UserDb).where(UserDb.is_admin.is_(True)) + if not include_superusers: + stmt = stmt.where(UserDb.is_superuser.is_(False)) + return [User.model_validate(obj) for obj in await self._session.scalars(stmt)] + + async def add_to_admins(self, username: str) -> User: + try: + user = await self._update( + UserDb.username == username, + UserDb.is_admin.is_(False), + is_admin=True, + ) + return User.model_validate(user) + except EntityNotFoundError as e: + raise UserNotFoundError from e + + async def delete_from_admins(self, user_id: int) -> User: + try: + user = await self._update( + UserDb.id == user_id, + UserDb.is_admin.is_(True), + is_admin=False, + ) + except EntityNotFoundError as e: + raise UserNotFoundError from e + return User.model_validate(user) + + async def get_list_by_user_types( + self, user_types: Sequence[UserType], ignore_admins: bool = True + ) -> list[User]: + stmt = select(UserDb) + if len(user_types) != 0: + stmt = ( + stmt.distinct(UserDb.id) + .join(UserTypeUserDb, UserDb.id == UserTypeUserDb.user_id) + .join(UserTypeDb, UserTypeUserDb.user_type_id == UserTypeDb.id) + .where(UserTypeDb.name.in_([ut.name for ut in user_types])) + ) + + if ignore_admins: + stmt = stmt.where(UserDb.is_admin.is_(False)) + result = await self._session.scalars(stmt) + return [User.model_validate(obj) for obj in result] + + async def total_count(self) -> int: + query = ( + select(func.count("*")) + .select_from(UserDb) + .where(UserDb.is_admin.is_(False)) + ) + return (await self._session.execute(query)).scalar_one() + + def _raise_error(self, e: DBAPIError) -> NoReturn: + constraint = e.__cause__.__cause__.constraint_name # type: ignore[union-attr] + if constraint == "pk__users": + raise UserAlreadyExistsError from e + raise InclusiveDanceError from e diff --git a/idb/db/repositories/user_type.py b/idb/db/repositories/user_type.py new file mode 100644 index 0000000..8a33cec --- /dev/null +++ b/idb/db/repositories/user_type.py @@ -0,0 +1,64 @@ +from typing import NoReturn + +from sqlalchemy import ScalarResult, select +from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.exc import DBAPIError, IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from idb.db.models import UserType as UserTypeDb +from idb.db.repositories.base import Repository +from idb.exceptions import ( + InclusiveDanceError, + UserTypeAlreadyExistsError, +) +from idb.generals.models.user_type import UserType + + +class UserTypeRepository(Repository[UserTypeDb]): + def __init__(self, session: AsyncSession) -> None: + super().__init__(model=UserTypeDb, session=session) + + async def create(self, *, name: str, id: int | None = None) -> UserType: + data: dict[str, str | int] = dict(name=name) + if id is not None: + data["id"] = id + stmt = insert(UserTypeDb).values(**data).returning(UserTypeDb) + try: + result: ScalarResult[UserTypeDb] = await self._session.scalars(stmt) + except IntegrityError as e: + self._raise_error(e) + await self._session.flush() + return UserType.model_validate(result.one()) + + async def list(self) -> tuple[UserType, ...]: + stmt = select(UserTypeDb).order_by(UserTypeDb.id) + return tuple( + UserType.model_validate(obj) + for obj in (await self._session.scalars(stmt)).all() + ) + + async def upsert(self, *, id: int, name: str) -> UserType: + stmt = ( + insert(UserTypeDb) + .values(id=id, name=name) + .on_conflict_do_update( + index_elements=[UserTypeDb.id], + set_={ + "id": id, + "name": name, + }, + ) + .returning(UserTypeDb) + ) + try: + result: ScalarResult[UserTypeDb] = await self._session.scalars(stmt) + except IntegrityError as e: + self._raise_error(e) + await self._session.flush() + return UserType.model_validate(result.one()) + + def _raise_error(self, e: DBAPIError) -> NoReturn: + constraint = e.__cause__.__cause__.constraint_name # type: ignore[union-attr] + if constraint in ("pk__user_type", "ix__user_type__name"): + raise UserTypeAlreadyExistsError from e + raise InclusiveDanceError from e diff --git a/inclusive_dance_bot/db/repositories/user_type_user.py b/idb/db/repositories/user_type_user.py similarity index 90% rename from inclusive_dance_bot/db/repositories/user_type_user.py rename to idb/db/repositories/user_type_user.py index dfdf446..b19b9ed 100644 --- a/inclusive_dance_bot/db/repositories/user_type_user.py +++ b/idb/db/repositories/user_type_user.py @@ -4,9 +4,9 @@ from sqlalchemy.exc import DBAPIError, IntegrityError from sqlalchemy.ext.asyncio import AsyncSession -from inclusive_dance_bot.db.models import UserTypeUser -from inclusive_dance_bot.db.repositories.base import Repository -from inclusive_dance_bot.exceptions import ( +from idb.db.models import UserTypeUser +from idb.db.repositories.base import Repository +from idb.exceptions import ( InclusiveDanceError, InvalidUserIDError, InvalidUserTypeIDError, diff --git a/idb/db/uow.py b/idb/db/uow.py new file mode 100644 index 0000000..8cc2549 --- /dev/null +++ b/idb/db/uow.py @@ -0,0 +1,44 @@ +import asyncio +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from idb.db.repositories.answer import AnswerRepository +from idb.db.repositories.feedback import FeedbackRepository +from idb.db.repositories.mailing import MailingRepository +from idb.db.repositories.submenu import SubmenuRepository +from idb.db.repositories.url import UrlRepository +from idb.db.repositories.user import UserRepository +from idb.db.repositories.user_type import UserTypeRepository +from idb.db.repositories.user_type_user import UserTypeUserRepository + + +class UnitOfWork: + def __init__(self, sessionmaker: async_sessionmaker[AsyncSession]) -> None: + self._session = sessionmaker() + self.submenus = SubmenuRepository(self._session) + self.feedbacks = FeedbackRepository(self._session) + self.mailings = MailingRepository(self._session) + self.urls = UrlRepository(self._session) + self.users = UserRepository(self._session) + self.user_types = UserTypeRepository(self._session) + self.user_type_users = UserTypeUserRepository(self._session) + self.answer = AnswerRepository(self._session) + + async def commit(self) -> None: + await self._session.commit() + + async def rollback(self) -> None: + await self._session.rollback() + task = asyncio.create_task(self._session.close()) + await asyncio.shield(task) + + +@asynccontextmanager +async def uow_context( + sessionmaker: async_sessionmaker[AsyncSession], +) -> AsyncGenerator[UnitOfWork, None]: + uow = UnitOfWork(sessionmaker=sessionmaker) + yield uow + await uow.rollback() diff --git a/inclusive_dance_bot/db/utils.py b/idb/db/utils.py similarity index 87% rename from inclusive_dance_bot/db/utils.py rename to idb/db/utils.py index b88e388..1e1792a 100644 --- a/inclusive_dance_bot/db/utils.py +++ b/idb/db/utils.py @@ -11,9 +11,9 @@ create_async_engine, ) -import inclusive_dance_bot +import idb -PROJECT_PATH = Path(inclusive_dance_bot.__file__).parent.parent.resolve() +PROJECT_PATH = Path(idb.__file__).parent.parent.resolve() def create_engine(connection_uri: str, **engine_kwargs: Any) -> AsyncEngine: @@ -30,7 +30,7 @@ def create_session_factory(engine: AsyncEngine) -> async_sessionmaker[AsyncSessi def make_alembic_config(cmd_opts: Namespace, base_path: Path = PROJECT_PATH) -> Config: if not os.path.isabs(cmd_opts.config): - cmd_opts.config = str(base_path / "inclusive_dance_bot/db" / cmd_opts.config) + cmd_opts.config = str(base_path / "idb/db" / cmd_opts.config) config = Config( file_=cmd_opts.config, diff --git a/idb/deps.py b/idb/deps.py new file mode 100644 index 0000000..6fb4faf --- /dev/null +++ b/idb/deps.py @@ -0,0 +1,44 @@ +import asyncio +from argparse import Namespace +from collections.abc import AsyncGenerator + +from aiogram import Bot +from aiomisc_dependency import dependency +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker + +from idb.bot.factory import get_bot +from idb.db.uow import uow_context +from idb.db.utils import create_engine, create_session_factory +from idb.utils.cache import MemoryCache + + +def config_deps(arguments: Namespace) -> None: + @dependency + async def bot() -> Bot: + return get_bot( + telegram_bot_token=arguments.telegram_bot_token, + parse_mode=arguments.telegram_parse_mode, + ) + + @dependency + async def engine() -> AsyncGenerator[AsyncEngine, None]: + engine = create_engine( + connection_uri=arguments.pg_dsn, + echo=arguments.debug, + ) + yield engine + await asyncio.shield(engine.dispose()) + + @dependency + async def sessionmaker(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]: + return create_session_factory(engine=engine) + + @dependency + async def cache(sessionmaker: async_sessionmaker[AsyncSession]) -> MemoryCache: + cache = MemoryCache() + async with uow_context(sessionmaker=sessionmaker) as uow: + await cache.load_cache(uow=uow) + + return cache + + return diff --git a/tests/test_unit/test_services/test_mailing/test_process_new_mailing.py b/idb/dto.py similarity index 100% rename from tests/test_unit/test_services/test_mailing/test_process_new_mailing.py rename to idb/dto.py diff --git a/inclusive_dance_bot/exceptions/__init__.py b/idb/exceptions/__init__.py similarity index 75% rename from inclusive_dance_bot/exceptions/__init__.py rename to idb/exceptions/__init__.py index 32ac10d..0a5f704 100644 --- a/inclusive_dance_bot/exceptions/__init__.py +++ b/idb/exceptions/__init__.py @@ -1,19 +1,19 @@ -from inclusive_dance_bot.exceptions.base import ( +from idb.exceptions.base import ( EntityAlreadyExistsError, EntityNotFoundError, InclusiveDanceError, ) -from inclusive_dance_bot.exceptions.mailing import MailingNotFoundError -from inclusive_dance_bot.exceptions.submenu import ( +from idb.exceptions.mailing import MailingNotFoundError +from idb.exceptions.submenu import ( SubmenuAlreadyExistsError, SubmenuNotFoundError, ) -from inclusive_dance_bot.exceptions.url import ( +from idb.exceptions.url import ( UrlAlreadyExistsError, UrlNotFoundError, UrlSlugAlreadyExistsError, ) -from inclusive_dance_bot.exceptions.user import ( +from idb.exceptions.user import ( InvalidUserIDError, InvalidUserTypeIDError, UserAlreadyExistsError, diff --git a/inclusive_dance_bot/exceptions/base.py b/idb/exceptions/base.py similarity index 100% rename from inclusive_dance_bot/exceptions/base.py rename to idb/exceptions/base.py diff --git a/idb/exceptions/mailing.py b/idb/exceptions/mailing.py new file mode 100644 index 0000000..e89efbc --- /dev/null +++ b/idb/exceptions/mailing.py @@ -0,0 +1,5 @@ +from idb.exceptions.base import EntityNotFoundError + + +class MailingNotFoundError(EntityNotFoundError): + pass diff --git a/inclusive_dance_bot/exceptions/submenu.py b/idb/exceptions/submenu.py similarity index 78% rename from inclusive_dance_bot/exceptions/submenu.py rename to idb/exceptions/submenu.py index 9855261..b7c4f0d 100644 --- a/inclusive_dance_bot/exceptions/submenu.py +++ b/idb/exceptions/submenu.py @@ -1,4 +1,4 @@ -from inclusive_dance_bot.exceptions.base import ( +from idb.exceptions.base import ( EntityAlreadyExistsError, EntityNotFoundError, ) diff --git a/inclusive_dance_bot/exceptions/url.py b/idb/exceptions/url.py similarity index 83% rename from inclusive_dance_bot/exceptions/url.py rename to idb/exceptions/url.py index f226023..0298824 100644 --- a/inclusive_dance_bot/exceptions/url.py +++ b/idb/exceptions/url.py @@ -1,4 +1,4 @@ -from inclusive_dance_bot.exceptions.base import ( +from idb.exceptions.base import ( EntityAlreadyExistsError, EntityNotFoundError, ) diff --git a/inclusive_dance_bot/exceptions/user.py b/idb/exceptions/user.py similarity index 90% rename from inclusive_dance_bot/exceptions/user.py rename to idb/exceptions/user.py index 4212207..87a487e 100644 --- a/inclusive_dance_bot/exceptions/user.py +++ b/idb/exceptions/user.py @@ -1,4 +1,4 @@ -from inclusive_dance_bot.exceptions.base import ( +from idb.exceptions.base import ( EntityAlreadyExistsError, EntityNotFoundError, InclusiveDanceError, diff --git a/tests/test_unit/test_services/test_mailing/test_save_mailing.py b/idb/generals/__init__.py similarity index 100% rename from tests/test_unit/test_services/test_mailing/test_save_mailing.py rename to idb/generals/__init__.py diff --git a/inclusive_dance_bot/enums.py b/idb/generals/enums.py similarity index 76% rename from inclusive_dance_bot/enums.py rename to idb/generals/enums.py index 2cb8e72..5938f84 100644 --- a/inclusive_dance_bot/enums.py +++ b/idb/generals/enums.py @@ -17,11 +17,16 @@ class FeedbackType(StrEnum): ADVERTISEMENT = "ADVERTISEMENT" +FEEDBACK_TYPE_MAPPING = { + FeedbackType.QUESTION: "Вопрос", + FeedbackType.ADVERTISEMENT: "Предложение", +} + + @unique -class StorageType(StrEnum): - URL = "URL" - USER_TYPE = "USER_TYPE" - SUBMENU = "SUBMENU" +class FeedbackStatus(StrEnum): + NEW = "NEW" + ARCHIVED = "ARCHIVED" @unique diff --git a/tests/test_unit/test_services/test_mailing/test_send_mailngs.py b/idb/generals/models/__init__.py similarity index 100% rename from tests/test_unit/test_services/test_mailing/test_send_mailngs.py rename to idb/generals/models/__init__.py diff --git a/idb/generals/models/answer.py b/idb/generals/models/answer.py new file mode 100644 index 0000000..d90e884 --- /dev/null +++ b/idb/generals/models/answer.py @@ -0,0 +1,15 @@ +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + + +class Answer(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + feedback_id: int + from_user_id: int + to_user_id: int + text: str + created_at: datetime + updated_at: datetime diff --git a/idb/generals/models/feedback.py b/idb/generals/models/feedback.py new file mode 100644 index 0000000..0f2834f --- /dev/null +++ b/idb/generals/models/feedback.py @@ -0,0 +1,21 @@ +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, PositiveInt + +from idb.generals.enums import FeedbackType + + +class Feedback(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: PositiveInt + user_id: int + type: FeedbackType + title: str + text: str + is_viewed: bool + viewed_at: datetime | None + is_answered: bool + answered_at: datetime | None + created_at: datetime + updated_at: datetime diff --git a/idb/generals/models/mailing.py b/idb/generals/models/mailing.py new file mode 100644 index 0000000..c052763 --- /dev/null +++ b/idb/generals/models/mailing.py @@ -0,0 +1,22 @@ +from collections.abc import Sequence +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, PositiveInt + +from idb.generals.enums import MailingStatus +from idb.generals.models.user_type import UserType + + +class Mailing(BaseModel): + model_config = ConfigDict(from_attributes=True) + + created_at: datetime + updated_at: datetime + id: PositiveInt + scheduled_at: datetime | None + sent_at: datetime | None + cancelled_at: datetime | None + status: MailingStatus + title: str + content: str + user_types: Sequence[UserType] diff --git a/idb/generals/models/submenu.py b/idb/generals/models/submenu.py new file mode 100644 index 0000000..22b8275 --- /dev/null +++ b/idb/generals/models/submenu.py @@ -0,0 +1,13 @@ +from pydantic import BaseModel, ConfigDict, PositiveInt + +from idb.generals.enums import SubmenuType + + +class Submenu(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: PositiveInt + type: SubmenuType + weight: int + button_text: str + message: str diff --git a/idb/generals/models/url.py b/idb/generals/models/url.py new file mode 100644 index 0000000..4ee168b --- /dev/null +++ b/idb/generals/models/url.py @@ -0,0 +1,12 @@ +from pydantic import BaseModel, ConfigDict, PositiveInt + + +class Url(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: PositiveInt + slug: str + value: str + + def __str__(self) -> str: + return self.value diff --git a/idb/generals/models/user.py b/idb/generals/models/user.py new file mode 100644 index 0000000..c6ea965 --- /dev/null +++ b/idb/generals/models/user.py @@ -0,0 +1,48 @@ +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any + +from aiogram.types import User as TelegramUser +from pydantic import BaseModel, ConfigDict, NonNegativeInt + +ANONYMOUS_USER_ID = 0 + + +class User(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: NonNegativeInt + username: str | None + is_admin: bool + is_superuser: bool + profile: Mapping[str, Any] + + @property + def name(self) -> str | None: + return self.profile.get("name") + + @property + def region(self) -> str | None: + return self.profile.get("region") + + @property + def phone_number(self) -> str | None: + return self.profile.get("phone_number") + + +@dataclass +class BotUser: + telegram_user: TelegramUser + user: User + + @property + def is_superuser(self) -> bool: + return self.user.is_superuser + + @property + def is_admin(self) -> bool: + return self.user.is_admin or self.is_superuser + + @property + def is_anonymous(self) -> bool: + return not bool(self.user.profile) diff --git a/idb/generals/models/user_type.py b/idb/generals/models/user_type.py new file mode 100644 index 0000000..79dcd56 --- /dev/null +++ b/idb/generals/models/user_type.py @@ -0,0 +1,8 @@ +from pydantic import BaseModel, ConfigDict, PositiveInt + + +class UserType(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: PositiveInt + name: str diff --git a/idb/logic/__init__.py b/idb/logic/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/idb/logic/answer.py b/idb/logic/answer.py new file mode 100644 index 0000000..866bf60 --- /dev/null +++ b/idb/logic/answer.py @@ -0,0 +1,24 @@ +from idb.db.uow import UnitOfWork +from idb.exceptions.base import InclusiveDanceError +from idb.generals.models.answer import Answer + + +async def create_feedback_answer( + uow: UnitOfWork, + from_user_id: int, + feedback_id: int, + text: str, +) -> Answer: + feedback = await uow.feedbacks.read_by_id(feedback_id) + try: + answer = await uow.answer.create( + feedback_id=feedback_id, + from_user_id=from_user_id, + to_user_id=feedback.user_id, + text=text, + ) + await uow.commit() + except InclusiveDanceError as e: + await uow.rollback() + raise e + return answer diff --git a/idb/logic/feedback.py b/idb/logic/feedback.py new file mode 100644 index 0000000..a0545a7 --- /dev/null +++ b/idb/logic/feedback.py @@ -0,0 +1,56 @@ +from datetime import datetime + +from idb.db.uow import UnitOfWork +from idb.exceptions.base import InclusiveDanceError +from idb.generals.enums import FeedbackType + + +async def create_feedback( + uow: UnitOfWork, user_id: int, type: FeedbackType, title: str, text: str +) -> None: + """Создает новую обратную связь от пользователя""" + try: + await uow.feedbacks.create( + user_id=user_id, + type=type, + title=title, + text=text, + ) + await uow.commit() + except InclusiveDanceError as e: + await uow.rollback() + raise e + + +async def set_feedback_as_viewed( + uow: UnitOfWork, + feedback_id: int, + dt: datetime, +) -> None: + try: + await uow.feedbacks.update_by_id( + feedback_id=feedback_id, + is_viewed=True, + viewed_at=dt, + ) + await uow.commit() + except InclusiveDanceError as e: + await uow.rollback() + raise e + + +async def update_answered_feedback( + uow: UnitOfWork, + feedback_id: int, + dt: datetime, +) -> None: + try: + await uow.feedbacks.update_by_id( + feedback_id=feedback_id, + is_answered=True, + answered_at=dt, + ) + await uow.commit() + except InclusiveDanceError as e: + await uow.rollback() + raise e diff --git a/inclusive_dance_bot/logic/mailing.py b/idb/logic/mailing.py similarity index 87% rename from inclusive_dance_bot/logic/mailing.py rename to idb/logic/mailing.py index 5d00727..d4264f9 100644 --- a/inclusive_dance_bot/logic/mailing.py +++ b/idb/logic/mailing.py @@ -1,14 +1,14 @@ import logging -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import Any import pytz from aiogram import Bot from aiogram.enums import ParseMode -from inclusive_dance_bot.db.uow.main import UnitOfWork -from inclusive_dance_bot.dto import MailingDto -from inclusive_dance_bot.enums import MailingStatus +from idb.db.uow import UnitOfWork +from idb.generals.enums import MailingStatus +from idb.generals.models.mailing import Mailing log = logging.getLogger(__name__) @@ -19,14 +19,14 @@ async def send_mailings( gap: int, ) -> None: new_mailings = await uow.mailings.get_new_mailings( - gap=gap, now=datetime.now(tz=timezone.utc) + gap=gap, now=datetime.now(tz=UTC) ) log.info("Found %d new mailings", len(new_mailings)) for mailing in new_mailings: await process_new_mailing(uow=uow, bot=bot, mailing=mailing) -async def process_new_mailing(uow: UnitOfWork, bot: Bot, mailing: MailingDto) -> None: +async def process_new_mailing(uow: UnitOfWork, bot: Bot, mailing: Mailing) -> None: log.info("Start process mailing_id=%s", mailing.id) users = await uow.users.get_list_by_user_types(user_types=mailing.user_types) for user in users: @@ -71,7 +71,7 @@ async def save_mailing( async def update_mailing_by_id( uow: UnitOfWork, mailing_id: int, **kwargs: Any -) -> MailingDto: +) -> Mailing: mailing = await uow.mailings.update_by_id(mailing_id=mailing_id, **kwargs) await uow.commit() return mailing diff --git a/inclusive_dance_bot/logic/submenu.py b/idb/logic/submenu.py similarity index 70% rename from inclusive_dance_bot/logic/submenu.py rename to idb/logic/submenu.py index 2d40f3c..9645450 100644 --- a/inclusive_dance_bot/logic/submenu.py +++ b/idb/logic/submenu.py @@ -1,19 +1,19 @@ -from inclusive_dance_bot.db.uow.main import UnitOfWork -from inclusive_dance_bot.dto import SubmenuDto -from inclusive_dance_bot.enums import SubmenuType -from inclusive_dance_bot.exceptions.base import InclusiveDanceError -from inclusive_dance_bot.logic.storage import Storage -from inclusive_dance_bot.utils import NOT_SET, NotSet +from idb.db.uow import UnitOfWork +from idb.exceptions.base import InclusiveDanceError +from idb.generals.enums import SubmenuType +from idb.generals.models.submenu import Submenu +from idb.utils.cache import AbstractBotCache +from idb.utils.urls import NOT_SET, NotSet async def create_submenu( uow: UnitOfWork, - storage: Storage, + cache: AbstractBotCache, type: SubmenuType, message: str, button_text: str, weight: int, -) -> SubmenuDto: +) -> Submenu: try: submenu = await uow.submenus.create( type=type, @@ -25,27 +25,27 @@ async def create_submenu( await uow.rollback() raise e await uow.commit() - await storage.refresh_submenus() + await cache.update_submenu(id_=submenu.id, submenu=submenu) return submenu async def delete_submenu_by_id( - uow: UnitOfWork, storage: Storage, submenu_id: int + uow: UnitOfWork, cache: AbstractBotCache, submenu_id: int ) -> None: await uow.submenus.delete_by_id(submenu_id=submenu_id) await uow.commit() - await storage.refresh_submenus() + await cache.update_submenu(id_=submenu_id, submenu=None) async def update_submenu_by_id( uow: UnitOfWork, - storage: Storage, + cache: AbstractBotCache, submenu_id: int, weight: int | NotSet = NOT_SET, type_: SubmenuType | NotSet = NOT_SET, button_text: str | NotSet = NOT_SET, message: str | NotSet = NOT_SET, -) -> SubmenuDto: +) -> Submenu: data: dict[str, str | int | SubmenuType] = {} if not isinstance(weight, NotSet): data["weight"] = weight @@ -63,5 +63,5 @@ async def update_submenu_by_id( await uow.rollback() raise e await uow.commit() - await storage.refresh_submenus() + await cache.update_submenu(id_=submenu.id, submenu=submenu) return submenu diff --git a/idb/logic/url.py b/idb/logic/url.py new file mode 100644 index 0000000..9d7933a --- /dev/null +++ b/idb/logic/url.py @@ -0,0 +1,47 @@ +from typing import Any + +from idb.db.uow import UnitOfWork +from idb.exceptions.base import InclusiveDanceError +from idb.exceptions.url import UrlSlugAlreadyExistsError +from idb.generals.models.url import Url +from idb.utils.cache import AbstractBotCache + + +async def create_url( + uow: UnitOfWork, cache: AbstractBotCache, slug: str, value: str +) -> Url: + """Создает новую ссылку""" + try: + url = await uow.urls.create(slug=slug, value=value) + except UrlSlugAlreadyExistsError as e: + await uow.rollback() + raise e + await uow.commit() + await cache.update_url(slug=url.slug, url=url) + return url + + +async def update_url_by_slug( + uow: UnitOfWork, + cache: AbstractBotCache, + url_slug: str, + **kwargs: Any, +) -> Url: + try: + url = await uow.urls.update_by_slug(url_slug=url_slug, **kwargs) + except InclusiveDanceError as e: + await uow.rollback() + raise e + await uow.commit() + if url.slug != url_slug: + await cache.update_url(slug=url_slug, url=None) + await cache.update_url(slug=url.slug, url=url) + return url + + +async def delete_url_by_slug( + uow: UnitOfWork, cache: AbstractBotCache, url_slug: str +) -> None: + await uow.urls.delete_by_slug(url_slug=url_slug) + await uow.commit() + await cache.update_url(slug=url_slug, url=None) diff --git a/idb/logic/users.py b/idb/logic/users.py new file mode 100644 index 0000000..1184905 --- /dev/null +++ b/idb/logic/users.py @@ -0,0 +1,51 @@ +from collections.abc import Iterable + +from idb.db.uow import UnitOfWork +from idb.exceptions.base import InclusiveDanceError + + +async def save_profile_user( + uow: UnitOfWork, + user_id: int, + name: str, + region: str, + phone_number: str, + user_type_ids: Iterable[int], +) -> None: + """Сохраняет профиль нового пользователя""" + try: + await uow.users.update_by_id( + user_id=user_id, + profile={ + "name": name, + "region": region, + "phone_number": phone_number, + }, + ) + for user_type_id in user_type_ids: + await uow.user_type_users.create( + user_id=user_id, + user_type_id=user_type_id, + ) + await uow.commit() + except InclusiveDanceError as e: + await uow.rollback() + raise e + + +async def add_user_to_admins(uow: UnitOfWork, username: str) -> None: + try: + await uow.users.add_to_admins(username=username) + await uow.commit() + except InclusiveDanceError as e: + await uow.rollback() + raise e + + +async def delete_from_admins(uow: UnitOfWork, user_id: int) -> None: + try: + await uow.users.delete_from_admins(user_id=user_id) + await uow.commit() + except InclusiveDanceError as e: + await uow.rollback() + raise e diff --git a/idb/utils/__init__.py b/idb/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/idb/utils/cache.py b/idb/utils/cache.py new file mode 100644 index 0000000..3ed2aea --- /dev/null +++ b/idb/utils/cache.py @@ -0,0 +1,220 @@ +import json +from abc import ABC, abstractmethod +from collections.abc import AsyncGenerator, Callable, Mapping, MutableMapping +from dataclasses import asdict, dataclass +from enum import StrEnum, unique +from typing import Any + +from redis.asyncio.client import Redis +from redis.asyncio.connection import ConnectionPool + +from idb.db.uow import UnitOfWork +from idb.generals.models.submenu import Submenu +from idb.generals.models.url import Url +from idb.generals.models.user_type import UserType + +_JsonLoads = Callable[..., Any] +_JsonDumps = Callable[..., str] + + +@unique +class CacheType(StrEnum): + URL = "URL" + USER_TYPE = "USER_TYPE" + SUBMENU = "SUBMENU" + + +@dataclass(frozen=True) +class CacheKey: + entity_type: CacheType + entity_id: str + + +class KeyBuilder: + def __init__(self, prefix: str = "bot", sep: str = "__") -> None: + self._sep = sep + self._prefix = prefix + + def build_key(self, group: CacheType, entity_id: str | int) -> str: + return self._sep.join([self._prefix, group, str(entity_id)]) + + def build_group_pattern(self, group: CacheType) -> str: + return self._sep.join([self._prefix, group]) + "*" + + def parse_id(self, dumped_key: str) -> str: + return dumped_key.split(self._sep)[-1] + + +class AbstractBotCache(ABC): + @abstractmethod + async def get_urls(self) -> Mapping[str, Url]: + pass + + @abstractmethod + async def get_url_by_slug(self, slug: str) -> Url: + pass + + @abstractmethod + async def get_submenus(self) -> Mapping[int, Submenu]: + pass + + @abstractmethod + async def get_submenu_by_id(self, id_: int) -> Submenu: + pass + + @abstractmethod + async def get_user_types(self) -> Mapping[int, UserType]: + pass + + @abstractmethod + async def update_url(self, slug: str, url: Url | None) -> None: + pass + + @abstractmethod + async def update_submenu(self, id_: int, submenu: Submenu | None) -> None: + pass + + @abstractmethod + async def load_cache(self, uow: UnitOfWork) -> None: + pass + + +class RedisCache(AbstractBotCache): + _redis: Redis + _key_builder: KeyBuilder + _json_dumps: _JsonDumps + _json_loads: _JsonLoads + + def __init__( + self, + redis: Redis, + key_builder: KeyBuilder, + json_dumps: _JsonDumps = json.dumps, + json_loads: _JsonLoads = json.loads, + ) -> None: + self._redis = redis + self._key_builder = key_builder + self._json_dumps = json_dumps + self._json_loads = json_loads + + @classmethod + def from_url( + cls, url: str, connection_kwargs: dict[str, Any] | None = None, **kwargs: Any + ) -> "RedisCache": + if connection_kwargs is None: + connection_kwargs = {} + pool = ConnectionPool.from_url(url, **connection_kwargs) + redis = Redis(connection_pool=pool) + return cls(redis=redis, **kwargs) + + async def close(self) -> None: + await self._redis.close() + + async def flushall(self) -> None: + await self._redis.flushall() + + async def _set_data(self, group: CacheType, entity_id: int | str, obj: Any) -> None: + key = self._key_builder.build_key(group, entity_id) + if obj is None: + await self._redis.delete(key) + else: + await self._redis.set(obj, self._json_dumps(asdict(obj))) + + async def _get_data( + self, group: CacheType, entity_id: int | str + ) -> Mapping[str, Any]: + key = self._key_builder.build_key(group, entity_id) + return self._json_loads(self._redis.get(key)) + + async def load_cache(self, uow: UnitOfWork) -> None: + for submenu in await uow.submenus.list(): + await self._set_data(CacheType.SUBMENU, submenu.id, submenu) + + for url in await uow.urls.list(): + await self._set_data(CacheType.URL, url.slug, url) + + for ut in await uow.user_types.list(): + await self._set_data(CacheType.USER_TYPE, ut.id, ut) + + async def _get_group_iter( + self, group: CacheType + ) -> AsyncGenerator[tuple[str, Any], None]: + pattern = self._key_builder.build_group_pattern(group) + async for key in self._redis.scan_iter(pattern): + yield key, await self._redis.get(key) + + async def get_submenus(self) -> Mapping[int, Submenu]: + submenus: dict[int, Submenu] = {} + async for key, value in self._get_group_iter(CacheType.SUBMENU): + submenus[int(key)] = Submenu(**self._json_loads(value)) + return submenus + + async def get_urls(self) -> Mapping[str, Url]: + urls: dict[str, Url] = {} + async for key, value in self._get_group_iter(CacheType.URL): + urls[key] = Url(**self._json_loads(value)) + return urls + + async def get_user_types(self) -> Mapping[int, UserType]: + user_types: dict[int, UserType] = {} + async for key, value in self._get_group_iter(CacheType.USER_TYPE): + user_types[int(key)] = UserType(**self._json_loads(value)) + return user_types + + async def update_submenu(self, id_: int, submenu: Submenu | None) -> None: + await self._set_data(group=CacheType.SUBMENU, entity_id=id_, obj=submenu) + + async def update_url(self, slug: str, url: Url | None) -> None: + await self._set_data(group=CacheType.URL, entity_id=slug, obj=url) + + async def get_submenu_by_id(self, id_: int) -> Submenu: + data = await self._get_data(group=CacheType.SUBMENU, entity_id=id_) + return Submenu(**data) + + async def get_url_by_slug(self, slug: str) -> Url: + data = await self._get_data(group=CacheType.URL, entity_id=slug) + return Url(**data) + + +class MemoryCache(AbstractBotCache): + def __init__(self) -> None: + self._urls: MutableMapping[str, Url] = {} + self._submenus: MutableMapping[int, Submenu] = {} + self._user_types: MutableMapping[int, UserType] = {} + + async def load_cache(self, uow: UnitOfWork) -> None: + urls = await uow.urls.list() + self._urls = {url.slug: url for url in urls} + + submenus = await uow.submenus.list() + self._submenus = {submenu.id: submenu for submenu in submenus} + + user_types = await uow.user_types.list() + self._user_types = {ut.id: ut for ut in user_types} + + async def get_urls(self) -> Mapping[str, Url]: + return self._urls + + async def get_url_by_slug(self, slug: str) -> Url: + return self._urls[slug] + + async def get_submenus(self) -> Mapping[int, Submenu]: + return self._submenus + + async def get_submenu_by_id(self, id_: int) -> Submenu: + return self._submenus[id_] + + async def get_user_types(self) -> Mapping[int, UserType]: + return self._user_types + + async def update_url(self, slug: str, url: Url | None) -> None: + if url is None and slug in self._urls: + del self._urls[slug] + elif url is not None: + self._urls[slug] = url + + async def update_submenu(self, id_: int, submenu: Submenu | None) -> None: + if submenu is None and id_ in self._submenus: + del self._submenus[id_] + elif submenu is not None: + self._submenus[id_] = submenu diff --git a/inclusive_dance_bot/utils.py b/idb/utils/urls.py similarity index 57% rename from inclusive_dance_bot/utils.py rename to idb/utils/urls.py index 2ace252..81e69d9 100644 --- a/inclusive_dance_bot/utils.py +++ b/idb/utils/urls.py @@ -1,9 +1,9 @@ import string -def check_slug(s: str) -> bool: +def check_slug(slug: str) -> bool: alphabet = string.ascii_lowercase + string.digits + "_" - return all(l in alphabet for l in s) + return all(symbol in alphabet for symbol in slug) class NotSet: diff --git a/inclusive_dance_bot/bot/dialogs/__init__.py b/inclusive_dance_bot/bot/dialogs/__init__.py deleted file mode 100644 index 9b7dcd3..0000000 --- a/inclusive_dance_bot/bot/dialogs/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -from aiogram import Router -from aiogram.filters import Command - -from inclusive_dance_bot.bot.dialogs import admins, users -from inclusive_dance_bot.bot.dialogs.commands import start_command -from inclusive_dance_bot.bot.ui_commands import Commands - - -def register_dialogs(router: Router) -> None: - dialog_router = Router() - dialog_router.include_routers(admins.dialog_router, users.dialog_router) - dialog_router.message(Command(Commands.START))(start_command) - router.include_router(dialog_router) diff --git a/inclusive_dance_bot/bot/dialogs/admins/__init__.py b/inclusive_dance_bot/bot/dialogs/admins/__init__.py deleted file mode 100644 index e218f48..0000000 --- a/inclusive_dance_bot/bot/dialogs/admins/__init__.py +++ /dev/null @@ -1,20 +0,0 @@ -from aiogram import Router - -from inclusive_dance_bot.bot.dialogs.admins import ( - feedbacks, - mailings, - main_menu, - manage_admins, - submenu, - url, -) - -dialog_router = Router(name="admin_router") -dialog_router.include_routers( - feedbacks.router, - main_menu.dialog, - submenu.router, - url.router, - manage_admins.router, - mailings.router, -) diff --git a/inclusive_dance_bot/bot/dialogs/admins/feedbacks/__init__.py b/inclusive_dance_bot/bot/dialogs/admins/feedbacks/__init__.py deleted file mode 100644 index 01cbd31..0000000 --- a/inclusive_dance_bot/bot/dialogs/admins/feedbacks/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -from aiogram import Router - -from inclusive_dance_bot.bot.dialogs.admins.feedbacks import answer, items - -router = Router() -router.include_routers( - items.dialog, - answer.dialog, -) diff --git a/inclusive_dance_bot/bot/dialogs/admins/feedbacks/answer/__init__.py b/inclusive_dance_bot/bot/dialogs/admins/feedbacks/answer/__init__.py deleted file mode 100644 index ac97b58..0000000 --- a/inclusive_dance_bot/bot/dialogs/admins/feedbacks/answer/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -from aiogram_dialog import Dialog - -from inclusive_dance_bot.bot.dialogs.admins.feedbacks.answer import input_message - -dialog = Dialog( - input_message.window, -) diff --git a/inclusive_dance_bot/bot/dialogs/admins/feedbacks/answer/input_message.py b/inclusive_dance_bot/bot/dialogs/admins/feedbacks/answer/input_message.py deleted file mode 100644 index 062e7f2..0000000 --- a/inclusive_dance_bot/bot/dialogs/admins/feedbacks/answer/input_message.py +++ /dev/null @@ -1,7 +0,0 @@ -from aiogram_dialog import Window - -from inclusive_dance_bot.bot.dialogs.admins.states import FeedbackAsnwerSG - -window = Window( - state=FeedbackAsnwerSG.input_message, -) diff --git a/inclusive_dance_bot/bot/dialogs/admins/feedbacks/items/__init__.py b/inclusive_dance_bot/bot/dialogs/admins/feedbacks/items/__init__.py deleted file mode 100644 index 0614ca0..0000000 --- a/inclusive_dance_bot/bot/dialogs/admins/feedbacks/items/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -from aiogram_dialog import Dialog - -from inclusive_dance_bot.bot.dialogs.admins.feedbacks.items import archive, new - -dialog = Dialog( - new.window, - archive.window, -) diff --git a/inclusive_dance_bot/bot/dialogs/admins/feedbacks/items/archive.py b/inclusive_dance_bot/bot/dialogs/admins/feedbacks/items/archive.py deleted file mode 100644 index 86057df..0000000 --- a/inclusive_dance_bot/bot/dialogs/admins/feedbacks/items/archive.py +++ /dev/null @@ -1,7 +0,0 @@ -from aiogram_dialog import Window - -from inclusive_dance_bot.bot.dialogs.admins.states import FeedbackItemsSG - -window = Window( - state=FeedbackItemsSG.archive, -) diff --git a/inclusive_dance_bot/bot/dialogs/admins/feedbacks/items/new.py b/inclusive_dance_bot/bot/dialogs/admins/feedbacks/items/new.py deleted file mode 100644 index 928dc2a..0000000 --- a/inclusive_dance_bot/bot/dialogs/admins/feedbacks/items/new.py +++ /dev/null @@ -1,7 +0,0 @@ -from aiogram_dialog import Window - -from inclusive_dance_bot.bot.dialogs.admins.states import FeedbackItemsSG - -window = Window( - state=FeedbackItemsSG.new, -) diff --git a/inclusive_dance_bot/bot/dialogs/admins/main_menu/__init__.py b/inclusive_dance_bot/bot/dialogs/admins/main_menu/__init__.py deleted file mode 100644 index ea42e07..0000000 --- a/inclusive_dance_bot/bot/dialogs/admins/main_menu/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from aiogram_dialog import Dialog - -from inclusive_dance_bot.bot.dialogs.admins.main_menu.read import menu - -dialog = Dialog(menu.window) diff --git a/inclusive_dance_bot/bot/dialogs/admins/main_menu/read/menu.py b/inclusive_dance_bot/bot/dialogs/admins/main_menu/read/menu.py deleted file mode 100644 index 16efc1d..0000000 --- a/inclusive_dance_bot/bot/dialogs/admins/main_menu/read/menu.py +++ /dev/null @@ -1,53 +0,0 @@ -from typing import Any - -from aiogram_dialog import DialogManager, Window -from aiogram_dialog.widgets.kbd import Start -from aiogram_dialog.widgets.text import Const, Format - -from inclusive_dance_bot.bot.dialogs.admins.states import ( - AdminFeedbackSG, - AdminMainMenuSG, - AdminSubmenuSG, - MailingsSG, - ManageAdminSG, - ReadUrlSG, -) -from inclusive_dance_bot.logic.user import MegaUser - - -def when_(data: dict, widget: Any, dialog_manager: DialogManager) -> bool: - user: MegaUser = data["middleware_data"]["user"] - return user.is_superuser - - -window = Window( - Const("Меню администратора"), - Format("*Здесь должна быть статистика по пользователям*"), - Start( - id="feedbacks", - text=Const("Обратная связь от пользователей"), - state=AdminFeedbackSG.items, - ), - Start( - id="mailings", - text=Const("Рассылки"), - state=MailingsSG.menu, - ), - Start( - id="manage_submenu_id", - text=Const("Управление подменю"), - state=AdminSubmenuSG.items, - ), - Start( - id="manage_url_id", - text=Const("Управление ссылками"), - state=ReadUrlSG.items, - ), - Start( - id="manage_admin_id", - text=Const("Управление администраторами"), - state=ManageAdminSG.items, - when=when_, - ), - state=AdminMainMenuSG.menu, -) diff --git a/inclusive_dance_bot/bot/dialogs/admins/manage_admins/add/__init__.py b/inclusive_dance_bot/bot/dialogs/admins/manage_admins/add/__init__.py deleted file mode 100644 index 8109571..0000000 --- a/inclusive_dance_bot/bot/dialogs/admins/manage_admins/add/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -from aiogram_dialog import Dialog - -from inclusive_dance_bot.bot.dialogs.admins.manage_admins.add import input_username - -dialog = Dialog( - input_username.window, -) diff --git a/inclusive_dance_bot/bot/dialogs/admins/manage_admins/delete/__init__.py b/inclusive_dance_bot/bot/dialogs/admins/manage_admins/delete/__init__.py deleted file mode 100644 index 01689c2..0000000 --- a/inclusive_dance_bot/bot/dialogs/admins/manage_admins/delete/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from aiogram_dialog import Dialog - -from inclusive_dance_bot.bot.dialogs.admins.manage_admins.delete import confirm - -dialog = Dialog(confirm.window) diff --git a/inclusive_dance_bot/bot/dialogs/admins/manage_admins/read/__init__.py b/inclusive_dance_bot/bot/dialogs/admins/manage_admins/read/__init__.py deleted file mode 100644 index b06f298..0000000 --- a/inclusive_dance_bot/bot/dialogs/admins/manage_admins/read/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -from aiogram_dialog import Dialog - -from inclusive_dance_bot.bot.dialogs.admins.manage_admins.read import items - -dialog = Dialog( - items.window, -) diff --git a/inclusive_dance_bot/bot/dialogs/commands.py b/inclusive_dance_bot/bot/dialogs/commands.py deleted file mode 100644 index f478f46..0000000 --- a/inclusive_dance_bot/bot/dialogs/commands.py +++ /dev/null @@ -1,25 +0,0 @@ -from aiogram.types import Message -from aiogram_dialog import DialogManager, StartMode - -from inclusive_dance_bot.bot.dialogs.admins.states import AdminMainMenuSG -from inclusive_dance_bot.bot.dialogs.messages import START_MESSAGE -from inclusive_dance_bot.bot.dialogs.users.states import MainMenuSG as UserMainMenuSG -from inclusive_dance_bot.bot.dialogs.users.states import RegistrationSG -from inclusive_dance_bot.logic.user import MegaUser - - -async def start_command( - message: Message, - dialog_manager: DialogManager, - user: MegaUser, -) -> None: - if user.is_admin: - await dialog_manager.start(AdminMainMenuSG.menu, mode=StartMode.RESET_STACK) - elif user.is_anonymous: - await message.answer(START_MESSAGE) - await dialog_manager.start( - RegistrationSG.input_name, - mode=StartMode.RESET_STACK, - ) - else: - await dialog_manager.start(UserMainMenuSG.menu, mode=StartMode.RESET_STACK) diff --git a/inclusive_dance_bot/bot/dialogs/users/main_menu/__init__.py b/inclusive_dance_bot/bot/dialogs/users/main_menu/__init__.py deleted file mode 100644 index 0d00d77..0000000 --- a/inclusive_dance_bot/bot/dialogs/users/main_menu/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -from aiogram_dialog import Dialog - -from inclusive_dance_bot.bot.dialogs.users.main_menu import menu -from inclusive_dance_bot.bot.dialogs.users.states import MainMenuSG -from inclusive_dance_bot.bot.dialogs.utils.submenu_window import SubmenuWindow - -dialog = Dialog( - menu.window, - SubmenuWindow(state=MainMenuSG.message), -) diff --git a/inclusive_dance_bot/bot/dialogs/utils/__init__.py b/inclusive_dance_bot/bot/dialogs/utils/__init__.py deleted file mode 100644 index b0d7c68..0000000 --- a/inclusive_dance_bot/bot/dialogs/utils/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -__all__ = ["sync_scroll", "start_with_data"] - -from inclusive_dance_bot.bot.dialogs.utils.start_with_data import start_with_data -from inclusive_dance_bot.bot.dialogs.utils.sync_scroll import sync_scroll diff --git a/inclusive_dance_bot/bot/dialogs/utils/getters.py b/inclusive_dance_bot/bot/dialogs/utils/getters.py deleted file mode 100644 index f380914..0000000 --- a/inclusive_dance_bot/bot/dialogs/utils/getters.py +++ /dev/null @@ -1,21 +0,0 @@ -from typing import Any - -from aiogram_dialog import DialogManager - -from inclusive_dance_bot.logic.storage import Storage - - -async def get_url_data( - storage: Storage, dialog_manager: DialogManager, **kwargs: Any -) -> dict[str, Any]: - return {"url": await storage.get_url_by_slug(dialog_manager.start_data["url_slug"])} - - -async def get_submenu_data( - storage: Storage, dialog_manager: DialogManager, **kwargs: Any -) -> dict[str, Any]: - return { - "submenu": await storage.get_submenu_by_id( - dialog_manager.start_data["submenu_id"] - ) - } diff --git a/inclusive_dance_bot/bot/middlewares/user.py b/inclusive_dance_bot/bot/middlewares/user.py deleted file mode 100644 index 50fb876..0000000 --- a/inclusive_dance_bot/bot/middlewares/user.py +++ /dev/null @@ -1,31 +0,0 @@ -from collections.abc import Awaitable, Callable -from typing import Any - -from aiogram import BaseMiddleware -from aiogram.types import TelegramObject, Update -from aiogram.types import User as AiogramUser - -from inclusive_dance_bot.db.uow.main import UnitOfWork -from inclusive_dance_bot.logic.user import MegaUser - - -class UserMiddleware(BaseMiddleware): - _telegram_bot_admin_ids: list[int] - - def __init__(self, telegram_bot_admin_ids: list[int]) -> None: - self._telegram_bot_admin_ids = telegram_bot_admin_ids - - async def __call__( - self, - handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]], - event: Update, # type: ignore[override] - data: dict[str, Any], - ) -> Any: - aiogram_user: AiogramUser = data["event_from_user"] - uow: UnitOfWork = data["uow"] - data["user"] = MegaUser( - aiogram_user=aiogram_user, - user=await uow.users.get_by_id(aiogram_user.id), - superuser_ids=self._telegram_bot_admin_ids, - ) - return await handler(event, data) diff --git a/inclusive_dance_bot/db/repositories/feedback.py b/inclusive_dance_bot/db/repositories/feedback.py deleted file mode 100644 index fe265aa..0000000 --- a/inclusive_dance_bot/db/repositories/feedback.py +++ /dev/null @@ -1,43 +0,0 @@ -from typing import NoReturn - -from sqlalchemy import ScalarResult, insert -from sqlalchemy.exc import DBAPIError, IntegrityError -from sqlalchemy.ext.asyncio import AsyncSession - -from inclusive_dance_bot.db.models import Feedback -from inclusive_dance_bot.db.repositories.base import Repository -from inclusive_dance_bot.dto import FeedbackDto -from inclusive_dance_bot.enums import FeedbackType -from inclusive_dance_bot.exceptions import InclusiveDanceError, InvalidUserIDError - - -class FeedbackRepository(Repository[Feedback]): - def __init__(self, session: AsyncSession) -> None: - super().__init__(model=Feedback, session=session) - - async def create( - self, *, user_id: int, type: FeedbackType, title: str, text: str - ) -> FeedbackDto: - query = ( - insert(Feedback) - .values( - user_id=user_id, - type=type, - title=title, - text=text, - ) - .returning(Feedback) - ) - try: - result: ScalarResult[Feedback] = await self._session.scalars(query) - except IntegrityError as e: - self._raise_error(e) - else: - await self._session.flush() - return FeedbackDto.from_orm(result.one()) - - def _raise_error(self, e: DBAPIError) -> NoReturn: - constraint = e.__cause__.__cause__.constraint_name # type: ignore[union-attr] - if constraint == "fk__feedback__user_id__users": - raise InvalidUserIDError from e - raise InclusiveDanceError from e diff --git a/inclusive_dance_bot/db/repositories/submenu.py b/inclusive_dance_bot/db/repositories/submenu.py deleted file mode 100644 index 03ce2f2..0000000 --- a/inclusive_dance_bot/db/repositories/submenu.py +++ /dev/null @@ -1,82 +0,0 @@ -from typing import Any, NoReturn - -from sqlalchemy import ScalarResult, delete, desc, insert, select -from sqlalchemy.exc import DBAPIError, IntegrityError, NoResultFound -from sqlalchemy.ext.asyncio import AsyncSession - -from inclusive_dance_bot.db.models import Submenu -from inclusive_dance_bot.db.repositories.base import Repository -from inclusive_dance_bot.dto import SubmenuDto -from inclusive_dance_bot.enums import SubmenuType -from inclusive_dance_bot.exceptions import ( - InclusiveDanceError, - SubmenuAlreadyExistsError, - SubmenuNotFoundError, -) -from inclusive_dance_bot.exceptions.base import EntityNotFoundError - - -class SubmenuRepository(Repository[Submenu]): - def __init__(self, session: AsyncSession) -> None: - super().__init__(model=Submenu, session=session) - - async def create( - self, - type: SubmenuType, - button_text: str, - message: str, - weight: int = 0, - id: int | None = None, - ) -> SubmenuDto: - data = dict(type=type, button_text=button_text, message=message, weight=weight) - if id is not None: - data["id"] = id - stmt = insert(Submenu).values(**data).returning(Submenu) - try: - result: ScalarResult[Submenu] = await self._session.scalars(stmt) - except IntegrityError as e: - self._raise_error(e) - else: - await self._session.flush() - return SubmenuDto.from_orm(result.one()) - - async def get_by_id(self, submenu_id: int) -> SubmenuDto: - try: - obj = await self._get_by_id(submenu_id) - return SubmenuDto.from_orm(obj) - except NoResultFound as e: - raise SubmenuNotFoundError from e - - async def update_by_id(self, submenu_id: int, **kwargs: Any) -> SubmenuDto: - try: - submenu = await self._update(Submenu.id == submenu_id, **kwargs) - except EntityNotFoundError as e: - raise SubmenuNotFoundError from e - except IntegrityError as e: - self._raise_error(e) - return SubmenuDto.from_orm(submenu) - - async def delete_by_id(self, submenu_id: int) -> None: - stmt = delete(Submenu).where(Submenu.id == submenu_id) - await self._session.execute(stmt) - - async def get_list(self) -> tuple[SubmenuDto, ...]: - query = select(Submenu).order_by(desc(Submenu.weight), Submenu.id) - objs = (await self._session.scalars(query)).all() - return tuple(SubmenuDto.from_orm(obj) for obj in objs) - - async def get_list_by_type(self, submenu_type: SubmenuType) -> list[SubmenuDto]: - stmt = ( - select(Submenu) - .where(Submenu.type == submenu_type) - .order_by(desc(Submenu.weight), Submenu.id) - ) - - objs = (await self._session.scalars(stmt)).all() - return [SubmenuDto.from_orm(obj) for obj in objs] - - def _raise_error(self, e: DBAPIError) -> NoReturn: - constraint = e.__cause__.__cause__.constraint_name # type: ignore[union-attr] - if constraint == "pk__submenu": - raise SubmenuAlreadyExistsError from e - raise InclusiveDanceError from e diff --git a/inclusive_dance_bot/db/repositories/url.py b/inclusive_dance_bot/db/repositories/url.py deleted file mode 100644 index 823b6c4..0000000 --- a/inclusive_dance_bot/db/repositories/url.py +++ /dev/null @@ -1,60 +0,0 @@ -from typing import Any, NoReturn - -from sqlalchemy import ScalarResult, delete, insert, select -from sqlalchemy.exc import DBAPIError, IntegrityError -from sqlalchemy.ext.asyncio import AsyncSession - -from inclusive_dance_bot.db.models import Url -from inclusive_dance_bot.db.repositories.base import Repository -from inclusive_dance_bot.dto import UrlDto -from inclusive_dance_bot.exceptions import ( - InclusiveDanceError, - UrlAlreadyExistsError, - UrlSlugAlreadyExistsError, -) -from inclusive_dance_bot.exceptions.base import EntityNotFoundError -from inclusive_dance_bot.exceptions.url import UrlNotFoundError - - -class UrlRepository(Repository[Url]): - def __init__(self, session: AsyncSession) -> None: - super().__init__(model=Url, session=session) - - async def create(self, slug: str, value: str, id: int | None = None) -> UrlDto: - data: dict[str, str | int] = dict(slug=slug, value=value) - if id is not None: - data["id"] = id - stmt = insert(Url).values(**data).returning(Url) - try: - result: ScalarResult[Url] = await self._session.scalars(stmt) - except IntegrityError as e: - self._raise_error(e) - else: - await self._session.flush() - return UrlDto.from_orm(result.one()) - - async def update_by_slug(self, url_slug: str, **kwargs: Any) -> UrlDto: - try: - url = await self._update(Url.slug == url_slug, **kwargs) - except EntityNotFoundError as e: - raise UrlNotFoundError from e - except IntegrityError as e: - self._raise_error(e) - return UrlDto.from_orm(url) - - async def delete_by_slug(self, url_slug: str) -> None: - stmt = delete(Url).where(Url.slug == url_slug) - await self._session.execute(stmt) - - async def get_list(self) -> tuple[UrlDto, ...]: - stmt = select(Url).order_by(Url.slug) - objs = (await self._session.scalars(stmt)).all() - return tuple(UrlDto.from_orm(obj) for obj in objs) - - def _raise_error(self, e: DBAPIError) -> NoReturn: - constraint = e.__cause__.__cause__.constraint_name # type: ignore[union-attr] - if constraint == "pk__url": - raise UrlAlreadyExistsError from e - if constraint == "uq__url__slug": - raise UrlSlugAlreadyExistsError from e - raise InclusiveDanceError from e diff --git a/inclusive_dance_bot/db/repositories/user.py b/inclusive_dance_bot/db/repositories/user.py deleted file mode 100644 index 3a7eb27..0000000 --- a/inclusive_dance_bot/db/repositories/user.py +++ /dev/null @@ -1,92 +0,0 @@ -from collections.abc import Sequence -from typing import NoReturn - -from sqlalchemy import ScalarResult, insert, select -from sqlalchemy.exc import DBAPIError, IntegrityError -from sqlalchemy.ext.asyncio import AsyncSession - -from inclusive_dance_bot.db.models import User, UserType, UserTypeUser -from inclusive_dance_bot.db.repositories.base import Repository -from inclusive_dance_bot.dto import ANONYMOUS_USER, UserDto, UserTypeDto -from inclusive_dance_bot.exceptions import ( - EntityNotFoundError, - InclusiveDanceError, - UserAlreadyExistsError, - UserNotFoundError, -) - - -class UserRepository(Repository[User]): - def __init__(self, session: AsyncSession) -> None: - super().__init__(model=User, session=session) - - async def create( - self, *, user_id: int, username: str, name: str, region: str, phone_number: str - ) -> UserDto: - stmt = ( - insert(User) - .values( - id=user_id, - username=username, - name=name, - region=region, - phone_number=phone_number, - ) - .returning(User) - ) - try: - result: ScalarResult[User] = await self._session.scalars(stmt) - except IntegrityError as e: - self._raise_error(e) - else: - await self._session.flush() - return UserDto.from_orm(result.one()) - - async def get_by_id(self, user_id: int) -> UserDto: - obj = await self._get_by_id_or_none(obj_id=user_id) - return UserDto.from_orm(obj) if obj else ANONYMOUS_USER - - async def get_admin_list(self) -> list[UserDto]: - stmt = select(User).where(User.is_admin.is_(True)) - return [UserDto.from_orm(obj) for obj in await self._session.scalars(stmt)] - - async def add_to_admins(self, username: str) -> UserDto: - try: - user = await self._update( - User.username == username, User.is_admin.is_(False), is_admin=True - ) - return UserDto.from_orm(user) - except EntityNotFoundError as e: - raise UserNotFoundError from e - - async def delete_from_admins(self, user_id: int) -> UserDto: - try: - user = await self._update( - User.id == user_id, User.is_admin.is_(True), is_admin=False - ) - return UserDto.from_orm(user) - except EntityNotFoundError as e: - raise UserNotFoundError from e - - async def get_list_by_user_types( - self, user_types: Sequence[UserTypeDto], ignore_admins: bool = True - ) -> list[UserDto]: - stmt = select(User) - if len(user_types) != 0: - stmt = ( - stmt.distinct(User.id) - .join(UserTypeUser, User.id == UserTypeUser.user_id) - .join(UserType, UserTypeUser.user_type_id == UserType.id) - .where(UserType.name.in_([ut.name for ut in user_types])) - ) - - if ignore_admins: - stmt = stmt.where(User.is_admin.is_(False)) - result = await self._session.scalars(stmt) - return [UserDto.from_orm(obj) for obj in result] - - def _raise_error(self, e: DBAPIError) -> NoReturn: - constraint = e.__cause__.__cause__.constraint_name # type: ignore[union-attr] - if constraint == "pk__users": - raise UserAlreadyExistsError from e - raise InclusiveDanceError from e diff --git a/inclusive_dance_bot/db/repositories/user_type.py b/inclusive_dance_bot/db/repositories/user_type.py deleted file mode 100644 index 3007363..0000000 --- a/inclusive_dance_bot/db/repositories/user_type.py +++ /dev/null @@ -1,44 +0,0 @@ -from typing import NoReturn - -from sqlalchemy import ScalarResult, insert, select -from sqlalchemy.exc import DBAPIError, IntegrityError -from sqlalchemy.ext.asyncio import AsyncSession - -from inclusive_dance_bot.db.models import UserType -from inclusive_dance_bot.db.repositories.base import Repository -from inclusive_dance_bot.dto import UserTypeDto -from inclusive_dance_bot.exceptions import ( - InclusiveDanceError, - UserTypeAlreadyExistsError, -) - - -class UserTypeRepository(Repository[UserType]): - def __init__(self, session: AsyncSession) -> None: - super().__init__(model=UserType, session=session) - - async def create(self, *, name: str, id: int | None = None) -> UserTypeDto: - data: dict[str, str | int] = dict(name=name) - if id is not None: - data["id"] = id - stmt = insert(UserType).values(**data).returning(UserType) - try: - result: ScalarResult[UserType] = await self._session.scalars(stmt) - except IntegrityError as e: - self._raise_error(e) - else: - await self._session.flush() - return UserTypeDto.from_orm(result.one()) - - async def get_list(self) -> tuple[UserTypeDto, ...]: - stmt = select(UserType).order_by(UserType.id) - return tuple( - UserTypeDto.from_orm(obj) - for obj in (await self._session.scalars(stmt)).all() - ) - - def _raise_error(self, e: DBAPIError) -> NoReturn: - constraint = e.__cause__.__cause__.constraint_name # type: ignore[union-attr] - if constraint in ("pk__user_type", "ix__user_type__name"): - raise UserTypeAlreadyExistsError from e - raise InclusiveDanceError from e diff --git a/inclusive_dance_bot/db/uow/base.py b/inclusive_dance_bot/db/uow/base.py deleted file mode 100644 index aa91713..0000000 --- a/inclusive_dance_bot/db/uow/base.py +++ /dev/null @@ -1,24 +0,0 @@ -from abc import ABC, abstractmethod -from types import TracebackType -from typing import Self - - -class UnitOfWorkBase(ABC): - async def __aenter__(self) -> Self: - return self - - async def __aexit__( - self, - exc_type: type[BaseException], - exc_value: BaseException, - traceback: TracebackType, - ) -> None: - await self.rollback() - - @abstractmethod - async def commit(self) -> None: - raise NotImplementedError() - - @abstractmethod - async def rollback(self) -> None: - raise NotImplementedError() diff --git a/inclusive_dance_bot/db/uow/main.py b/inclusive_dance_bot/db/uow/main.py deleted file mode 100644 index 102ed95..0000000 --- a/inclusive_dance_bot/db/uow/main.py +++ /dev/null @@ -1,46 +0,0 @@ -import asyncio -from types import TracebackType -from typing import Self - -from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker - -from inclusive_dance_bot.db.repositories.feedback import FeedbackRepository -from inclusive_dance_bot.db.repositories.mailing import MailingRepository -from inclusive_dance_bot.db.repositories.submenu import SubmenuRepository -from inclusive_dance_bot.db.repositories.url import UrlRepository -from inclusive_dance_bot.db.repositories.user import UserRepository -from inclusive_dance_bot.db.repositories.user_type import UserTypeRepository -from inclusive_dance_bot.db.repositories.user_type_user import UserTypeUserRepository -from inclusive_dance_bot.db.uow.base import UnitOfWorkBase - - -class UnitOfWork(UnitOfWorkBase): - def __init__(self, sessionmaker: async_sessionmaker[AsyncSession]) -> None: - self._sessionmaker = sessionmaker - - async def __aenter__(self) -> Self: - self._session = self._sessionmaker() - self.submenus = SubmenuRepository(self._session) - self.feedbacks = FeedbackRepository(self._session) - self.mailings = MailingRepository(self._session) - self.urls = UrlRepository(self._session) - self.users = UserRepository(self._session) - self.user_types = UserTypeRepository(self._session) - self.user_type_users = UserTypeUserRepository(self._session) - return await super().__aenter__() - - async def __aexit__( - self, - exc_type: type[BaseException], - exc_value: BaseException, - traceback: TracebackType, - ) -> None: - await self._session.rollback() - task = asyncio.create_task(self._session.close()) - await asyncio.shield(task) - - async def commit(self) -> None: - await self._session.commit() - - async def rollback(self) -> None: - await self._session.rollback() diff --git a/inclusive_dance_bot/deps.py b/inclusive_dance_bot/deps.py deleted file mode 100644 index 706d70b..0000000 --- a/inclusive_dance_bot/deps.py +++ /dev/null @@ -1,35 +0,0 @@ -from argparse import Namespace -from collections.abc import AsyncGenerator - -from aiogram import Bot -from aiomisc_dependency import dependency -from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker - -from inclusive_dance_bot.bot.factory import get_bot -from inclusive_dance_bot.db.uow.main import UnitOfWork -from inclusive_dance_bot.db.utils import create_engine, create_session_factory - - -def config_deps(arguments: Namespace) -> None: - @dependency - async def bot() -> Bot: - return get_bot(telegram_bot_token=arguments.telegram_bot_token) - - @dependency - async def engine() -> AsyncGenerator[AsyncEngine, None]: - engine = create_engine( - connection_uri=arguments.pg_dsn, - echo=arguments.debug, - ) - yield engine - await engine.dispose() - - @dependency - async def session_factory(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]: - return create_session_factory(engine=engine) - - @dependency - async def uow(session_factory: async_sessionmaker[AsyncSession]) -> UnitOfWork: - return UnitOfWork(sessionmaker=session_factory) - - return diff --git a/inclusive_dance_bot/dto.py b/inclusive_dance_bot/dto.py deleted file mode 100644 index 3a7db6c..0000000 --- a/inclusive_dance_bot/dto.py +++ /dev/null @@ -1,148 +0,0 @@ -from __future__ import annotations - -from collections.abc import Sequence -from dataclasses import dataclass -from datetime import datetime -from typing import TYPE_CHECKING - -from inclusive_dance_bot.enums import FeedbackType, MailingStatus, SubmenuType - -if TYPE_CHECKING: - from inclusive_dance_bot.db.models import ( - Feedback, - Mailing, - Submenu, - Url, - User, - UserType, - ) - - -@dataclass(frozen=True, slots=True) -class UserDto: - id: int - name: str - username: str - region: str - phone_number: str - is_admin: bool - - @classmethod - def from_orm(cls, obj: User) -> UserDto: - return cls( - id=obj.id, - name=obj.name, - username=obj.username, - region=obj.name, - phone_number=obj.phone_number, - is_admin=obj.is_admin, - ) - - -ANONYMOUS_USER = UserDto( - id=0, - name="Anonymous", - region="Earth", - phone_number="", - is_admin=False, - username="anonymous", -) - - -@dataclass(frozen=True, slots=True) -class UserTypeDto: - id: int - name: str - - @classmethod - def from_orm(cls, obj: UserType) -> UserTypeDto: - return cls(id=obj.id, name=obj.name) - - -@dataclass(frozen=True, slots=True) -class SubmenuDto: - id: int - type: SubmenuType - weight: int - button_text: str - message: str - - @classmethod - def from_orm(cls, obj: Submenu) -> SubmenuDto: - return cls( - id=obj.id, - type=obj.type, - weight=obj.weight, - button_text=obj.button_text, - message=obj.message, - ) - - -@dataclass(frozen=True, slots=True) -class UrlDto: - id: int - slug: str - value: str - - @classmethod - def from_orm(cls, obj: Url) -> UrlDto: - return cls(id=obj.id, slug=obj.slug, value=obj.value) - - def __str__(self) -> str: - return self.value - - -@dataclass(frozen=True, slots=True) -class FeedbackDto: - id: int - user_id: int - type: FeedbackType - title: str - text: str - is_viewed: bool - viewed_at: datetime | None - is_answered: bool - answered_at: datetime | None - - @classmethod - def from_orm(cls, obj: Feedback) -> FeedbackDto: - return cls( - id=obj.id, - user_id=obj.user_id, - type=obj.type, - title=obj.title, - text=obj.text, - is_viewed=obj.is_viewed, - viewed_at=obj.viewed_at, - is_answered=obj.is_answered, - answered_at=obj.answered_at, - ) - - -@dataclass(frozen=True, slots=True) -class MailingDto: - created_at: datetime - updated_at: datetime - id: int - scheduled_at: datetime | None - sent_at: datetime | None - cancelled_at: datetime | None - status: MailingStatus - title: str - content: str - user_types: Sequence[UserTypeDto] - - @classmethod - def from_orm(cls, obj: Mailing) -> MailingDto: - return cls( - created_at=obj.created_at, - updated_at=obj.updated_at, - id=obj.id, - scheduled_at=obj.scheduled_at, - cancelled_at=obj.cancelled_at, - status=obj.status, - title=obj.title, - content=obj.content, - user_types=tuple(UserTypeDto.from_orm(ut) for ut in obj.user_types), - sent_at=obj.sent_at, - ) diff --git a/inclusive_dance_bot/exceptions/mailing.py b/inclusive_dance_bot/exceptions/mailing.py deleted file mode 100644 index 1539855..0000000 --- a/inclusive_dance_bot/exceptions/mailing.py +++ /dev/null @@ -1,5 +0,0 @@ -from inclusive_dance_bot.exceptions.base import EntityNotFoundError - - -class MailingNotFoundError(EntityNotFoundError): - pass diff --git a/inclusive_dance_bot/init_data.py b/inclusive_dance_bot/init_data.py deleted file mode 100644 index 83abda8..0000000 --- a/inclusive_dance_bot/init_data.py +++ /dev/null @@ -1,130 +0,0 @@ -import asyncio -import logging - -from inclusive_dance_bot.arguments import get_parser -from inclusive_dance_bot.db.uow.main import UnitOfWork -from inclusive_dance_bot.db.utils import create_engine, create_session_factory -from inclusive_dance_bot.enums import SubmenuType -from inclusive_dance_bot.exceptions import ( - SubmenuAlreadyExistsError, - UrlAlreadyExistsError, - UserTypeAlreadyExistsError, -) - -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s | %(name)-12s | %(levelname)-8s | %(message)s", - datefmt="%H:%M:%S %d.%m.%Y", -) -log = logging.getLogger(__name__) - -USER_TYPES = ( - (1, "Руководитель коллектива"), - (2, "Хореограф / педагог"), - (3, "Специалист социокультурной сферы"), - (4, "Танцор с ОВЗ"), - (5, "Родитель танцора"), - (6, "Танцующий волонтер"), - (7, "Волонтер-организатор"), - (8, "Зритель"), - (9, "Партнер / благотворитель"), - (10, "Представитель СМИ"), - (11, "Просто интересуюсь"), -) -URLS = ( - (1, "buy_form", "https://example.com"), - (2, "google_doc", "https://example.com"), - (3, "ticket_timepad", "https://example.com"), - (4, "google_form_seminar", "https://example.com"), - (5, "buy_form_url_1", "https://example.com"), - (6, "buy_form_url_2", "https://example.com"), - (7, "buy_form_url_3", "https://example.com"), - (8, "buy_form_url_4", "https://example.com"), - (9, "buy_form_url_5", "https://example.com"), - (10, "buy_form_url_6", "https://example.com"), - (11, "buy_form_url_7", "https://example.com"), - (12, "buy_form_url_8", "https://example.com"), -) -SUBMENUS = ( - (1, SubmenuType.EVENT, "Клуб професионалов Inclusive Dance", "message"), - ( - 2, - SubmenuType.EVENT, - "Фестиваль Inclusive Dance в Москве - октябрь 2023", - "message", - ), - (3, SubmenuType.EVENT, "Социальное исследование", "message"), - (4, SubmenuType.EDUCATION, "Клуб профессионалов Inclusive Dance", "message"), - (5, SubmenuType.EDUCATION, "Семинары по инклюзивному танцу", "message"), - (6, SubmenuType.EDUCATION, "Онлайн-курсы по инклюзивному танцу", "message"), - (7, SubmenuType.ENROLL, 'Студия м. "Авимоторная" (Москва)', "message"), - (8, SubmenuType.ENROLL, 'Студия м. "Войковская" (Москва)', "message"), - (9, SubmenuType.CHARITY, "Сделать пожертвование", "message"), - (10, SubmenuType.CHARITY, "Стать волонтером проекта", "message"), - (11, SubmenuType.CHARITY, "Стать партнером проекта", "message"), - (12, SubmenuType.CHARITY, "Рассказать о проекте", "message"), - (13, SubmenuType.CHARITY, "Организовать показ фильма", "message"), - (14, SubmenuType.INFORMATION, "Что такое инклюзивный танец?", "message"), - (15, SubmenuType.INFORMATION, "О проекте Inclusive Dance?", "message"), - (16, SubmenuType.INFORMATION, "Новости проекта", "message"), - ( - 17, - SubmenuType.INFORMATION, - 'Документальный фильм "Танцевать под дождем"', - "Здесь должна быть очень важная информация о фильме" - ' и ссылка', - ), - (18, SubmenuType.INFORMATION, "Ссылки на наши ресурсы", "message"), - (19, SubmenuType.INFORMATION, "Задать вопрос команде", "message"), - (20, SubmenuType.OTHER, "Стать волонтером", "message"), - (21, SubmenuType.OTHER, "Купить билет", "message"), -) - - -async def init_data(uow: UnitOfWork) -> None: - log.info("Run init data") - try: - async with uow: - for url in URLS: - await uow.urls.create(slug=url[1], value=url[2], id=url[0]) - await uow.commit() - log.info("Urls successfully initialized") - except UrlAlreadyExistsError: - log.warning("Urls already in database") - - try: - async with uow: - for user_type in USER_TYPES: - await uow.user_types.create(id=user_type[0], name=user_type[1]) - await uow.commit() - log.info("UserTypes successfully initialized") - except UserTypeAlreadyExistsError: - log.warning("UserTypes already in database") - - try: - async with uow: - for submenu in SUBMENUS: - await uow.submenus.create( - id=submenu[0], - type=submenu[1], - button_text=submenu[2], - message=submenu[3], - ) - await uow.commit() - log.info("Submenu successfully initialized") - except SubmenuAlreadyExistsError: - log.warning("Submenu already in database") - log.info("Finish init data") - - -def main() -> None: - parser = get_parser() - arguments = parser.parse_args() - engine = create_engine(connection_uri=arguments.pg_dsn) - session_factory = create_session_factory(engine=engine) - uow = UnitOfWork(sessionmaker=session_factory) - asyncio.run(init_data(uow=uow)) - - -if __name__ == "__main__": - main() diff --git a/inclusive_dance_bot/logic/feedback.py b/inclusive_dance_bot/logic/feedback.py deleted file mode 100644 index 6ca7db3..0000000 --- a/inclusive_dance_bot/logic/feedback.py +++ /dev/null @@ -1,20 +0,0 @@ -from inclusive_dance_bot.db.uow.main import UnitOfWork -from inclusive_dance_bot.enums import FeedbackType -from inclusive_dance_bot.exceptions.base import InclusiveDanceError - - -async def create_feedback( - uow: UnitOfWork, user_id: int, type: FeedbackType, title: str, text: str -) -> None: - """Создает новую обратную связь от пользователя""" - try: - await uow.feedbacks.create( - user_id=user_id, - type=type, - title=title, - text=text, - ) - await uow.commit() - except InclusiveDanceError as e: - await uow.rollback() - raise e diff --git a/inclusive_dance_bot/logic/storage.py b/inclusive_dance_bot/logic/storage.py deleted file mode 100644 index b293068..0000000 --- a/inclusive_dance_bot/logic/storage.py +++ /dev/null @@ -1,86 +0,0 @@ -from collections.abc import Iterator, MutableMapping -from typing import Any - -from inclusive_dance_bot.db.uow.main import UnitOfWork -from inclusive_dance_bot.dto import SubmenuDto, UrlDto, UserTypeDto -from inclusive_dance_bot.enums import StorageType - - -class CacheStorage(MutableMapping): - def __init__(self) -> None: - self.__storage: dict[str, Any] = {} - - def __getitem__(self, key: str) -> Any: - return self.__storage[key] - - def __setitem__(self, key: str, value: Any) -> None: - self.__storage[key] = value - - def __delitem__(self, key: str) -> None: - del self.__storage[key] - - def __len__(self) -> int: - return len(self.__storage) - - def __iter__(self) -> Iterator: - return iter(self.__storage) - - def __repr__(self) -> str: - return repr(self.__storage) - - def clear(self) -> None: - self.__storage.clear() - - -class Storage: - _uow: UnitOfWork - _cache: CacheStorage - - def __init__(self, uow: UnitOfWork) -> None: - self._uow = uow - self._cache = CacheStorage() - - async def get_urls(self) -> dict[str, UrlDto]: - if StorageType.URL not in self._cache: - self._cache[StorageType.URL] = { - url.slug: url for url in await self._uow.urls.get_list() - } - return self._cache[StorageType.URL] - - async def get_url_by_slug(self, slug: str) -> UrlDto: - urls = await self.get_urls() - return urls[slug] - - async def get_user_types(self) -> dict[int, UserTypeDto]: - if StorageType.USER_TYPE not in self._cache: - self._cache[StorageType.USER_TYPE] = { - ut.id: ut for ut in await self._uow.user_types.get_list() - } - return self._cache[StorageType.USER_TYPE] - - async def get_submenus(self) -> dict[int, SubmenuDto]: - if StorageType.SUBMENU not in self._cache: - self._cache[StorageType.SUBMENU] = { - e.id: e for e in await self._uow.submenus.get_list() - } - return self._cache[StorageType.SUBMENU] - - async def get_submenu_by_id(self, submenu_id: int) -> SubmenuDto: - submenus = await self.get_submenus() - return submenus[submenu_id] - - async def refresh_all(self) -> None: - self._cache.clear() - await self.get_urls() - await self.get_submenus() - await self.get_user_types() - - async def refresh_urls(self) -> None: - if StorageType.URL in self._cache: - del self._cache[StorageType.URL] - await self.get_urls() - - async def refresh_submenus(self) -> None: - if StorageType.SUBMENU in self._cache: - del self._cache[StorageType.SUBMENU] - await self.get_submenus() diff --git a/inclusive_dance_bot/logic/url.py b/inclusive_dance_bot/logic/url.py deleted file mode 100644 index 54b483e..0000000 --- a/inclusive_dance_bot/logic/url.py +++ /dev/null @@ -1,43 +0,0 @@ -from typing import Any - -from inclusive_dance_bot.db.uow.main import UnitOfWork -from inclusive_dance_bot.dto import UrlDto -from inclusive_dance_bot.exceptions.base import InclusiveDanceError -from inclusive_dance_bot.exceptions.url import UrlSlugAlreadyExistsError -from inclusive_dance_bot.logic.storage import Storage - - -async def create_url( - uow: UnitOfWork, storage: Storage, slug: str, value: str -) -> UrlDto: - """Создает новую ссылку""" - try: - url = await uow.urls.create(slug=slug, value=value) - except UrlSlugAlreadyExistsError as e: - await uow.rollback() - raise e - await uow.commit() - await storage.refresh_urls() - return url - - -async def update_url_by_slug( - uow: UnitOfWork, - storage: Storage, - url_slug: str, - **kwargs: Any, -) -> UrlDto: - try: - url = await uow.urls.update_by_slug(url_slug=url_slug, **kwargs) - except InclusiveDanceError as e: - await uow.rollback() - raise e - await uow.commit() - await storage.refresh_urls() - return url - - -async def delete_url_by_slug(uow: UnitOfWork, storage: Storage, url_slug: str) -> None: - await uow.urls.delete_by_slug(url_slug=url_slug) - await uow.commit() - await storage.refresh_urls() diff --git a/inclusive_dance_bot/logic/user.py b/inclusive_dance_bot/logic/user.py deleted file mode 100644 index da92f98..0000000 --- a/inclusive_dance_bot/logic/user.py +++ /dev/null @@ -1,75 +0,0 @@ -from collections.abc import Iterable - -from aiogram.types import User as AiogramUser - -from inclusive_dance_bot.db.uow.main import UnitOfWork -from inclusive_dance_bot.dto import UserDto -from inclusive_dance_bot.exceptions.base import InclusiveDanceError - - -class MegaUser: - def __init__( - self, aiogram_user: AiogramUser, user: UserDto, superuser_ids: list[int] - ) -> None: - self._aiogram_user = aiogram_user - self._user = user - self._superuser_ids = superuser_ids - - def __repr__(self) -> str: - return f"MegaUser(user={self._user},auser={self._aiogram_user})" - - @property - def is_superuser(self) -> bool: - return self._aiogram_user.id in self._superuser_ids - - @property - def is_admin(self) -> bool: - return self._user.is_admin or self.is_superuser - - @property - def is_anonymous(self) -> bool: - return self._user.id == 0 - - -async def create_user( - uow: UnitOfWork, - user_id: int, - username: str, - name: str, - region: str, - phone_number: str, - user_type_ids: Iterable[int], -) -> None: - """Создает нового пользователя""" - try: - await uow.users.create( - user_id=user_id, - username=username, - name=name, - region=region, - phone_number=phone_number, - ) - for user_type_id in user_type_ids: - await uow.user_type_users.create(user_id=user_id, user_type_id=user_type_id) - await uow.commit() - except InclusiveDanceError as e: - await uow.rollback() - raise e - - -async def add_user_to_admins(uow: UnitOfWork, username: str) -> None: - try: - await uow.users.add_to_admins(username=username) - await uow.commit() - except InclusiveDanceError as e: - await uow.rollback() - raise e - - -async def delete_from_admins(uow: UnitOfWork, user_id: int) -> None: - try: - await uow.users.delete_from_admins(user_id=user_id) - await uow.commit() - except InclusiveDanceError as e: - await uow.rollback() - raise e diff --git a/inclusive_dance_bot/services/bot.py b/inclusive_dance_bot/services/bot.py deleted file mode 100644 index 3ae470a..0000000 --- a/inclusive_dance_bot/services/bot.py +++ /dev/null @@ -1,59 +0,0 @@ -import logging - -from aiogram import Bot, Dispatcher -from aiogram.fsm.storage.memory import SimpleEventIsolation -from aiogram_dialog import setup_dialogs -from aiomisc import Service - -from inclusive_dance_bot.bot.dialogs import register_dialogs -from inclusive_dance_bot.bot.factory import get_storage -from inclusive_dance_bot.bot.middlewares.storage import StorageMiddleware -from inclusive_dance_bot.bot.middlewares.uow import UowMiddleware -from inclusive_dance_bot.bot.middlewares.user import UserMiddleware -from inclusive_dance_bot.bot.ui_commands import set_ui_commands -from inclusive_dance_bot.db.uow.main import UnitOfWork -from inclusive_dance_bot.logic.storage import Storage - -log = logging.getLogger(__name__) - - -class AiogramBotService(Service): - __required__ = ("debug", "redis_dsn", "telegram_bot_admin_ids") - - __dependencies__ = ("uow", "bot") - - debug: bool - redis_dsn: str - telegram_bot_admin_ids: list[int] - - uow: UnitOfWork - bot: Bot - - async def start(self) -> None: - log.info("Initialize bot") - await set_ui_commands(self.bot) - await self.bot.delete_webhook(drop_pending_updates=True) - - storage = Storage(uow=self.uow) - - async with self.uow: - await storage.refresh_all() - - dp = Dispatcher( - storage=get_storage( - debug=self.debug, - redis_dsn=self.redis_dsn, - ), - events_isolation=SimpleEventIsolation(), - ) - dp.update.outer_middleware(UowMiddleware(uow=self.uow)) - dp.update.outer_middleware(StorageMiddleware(storage=storage)) - dp.update.outer_middleware( - UserMiddleware(telegram_bot_admin_ids=self.telegram_bot_admin_ids) - ) - register_dialogs(dp) - setup_dialogs(dp) - - self.start_event.set() - log.info("Start polling") - await dp.start_polling(self.bot) diff --git a/inclusive_dance_bot/services/periodic.py b/inclusive_dance_bot/services/periodic.py deleted file mode 100644 index 4b18f5c..0000000 --- a/inclusive_dance_bot/services/periodic.py +++ /dev/null @@ -1,20 +0,0 @@ -from typing import Any - -from aiogram import Bot -from aiomisc.service.periodic import PeriodicService - -from inclusive_dance_bot.db.uow.main import UnitOfWork -from inclusive_dance_bot.logic.mailing import send_mailings - - -class PeriodicMailingService(PeriodicService): - __required__ = ("gap",) - __dependencies__ = ("bot", "uow") - - bot: Bot - uow: UnitOfWork - gap: int - - async def callback(self) -> Any: - async with self.uow: - await send_mailings(uow=self.uow, bot=self.bot, gap=self.gap) diff --git a/inutils/init_data.py b/inutils/init_data.py new file mode 100644 index 0000000..8488662 --- /dev/null +++ b/inutils/init_data.py @@ -0,0 +1,139 @@ +import argparse +import asyncio +import logging +from pathlib import Path +from typing import Any + +import yaml +from aiomisc_log import LogFormat, LogLevel, basic_config +from configargparse import ArgumentParser +from pydantic import BaseModel, HttpUrl, ValidationError, field_validator + +from idb.db.uow import UnitOfWork +from idb.db.utils import create_engine, create_session_factory +from idb.generals.enums import SubmenuType +from idb.utils.urls import check_slug + +log = logging.getLogger(__name__) + + +class UserTypeSchema(BaseModel): + id: int + name: str + + +class UrlSchema(BaseModel): + id: int + slug: str + value: HttpUrl + + @field_validator("slug") + @classmethod + def check_slug(cls, v: str) -> str: + if not check_slug(v): + raise ValueError + + return v + + +class SubmenuSchema(BaseModel): + id: int + type: SubmenuType + weight: int + button_text: str + message: str + + +async def write_urls(uow: UnitOfWork, urls: list[dict[str, Any]]) -> None: + for u in urls: + try: + url = UrlSchema.model_validate(u) + except ValidationError: + log.warning("Incorrect url data: %s", u) + else: + await uow.urls.upsert(**url.model_dump(mode="json")) + + +async def write_user_types(uow: UnitOfWork, user_types: list[dict[str, Any]]) -> None: + for ut in user_types: + try: + user_type = UserTypeSchema.model_validate(ut) + except ValidationError: + log.warning("Incorrect user type data: %s", ut) + else: + await uow.user_types.upsert(**user_type.model_dump(mode="json")) + + +async def write_submenus(uow: UnitOfWork, submenus: list[dict[str, Any]]) -> None: + for s in submenus: + try: + submenu = SubmenuSchema.model_validate(s) + except ValidationError: + log.warning("Incorrect submenu data: %s", s) + else: + await uow.submenus.upsert(**submenu.model_dump(mode="json")) + + +async def write_data(uow: UnitOfWork, filename: Path) -> None: + log.info("Run init data") + data = read_data(filename=filename) + user_types = data.get("user_types") + urls = data.get("urls") + submenus = data.get("submenus") + if urls: + await write_urls(uow=uow, urls=urls) + + if user_types: + await write_user_types(uow=uow, user_types=user_types) + + if submenus: + await write_submenus(uow=uow, submenus=submenus) + + await uow.commit() + + +def get_parser() -> ArgumentParser: + parser = ArgumentParser( + allow_abbrev=False, + auto_env_var_prefix="APP_", + description="Inclusive Dance Bot", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + + group = parser.add_argument_group("Logging options") + group.add_argument("--log-level", choices=LogLevel.choices(), default=LogLevel.info) + group.add_argument( + "--log-format", choices=LogFormat.choices(), default=LogFormat.color + ) + + group = parser.add_argument_group("PostgreSQL options") + group.add_argument("--pg-dsn", type=str, required=True) + + group = parser.add_argument_group("Load data params") + group.add_argument("--init-data-path", type=Path, required=True) + + return parser + + +def read_data(filename: Path) -> dict[str, Any]: + with open(filename) as f: + return yaml.safe_load(f) + + +def main() -> None: + parser = get_parser() + arguments = parser.parse_args() + if not arguments.init_data_path.exists(): + raise FileNotFoundError + basic_config( + level=arguments.log_level, + log_format=arguments.log_format, + ) + engine = create_engine(connection_uri=arguments.pg_dsn) + session_factory = create_session_factory(engine=engine) + uow = UnitOfWork(sessionmaker=session_factory) + asyncio.run(write_data(uow=uow, filename=arguments.init_data_path)) + + +if __name__ == "__main__": + main() diff --git a/inutils/init_data.yaml b/inutils/init_data.yaml new file mode 100644 index 0000000..2017b1d --- /dev/null +++ b/inutils/init_data.yaml @@ -0,0 +1,180 @@ +user_types: + - id: 1 + name: Руководитель коллектива + + - id: 2 + name: Хореограф / педагог + + - id: 3 + name: Специалист социокультурной сферы + + - id: 4 + name: Танцор с ОВЗ + + - id: 5 + name: Родитель танцора + + - id: 6 + name: Танцующий волонтер + + - id: 7 + name: Волонтер-организатор + + - id: 8 + name: Зритель + + - id: 9 + name: Партнер / благотворитель + + - id: 10 + name: Представитель СМИ + + - id: 11 + name: Просто интересуюсь + +urls: + - id: 1 + slug: club_payment + description: Оплата за участие в Клубе + value: https://c.cloudpayments.ru/payments/b37773a15dc544f996ce2de65265d75d + + - id: 2 + slug: festival_press_release + description: Пресс-релиз фестиваля (ссылка на гугл-док) + value: + + - id: 3 + slug: buy_ticket + description: Купить билет (ссылка на таймпед) + value: + + - id: 4 + slug: seminar_registration + description: Зарегистрироваться на семинар (ссылка на гугл форму) + value: https://docs.google.com/forms/d/e/1FAIpQLSfqtSmNF2pR0h1gj2_8NO9tAqTBLTAPvR6HgXFiYdgu-wBdSw/viewform + + - id: 5 + slug: seminar_payment + description: Оплата за участие в семинаре + value: https://c.cloudpayments.ru/payments/75b956c67f444832b6c1d48c815874fd + + - id: 6 + slug: studio_registration + description: Запись в студию + value: https://docs.google.com/forms/d/e/1FAIpQLSf9gn-X7ERtEcChdmqNFqbIPxD7dWwh_RR_iL97ySZe6bBJDQ/viewform + + - id: 7 + slug: volunteer_form + description: Анкета волонтера + value: https://docs.google.com/forms/d/e/1FAIpQLScFw1yGSI2ZF4jiUiO6xeTYY2gn8gwlnDXrI9M1DHctZ81-UQ/viewform + + - id: 8 + slug: charity_payment + description: Форма пожертвования + value: https://inclusive-dance.ru/pomoch-nam/?to=one + + - id: 9 + slug: about_us + description: О нас + value: https://inclusive-dance.ru/o-fonde/ + + - id: 10 + slug: blog + description: Блог + value: https://inclusive-dance.ru/news/ + + - id: 11 + slug: dancing_film_site + description: Сайт фильма "Танцевать под дождем" + value: https://www.id-film.ru/ + + - id: 12 + slug: dancing_film_watch + description: Посмотреть фильм "Танцевать под дождем" + value: https://wink.ru/movies/tantsevat-pod-dozhdem-year-2023 + +submenus: + - id: 1 + type: CHARITY + button_text: Сделать пожертвование + weight: 100 + message: | + Сделать пожертвование + + - id: 2 + type: CHARITY + button_text: Стать волонтером проекта + weight: 90 + message: | + Вы увлекаетесь танцами и хотите заниматься в инклюзивном танцевальном коллективе? А может вы желаете помогать на социальных мероприятиях? + + Тогда заполните анкету, чтобы мы могли связаться с вами и обсудить возможные варианты вашей помощи. + + Заполнить анкету волонтера + + - id: 3 + type: CHARITY + button_text: Стать партнером проекта + weight: 80 + message: | + Ссылка на сайт для партнеров + + - id: 4 + type: CHARITY + button_text: Рассказать о проекте + weight: 70 + message: | + Ссылка на форму + + - id: 5 + type: CHARITY + button_text: Организовать показ фильма + weight: 60 + message: | + Ссылка на форму + + - id: 6 + type: EVENT + button_text: Клуб профессионалов Inclusive dancing_film_site + weight: 100 + message: | + Оплата за участие в Клубе + + - id: 7 + type: EVENT + button_text: Фестиваль Inclusive Dance в Москве - октябрь 2023 + weight: 90 + message: | + Пресс-релиз фестиваля + + Купить билет + + - id: 8 + type: EVENT + button_text: Социальное исследование + weight: 80 + message: | + Запись на исследование + + - id: 9 + type: INFORMATION + button_text: Что такое инклюзивный танец? + weight: 100 + message: | + Короткое сообщение от бота об инклюзивном танце + + - id: 10 + type: INFORMATION + button_text: О проекте Inclusive Dance + weight: 90 + message: | + Подробнее о проекте Inclusive Dance можно узнать на официальном сайте + + - id: 11 + type: INFORMATION + button_text: Новости проекта + weight: 80 + message: |- + Все новости на нашем официальном сайте + + Хотите оперативно получ diff --git a/poetry.lock b/poetry.lock index 28e6d7d..b3e771c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -387,46 +387,6 @@ test = ["beautifulsoup4 (>=4.8.0)", "coverage (>=4.5.4)", "fixtures (>=3.0.0)", toml = ["tomli (>=1.1.0)"] yaml = ["PyYAML"] -[[package]] -name = "black" -version = "23.10.1" -description = "The uncompromising code formatter." -optional = false -python-versions = ">=3.8" -files = [ - {file = "black-23.10.1-cp310-cp310-macosx_10_16_arm64.whl", hash = "sha256:ec3f8e6234c4e46ff9e16d9ae96f4ef69fa328bb4ad08198c8cee45bb1f08c69"}, - {file = "black-23.10.1-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:1b917a2aa020ca600483a7b340c165970b26e9029067f019e3755b56e8dd5916"}, - {file = "black-23.10.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c74de4c77b849e6359c6f01987e94873c707098322b91490d24296f66d067dc"}, - {file = "black-23.10.1-cp310-cp310-win_amd64.whl", hash = "sha256:7b4d10b0f016616a0d93d24a448100adf1699712fb7a4efd0e2c32bbb219b173"}, - {file = "black-23.10.1-cp311-cp311-macosx_10_16_arm64.whl", hash = "sha256:b15b75fc53a2fbcac8a87d3e20f69874d161beef13954747e053bca7a1ce53a0"}, - {file = "black-23.10.1-cp311-cp311-macosx_10_16_x86_64.whl", hash = "sha256:e293e4c2f4a992b980032bbd62df07c1bcff82d6964d6c9496f2cd726e246ace"}, - {file = "black-23.10.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d56124b7a61d092cb52cce34182a5280e160e6aff3137172a68c2c2c4b76bcb"}, - {file = "black-23.10.1-cp311-cp311-win_amd64.whl", hash = "sha256:3f157a8945a7b2d424da3335f7ace89c14a3b0625e6593d21139c2d8214d55ce"}, - {file = "black-23.10.1-cp38-cp38-macosx_10_16_arm64.whl", hash = "sha256:cfcce6f0a384d0da692119f2d72d79ed07c7159879d0bb1bb32d2e443382bf3a"}, - {file = "black-23.10.1-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:33d40f5b06be80c1bbce17b173cda17994fbad096ce60eb22054da021bf933d1"}, - {file = "black-23.10.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:840015166dbdfbc47992871325799fd2dc0dcf9395e401ada6d88fe11498abad"}, - {file = "black-23.10.1-cp38-cp38-win_amd64.whl", hash = "sha256:037e9b4664cafda5f025a1728c50a9e9aedb99a759c89f760bd83730e76ba884"}, - {file = "black-23.10.1-cp39-cp39-macosx_10_16_arm64.whl", hash = "sha256:7cb5936e686e782fddb1c73f8aa6f459e1ad38a6a7b0e54b403f1f05a1507ee9"}, - {file = "black-23.10.1-cp39-cp39-macosx_10_16_x86_64.whl", hash = "sha256:7670242e90dc129c539e9ca17665e39a146a761e681805c54fbd86015c7c84f7"}, - {file = "black-23.10.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ed45ac9a613fb52dad3b61c8dea2ec9510bf3108d4db88422bacc7d1ba1243d"}, - {file = "black-23.10.1-cp39-cp39-win_amd64.whl", hash = "sha256:6d23d7822140e3fef190734216cefb262521789367fbdc0b3f22af6744058982"}, - {file = "black-23.10.1-py3-none-any.whl", hash = "sha256:d431e6739f727bb2e0495df64a6c7a5310758e87505f5f8cde9ff6c0f2d7e4fe"}, - {file = "black-23.10.1.tar.gz", hash = "sha256:1f8ce316753428ff68749c65a5f7844631aa18c8679dfd3ca9dc1a289979c258"}, -] - -[package.dependencies] -click = ">=8.0.0" -mypy-extensions = ">=0.4.3" -packaging = ">=22.0" -pathspec = ">=0.9.0" -platformdirs = ">=2" - -[package.extras] -colorama = ["colorama (>=0.4.3)"] -d = ["aiohttp (>=3.7.4)"] -jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] -uvloop = ["uvloop (>=0.15.2)"] - [[package]] name = "cachetools" version = "5.3.2" @@ -559,20 +519,6 @@ files = [ {file = "charset_normalizer-3.3.1-py3-none-any.whl", hash = "sha256:800561453acdecedaac137bf09cd719c7a440b6800ec182f077bb8e7025fb708"}, ] -[[package]] -name = "click" -version = "8.1.7" -description = "Composable command line interface toolkit" -optional = false -python-versions = ">=3.7" -files = [ - {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, - {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "platform_system == \"Windows\""} - [[package]] name = "colorama" version = "0.4.6" @@ -1284,17 +1230,6 @@ files = [ {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, ] -[[package]] -name = "pathspec" -version = "0.11.2" -description = "Utility library for gitignore style pattern matching of file paths." -optional = false -python-versions = ">=3.7" -files = [ - {file = "pathspec-0.11.2-py3-none-any.whl", hash = "sha256:1d6ed233af05e679efb96b1851550ea95bbb64b7c490b0f5aa52996c11e92a20"}, - {file = "pathspec-0.11.2.tar.gz", hash = "sha256:e0d8d0ac2f12da61956eb2306b69f9469b42f4deb0f3cb6ed47b9cce9996ced3"}, -] - [[package]] name = "pbr" version = "5.11.1" @@ -1849,6 +1784,32 @@ pygments = ">=2.13.0,<3.0.0" [package.extras] jupyter = ["ipywidgets (>=7.5.1,<9)"] +[[package]] +name = "ruff" +version = "0.1.11" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +files = [ + {file = "ruff-0.1.11-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:a7f772696b4cdc0a3b2e527fc3c7ccc41cdcb98f5c80fdd4f2b8c50eb1458196"}, + {file = "ruff-0.1.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:934832f6ed9b34a7d5feea58972635c2039c7a3b434fe5ba2ce015064cb6e955"}, + {file = "ruff-0.1.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea0d3e950e394c4b332bcdd112aa566010a9f9c95814844a7468325290aabfd9"}, + {file = "ruff-0.1.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9bd4025b9c5b429a48280785a2b71d479798a69f5c2919e7d274c5f4b32c3607"}, + {file = "ruff-0.1.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1ad00662305dcb1e987f5ec214d31f7d6a062cae3e74c1cbccef15afd96611d"}, + {file = "ruff-0.1.11-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:4b077ce83f47dd6bea1991af08b140e8b8339f0ba8cb9b7a484c30ebab18a23f"}, + {file = "ruff-0.1.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c4a88efecec23c37b11076fe676e15c6cdb1271a38f2b415e381e87fe4517f18"}, + {file = "ruff-0.1.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5b25093dad3b055667730a9b491129c42d45e11cdb7043b702e97125bcec48a1"}, + {file = "ruff-0.1.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:231d8fb11b2cc7c0366a326a66dafc6ad449d7fcdbc268497ee47e1334f66f77"}, + {file = "ruff-0.1.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:09c415716884950080921dd6237767e52e227e397e2008e2bed410117679975b"}, + {file = "ruff-0.1.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:0f58948c6d212a6b8d41cd59e349751018797ce1727f961c2fa755ad6208ba45"}, + {file = "ruff-0.1.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:190a566c8f766c37074d99640cd9ca3da11d8deae2deae7c9505e68a4a30f740"}, + {file = "ruff-0.1.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6464289bd67b2344d2a5d9158d5eb81025258f169e69a46b741b396ffb0cda95"}, + {file = "ruff-0.1.11-py3-none-win32.whl", hash = "sha256:9b8f397902f92bc2e70fb6bebfa2139008dc72ae5177e66c383fa5426cb0bf2c"}, + {file = "ruff-0.1.11-py3-none-win_amd64.whl", hash = "sha256:eb85ee287b11f901037a6683b2374bb0ec82928c5cbc984f575d0437979c521a"}, + {file = "ruff-0.1.11-py3-none-win_arm64.whl", hash = "sha256:97ce4d752f964ba559c7023a86e5f8e97f026d511e48013987623915431c7ea9"}, + {file = "ruff-0.1.11.tar.gz", hash = "sha256:f9d4d88cb6eeb4dfe20f9f0519bd2eaba8119bde87c3d5065c541dbae2b5a2cb"}, +] + [[package]] name = "setuptools" version = "68.2.2" @@ -2026,6 +1987,28 @@ files = [ {file = "types_pytz-2023.3.1.1-py3-none-any.whl", hash = "sha256:1999a123a3dc0e39a2ef6d19f3f8584211de9e6a77fe7a0259f04a524e90a5cf"}, ] +[[package]] +name = "types-pyyaml" +version = "6.0.12.12" +description = "Typing stubs for PyYAML" +optional = false +python-versions = "*" +files = [ + {file = "types-PyYAML-6.0.12.12.tar.gz", hash = "sha256:334373d392fde0fdf95af5c3f1661885fa10c52167b14593eb856289e1855062"}, + {file = "types_PyYAML-6.0.12.12-py3-none-any.whl", hash = "sha256:c05bc6c158facb0676674b7f11fe3960db4f389718e19e62bd2b84d6205cfd24"}, +] + +[[package]] +name = "types-ujson" +version = "5.9.0.0" +description = "Typing stubs for ujson" +optional = false +python-versions = ">=3.7" +files = [ + {file = "types-ujson-5.9.0.0.tar.gz", hash = "sha256:7e7042454dc7cd7f31b09c420d7caf36b93d30bdf4b8db93791bd0561713d017"}, + {file = "types_ujson-5.9.0.0-py3-none-any.whl", hash = "sha256:f274fa604ed6317effcd1c424ef4cf292c3b0689cb118fb3180689d40ed1f4ed"}, +] + [[package]] name = "typing-extensions" version = "4.7.1" @@ -2037,6 +2020,80 @@ files = [ {file = "typing_extensions-4.7.1.tar.gz", hash = "sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2"}, ] +[[package]] +name = "ujson" +version = "5.9.0" +description = "Ultra fast JSON encoder and decoder for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "ujson-5.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ab71bf27b002eaf7d047c54a68e60230fbd5cd9da60de7ca0aa87d0bccead8fa"}, + {file = "ujson-5.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7a365eac66f5aa7a7fdf57e5066ada6226700884fc7dce2ba5483538bc16c8c5"}, + {file = "ujson-5.9.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e015122b337858dba5a3dc3533af2a8fc0410ee9e2374092f6a5b88b182e9fcc"}, + {file = "ujson-5.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:779a2a88c53039bebfbccca934430dabb5c62cc179e09a9c27a322023f363e0d"}, + {file = "ujson-5.9.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:10ca3c41e80509fd9805f7c149068fa8dbee18872bbdc03d7cca928926a358d5"}, + {file = "ujson-5.9.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4a566e465cb2fcfdf040c2447b7dd9718799d0d90134b37a20dff1e27c0e9096"}, + {file = "ujson-5.9.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:f833c529e922577226a05bc25b6a8b3eb6c4fb155b72dd88d33de99d53113124"}, + {file = "ujson-5.9.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b68a0caab33f359b4cbbc10065c88e3758c9f73a11a65a91f024b2e7a1257106"}, + {file = "ujson-5.9.0-cp310-cp310-win32.whl", hash = "sha256:7cc7e605d2aa6ae6b7321c3ae250d2e050f06082e71ab1a4200b4ae64d25863c"}, + {file = "ujson-5.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:a6d3f10eb8ccba4316a6b5465b705ed70a06011c6f82418b59278fbc919bef6f"}, + {file = "ujson-5.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b23bbb46334ce51ddb5dded60c662fbf7bb74a37b8f87221c5b0fec1ec6454b"}, + {file = "ujson-5.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6974b3a7c17bbf829e6c3bfdc5823c67922e44ff169851a755eab79a3dd31ec0"}, + {file = "ujson-5.9.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5964ea916edfe24af1f4cc68488448fbb1ec27a3ddcddc2b236da575c12c8ae"}, + {file = "ujson-5.9.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8ba7cac47dd65ff88571eceeff48bf30ed5eb9c67b34b88cb22869b7aa19600d"}, + {file = "ujson-5.9.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6bbd91a151a8f3358c29355a491e915eb203f607267a25e6ab10531b3b157c5e"}, + {file = "ujson-5.9.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:829a69d451a49c0de14a9fecb2a2d544a9b2c884c2b542adb243b683a6f15908"}, + {file = "ujson-5.9.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:a807ae73c46ad5db161a7e883eec0fbe1bebc6a54890152ccc63072c4884823b"}, + {file = "ujson-5.9.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8fc2aa18b13d97b3c8ccecdf1a3c405f411a6e96adeee94233058c44ff92617d"}, + {file = "ujson-5.9.0-cp311-cp311-win32.whl", hash = "sha256:70e06849dfeb2548be48fdd3ceb53300640bc8100c379d6e19d78045e9c26120"}, + {file = "ujson-5.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:7309d063cd392811acc49b5016728a5e1b46ab9907d321ebbe1c2156bc3c0b99"}, + {file = "ujson-5.9.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:20509a8c9f775b3a511e308bbe0b72897ba6b800767a7c90c5cca59d20d7c42c"}, + {file = "ujson-5.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b28407cfe315bd1b34f1ebe65d3bd735d6b36d409b334100be8cdffae2177b2f"}, + {file = "ujson-5.9.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d302bd17989b6bd90d49bade66943c78f9e3670407dbc53ebcf61271cadc399"}, + {file = "ujson-5.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9f21315f51e0db8ee245e33a649dd2d9dce0594522de6f278d62f15f998e050e"}, + {file = "ujson-5.9.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5635b78b636a54a86fdbf6f027e461aa6c6b948363bdf8d4fbb56a42b7388320"}, + {file = "ujson-5.9.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:82b5a56609f1235d72835ee109163c7041b30920d70fe7dac9176c64df87c164"}, + {file = "ujson-5.9.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:5ca35f484622fd208f55041b042d9d94f3b2c9c5add4e9af5ee9946d2d30db01"}, + {file = "ujson-5.9.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:829b824953ebad76d46e4ae709e940bb229e8999e40881338b3cc94c771b876c"}, + {file = "ujson-5.9.0-cp312-cp312-win32.whl", hash = "sha256:25fa46e4ff0a2deecbcf7100af3a5d70090b461906f2299506485ff31d9ec437"}, + {file = "ujson-5.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:60718f1720a61560618eff3b56fd517d107518d3c0160ca7a5a66ac949c6cf1c"}, + {file = "ujson-5.9.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d581db9db9e41d8ea0b2705c90518ba623cbdc74f8d644d7eb0d107be0d85d9c"}, + {file = "ujson-5.9.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:ff741a5b4be2d08fceaab681c9d4bc89abf3c9db600ab435e20b9b6d4dfef12e"}, + {file = "ujson-5.9.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdcb02cabcb1e44381221840a7af04433c1dc3297af76fde924a50c3054c708c"}, + {file = "ujson-5.9.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e208d3bf02c6963e6ef7324dadf1d73239fb7008491fdf523208f60be6437402"}, + {file = "ujson-5.9.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f4b3917296630a075e04d3d07601ce2a176479c23af838b6cf90a2d6b39b0d95"}, + {file = "ujson-5.9.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:0c4d6adb2c7bb9eb7c71ad6f6f612e13b264942e841f8cc3314a21a289a76c4e"}, + {file = "ujson-5.9.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:0b159efece9ab5c01f70b9d10bbb77241ce111a45bc8d21a44c219a2aec8ddfd"}, + {file = "ujson-5.9.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:f0cb4a7814940ddd6619bdce6be637a4b37a8c4760de9373bac54bb7b229698b"}, + {file = "ujson-5.9.0-cp38-cp38-win32.whl", hash = "sha256:dc80f0f5abf33bd7099f7ac94ab1206730a3c0a2d17549911ed2cb6b7aa36d2d"}, + {file = "ujson-5.9.0-cp38-cp38-win_amd64.whl", hash = "sha256:506a45e5fcbb2d46f1a51fead991c39529fc3737c0f5d47c9b4a1d762578fc30"}, + {file = "ujson-5.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d0fd2eba664a22447102062814bd13e63c6130540222c0aa620701dd01f4be81"}, + {file = "ujson-5.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bdf7fc21a03bafe4ba208dafa84ae38e04e5d36c0e1c746726edf5392e9f9f36"}, + {file = "ujson-5.9.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2f909bc08ce01f122fd9c24bc6f9876aa087188dfaf3c4116fe6e4daf7e194f"}, + {file = "ujson-5.9.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd4ea86c2afd41429751d22a3ccd03311c067bd6aeee2d054f83f97e41e11d8f"}, + {file = "ujson-5.9.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:63fb2e6599d96fdffdb553af0ed3f76b85fda63281063f1cb5b1141a6fcd0617"}, + {file = "ujson-5.9.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:32bba5870c8fa2a97f4a68f6401038d3f1922e66c34280d710af00b14a3ca562"}, + {file = "ujson-5.9.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:37ef92e42535a81bf72179d0e252c9af42a4ed966dc6be6967ebfb929a87bc60"}, + {file = "ujson-5.9.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:f69f16b8f1c69da00e38dc5f2d08a86b0e781d0ad3e4cc6a13ea033a439c4844"}, + {file = "ujson-5.9.0-cp39-cp39-win32.whl", hash = "sha256:3382a3ce0ccc0558b1c1668950008cece9bf463ebb17463ebf6a8bfc060dae34"}, + {file = "ujson-5.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:6adef377ed583477cf005b58c3025051b5faa6b8cc25876e594afbb772578f21"}, + {file = "ujson-5.9.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ffdfebd819f492e48e4f31c97cb593b9c1a8251933d8f8972e81697f00326ff1"}, + {file = "ujson-5.9.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4eec2ddc046360d087cf35659c7ba0cbd101f32035e19047013162274e71fcf"}, + {file = "ujson-5.9.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2fbb90aa5c23cb3d4b803c12aa220d26778c31b6e4b7a13a1f49971f6c7d088e"}, + {file = "ujson-5.9.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ba0823cb70866f0d6a4ad48d998dd338dce7314598721bc1b7986d054d782dfd"}, + {file = "ujson-5.9.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:4e35d7885ed612feb6b3dd1b7de28e89baaba4011ecdf995e88be9ac614765e9"}, + {file = "ujson-5.9.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:b048aa93eace8571eedbd67b3766623e7f0acbf08ee291bef7d8106210432427"}, + {file = "ujson-5.9.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:323279e68c195110ef85cbe5edce885219e3d4a48705448720ad925d88c9f851"}, + {file = "ujson-5.9.0-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9ac92d86ff34296f881e12aa955f7014d276895e0e4e868ba7fddebbde38e378"}, + {file = "ujson-5.9.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:6eecbd09b316cea1fd929b1e25f70382917542ab11b692cb46ec9b0a26c7427f"}, + {file = "ujson-5.9.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:473fb8dff1d58f49912323d7cb0859df5585cfc932e4b9c053bf8cf7f2d7c5c4"}, + {file = "ujson-5.9.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f91719c6abafe429c1a144cfe27883eace9fb1c09a9c5ef1bcb3ae80a3076a4e"}, + {file = "ujson-5.9.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b1c0991c4fe256f5fdb19758f7eac7f47caac29a6c57d0de16a19048eb86bad"}, + {file = "ujson-5.9.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a8ea0f55a1396708e564595aaa6696c0d8af532340f477162ff6927ecc46e21"}, + {file = "ujson-5.9.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:07e0cfdde5fd91f54cd2d7ffb3482c8ff1bf558abf32a8b953a5d169575ae1cd"}, + {file = "ujson-5.9.0.tar.gz", hash = "sha256:89cc92e73d5501b8a7f48575eeb14ad27156ad092c2e9fc7e3cf949f07e75532"}, +] + [[package]] name = "uvloop" version = "0.19.0" @@ -2191,4 +2248,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "4ed699093b3c5b50b7cacf5273f4f7cde9c4efb677d5470a47e2ccfc19b72b3f" +content-hash = "9f8daf9792cda9be7e4181c4adf9c4801c0f9dc61a79e9e7621b0c21b37204a2" diff --git a/pyproject.toml b/pyproject.toml index 37907be..65cd90e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,11 +24,11 @@ aiomisc = {extras = ["uvloop"], version = "^17.3.23"} aiomisc-dependency = "^0.1.20" aiomisc-pytest = "^1.1.1" configargparse = "^1.7" +ujson = "^5.9.0" [tool.poetry.group.dev.dependencies] mypy = "^1.5.1" pre-commit = "^3.4.0" -black = "^23.9.1" flake8 = "^6.1.0" bandit = "^1.7.5" types-pytz = "^2023.3.1.1" @@ -37,6 +37,9 @@ pytest = "^7.4.2" pytest-asyncio = "^0.21.1" pytest-cov = "^4.1.0" pytest-subtests = "^0.11.0" +types-pyyaml = "^6.0.12.12" +types-ujson = "^5.9.0.0" +ruff = "^0.1.11" [tool.poetry.scripts] bot = "inclusive_dance_bot.__main__:main" @@ -58,7 +61,7 @@ addopts = "-p no:cacheprovider" target-version = ["py311"] [tool.isort] -known_local_folder = ["inclusive_dance_bot", "tests"] +known_local_folder = ["idb", "tests"] py_version = "311" profile = "black" @@ -92,4 +95,44 @@ ignore_missing_imports = true [[tool.mypy.overrides]] module = "configargparse.*" -ignore_missing_imports = true \ No newline at end of file +ignore_missing_imports = true + +[tool.ruff] +exclude = [ + ".git", + ".mypy_cache", + ".ruff_cache", + ".venv", +] + +line-length = 88 +indent-width = 4 + +target-version = "py311" + +[tool.ruff.lint] +select = [ + "BLE", + "C90", + "E", + "F", + "G", + "I", + "ICN", + "ISC", + "PLE", + "Q", + "RUF006", + "RUF100", + "T10", + "T20", + "TID", + "UP", + "W", +] +ignore = ["ISC001"] +fixable = ["ALL"] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index 0a9407d..bd1a258 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,110 +1,4 @@ -import asyncio -import os -from pathlib import Path -from types import SimpleNamespace - -import pytest -from alembic.config import Config as AlembicConfig -from sqlalchemy import text -from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker - -from inclusive_dance_bot.db.base import Base -from inclusive_dance_bot.db.utils import ( - create_engine, - create_session_factory, - make_alembic_config, +pytest_plugins = ( + "tests.plugins.cache", + "tests.plugins.database", ) -from tests.factories import FACTORIES -from tests.utils import prepare_new_database, run_async_migrations - -TABLES_FOR_TRUNCATE = ( - "submenu", - "url", - "users", - "user_type", - "user_type_user", - "feedback", -) - -PROJECT_PATH = Path(__file__).parent.parent.resolve() - - -@pytest.fixture(scope="session") -def event_loop(): - policy = asyncio.get_event_loop_policy() - loop = policy.new_event_loop() - yield loop - loop.close() - - -@pytest.fixture(scope="session") -def db_name(): - default = "test_pgdb" - return os.getenv("APP_PG_DB_NAME", default) - - -@pytest.fixture(scope="session") -def pg_dsn(localhost, db_name: str) -> str: - default = f"postgresql+asyncpg://pguser:pguser@{localhost}:5432/{db_name}" - return os.getenv("APP_PG_DSN", default) - - -@pytest.fixture(scope="session") -def base_pg_dsn(localhost) -> str: - default = f"postgresql+asyncpg://pguser:pguser@{localhost}:5432/postgres" - return os.getenv("APP_BASE_PG_DSN", default) - - -@pytest.fixture(scope="session") -def alembic_config(pg_dsn: str) -> AlembicConfig: - cmd_options = SimpleNamespace( - config="alembic.ini", - name="alembic", - pg_url=pg_dsn, - raiseerr=False, - x=None, - ) - return make_alembic_config(cmd_options) - - -@pytest.fixture(scope="session") -async def async_engine( - alembic_config: AlembicConfig, - base_pg_dsn: str, - pg_dsn: str, - db_name: str, -) -> AsyncEngine: - await prepare_new_database(base_pg_dsn=base_pg_dsn, db_name=db_name) - await run_async_migrations(alembic_config, Base.metadata, "head") - engine = create_engine(pg_dsn) - yield engine - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.drop_all) - await engine.dispose() - - -@pytest.fixture(scope="session") -def sessionmaker(async_engine: AsyncEngine) -> async_sessionmaker[AsyncSession]: - yield create_session_factory(engine=async_engine) - - -@pytest.fixture(autouse=True) -async def session( - sessionmaker: async_sessionmaker[AsyncSession], async_engine: AsyncEngine -) -> AsyncSession: - try: - session: AsyncSession = sessionmaker() - for factory in FACTORIES: - factory.__async_session__ = session - yield session - finally: - await session.close() - await _clear_db(async_engine) - - -async def _clear_db(engine: AsyncEngine) -> None: - tables = ", ".join(TABLES_FOR_TRUNCATE) - sql = f"TRUNCATE TABLE {tables} CASCADE" - async with engine.connect() as conn: - await conn.execute(text(sql)) - await conn.commit() diff --git a/tests/factories.py b/tests/factories.py index 5f39ab7..ab5d493 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -1,11 +1,35 @@ +from typing import TypedDict + from polyfactory import Use +from polyfactory.factories import TypedDictFactory from polyfactory.factories.sqlalchemy_factory import SQLAlchemyFactory from polyfactory.value_generators.constrained_strings import ( handle_constrained_string_or_bytes, ) -from inclusive_dance_bot.db.models import Feedback, Submenu, Url, User, UserType -from inclusive_dance_bot.enums import SubmenuType +from idb.db.models import Feedback, Submenu, Url, User, UserType +from idb.generals.enums import SubmenuType + + +def _phone_number(): + return handle_constrained_string_or_bytes( + random=SQLAlchemyFactory.__random__, + t_type=str, + min_length=8, + max_length=16, + ) + + +class Profile(TypedDict): + name: str + region: str + phone_number: str + + +class ProfileFactory(TypedDictFactory[Profile]): + __model__ = Profile + + phone_number = _phone_number class UserFactory(SQLAlchemyFactory[User]): @@ -13,9 +37,7 @@ class UserFactory(SQLAlchemyFactory[User]): __set_foreign_keys__ = False __set_relationships__ = True - phone_number = lambda: handle_constrained_string_or_bytes( - random=SQLAlchemyFactory.__random__, t_type=str, min_length=8, max_length=16 - ) + profile = ProfileFactory class UserTypeFactory(SQLAlchemyFactory[UserType]): diff --git a/tests/plugins/__init__.py b/tests/plugins/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/plugins/cache.py b/tests/plugins/cache.py new file mode 100644 index 0000000..82e99c7 --- /dev/null +++ b/tests/plugins/cache.py @@ -0,0 +1,27 @@ +import os + +import pytest + +from idb.utils.cache import KeyBuilder, MemoryCache, RedisCache + + +@pytest.fixture +async def memory_cache() -> MemoryCache: + return MemoryCache() + + +@pytest.fixture(scope="session") +def redis_dsn(localhost) -> str: + default = f"redis://{localhost}:6379/0" + return os.getenv("APP_REDIS_DSN", default) + + +@pytest.fixture +async def redis_cache(redis_dsn: str) -> RedisCache: + cache = RedisCache.from_url(url=redis_dsn, key_builder=KeyBuilder()) + try: + await cache.flushall() + yield cache + finally: + await cache.flushall() + await cache.close() diff --git a/tests/plugins/database.py b/tests/plugins/database.py new file mode 100644 index 0000000..a34f78f --- /dev/null +++ b/tests/plugins/database.py @@ -0,0 +1,117 @@ +import asyncio +import os +from pathlib import Path +from types import SimpleNamespace + +import pytest +from alembic.config import Config as AlembicConfig +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker + +from idb.db.base import Base +from idb.db.uow import UnitOfWork, uow_context +from idb.db.utils import ( + create_engine, + create_session_factory, + make_alembic_config, +) +from tests.factories import FACTORIES +from tests.utils import prepare_new_database, run_async_migrations + +TABLES_FOR_TRUNCATE = ( + "submenu", + "url", + "users", + "user_type", + "user_type_user", + "feedback", +) + +PROJECT_PATH = Path(__file__).parent.parent.resolve() + + +@pytest.fixture(scope="session") +def event_loop(): + policy = asyncio.get_event_loop_policy() + loop = policy.new_event_loop() + yield loop + loop.close() + + +@pytest.fixture(scope="session") +def db_name(): + default = "test_pgdb" + return os.getenv("APP_PG_DB_NAME", default) + + +@pytest.fixture(scope="session") +def pg_dsn(localhost, db_name: str) -> str: + default = f"postgresql+asyncpg://pguser:pguser@{localhost}:5432/{db_name}" + return os.getenv("APP_PG_DSN", default) + + +@pytest.fixture(scope="session") +def base_pg_dsn(localhost) -> str: + default = f"postgresql+asyncpg://pguser:pguser@{localhost}:5432/postgres" + return os.getenv("APP_BASE_PG_DSN", default) + + +@pytest.fixture(scope="session") +def alembic_config(pg_dsn: str) -> AlembicConfig: + cmd_options = SimpleNamespace( + config="alembic.ini", + name="alembic", + pg_url=pg_dsn, + raiseerr=False, + x=None, + ) + return make_alembic_config(cmd_options) + + +@pytest.fixture(scope="session") +async def async_engine( + alembic_config: AlembicConfig, + base_pg_dsn: str, + pg_dsn: str, + db_name: str, +) -> AsyncEngine: + await prepare_new_database(base_pg_dsn=base_pg_dsn, db_name=db_name) + await run_async_migrations(alembic_config, Base.metadata, "head") + engine = create_engine(pg_dsn) + yield engine + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + await engine.dispose() + + +@pytest.fixture(scope="session") +def sessionmaker(async_engine: AsyncEngine) -> async_sessionmaker[AsyncSession]: + yield create_session_factory(engine=async_engine) + + +@pytest.fixture(autouse=True) +async def session( + sessionmaker: async_sessionmaker[AsyncSession], async_engine: AsyncEngine +) -> AsyncSession: + try: + session: AsyncSession = sessionmaker() + for factory in FACTORIES: + factory.__async_session__ = session + yield session + finally: + await session.close() + await _clear_db(async_engine) + + +async def _clear_db(engine: AsyncEngine) -> None: + tables = ", ".join(TABLES_FOR_TRUNCATE) + sql = f"TRUNCATE TABLE {tables} CASCADE" + async with engine.connect() as conn: + await conn.execute(text(sql)) + await conn.commit() + + +@pytest.fixture +async def uow(sessionmaker: async_sessionmaker[AsyncSession]) -> UnitOfWork: + async with uow_context(sessionmaker=sessionmaker) as uow: + yield uow diff --git a/tests/test_database/test_migrations_up_to_date.py b/tests/test_database/test_migrations_up_to_date.py index 1e20091..b35270a 100644 --- a/tests/test_database/test_migrations_up_to_date.py +++ b/tests/test_database/test_migrations_up_to_date.py @@ -1,6 +1,6 @@ from sqlalchemy.ext.asyncio import AsyncEngine -from inclusive_dance_bot.db.models import Base +from idb.db.models import Base from tests.utils import get_diff_db_metadata diff --git a/tests/test_logic/__init__.py b/tests/test_logic/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_logic/test_feedback/__init__.py b/tests/test_logic/test_feedback/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_unit/test_services/test_feedback/test_create_feedback.py b/tests/test_logic/test_feedback/test_create_feedback.py similarity index 78% rename from tests/test_unit/test_services/test_feedback/test_create_feedback.py rename to tests/test_logic/test_feedback/test_create_feedback.py index 5b59ef1..2574b36 100644 --- a/tests/test_unit/test_services/test_feedback/test_create_feedback.py +++ b/tests/test_logic/test_feedback/test_create_feedback.py @@ -2,11 +2,11 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from inclusive_dance_bot.db.models import Feedback -from inclusive_dance_bot.db.uow.main import UnitOfWork -from inclusive_dance_bot.enums import FeedbackType -from inclusive_dance_bot.exceptions import InvalidUserIDError -from inclusive_dance_bot.logic.feedback import create_feedback +from idb.db.models import Feedback +from idb.db.uow import UnitOfWork +from idb.exceptions import InvalidUserIDError +from idb.generals.enums import FeedbackType +from idb.logic.feedback import create_feedback from tests.factories import UserFactory diff --git a/tests/test_logic/test_mailing/__init__.py b/tests/test_logic/test_mailing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_logic/test_mailing/test_process_new_mailing.py b/tests/test_logic/test_mailing/test_process_new_mailing.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_logic/test_mailing/test_save_mailing.py b/tests/test_logic/test_mailing/test_save_mailing.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_logic/test_mailing/test_send_mailngs.py b/tests/test_logic/test_mailing/test_send_mailngs.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_logic/test_url/__init__.py b/tests/test_logic/test_url/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_logic/test_url/test_create_url.py b/tests/test_logic/test_url/test_create_url.py new file mode 100644 index 0000000..6fa7b9d --- /dev/null +++ b/tests/test_logic/test_url/test_create_url.py @@ -0,0 +1,33 @@ +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from idb.db.models import Url as UrlDb +from idb.db.uow import UnitOfWork +from idb.exceptions.url import UrlSlugAlreadyExistsError +from idb.generals.models.url import Url +from idb.logic.url import create_url +from idb.utils.cache import MemoryCache +from tests.factories import UrlFactory + + +async def test_create_successful( + uow: UnitOfWork, + memory_cache: MemoryCache, + session: AsyncSession, +) -> None: + slug = "new_url_slug" + value = "https://example.com" + + url = await create_url(uow=uow, cache=memory_cache, slug=slug, value=value) + + loaded_url = await session.get(UrlDb, url.id) + assert Url.model_validate(loaded_url) == url + + +async def test_error_url_slug_already_exists( + uow: UnitOfWork, + memory_cache: MemoryCache, +) -> None: + url = await UrlFactory.create_async() + with pytest.raises(UrlSlugAlreadyExistsError): + await create_url(uow=uow, cache=memory_cache, slug=url.slug, value="somevalue") diff --git a/tests/test_logic/test_url/test_delete_url_by_slug.py b/tests/test_logic/test_url/test_delete_url_by_slug.py new file mode 100644 index 0000000..0a7f7f5 --- /dev/null +++ b/tests/test_logic/test_url/test_delete_url_by_slug.py @@ -0,0 +1,20 @@ +from sqlalchemy.ext.asyncio import AsyncSession + +from idb.db.models import Url +from idb.db.uow import UnitOfWork +from idb.logic.url import delete_url_by_slug +from idb.utils.cache import MemoryCache +from tests.factories import UrlFactory + + +async def test_delete_successful( + uow: UnitOfWork, memory_cache: MemoryCache, session: AsyncSession +) -> None: + await UrlFactory.create_async(id=1, slug="new_url") + + await delete_url_by_slug(uow=uow, cache=memory_cache, url_slug="new_url") + assert await session.get(Url, 1) is None + + +async def test_delete_unknown_slug(uow: UnitOfWork, memory_cache: MemoryCache) -> None: + await delete_url_by_slug(uow=uow, cache=memory_cache, url_slug="unknown") diff --git a/tests/test_logic/test_url/test_update_url_by_slug.py b/tests/test_logic/test_url/test_update_url_by_slug.py new file mode 100644 index 0000000..a2d49e1 --- /dev/null +++ b/tests/test_logic/test_url/test_update_url_by_slug.py @@ -0,0 +1,47 @@ +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from idb.db.uow import UnitOfWork +from idb.exceptions.url import ( + UrlNotFoundError, + UrlSlugAlreadyExistsError, +) +from idb.generals.models.url import Url +from idb.logic.url import update_url_by_slug +from idb.utils.cache import MemoryCache +from tests.factories import UrlFactory + + +async def test_update_successful( + uow: UnitOfWork, memory_cache: MemoryCache, session: AsyncSession +) -> None: + url = await UrlFactory.create_async(slug="slug") + updated_url = await update_url_by_slug( + uow=uow, cache=memory_cache, url_slug="slug", value="https://vk.com" + ) + + await session.refresh(url) + assert url.value == "https://vk.com" + + assert updated_url == Url.model_validate(url) + + +async def test_error_url_not_found(uow: UnitOfWork, memory_cache: MemoryCache) -> None: + with pytest.raises(UrlNotFoundError): + await update_url_by_slug( + uow=uow, cache=memory_cache, url_slug="unknown", value="" + ) + + +async def test_error_url_slug_already_exists( + uow: UnitOfWork, memory_cache: MemoryCache +) -> None: + await UrlFactory.create_async(slug="first_url") + await UrlFactory.create_async(slug="second_url") + with pytest.raises(UrlSlugAlreadyExistsError): + await update_url_by_slug( + uow=uow, + cache=memory_cache, + url_slug="second_url", + slug="first_url", + ) diff --git a/tests/test_logic/test_user/__init__.py b/tests/test_logic/test_user/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_logic/test_user/test_create_user.py b/tests/test_logic/test_user/test_create_user.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_unit/conftest.py b/tests/test_unit/conftest.py deleted file mode 100644 index ce0ae9a..0000000 --- a/tests/test_unit/conftest.py +++ /dev/null @@ -1,17 +0,0 @@ -import pytest -from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker - -from inclusive_dance_bot.db.uow.main import UnitOfWork -from inclusive_dance_bot.logic.storage import Storage - - -@pytest.fixture -async def uow(sessionmaker: async_sessionmaker[AsyncSession]) -> UnitOfWork: - uow = UnitOfWork(sessionmaker=sessionmaker) - async with uow: - yield uow - - -@pytest.fixture -async def storage(uow: UnitOfWork) -> Storage: - return Storage(uow=uow) diff --git a/tests/test_unit/test_cache/__init__.py b/tests/test_unit/test_cache/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_unit/test_cache/test_memory.py b/tests/test_unit/test_cache/test_memory.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_unit/test_cache/test_redis.py b/tests/test_unit/test_cache/test_redis.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_unit/test_init_data.py b/tests/test_unit/test_init_data.py index 1c7d1c7..c880f53 100644 --- a/tests/test_unit/test_init_data.py +++ b/tests/test_unit/test_init_data.py @@ -1,12 +1,48 @@ -from sqlalchemy.ext.asyncio import AsyncSession +from idb.db.uow import UnitOfWork +from idb.generals.enums import SubmenuType +from inutils.init_data import write_submenus, write_urls, write_user_types -from inclusive_dance_bot.db.uow.main import UnitOfWork -from inclusive_dance_bot.init_data import SUBMENUS, URLS, USER_TYPES, init_data +# async def test_init_data_from_scratch(uow: UnitOfWork) -> None: +# await write_data(uow=uow, filename=PROJECT_FOLDER / "inutils/init_data.yaml") +# assert len(await uow.urls.list()) > 0 +# assert len(await uow.submenus.list()) > 0 +# assert len(await uow.user_types.list()) > 0 -async def test_init_data_from_scratch(uow: UnitOfWork, session: AsyncSession) -> None: - await init_data(uow=uow) - assert len(await uow.urls.get_list()) == len(URLS) - assert len(await uow.submenus.get_list()) == len(SUBMENUS) - assert len(await uow.user_types.get_list()) == len(USER_TYPES) +async def test_write_urls_from_scratch(uow: UnitOfWork) -> None: + urls = [ + { + "id": 1, + "slug": "some_url", + "value": "https://example.com", + } + ] + + await write_urls(uow, urls) + assert len(await uow.urls.list()) == 1 + + +async def test_write_submenus_from_scratch(uow: UnitOfWork) -> None: + submenus = [ + { + "id": 1, + "type": SubmenuType.CHARITY, + "button_text": "something", + "message": "Hello", + "weight": 1.0, + } + ] + await write_submenus(uow, submenus) + assert len(await uow.submenus.list()) == 1 + + +async def test_write_user_types_from_scratch(uow: UnitOfWork) -> None: + user_types = [ + { + "id": 1, + "name": "Somebody", + } + ] + await write_user_types(uow, user_types) + assert len(await uow.user_types.list()) == 1 diff --git a/tests/test_unit/test_repositories/conftest.py b/tests/test_unit/test_repositories/conftest.py index 83794cc..852af51 100644 --- a/tests/test_unit/test_repositories/conftest.py +++ b/tests/test_unit/test_repositories/conftest.py @@ -1,12 +1,12 @@ import pytest from sqlalchemy.ext.asyncio import AsyncSession -from inclusive_dance_bot.db.repositories.feedback import FeedbackRepository -from inclusive_dance_bot.db.repositories.submenu import SubmenuRepository -from inclusive_dance_bot.db.repositories.url import UrlRepository -from inclusive_dance_bot.db.repositories.user import UserRepository -from inclusive_dance_bot.db.repositories.user_type import UserTypeRepository -from inclusive_dance_bot.db.repositories.user_type_user import UserTypeUserRepository +from idb.db.repositories.feedback import FeedbackRepository +from idb.db.repositories.submenu import SubmenuRepository +from idb.db.repositories.url import UrlRepository +from idb.db.repositories.user import UserRepository +from idb.db.repositories.user_type import UserTypeRepository +from idb.db.repositories.user_type_user import UserTypeUserRepository @pytest.fixture diff --git a/tests/test_unit/test_repositories/test_feedback.py b/tests/test_unit/test_repositories/test_feedback.py index bc1459c..2b0f1dc 100644 --- a/tests/test_unit/test_repositories/test_feedback.py +++ b/tests/test_unit/test_repositories/test_feedback.py @@ -1,11 +1,11 @@ import pytest from sqlalchemy.ext.asyncio import AsyncSession -from inclusive_dance_bot.db.models import Feedback -from inclusive_dance_bot.db.repositories.feedback import FeedbackRepository -from inclusive_dance_bot.dto import FeedbackDto -from inclusive_dance_bot.enums import FeedbackType -from inclusive_dance_bot.exceptions import InvalidUserIDError +from idb.db.models import Feedback as FeedbackDb +from idb.db.repositories.feedback import FeedbackRepository +from idb.exceptions import InvalidUserIDError +from idb.generals.enums import FeedbackType +from idb.generals.models.feedback import Feedback from tests.factories import UserFactory @@ -17,8 +17,8 @@ async def test_create(feedback_repo: FeedbackRepository, session: AsyncSession) title="Some question", text="Very important question", ) - loaded_feedback = await session.get(Feedback, feedback.id) - assert feedback == FeedbackDto.from_orm(loaded_feedback) + loaded_feedback = await session.get(FeedbackDb, feedback.id) + assert feedback == Feedback.model_validate(loaded_feedback) async def test_invalid_user_id(feedback_repo: FeedbackRepository) -> None: diff --git a/tests/test_unit/test_repositories/test_submenu.py b/tests/test_unit/test_repositories/test_submenu.py index bcbaf18..426a3fe 100644 --- a/tests/test_unit/test_repositories/test_submenu.py +++ b/tests/test_unit/test_repositories/test_submenu.py @@ -1,14 +1,14 @@ import pytest from sqlalchemy.ext.asyncio import AsyncSession -from inclusive_dance_bot.db.models import Submenu -from inclusive_dance_bot.db.repositories.submenu import SubmenuRepository -from inclusive_dance_bot.dto import SubmenuDto -from inclusive_dance_bot.enums import SubmenuType -from inclusive_dance_bot.exceptions import ( +from idb.db.models import Submenu as SubmenuDb +from idb.db.repositories.submenu import SubmenuRepository +from idb.exceptions import ( SubmenuAlreadyExistsError, SubmenuNotFoundError, ) +from idb.generals.enums import SubmenuType +from idb.generals.models.submenu import Submenu from tests.factories import SubmenuFactory @@ -19,8 +19,8 @@ async def test_create(submenu_repo: SubmenuRepository, session: AsyncSession) -> message="Very long message message", ) await session.commit() - saved_submenu = await session.get(Submenu, submenu.id) - assert submenu == SubmenuDto.from_orm(saved_submenu) + saved_submenu = await session.get(SubmenuDb, submenu.id) + assert submenu == Submenu.model_validate(saved_submenu) async def test_invalid_double_create( @@ -45,7 +45,7 @@ async def test_invalid_double_create( async def test_get_by_id(submenu_repo: SubmenuRepository) -> None: submenu = await SubmenuFactory.create_async() loaded_submenu = await submenu_repo.get_by_id(submenu.id) - assert SubmenuDto.from_orm(submenu) == loaded_submenu + assert Submenu.model_validate(submenu) == loaded_submenu async def test_not_found_by_id(submenu_repo: SubmenuRepository) -> None: @@ -54,14 +54,15 @@ async def test_not_found_by_id(submenu_repo: SubmenuRepository) -> None: async def test_get_list_emtpy(submenu_repo: SubmenuRepository) -> None: - empty = await submenu_repo.get_list() + empty = await submenu_repo.list() assert empty == tuple() async def test_get_list(submenu_repo: SubmenuRepository) -> None: submenus = await SubmenuFactory.create_batch_async(size=5) - loaded_submenus = await submenu_repo.get_list() - assert {SubmenuDto.from_orm(s) for s in submenus} == set(loaded_submenus) + submenus.sort(key=lambda x: (-x.weight, x.id)) + loaded_submenus = await submenu_repo.list() + assert loaded_submenus == tuple(Submenu.model_validate(s) for s in submenus) async def test_get_list_order_by_weight(submenu_repo: SubmenuRepository) -> None: @@ -69,22 +70,22 @@ async def test_get_list_order_by_weight(submenu_repo: SubmenuRepository) -> None first = await SubmenuFactory.create_async(weight=100) second = await SubmenuFactory.create_async(weight=30) - loaded_submenus = await submenu_repo.get_list() + loaded_submenus = await submenu_repo.list() assert loaded_submenus == tuple( - SubmenuDto.from_orm(e) for e in (first, second, third) + Submenu.model_validate(e) for e in (first, second, third) ) -async def test_get_list_by_type(submenu_repo: SubmenuRepository) -> None: +async def test_get_list_by_correct_type(submenu_repo: SubmenuRepository) -> None: target_type = await SubmenuFactory.create_async(type=SubmenuType.CHARITY) charities = await submenu_repo.get_list_by_type(SubmenuType.CHARITY) - assert set(charities) == {SubmenuDto.from_orm(target_type)} + assert charities == [Submenu.model_validate(target_type)] -async def test_get_list_by_type(submenu_repo: SubmenuRepository) -> None: +async def test_get_list_by_incorrect_type(submenu_repo: SubmenuRepository) -> None: await SubmenuFactory.create_async(type=SubmenuType.EDUCATION) charities = await submenu_repo.get_list_by_type(SubmenuType.CHARITY) - assert set(charities) == set() + assert charities == list() diff --git a/tests/test_unit/test_repositories/test_url.py b/tests/test_unit/test_repositories/test_url.py index ac4ef82..76f48ec 100644 --- a/tests/test_unit/test_repositories/test_url.py +++ b/tests/test_unit/test_repositories/test_url.py @@ -1,13 +1,13 @@ import pytest from sqlalchemy.ext.asyncio import AsyncSession -from inclusive_dance_bot.db.models import Url -from inclusive_dance_bot.db.repositories.url import UrlRepository -from inclusive_dance_bot.dto import UrlDto -from inclusive_dance_bot.exceptions import ( +from idb.db.models import Url as UrlDb +from idb.db.repositories.url import UrlRepository +from idb.exceptions import ( UrlAlreadyExistsError, UrlSlugAlreadyExistsError, ) +from idb.generals.models.url import Url from tests.factories import UrlFactory @@ -17,8 +17,8 @@ async def test_create_url(url_repo: UrlRepository, session: AsyncSession) -> Non value="https://yandex.ru", ) await session.commit() - saved_url = await session.get(Url, url.id) - assert url == UrlDto.from_orm(saved_url) + saved_url = await session.get(UrlDb, url.id) + assert url == Url.model_validate(saved_url) async def test_invalid_double_create_by_id( @@ -53,12 +53,12 @@ async def test_invalid_double_create_by_slug( async def test_get_list_empty(url_repo: UrlRepository) -> None: - loaded_urls = await url_repo.get_list() + loaded_urls = await url_repo.list() assert loaded_urls == tuple() async def test_get_list(url_repo: UrlRepository) -> None: urls = await UrlFactory.create_batch_async(size=5) - - loaded_urls = await url_repo.get_list() - assert set(loaded_urls) == {UrlDto.from_orm(u) for u in urls} + urls.sort(key=lambda x: x.id) + loaded_urls = await url_repo.list() + assert loaded_urls == tuple(Url.model_validate(u) for u in urls) diff --git a/tests/test_unit/test_repositories/test_user.py b/tests/test_unit/test_repositories/test_user.py index ba1701b..c53e761 100644 --- a/tests/test_unit/test_repositories/test_user.py +++ b/tests/test_unit/test_repositories/test_user.py @@ -1,50 +1,63 @@ import pytest from sqlalchemy.ext.asyncio import AsyncSession -from inclusive_dance_bot.db.models import User -from inclusive_dance_bot.db.repositories.user import UserRepository -from inclusive_dance_bot.dto import ANONYMOUS_USER, UserDto -from inclusive_dance_bot.exceptions import UserAlreadyExistsError +from idb.db.models import User as UserDb +from idb.db.repositories.user import UserRepository +from idb.exceptions import UserAlreadyExistsError +from idb.exceptions.user import UserNotFoundError +from idb.generals.models.user import User from tests.factories import UserFactory async def test_create_user(user_repo: UserRepository, session: AsyncSession) -> None: user = await user_repo.create( username="username", - user_id=1, - name="user name", - region="Tatuin", - phone_number="+77777777", + id=1, + profile={ + "name": "user name", + "region": "Tatuin", + "phone_number": "+77777777777", + }, + is_admin=False, + is_superuser=False, ) await session.commit() - loaded_user = await session.get(User, user.id) - assert user == UserDto.from_orm(loaded_user) + loaded_user = await session.get(UserDb, user.id) + assert user == User.model_validate(loaded_user) async def test_invalid_double_create(user_repo: UserRepository) -> None: await user_repo.create( - user_id=1, username="username", - name="user name", - region="Tatuin", - phone_number="+77777777", + id=1, + is_admin=False, + is_superuser=False, + profile={ + "name": "user name", + "region": "Tatuin", + "phone_number": "+77777777777", + }, ) with pytest.raises(UserAlreadyExistsError): await user_repo.create( - user_id=1, username="username", - name="user name", - region="Tatuin", - phone_number="+77777777", + id=1, + is_admin=False, + is_superuser=False, + profile={ + "name": "user name", + "region": "Tatuin", + "phone_number": "+77777777777", + }, ) async def test_get_by_id(user_repo: UserRepository) -> None: user = await UserFactory.create_async() loaded_user = await user_repo.get_by_id(user.id) - assert loaded_user == UserDto.from_orm(user) + assert loaded_user == User.model_validate(user) async def test_get_anonymous(user_repo: UserRepository) -> None: - anonymous = await user_repo.get_by_id(-1) - assert anonymous == ANONYMOUS_USER + with pytest.raises(UserNotFoundError): + await user_repo.get_by_id(-1) diff --git a/tests/test_unit/test_repositories/test_user_type.py b/tests/test_unit/test_repositories/test_user_type.py index a15dfa7..53bad1e 100644 --- a/tests/test_unit/test_repositories/test_user_type.py +++ b/tests/test_unit/test_repositories/test_user_type.py @@ -1,10 +1,10 @@ import pytest from sqlalchemy.ext.asyncio import AsyncSession -from inclusive_dance_bot.db.models import UserType -from inclusive_dance_bot.db.repositories.user_type import UserTypeRepository -from inclusive_dance_bot.dto import UserTypeDto -from inclusive_dance_bot.exceptions import UserTypeAlreadyExistsError +from idb.db.models import UserType as UserTypeDb +from idb.db.repositories.user_type import UserTypeRepository +from idb.exceptions import UserTypeAlreadyExistsError +from idb.generals.models.user_type import UserType from tests.factories import UserTypeFactory @@ -13,8 +13,8 @@ async def test_create_user_type( ) -> None: user_type = await user_type_repo.create(name="New user type") await session.commit() - saved_user_type = await session.get(UserType, user_type.id) - assert user_type == UserTypeDto.from_orm(saved_user_type) + saved_user_type = await session.get(UserTypeDb, user_type.id) + assert user_type == UserType.model_validate(saved_user_type) async def test_invalid_double_create(user_type_repo: UserTypeRepository) -> None: @@ -24,12 +24,12 @@ async def test_invalid_double_create(user_type_repo: UserTypeRepository) -> None async def test_get_list_empty(user_type_repo: UserTypeRepository) -> None: - user_types = await user_type_repo.get_list() + user_types = await user_type_repo.list() assert user_types == tuple() async def test_get_list(user_type_repo: UserTypeRepository) -> None: user_types = await UserTypeFactory.create_batch_async(size=5) - - loaded_user_types = await user_type_repo.get_list() - assert set(loaded_user_types) == {UserTypeDto.from_orm(ut) for ut in user_types} + user_types.sort(key=lambda x: x.id) + loaded_user_types = await user_type_repo.list() + assert loaded_user_types == tuple(UserType.model_validate(ut) for ut in user_types) diff --git a/tests/test_unit/test_repositories/test_user_type_user.py b/tests/test_unit/test_repositories/test_user_type_user.py index bf5b355..bfcd265 100644 --- a/tests/test_unit/test_repositories/test_user_type_user.py +++ b/tests/test_unit/test_repositories/test_user_type_user.py @@ -1,9 +1,9 @@ import pytest from sqlalchemy.ext.asyncio import AsyncSession -from inclusive_dance_bot.db.models import UserTypeUser -from inclusive_dance_bot.db.repositories.user_type_user import UserTypeUserRepository -from inclusive_dance_bot.exceptions import ( +from idb.db.models import UserTypeUser +from idb.db.repositories.user_type_user import UserTypeUserRepository +from idb.exceptions import ( InvalidUserIDError, InvalidUserTypeIDError, UserTypeUserAlreadyExistsError, diff --git a/tests/test_unit/test_services/test_storage.py b/tests/test_unit/test_services/test_storage.py deleted file mode 100644 index 70d8ac9..0000000 --- a/tests/test_unit/test_services/test_storage.py +++ /dev/null @@ -1,73 +0,0 @@ -from sqlalchemy.ext.asyncio import AsyncSession - -from inclusive_dance_bot.dto import UrlDto -from inclusive_dance_bot.logic.storage import Storage -from tests.factories import UrlFactory - - -def test_initial_storage_is_empty(storage: Storage) -> None: - assert len(storage._cache) == 0 - - -async def test_get_urls(storage: Storage) -> None: - first_url = await UrlFactory.create_async() - second_url = await UrlFactory.create_async() - urls = await storage.get_urls() - assert urls == { - first_url.slug: UrlDto.from_orm(first_url), - second_url.slug: UrlDto.from_orm(second_url), - } - - -async def test_cache_get_urls(storage: Storage, session: AsyncSession) -> None: - first_url = await UrlFactory.create_async() - second_url = await UrlFactory.create_async() - await storage.get_urls() - await session.delete(second_url) - await session.delete(first_url) - await session.commit() - urls = await storage.get_urls() - assert urls == { - first_url.slug: UrlDto.from_orm(first_url), - second_url.slug: UrlDto.from_orm(second_url), - } - - -async def test_get_url_by_slug(storage: Storage) -> None: - new_url = await UrlFactory.create_async(slug="my_url") - url = await storage.get_url_by_slug("my_url") - assert UrlDto.from_orm(new_url) == url - - -async def test_cache_get_url_by_slug(storage: Storage, session: AsyncSession) -> None: - url = await UrlFactory.create_async(slug="my_slug") - await storage.get_url_by_slug("my_slug") - - await session.delete(url) - - cached_url = await storage.get_url_by_slug("my_slug") - assert cached_url.slug == "my_slug" - - -async def test_get_user_types(storage: Storage) -> None: - pass - - -async def test_cache_get_user_types(storage: Storage, session: AsyncSession) -> None: - pass - - -async def test_get_submenus(storage: Storage) -> None: - pass - - -async def test_cache_get_submenus(storage: Storage, session: AsyncSession) -> None: - pass - - -async def test_refresh_all(storage: Storage, session: AsyncSession) -> None: - pass - - -async def test_refresh_urls(storage: Storage, session: AsyncSession) -> None: - pass diff --git a/tests/test_unit/test_services/test_url/test_create_url.py b/tests/test_unit/test_services/test_url/test_create_url.py deleted file mode 100644 index 7ad39fe..0000000 --- a/tests/test_unit/test_services/test_url/test_create_url.py +++ /dev/null @@ -1,30 +0,0 @@ -import pytest -from sqlalchemy.ext.asyncio import AsyncSession - -from inclusive_dance_bot.db.models import Url -from inclusive_dance_bot.db.uow.main import UnitOfWork -from inclusive_dance_bot.dto import UrlDto -from inclusive_dance_bot.exceptions.url import UrlSlugAlreadyExistsError -from inclusive_dance_bot.logic.storage import Storage -from inclusive_dance_bot.logic.url import create_url -from tests.factories import UrlFactory - - -async def test_create_successful( - uow: UnitOfWork, - storage: Storage, - session: AsyncSession, -) -> None: - slug = "new_url_slug" - value = "https://example.com" - - url = await create_url(uow=uow, storage=storage, slug=slug, value=value) - - loaded_url = await session.get(Url, url.id) - assert UrlDto.from_orm(loaded_url) == url - - -async def test_error_url_slug_already_exists(uow: UnitOfWork, storage: Storage) -> None: - url = await UrlFactory.create_async() - with pytest.raises(UrlSlugAlreadyExistsError): - await create_url(uow=uow, storage=storage, slug=url.slug, value="somevalue") diff --git a/tests/test_unit/test_services/test_url/test_delete_url_by_slug.py b/tests/test_unit/test_services/test_url/test_delete_url_by_slug.py deleted file mode 100644 index 500794c..0000000 --- a/tests/test_unit/test_services/test_url/test_delete_url_by_slug.py +++ /dev/null @@ -1,20 +0,0 @@ -from sqlalchemy.ext.asyncio import AsyncSession - -from inclusive_dance_bot.db.models import Url -from inclusive_dance_bot.db.uow.main import UnitOfWork -from inclusive_dance_bot.logic.storage import Storage -from inclusive_dance_bot.logic.url import delete_url_by_slug -from tests.factories import UrlFactory - - -async def test_delete_successful( - uow: UnitOfWork, storage: Storage, session: AsyncSession -) -> None: - await UrlFactory.create_async(id=1, slug="new_url") - - await delete_url_by_slug(uow=uow, storage=storage, url_slug="new_url") - assert await session.get(Url, 1) is None - - -async def test_delete_unknown_slug(uow: UnitOfWork, storage: Storage) -> None: - await delete_url_by_slug(uow=uow, storage=storage, url_slug="unknown") diff --git a/tests/test_unit/test_services/test_url/test_update_url_by_slug.py b/tests/test_unit/test_services/test_url/test_update_url_by_slug.py deleted file mode 100644 index b2140f2..0000000 --- a/tests/test_unit/test_services/test_url/test_update_url_by_slug.py +++ /dev/null @@ -1,43 +0,0 @@ -import pytest -from sqlalchemy.ext.asyncio import AsyncSession - -from inclusive_dance_bot.db.uow.main import UnitOfWork -from inclusive_dance_bot.dto import UrlDto -from inclusive_dance_bot.exceptions.url import ( - UrlNotFoundError, - UrlSlugAlreadyExistsError, -) -from inclusive_dance_bot.logic.storage import Storage -from inclusive_dance_bot.logic.url import update_url_by_slug -from tests.factories import UrlFactory - - -async def test_update_successful( - uow: UnitOfWork, storage: Storage, session: AsyncSession -) -> None: - url = await UrlFactory.create_async(slug="slug") - updated_url = await update_url_by_slug( - uow=uow, storage=storage, url_slug="slug", value="https://vk.com" - ) - - await session.refresh(url) - assert url.value == "https://vk.com" - - assert updated_url == UrlDto.from_orm(url) - - -async def test_error_url_not_found(uow: UnitOfWork, storage: Storage) -> None: - with pytest.raises(UrlNotFoundError): - await update_url_by_slug(uow=uow, storage=storage, url_slug="unknown", value="") - - -async def test_error_url_slug_already_exists(uow: UnitOfWork, storage: Storage) -> None: - await UrlFactory.create_async(slug="first_url") - await UrlFactory.create_async(slug="second_url") - with pytest.raises(UrlSlugAlreadyExistsError): - await update_url_by_slug( - uow=uow, - storage=storage, - url_slug="second_url", - slug="first_url", - ) diff --git a/tests/test_unit/test_services/test_user/test_create_user.py b/tests/test_unit/test_services/test_user/test_create_user.py deleted file mode 100644 index 98b9dc6..0000000 --- a/tests/test_unit/test_services/test_user/test_create_user.py +++ /dev/null @@ -1,68 +0,0 @@ -import pytest -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from inclusive_dance_bot.db.models import User, UserTypeUser -from inclusive_dance_bot.db.uow.main import UnitOfWork -from inclusive_dance_bot.exceptions import ( - InvalidUserTypeIDError, - UserAlreadyExistsError, -) -from inclusive_dance_bot.logic.user import create_user -from tests.factories import UserFactory, UserTypeFactory - - -async def test_create_successful(uow: UnitOfWork, session: AsyncSession) -> None: - user_types = await UserTypeFactory.create_batch_async(size=3) - user_id = 1 - await create_user( - uow=uow, - username="username", - user_id=user_id, - name="New user", - region="Some region", - phone_number="+79999999", - user_type_ids=tuple(ut.id for ut in user_types), - ) - - user = await session.get(User, user_id) - assert user is not None - - user_type_users = ( - await session.scalars( - select(UserTypeUser).where(UserTypeUser.user_id == user_id) - ) - ).all() - assert {(utu.user_id, utu.user_type_id) for utu in user_type_users} == { - (user_id, ut.id) for ut in user_types - } - - -async def test_error_user_type(uow: UnitOfWork, session: AsyncSession) -> None: - user_id = 1 - with pytest.raises(InvalidUserTypeIDError): - await create_user( - uow=uow, - user_id=user_id, - username="username", - name="New user", - region="Some region", - phone_number="+79999999", - user_type_ids=(-1,), - ) - user = await session.get(User, user_id) - assert user is None - - -async def test_error_user_id_already_exists(uow: UnitOfWork) -> None: - user = await UserFactory.create_async() - with pytest.raises(UserAlreadyExistsError): - await create_user( - uow=uow, - user_id=user.id, - username="username", - name="New user", - region="Some region", - phone_number="+79999999", - user_type_ids=[], - )