diff --git a/src/dutylog/application/__main__.py b/src/dutylog/application/__main__.py index 606b0dd..b05e501 100644 --- a/src/dutylog/application/__main__.py +++ b/src/dutylog/application/__main__.py @@ -11,6 +11,7 @@ from dishka.integrations.aiogram import setup_dishka from dutylog.application.bot.user_handlers import router as user_router from dutylog.application.bot.user_dialogs import main_menu_dialog from dutylog.application.bot.admin_dialogs import admin_menu_dialog +from dutylog.application.bot.creator_dialogs import creator_menu_dialog from dutylog.application.bot.user_dialogs.registration_dialog import registration_dialog from dutylog.infrastructure.ioc import ( ConfigProvider, @@ -46,6 +47,7 @@ async def main(): dp.include_router(user_router) dp.include_router(main_menu_dialog) dp.include_router(admin_menu_dialog) + dp.include_router(creator_menu_dialog) dp.include_router(registration_dialog) setup_dialogs(dp) diff --git a/src/dutylog/application/bot/admin_dialogs/main_menu.py b/src/dutylog/application/bot/admin_dialogs/main_menu.py index 4c75de1..79e9212 100644 --- a/src/dutylog/application/bot/admin_dialogs/main_menu.py +++ b/src/dutylog/application/bot/admin_dialogs/main_menu.py @@ -6,7 +6,7 @@ from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject from datetime import datetime, timedelta -from dutylog.application.bot.user_dialogs.states import AdminMenuSG +from dutylog.application.bot.user_dialogs.states import AdminMenuSG, CreatorMenuSG from dutylog.infrastructure.database.repositories.users_repository import ( UsersRepository, ) @@ -86,7 +86,7 @@ async def get_admin_menu_data( Выберите действие: """ - return {"content": content} + return {"content": content, "is_creator": is_creator} @inject @@ -137,6 +137,10 @@ async def on_reporting_period_click(callback, button, dialog_manager): await dialog_manager.switch_to(AdminMenuSG.reporting_period) +async def on_admins_click(callback, button, dialog_manager): + await dialog_manager.start(CreatorMenuSG.admins_list) + + main_menu_window = Window( Format("{content}"), SwitchTo( @@ -161,6 +165,12 @@ main_menu_window = Window( id="reporting_period_btn", on_click=on_reporting_period_click, ), + Button( + Const("👨‍💼 Админы"), + id="admins_btn", + on_click=on_admins_click, + when="is_creator", + ), SwitchTo( Const("📊 Статистика"), id="stats_btn", diff --git a/src/dutylog/application/bot/admin_dialogs/reporting_period_management.py b/src/dutylog/application/bot/admin_dialogs/reporting_period_management.py index e287736..f6f62fc 100644 --- a/src/dutylog/application/bot/admin_dialogs/reporting_period_management.py +++ b/src/dutylog/application/bot/admin_dialogs/reporting_period_management.py @@ -63,8 +63,6 @@ async def get_reporting_period_data( Дата начала: {start_date.strftime('%d.%m.%Y')} Прошло дней: {days_passed} -━━━━━━━━━━━━━━━━━━━━ - Используйте кнопки ниже для управления периодом. """ has_active = True @@ -74,8 +72,6 @@ async def get_reporting_period_data( Статус: ⚪️ Нет активного периода -━━━━━━━━━━━━━━━━━━━━ - Создайте новый отчётный период, чтобы начать учёт дежурств. """ has_active = False diff --git a/src/dutylog/application/bot/creator_dialogs/__init__.py b/src/dutylog/application/bot/creator_dialogs/__init__.py new file mode 100644 index 0000000..4fa302d --- /dev/null +++ b/src/dutylog/application/bot/creator_dialogs/__init__.py @@ -0,0 +1,5 @@ +from dutylog.application.bot.creator_dialogs.creator_menu_dialog import ( + creator_menu_dialog, +) + +__all__ = ["creator_menu_dialog"] diff --git a/src/dutylog/application/bot/creator_dialogs/admins_management.py b/src/dutylog/application/bot/creator_dialogs/admins_management.py new file mode 100644 index 0000000..893a050 --- /dev/null +++ b/src/dutylog/application/bot/creator_dialogs/admins_management.py @@ -0,0 +1,374 @@ +from aiogram.types import CallbackQuery +from aiogram_dialog import Window, DialogManager +from aiogram_dialog.widgets.text import Format, Const +from aiogram_dialog.widgets.kbd import SwitchTo, Button, Select, ScrollingGroup, Row +from dishka import FromDishka +from dishka.integrations.aiogram_dialog import inject + +from dutylog.application.bot.user_dialogs.states import CreatorMenuSG, AdminMenuSG +from dutylog.infrastructure.database.repositories.users_repository import UsersRepository + + +@inject +async def get_admins_list_data( + users_repository: FromDishka[UsersRepository], + **kwargs, +) -> dict[str, str | list[tuple[str, int]]]: + all_users = await users_repository.get_all_users() + admins = [u for u in all_users if u.is_admin] + + if not admins: + content = """ +
👨‍💼 Администраторы
+ +⚠️ Нет администраторов в системе. +""" + admins_list = [] + else: + content = f""" +
👨‍💼 Администраторы
+ +Всего администраторов: {len(admins)} + +Выберите администратора для просмотра информации: +""" + admins_list = [] + for admin in admins: + display_name = f"@{admin.username}" if admin.username else f"ID: {admin.id}" + if admin.first_name: + display_name = f"{admin.first_name} ({display_name})" + admins_list.append((display_name, admin.id)) + + return { + "content": content, + "admins": admins_list, + } + + +async def on_admin_selected( + callback: CallbackQuery, + widget, + dialog_manager: DialogManager, + item_id: str, +) -> None: + dialog_manager.dialog_data["selected_admin_id"] = int(item_id) + await dialog_manager.switch_to(CreatorMenuSG.admin_info) + + +async def on_add_admin_click( + callback: CallbackQuery, + button: Button, + dialog_manager: DialogManager, +) -> None: + await dialog_manager.switch_to(CreatorMenuSG.add_admin_select_user) + + +@inject +async def get_admin_info_data( + users_repository: FromDishka[UsersRepository], + dialog_manager: DialogManager, + **kwargs, +) -> dict[str, str]: + admin_id = dialog_manager.dialog_data.get("selected_admin_id") + if not admin_id: + return {"content": "⚠️ Администратор не выбран"} + + admin = await users_repository.get_user_by_id(int(admin_id)) + + if not admin: + return {"content": "⚠️ Администратор не найден"} + + username = f"@{admin.username}" if admin.username else "—" + first_name = admin.first_name or "—" + last_name = admin.last_name or "—" + + content = f""" +
👨‍💼 Информация об администраторе
+ +ID: {admin.id} +Username: {username} +Имя: {first_name} +Фамилия: {last_name} + +Дата добавления: {admin.created_at.strftime('%d.%m.%Y %H:%M')} +""" + + return {"content": content} + + +async def on_remove_admin_click( + callback: CallbackQuery, + button: Button, + dialog_manager: DialogManager, +) -> None: + await dialog_manager.switch_to(CreatorMenuSG.remove_admin_confirm) + + +@inject +async def get_remove_admin_confirm_data( + users_repository: FromDishka[UsersRepository], + dialog_manager: DialogManager, + **kwargs, +) -> dict[str, str]: + admin_id = dialog_manager.dialog_data.get("selected_admin_id") + if not admin_id: + return {"content": "⚠️ Администратор не выбран"} + + admin = await users_repository.get_user_by_id(int(admin_id)) + + if not admin: + return {"content": "⚠️ Администратор не найден"} + + username = f"@{admin.username}" if admin.username else f"ID: {admin.id}" + display_name = admin.first_name or username + + content = f""" +
⚠️ Подтверждение удаления
+ +Вы уверены, что хотите удалить администратора {display_name}? + +Пользователь потеряет права администратора. +""" + + return {"content": content} + + +@inject +async def on_remove_admin_confirm( + callback: CallbackQuery, + button: Button, + dialog_manager: DialogManager, + users_repository: FromDishka[UsersRepository], +) -> None: + admin_id = dialog_manager.dialog_data.get("selected_admin_id") + if not admin_id: + await callback.answer("⚠️ Администратор не выбран", show_alert=True) + return + + await users_repository.update_user(int(admin_id), is_admin=False) + + await callback.answer("✅ Администратор удалён!") + await dialog_manager.switch_to(CreatorMenuSG.admins_list) + + +async def on_remove_admin_cancel( + callback: CallbackQuery, + button: Button, + dialog_manager: DialogManager, +) -> None: + await dialog_manager.switch_to(CreatorMenuSG.admin_info) + + +@inject +async def get_add_admin_select_user_data( + users_repository: FromDishka[UsersRepository], + **kwargs, +) -> dict[str, str | list[tuple[str, int]]]: + all_users = await users_repository.get_all_users() + non_admin_users = [u for u in all_users if not u.is_admin] + + if not non_admin_users: + content = """ +
Добавить администратора
+ +⚠️ Нет пользователей, которых можно сделать администраторами. + +Все пользователи уже являются администраторами. +""" + users_list = [] + else: + content = f""" +
Добавить администратора
+ +Всего пользователей: {len(non_admin_users)} + +Выберите пользователя для назначения администратором: +""" + users_list = [] + for user in non_admin_users: + display_name = f"@{user.username}" if user.username else f"ID: {user.id}" + if user.first_name: + display_name = f"{user.first_name} ({display_name})" + users_list.append((display_name, user.id)) + + return { + "content": content, + "users": users_list, + } + + +async def on_user_selected( + callback: CallbackQuery, + widget, + dialog_manager: DialogManager, + item_id: str, +) -> None: + dialog_manager.dialog_data["selected_user_id"] = int(item_id) + await dialog_manager.switch_to(CreatorMenuSG.add_admin_confirm) + + +@inject +async def get_add_admin_confirm_data( + users_repository: FromDishka[UsersRepository], + dialog_manager: DialogManager, + **kwargs, +) -> dict[str, str]: + user_id = dialog_manager.dialog_data.get("selected_user_id") + if not user_id: + return {"content": "⚠️ Пользователь не выбран"} + + user = await users_repository.get_user_by_id(int(user_id)) + + if not user: + return {"content": "⚠️ Пользователь не найден"} + + username = f"@{user.username}" if user.username else f"ID: {user.id}" + display_name = user.first_name or username + + content = f""" +
⚠️ Подтверждение
+ +Вы уверены, что хотите назначить {display_name} администратором? + +Пользователь получит доступ к панели управления. +""" + + return {"content": content} + + +@inject +async def on_add_admin_confirm( + callback: CallbackQuery, + button: Button, + dialog_manager: DialogManager, + users_repository: FromDishka[UsersRepository], +) -> None: + user_id = dialog_manager.dialog_data.get("selected_user_id") + if not user_id: + await callback.answer("⚠️ Пользователь не выбран", show_alert=True) + return + + await users_repository.update_user(int(user_id), is_admin=True) + + await callback.answer("✅ Администратор добавлен!") + await dialog_manager.switch_to(CreatorMenuSG.admins_list) + + +async def on_add_admin_cancel( + callback: CallbackQuery, + button: Button, + dialog_manager: DialogManager, +) -> None: + await dialog_manager.switch_to(CreatorMenuSG.add_admin_select_user) + + +async def on_back_to_main( + callback: CallbackQuery, + button: Button, + dialog_manager: DialogManager, +) -> None: + await dialog_manager.done() + + +admins_list_window = Window( + Format("{content}"), + ScrollingGroup( + Select( + Format("{item[0]}"), + id="admin_select", + item_id_getter=lambda x: x[1], + items="admins", + on_click=on_admin_selected, + ), + id="admins_scroll", + width=1, + height=8, + ), + Button( + Const("➕ Добавить администратора"), + id="add_admin_btn", + on_click=on_add_admin_click, + ), + Button( + Const("◀️ Назад"), + id="back_to_main_from_admins", + on_click=on_back_to_main, + ), + state=CreatorMenuSG.admins_list, + getter=get_admins_list_data, +) + +admin_info_window = Window( + Format("{content}"), + Button( + Const("🗑 Удалить администратора"), + id="remove_admin_btn", + on_click=on_remove_admin_click, + ), + SwitchTo( + Const("◀️ Назад"), + id="back_to_admins_list", + state=CreatorMenuSG.admins_list, + ), + state=CreatorMenuSG.admin_info, + getter=get_admin_info_data, +) + +remove_admin_confirm_window = Window( + Format("{content}"), + Row( + Button( + Const("✅ Да"), + id="confirm_remove_admin", + on_click=on_remove_admin_confirm, + ), + Button( + Const("❌ Нет"), + id="cancel_remove_admin", + on_click=on_remove_admin_cancel, + ), + ), + state=CreatorMenuSG.remove_admin_confirm, + getter=get_remove_admin_confirm_data, +) + +add_admin_select_user_window = Window( + Format("{content}"), + ScrollingGroup( + Select( + Format("{item[0]}"), + id="user_select", + item_id_getter=lambda x: x[1], + items="users", + on_click=on_user_selected, + ), + id="users_scroll", + width=1, + height=8, + ), + SwitchTo( + Const("◀️ Назад"), + id="back_to_admins_from_add", + state=CreatorMenuSG.admins_list, + ), + state=CreatorMenuSG.add_admin_select_user, + getter=get_add_admin_select_user_data, +) + +add_admin_confirm_window = Window( + Format("{content}"), + Row( + Button( + Const("✅ Да"), + id="confirm_add_admin", + on_click=on_add_admin_confirm, + ), + Button( + Const("❌ Нет"), + id="cancel_add_admin", + on_click=on_add_admin_cancel, + ), + ), + state=CreatorMenuSG.add_admin_confirm, + getter=get_add_admin_confirm_data, +) diff --git a/src/dutylog/application/bot/creator_dialogs/creator_menu_dialog.py b/src/dutylog/application/bot/creator_dialogs/creator_menu_dialog.py new file mode 100644 index 0000000..cedc4a1 --- /dev/null +++ b/src/dutylog/application/bot/creator_dialogs/creator_menu_dialog.py @@ -0,0 +1,18 @@ +from aiogram_dialog import Dialog + +from dutylog.application.bot.creator_dialogs.admins_management import ( + admins_list_window, + admin_info_window, + remove_admin_confirm_window, + add_admin_select_user_window, + add_admin_confirm_window, +) + + +creator_menu_dialog = Dialog( + admins_list_window, + admin_info_window, + remove_admin_confirm_window, + add_admin_select_user_window, + add_admin_confirm_window, +) diff --git a/src/dutylog/application/bot/user_dialogs/registration_dialog.py b/src/dutylog/application/bot/user_dialogs/registration_dialog.py index f8056d5..edee3dd 100644 --- a/src/dutylog/application/bot/user_dialogs/registration_dialog.py +++ b/src/dutylog/application/bot/user_dialogs/registration_dialog.py @@ -116,19 +116,11 @@ async def on_resident_selected( widget: Select, dialog_manager: DialogManager, item_id: str, - residents_repository: FromDishka[ResidentsRepository], - users_repository: FromDishka[UsersRepository], + residents_repository: FromDishka[ResidentsRepository] ): user_id = callback.from_user.id resident_id = int(item_id) - await users_repository.get_or_create_user( - user_id=user_id, - username=callback.from_user.username, - first_name=callback.from_user.first_name, - last_name=callback.from_user.last_name, - ) - await residents_repository.bind_user_to_resident(resident_id, user_id) await callback.answer("✅ Регистрация успешна!") diff --git a/src/dutylog/application/bot/user_dialogs/states.py b/src/dutylog/application/bot/user_dialogs/states.py index a1563bc..bb84ef6 100644 --- a/src/dutylog/application/bot/user_dialogs/states.py +++ b/src/dutylog/application/bot/user_dialogs/states.py @@ -49,6 +49,14 @@ class AdminMenuSG(StatesGroup): broadcast_confirm = State() +class CreatorMenuSG(StatesGroup): + admins_list = State() + admin_info = State() + remove_admin_confirm = State() + add_admin_select_user = State() + add_admin_confirm = State() + + class RegistrationSG(StatesGroup): select_floor = State() select_room = State() diff --git a/src/dutylog/application/bot/user_handlers.py b/src/dutylog/application/bot/user_handlers.py index b54807e..a69d764 100644 --- a/src/dutylog/application/bot/user_handlers.py +++ b/src/dutylog/application/bot/user_handlers.py @@ -29,7 +29,13 @@ async def start_handler( config: FromDishka[Config], ): assert message.from_user is not None - user = await users_repository.get_user_by_id(message.from_user.id) + + user = await users_repository.get_or_create_user( + user_id=message.from_user.id, + username=message.from_user.username, + first_name=message.from_user.first_name, + last_name=message.from_user.last_name, + ) is_creator = message.from_user.id == config.bot.creator_id is_admin = user.is_admin if user else False diff --git a/src/dutylog/infrastructure/database/repositories/users_repository.py b/src/dutylog/infrastructure/database/repositories/users_repository.py index f79e154..fefa6de 100644 --- a/src/dutylog/infrastructure/database/repositories/users_repository.py +++ b/src/dutylog/infrastructure/database/repositories/users_repository.py @@ -50,3 +50,6 @@ class UsersRepository: async def get_all_users(self) -> list[User]: return await self.users_dao.get_all() + + async def update_user(self, user_id: int, **kwargs) -> User | None: + return await self.users_dao.update(user_id, **kwargs) diff --git a/test_report_2026-03-01_2026-03-01.xlsx b/test_report_2026-03-01_2026-03-01.xlsx deleted file mode 100644 index 47a9a97..0000000 Binary files a/test_report_2026-03-01_2026-03-01.xlsx and /dev/null differ diff --git a/test_report_generation.py b/test_report_generation.py deleted file mode 100644 index fbd57c8..0000000 --- a/test_report_generation.py +++ /dev/null @@ -1,79 +0,0 @@ -import asyncio -from datetime import date - -from dutylog.infrastructure.database.config import create_engine, create_session_maker -from dutylog.infrastructure.database.dao.hours_transactions_dao import HoursTransactionsDAO -from dutylog.infrastructure.database.dao.residents_dao import ResidentsDAO -from dutylog.infrastructure.database.dao.rooms_dao import RoomsDAO -from dutylog.infrastructure.database.dao.floors_dao import FloorsDAO -from dutylog.infrastructure.database.dao.reporting_periods_dao import ReportingPeriodsDAO -from dutylog.infrastructure.database.dao.users_dao import UsersDAO -from dutylog.infrastructure.database.repositories.hours_transactions_repository import ( - HoursTransactionsRepository, -) -from dutylog.infrastructure.database.repositories.residents_repository import ResidentsRepository -from dutylog.infrastructure.database.repositories.rooms_repository import RoomsRepository -from dutylog.infrastructure.database.repositories.floors_repository import FloorsRepository -from dutylog.infrastructure.database.repositories.reporting_periods_repository import ( - ReportingPeriodsRepository, -) -from dutylog.infrastructure.database.repositories.users_repository import UsersRepository -from dutylog.infrastructure.utils.config import load_config -from dutylog.services.report_service import ReportService - - -async def test_report_generation() -> None: - config = load_config() - engine = create_engine(config.database.url) - session_maker = create_session_maker(engine) - - async with session_maker() as session: - hours_transactions_dao = HoursTransactionsDAO(session) - residents_dao = ResidentsDAO(session) - rooms_dao = RoomsDAO(session) - floors_dao = FloorsDAO(session) - reporting_periods_dao = ReportingPeriodsDAO(session) - users_dao = UsersDAO(session) - - hours_transactions_repository = HoursTransactionsRepository( - hours_transactions_dao, residents_dao - ) - residents_repository = ResidentsRepository(residents_dao) - rooms_repository = RoomsRepository(rooms_dao) - floors_repository = FloorsRepository(floors_dao) - reporting_periods_repository = ReportingPeriodsRepository(reporting_periods_dao) - users_repository = UsersRepository(users_dao) - - report_service = ReportService( - hours_transactions_repository, - residents_repository, - rooms_repository, - floors_repository, - users_repository, - ) - - active_period = await reporting_periods_repository.get_active_period() - - if not active_period: - print("⚠️ Нет активного периода") - return - - print(f"📅 Активный период: с {active_period.start_date}") - - end_date = active_period.end_date if active_period.end_date else date.today() - - print(f"📊 Генерирую отчёт за период {active_period.start_date} - {end_date}") - - report_file = await report_service.generate_period_report( - active_period.start_date, end_date - ) - - filename = f"test_report_{active_period.start_date}_{end_date}.xlsx" - with open(filename, "wb") as f: - f.write(report_file.read()) - - print(f"✅ Отчёт сохранён в файл: {filename}") - - -if __name__ == "__main__": - asyncio.run(test_report_generation())