From 3c0d50a1aaa7227101f69fec770a2ddc44120bcc Mon Sep 17 00:00:00 2001 From: kolo Date: Sat, 28 Feb 2026 11:20:41 +0300 Subject: [PATCH] update --- src/dutylog/application/__main__.py | 27 +- .../user_dialogs/admin_dialogs/__init__.py | 4 +- .../admin_dialogs/admin_menu_dialog.py | 373 +++++++++++++++--- .../bot/user_dialogs/main_menu_dialog.py | 51 ++- .../bot/user_dialogs/registration_dialog.py | 64 ++- .../application/bot/user_dialogs/states.py | 7 +- src/dutylog/application/bot/user_handlers.py | 26 +- .../infrastructure/database/dao/floors_dao.py | 12 +- .../database/dao/residents_dao.py | 4 +- .../infrastructure/database/dao/rooms_dao.py | 12 +- .../infrastructure/database/dao/users_dao.py | 8 +- .../database/models/hours_transaction.py | 12 +- .../database/models/resident.py | 23 +- .../infrastructure/database/models/room.py | 4 +- .../infrastructure/database/models/user.py | 12 +- .../hours_transactions_repository.py | 29 +- .../repositories/residents_repository.py | 8 +- src/dutylog/infrastructure/ioc.py | 37 +- src/dutylog/infrastructure/utils/config.py | 16 +- 19 files changed, 544 insertions(+), 185 deletions(-) diff --git a/src/dutylog/application/__main__.py b/src/dutylog/application/__main__.py index 70c1709..05e6d23 100644 --- a/src/dutylog/application/__main__.py +++ b/src/dutylog/application/__main__.py @@ -12,42 +12,45 @@ 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.user_dialogs.admin_dialogs import admin_menu_dialog from dutylog.application.bot.user_dialogs.registration_dialog import registration_dialog -from dutylog.infrastructure.ioc import ConfigProvider, DatabaseProvider, DAOProvider, RepositoryProvider +from dutylog.infrastructure.ioc import ( + ConfigProvider, + DatabaseProvider, + DAOProvider, + RepositoryProvider, +) from dutylog.infrastructure.utils.config import load_config async def main(): logging.basicConfig(level=logging.INFO) - + config = load_config() - + bot = Bot( - token=config.bot.token, - default=DefaultBotProperties(parse_mode=ParseMode.HTML) + token=config.bot.token, default=DefaultBotProperties(parse_mode=ParseMode.HTML) ) - + await bot.delete_webhook(drop_pending_updates=True) - + dp = Dispatcher() - + container = make_async_container( ConfigProvider(), DatabaseProvider(), DAOProvider(), RepositoryProvider(), ) - + dp.include_router(user_router) dp.include_router(main_menu_dialog) dp.include_router(admin_menu_dialog) dp.include_router(registration_dialog) - + setup_dialogs(dp) setup_dishka(container, dp, auto_inject=True) - + await dp.start_polling(bot) if __name__ == "__main__": asyncio.run(main()) - diff --git a/src/dutylog/application/bot/user_dialogs/admin_dialogs/__init__.py b/src/dutylog/application/bot/user_dialogs/admin_dialogs/__init__.py index 448aa9b..73f79e8 100644 --- a/src/dutylog/application/bot/user_dialogs/admin_dialogs/__init__.py +++ b/src/dutylog/application/bot/user_dialogs/admin_dialogs/__init__.py @@ -1,3 +1,5 @@ -from dutylog.application.bot.user_dialogs.admin_dialogs.admin_menu_dialog import admin_menu_dialog +from dutylog.application.bot.user_dialogs.admin_dialogs.admin_menu_dialog import ( + admin_menu_dialog, +) __all__ = ["admin_menu_dialog"] diff --git a/src/dutylog/application/bot/user_dialogs/admin_dialogs/admin_menu_dialog.py b/src/dutylog/application/bot/user_dialogs/admin_dialogs/admin_menu_dialog.py index 6eedaa7..c85fd2a 100644 --- a/src/dutylog/application/bot/user_dialogs/admin_dialogs/admin_menu_dialog.py +++ b/src/dutylog/application/bot/user_dialogs/admin_dialogs/admin_menu_dialog.py @@ -3,15 +3,24 @@ from aiogram import Bot from aiogram.exceptions import TelegramForbiddenError, TelegramBadRequest from aiogram_dialog import Dialog, Window, DialogManager from aiogram_dialog.widgets.text import Format, Const -from aiogram_dialog.widgets.kbd import Row, SwitchTo, Button, ScrollingGroup, Select +from aiogram_dialog.widgets.kbd import Row, SwitchTo, Button, ScrollingGroup, Select, Group from aiogram_dialog.widgets.input import MessageInput from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject from dutylog.application.bot.user_dialogs.states import AdminMenuSG -from dutylog.infrastructure.database.repositories.users_repository import UsersRepository -from dutylog.infrastructure.database.repositories.residents_repository import ResidentsRepository -from dutylog.infrastructure.database.repositories.rooms_repository import RoomsRepository +from dutylog.infrastructure.database.repositories.users_repository import ( + UsersRepository, +) +from dutylog.infrastructure.database.repositories.residents_repository import ( + ResidentsRepository, +) +from dutylog.infrastructure.database.repositories.rooms_repository import ( + RoomsRepository, +) +from dutylog.infrastructure.database.repositories.hours_transactions_repository import ( + HoursTransactionsRepository, +) from dutylog.infrastructure.utils.config import Config @@ -28,14 +37,14 @@ async def get_admin_menu_data( first_name=event_from_user.first_name, last_name=event_from_user.last_name, ) - + is_creator = event_from_user.id == config.bot.creator_id - + if is_creator: greeting = "👑 Создатель" else: greeting = "👨‍💼 Администратор" - + content = f""" {greeting} @@ -43,7 +52,7 @@ async def get_admin_menu_data( Выберите действие: """ - + return {"content": content} @@ -55,14 +64,14 @@ async def get_statistics_data( ): all_users = await users_repository.get_all_users() all_residents = await residents_repository.get_all_residents() - + total_users = len(all_users) total_residents = len(all_residents) busy_residents = len([r for r in all_residents if r.is_busy]) total_active_hours = sum(r.active_hours for r in all_residents) total_inactive_hours = sum(r.inactive_hours for r in all_residents) admins_count = len([u for u in all_users if u.is_admin]) - + stats_text = f"""
📊 Статистика системы
@@ -79,7 +88,7 @@ async def get_statistics_data( 🔴 Всего неактивных часов: {total_inactive_hours} ч 📊 Общий итог: {total_active_hours + total_inactive_hours} ч """ - + return {"stats_content": stats_text} @@ -90,7 +99,7 @@ async def on_broadcast_message( ): dialog_manager.dialog_data["broadcast_message_id"] = message.message_id dialog_manager.dialog_data["broadcast_chat_id"] = message.chat.id - + await message.copy_to(message.chat.id) await dialog_manager.switch_to(AdminMenuSG.broadcast_confirm) @@ -103,21 +112,21 @@ async def on_broadcast_confirm( users_repository: FromDishka[UsersRepository], ): assert callback.message is not None - + bot: Bot = dialog_manager.middleware_data["bot"] message_id = dialog_manager.dialog_data["broadcast_message_id"] chat_id = dialog_manager.dialog_data["broadcast_chat_id"] admin_id = callback.from_user.id - + all_users = await users_repository.get_all_users() - + success_count = 0 failed_count = 0 - + for user in all_users: if user.id == admin_id: continue - + try: await bot.copy_message( chat_id=user.id, @@ -129,9 +138,7 @@ async def on_broadcast_confirm( failed_count += 1 except TelegramBadRequest: failed_count += 1 - except Exception: - failed_count += 1 - + result_text = f"""
📢 Результаты рассылки
@@ -139,7 +146,7 @@ async def on_broadcast_confirm( ❌ Не удалось отправить: {failed_count} 📊 Всего пользователей: {len(all_users) - 1} """ - + await callback.message.answer(result_text) await callback.message.delete() await dialog_manager.start(AdminMenuSG.main) @@ -160,25 +167,24 @@ async def get_residents_list_data( **kwargs, ): all_residents = await residents_repository.get_all_residents() - + residents_with_rooms = [] for resident in all_residents: room = await rooms_repository.get_room_by_id(resident.room) room_number = room.number if room else 999999 residents_with_rooms.append((resident, room_number)) - + residents_with_rooms.sort(key=lambda x: x[1]) - + residents_data = [] for resident, room_number in residents_with_rooms: status = "🟢" if resident.is_busy else "⚪️" name = resident.real_name if resident.real_name else "Без имени" - - residents_data.append(( - f"{name} | Комната {room_number} | {status}", - resident.id - )) - + + residents_data.append( + (f"{name} | Комната {room_number} | {status}", resident.id) + ) + content = f"""
🏠 Резиденты
@@ -186,7 +192,7 @@ async def get_residents_list_data( Выберите резидента для просмотра информации: """ - + return { "content": content, "residents": residents_data, @@ -202,28 +208,31 @@ async def get_resident_info_data( **kwargs, ): resident_id = dialog_manager.dialog_data.get("selected_resident_id") - + if not resident_id: return {"info_content": "Ошибка: резидент не выбран"} - + resident = await residents_repository.get_resident_by_id(resident_id) - + if not resident: return {"info_content": "Ошибка: резидент не найден"} - + room = await rooms_repository.get_room_by_id(resident.room) room_number = room.number if room else "???" - + name = resident.real_name if resident.real_name else "Без имени" status = "🟢 Занят" if resident.is_busy else "⚪️ Свободен" - + user_info = "Не привязан" if resident.user_entity: user = await users_repository.get_user_by_id(resident.user_entity) if user: - username = f"@{user.username}" if user.username else "без username" + if user.username: + username = f"@{user.username}" + else: + username = f"ID: {user.id}" user_info = f"{user.first_name} ({username})" - + info_content = f"""
👤 Информация о резиденте
@@ -239,7 +248,7 @@ async def get_resident_info_data( 🔴 Неактивные часы: {resident.inactive_hours} ч 📊 Всего часов: {resident.active_hours + resident.inactive_hours} ч """ - + return { "info_content": info_content, "is_busy": resident.is_busy, @@ -288,10 +297,10 @@ async def on_logout_resident_confirm( residents_repository: FromDishka[ResidentsRepository], ): resident_id = dialog_manager.dialog_data.get("selected_resident_id") - + if resident_id: await residents_repository.unbind_user_from_resident(resident_id) - + await dialog_manager.switch_to(AdminMenuSG.resident_info) @@ -303,6 +312,142 @@ async def on_logout_resident_cancel( await dialog_manager.switch_to(AdminMenuSG.resident_info) +async def on_add_hours_click( + callback: CallbackQuery, + button: Button, + dialog_manager: DialogManager, +): + await dialog_manager.switch_to(AdminMenuSG.add_hours_select) + + +async def on_remove_hours_click( + callback: CallbackQuery, + button: Button, + dialog_manager: DialogManager, +): + await dialog_manager.switch_to(AdminMenuSG.remove_hours_select) + + +async def get_hours_select_data(**kwargs): + hours_options = [ + (5, "5"), (10, "10"), (15, "15"), (20, "20"), + (25, "25"), (30, "30"), (35, "35"), (40, "40"), + (45, "45"), (50, "50"), (55, "55"), (60, "60"), + (65, "65"), (70, "70"), (75, "75"), (80, "80"), + ] + return {"hours_options": hours_options} + + +async def on_hours_selected( + callback: CallbackQuery, + widget: Select, + dialog_manager: DialogManager, + item_id: str, +): + dialog_manager.dialog_data["selected_hours"] = int(item_id) + + if dialog_manager.current_context().state == AdminMenuSG.add_hours_select: + await dialog_manager.switch_to(AdminMenuSG.add_hours_confirm) + else: + await dialog_manager.switch_to(AdminMenuSG.remove_hours_confirm) + + +async def on_custom_hours_click( + callback: CallbackQuery, + button: Button, + dialog_manager: DialogManager, +): + if dialog_manager.current_context().state == AdminMenuSG.add_hours_select: + await dialog_manager.switch_to(AdminMenuSG.add_hours_custom) + else: + await dialog_manager.switch_to(AdminMenuSG.remove_hours_custom) + + +async def on_custom_hours_input( + message: Message, + widget: MessageInput, + dialog_manager: DialogManager, +): + if not message.text: + await message.answer("⚠️ Пожалуйста, введите число") + return + + try: + hours = int(message.text) + if hours <= 0: + await message.answer("⚠️ Количество часов должно быть положительным числом") + return + + dialog_manager.dialog_data["selected_hours"] = hours + + if dialog_manager.current_context().state == AdminMenuSG.add_hours_custom: + await dialog_manager.switch_to(AdminMenuSG.add_hours_confirm) + else: + await dialog_manager.switch_to(AdminMenuSG.remove_hours_confirm) + except ValueError: + await message.answer("⚠️ Пожалуйста, введите корректное число") + + +async def get_hours_confirm_data( + dialog_manager: DialogManager, + **kwargs, +): + hours = dialog_manager.dialog_data.get("selected_hours", 0) + return {"hours": hours} + + +@inject +async def on_add_hours_confirm( + callback: CallbackQuery, + button: Button, + dialog_manager: DialogManager, + transactions_repository: FromDishka[HoursTransactionsRepository], +): + resident_id = dialog_manager.dialog_data.get("selected_resident_id") + hours = dialog_manager.dialog_data.get("selected_hours") + admin_id = callback.from_user.id + + if resident_id and hours: + await transactions_repository.add_hours( + resident_id=resident_id, + amount=hours, + admin_id=admin_id, + is_active=True, + ) + + await dialog_manager.switch_to(AdminMenuSG.resident_info) + + +@inject +async def on_remove_hours_confirm( + callback: CallbackQuery, + button: Button, + dialog_manager: DialogManager, + transactions_repository: FromDishka[HoursTransactionsRepository], +): + resident_id = dialog_manager.dialog_data.get("selected_resident_id") + hours = dialog_manager.dialog_data.get("selected_hours") + admin_id = callback.from_user.id + + if resident_id and hours: + await transactions_repository.remove_hours( + resident_id=resident_id, + amount=hours, + admin_id=admin_id, + is_active=True, + ) + + await dialog_manager.switch_to(AdminMenuSG.resident_info) + + +async def on_hours_cancel( + callback: CallbackQuery, + button: Button, + dialog_manager: DialogManager, +): + await dialog_manager.switch_to(AdminMenuSG.resident_info) + + admin_menu_dialog = Dialog( Window( Format("{content}"), @@ -311,11 +456,6 @@ admin_menu_dialog = Dialog( id="residents_btn", state=AdminMenuSG.residents, ), - SwitchTo( - Const("👥 Пользователи"), - id="users_btn", - state=AdminMenuSG.users, - ), SwitchTo( Const("📊 Статистика"), id="stats_btn", @@ -370,6 +510,18 @@ admin_menu_dialog = Dialog( ), Window( Format("{info_content}"), + Row( + Button( + Const("➕ Добавить часы"), + id="add_hours_btn", + on_click=on_add_hours_click, + ), + Button( + Const("➖ Отнять часы"), + id="remove_hours_btn", + on_click=on_remove_hours_click, + ), + ), Button( Const("🚪 Разлогинить"), id="logout_resident_btn", @@ -385,7 +537,9 @@ admin_menu_dialog = Dialog( getter=get_resident_info_data, ), Window( - Const("
⚠️ Подтверждение
\n\nВы уверены, что хотите разлогинить этого резидента?"), + Const( + "
⚠️ Подтверждение
\n\nВы уверены, что хотите разлогинить этого резидента?" + ), Row( Button( Const("✅ Да"), @@ -401,9 +555,108 @@ admin_menu_dialog = Dialog( state=AdminMenuSG.resident_logout_confirm, ), Window( - Const("
👥 Пользователи
\n\nФункционал в разработке"), - SwitchTo(Const("◀️ Назад"), id="back_from_users", state=AdminMenuSG.main), - state=AdminMenuSG.users, + Const("
Добавить часы
\n\nВыберите количество часов:"), + Group( + Select( + Format("{item[1]} ч"), + id="hours_select_add", + item_id_getter=lambda x: x[0], + items="hours_options", + on_click=on_hours_selected, + ), + width=4, + ), + Button( + Const("✏️ Ввести свое количество"), + id="custom_hours_add_btn", + on_click=on_custom_hours_click, + ), + SwitchTo( + Const("◀️ Отмена"), + id="cancel_add_hours", + state=AdminMenuSG.resident_info, + ), + state=AdminMenuSG.add_hours_select, + getter=get_hours_select_data, + ), + Window( + Const("
Отнять часы
\n\nВыберите количество часов:"), + Group( + Select( + Format("{item[1]} ч"), + id="hours_select_remove", + item_id_getter=lambda x: x[0], + items="hours_options", + on_click=on_hours_selected, + ), + width=4, + ), + Button( + Const("✏️ Ввести свое количество"), + id="custom_hours_remove_btn", + on_click=on_custom_hours_click, + ), + SwitchTo( + Const("◀️ Отмена"), + id="cancel_remove_hours", + state=AdminMenuSG.resident_info, + ), + state=AdminMenuSG.remove_hours_select, + getter=get_hours_select_data, + ), + Window( + Const("
✏️ Добавить часы
\n\nВведите количество часов:"), + MessageInput(on_custom_hours_input), + SwitchTo( + Const("◀️ Отмена"), + id="cancel_custom_add", + state=AdminMenuSG.add_hours_select, + ), + state=AdminMenuSG.add_hours_custom, + ), + Window( + Const("
✏️ Отнять часы
\n\nВведите количество часов:"), + MessageInput(on_custom_hours_input), + SwitchTo( + Const("◀️ Отмена"), + id="cancel_custom_remove", + state=AdminMenuSG.remove_hours_select, + ), + state=AdminMenuSG.remove_hours_custom, + ), + Window( + Format("
Подтверждение
\n\nВы уверены, что хотите добавить {hours} часов?"), + Row( + Button( + Const("✅ Да"), + id="confirm_add_hours", + on_click=on_add_hours_confirm, + ), + Button( + Const("❌ Нет"), + id="cancel_add_hours_confirm", + on_click=on_hours_cancel, + ), + ), + state=AdminMenuSG.add_hours_confirm, + getter=get_hours_confirm_data, + ), + Window( + Format("
Подтверждение
\n\nВы уверены, что хотите отнять {hours} часов?"), + Row( + Button( + Const("✅ Да"), + id="confirm_remove_hours", + on_click=on_remove_hours_confirm, + ), + Button( + Const("❌ Нет"), + id="cancel_remove_hours_confirm", + on_click=on_hours_cancel, + ), + ), + state=AdminMenuSG.remove_hours_confirm, + getter=get_hours_confirm_data, ), Window( Format("{stats_content}"), @@ -412,16 +665,26 @@ admin_menu_dialog = Dialog( getter=get_statistics_data, ), Window( - Const("
📢 Рассылка
\n\nОтправьте сообщение, которое хотите разослать всем пользователям:"), + Const( + "
📢 Рассылка
\n\nОтправьте сообщение, которое хотите разослать всем пользователям:" + ), MessageInput(on_broadcast_message), SwitchTo(Const("◀️ Отмена"), id="cancel_broadcast", state=AdminMenuSG.main), state=AdminMenuSG.broadcast, ), Window( - Const("
📢 Подтверждение рассылки
\n\n⚠️ Вы уверены, что хотите отправить это сообщение всем пользователям?"), + Const( + "
📢 Подтверждение рассылки
\n\n⚠️ Вы уверены, что хотите отправить это сообщение всем пользователям?" + ), Row( - Button(Const("✅ Да"), id="confirm_broadcast", on_click=on_broadcast_confirm), - Button(Const("❌ Нет"), id="cancel_broadcast_confirm", on_click=on_broadcast_cancel), + Button( + Const("✅ Да"), id="confirm_broadcast", on_click=on_broadcast_confirm + ), + Button( + Const("❌ Нет"), + id="cancel_broadcast_confirm", + on_click=on_broadcast_cancel, + ), ), state=AdminMenuSG.broadcast_confirm, ), diff --git a/src/dutylog/application/bot/user_dialogs/main_menu_dialog.py b/src/dutylog/application/bot/user_dialogs/main_menu_dialog.py index 517f7ea..7f84eb2 100644 --- a/src/dutylog/application/bot/user_dialogs/main_menu_dialog.py +++ b/src/dutylog/application/bot/user_dialogs/main_menu_dialog.py @@ -6,10 +6,18 @@ from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject from dutylog.application.bot.user_dialogs.states import MainMenuSG -from dutylog.infrastructure.database.repositories.users_repository import UsersRepository -from dutylog.infrastructure.database.repositories.residents_repository import ResidentsRepository -from dutylog.infrastructure.database.repositories.rooms_repository import RoomsRepository -from dutylog.infrastructure.database.repositories.hours_transactions_repository import HoursTransactionsRepository +from dutylog.infrastructure.database.repositories.users_repository import ( + UsersRepository, +) +from dutylog.infrastructure.database.repositories.residents_repository import ( + ResidentsRepository, +) +from dutylog.infrastructure.database.repositories.rooms_repository import ( + RoomsRepository, +) +from dutylog.infrastructure.database.repositories.hours_transactions_repository import ( + HoursTransactionsRepository, +) from dutylog.infrastructure.utils.config import Config @@ -28,27 +36,35 @@ async def get_main_menu_data( first_name=event_from_user.first_name, last_name=event_from_user.last_name, ) - + is_creator = event_from_user.id == config.bot.creator_id is_admin = user.is_admin - + if is_creator: greeting = "👑 Создатель" elif is_admin: greeting = "👨‍💼 Администратор" else: - resident = await residents_repository.get_resident_by_user_id(event_from_user.id) + resident = await residents_repository.get_resident_by_user_id( + event_from_user.id + ) if resident: room = await rooms_repository.get_room_by_id(resident.room) room_number = room.number if room else "???" - real_name = resident.real_name if resident.real_name else event_from_user.first_name - greeting = f"👋 Привет, {real_name}!\n🚪 Комната {room_number}" + real_name = ( + resident.real_name if resident.real_name else event_from_user.first_name + ) + greeting = ( + f"👋 Привет, {real_name}!\n🚪 Комната {room_number}" + ) else: greeting = f"👋 Привет, {event_from_user.first_name}!" - + if not is_admin and not is_creator: - resident = await residents_repository.get_resident_by_user_id(event_from_user.id) - + resident = await residents_repository.get_resident_by_user_id( + event_from_user.id + ) + if not resident: content = f""" {greeting} @@ -79,7 +95,7 @@ async def get_main_menu_data( Добро пожаловать в систему учета дежурств! """ has_resident = False - + return { "content": content, "is_regular_user": not is_admin and not is_creator, @@ -95,7 +111,7 @@ async def get_history_data( **kwargs, ): resident = await residents_repository.get_resident_by_user_id(event_from_user.id) - + if not resident: history_text = """
📜 История операций
@@ -105,7 +121,7 @@ async def get_history_data( else: transactions = await transactions_repository.get_resident_history(resident.id) last_10 = transactions[:10] - + if not last_10: history_text = """
📜 История операций
@@ -120,14 +136,14 @@ async def get_history_data( history_lines.append( f"{emoji} {tx.amount} ч • {date_str}" ) - + history_text = f"""
📜 История операций
{"".join(f"{line}\n" for line in history_lines)} Показаны последние 10 операций """ - + return {"history_content": history_text} @@ -177,4 +193,3 @@ main_menu_dialog = Dialog( state=MainMenuSG.faq, ), ) - diff --git a/src/dutylog/application/bot/user_dialogs/registration_dialog.py b/src/dutylog/application/bot/user_dialogs/registration_dialog.py index 762f675..76761ed 100644 --- a/src/dutylog/application/bot/user_dialogs/registration_dialog.py +++ b/src/dutylog/application/bot/user_dialogs/registration_dialog.py @@ -7,9 +7,15 @@ from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject from dutylog.application.bot.user_dialogs.states import RegistrationSG, MainMenuSG -from dutylog.infrastructure.database.repositories.floors_repository import FloorsRepository -from dutylog.infrastructure.database.repositories.rooms_repository import RoomsRepository -from dutylog.infrastructure.database.repositories.residents_repository import ResidentsRepository +from dutylog.infrastructure.database.repositories.floors_repository import ( + FloorsRepository, +) +from dutylog.infrastructure.database.repositories.rooms_repository import ( + RoomsRepository, +) +from dutylog.infrastructure.database.repositories.residents_repository import ( + ResidentsRepository, +) @inject @@ -21,21 +27,21 @@ async def get_floors_data( ): all_floors = await floors_repository.get_all_floors() available_residents = await residents_repository.get_available_residents() - + if not available_residents: return { "has_available": False, "floors": [], } - + available_room_ids = {r.room for r in available_residents} all_rooms = await rooms_repository.get_all_rooms() available_rooms = [r for r in all_rooms if r.id in available_room_ids] available_floor_ids = {r.on_floor for r in available_rooms} - + available_floors = [f for f in all_floors if f.id in available_floor_ids] available_floors.sort(key=lambda f: f.number) - + return { "has_available": True, "floors": [(f.id, f"Этаж {f.number}") for f in available_floors], @@ -50,14 +56,14 @@ async def get_rooms_data( **kwargs, ): floor_id = dialog_manager.dialog_data["floor_id"] - + rooms = await rooms_repository.get_rooms_by_floor(floor_id) available_residents = await residents_repository.get_available_residents() available_room_ids = {r.room for r in available_residents} - + available_rooms = [r for r in rooms if r.id in available_room_ids] available_rooms.sort(key=lambda r: r.number) - + return { "rooms": [(r.id, str(r.number)) for r in available_rooms], } @@ -70,12 +76,14 @@ async def get_residents_data( **kwargs, ): room_id = dialog_manager.dialog_data["room_id"] - + residents = await residents_repository.get_residents_by_room(room_id) available_residents = [r for r in residents if not r.is_busy] - + return { - "residents": [(r.id, r.real_name or f"Резидент #{r.id}") for r in available_residents], + "residents": [ + (r.id, r.real_name or f"Резидент #{r.id}") for r in available_residents + ], } @@ -109,17 +117,23 @@ async def on_resident_selected( ): user_id = callback.from_user.id resident_id = int(item_id) - + await residents_repository.bind_user_to_resident(resident_id, user_id) - + await callback.answer("✅ Регистрация успешна!") await dialog_manager.start(MainMenuSG.main) registration_dialog = Dialog( Window( - Const("
🏢 Выбор этажа
\n\n
⚠️ Внимание! Перерегистрацию может выполнить только администратор. Выбирайте внимательно!
\n\nВыберите этаж, на котором вы живете:", when="has_available"), - Const("
⚠️ Нет доступных резидентов
\n\nВсе резиденты уже заняты.\nОбратитесь к администратору.", when=~F["has_available"]), + Const( + "
🏢 Выбор этажа
\n\n
⚠️ Внимание! Перерегистрацию может выполнить только администратор. Выбирайте внимательно!
\n\nВыберите этаж, на котором вы живете:", + when="has_available", + ), + Const( + "
⚠️ Нет доступных резидентов
\n\nВсе резиденты уже заняты.\nОбратитесь к администратору.", + when=~F["has_available"], + ), Group( Select( Format("{item[1]}"), @@ -135,7 +149,9 @@ registration_dialog = Dialog( getter=get_floors_data, ), Window( - Const("
🚪 Выбор комнаты
\n\nВыберите вашу комнату:"), + Const( + "
🚪 Выбор комнаты
\n\nВыберите вашу комнату:" + ), Group( Select( Format("{item[1]}"), @@ -146,12 +162,16 @@ registration_dialog = Dialog( ), width=3, ), - SwitchTo(Const("◀️ Назад"), id="back_to_floors", state=RegistrationSG.select_floor), + SwitchTo( + Const("◀️ Назад"), id="back_to_floors", state=RegistrationSG.select_floor + ), state=RegistrationSG.select_room, getter=get_rooms_data, ), Window( - Const("
👤 Выбор резидента
\n\nВыберите себя из списка:"), + Const( + "
👤 Выбор резидента
\n\nВыберите себя из списка:" + ), Group( Select( Format("{item[1]}"), @@ -162,7 +182,9 @@ registration_dialog = Dialog( ), width=1, ), - SwitchTo(Const("◀️ Назад"), id="back_to_rooms", state=RegistrationSG.select_room), + SwitchTo( + Const("◀️ Назад"), id="back_to_rooms", state=RegistrationSG.select_room + ), state=RegistrationSG.select_resident, getter=get_residents_data, ), diff --git a/src/dutylog/application/bot/user_dialogs/states.py b/src/dutylog/application/bot/user_dialogs/states.py index d337734..8e54ad2 100644 --- a/src/dutylog/application/bot/user_dialogs/states.py +++ b/src/dutylog/application/bot/user_dialogs/states.py @@ -12,7 +12,12 @@ class AdminMenuSG(StatesGroup): residents = State() resident_info = State() resident_logout_confirm = State() - users = State() + add_hours_select = State() + remove_hours_select = State() + add_hours_custom = State() + remove_hours_custom = State() + add_hours_confirm = State() + remove_hours_confirm = State() statistics = State() broadcast = State() broadcast_confirm = State() diff --git a/src/dutylog/application/bot/user_handlers.py b/src/dutylog/application/bot/user_handlers.py index 6531e57..b54807e 100644 --- a/src/dutylog/application/bot/user_handlers.py +++ b/src/dutylog/application/bot/user_handlers.py @@ -4,9 +4,17 @@ from aiogram.types import Message from aiogram_dialog import DialogManager, StartMode from dishka import FromDishka -from dutylog.application.bot.user_dialogs.states import MainMenuSG, AdminMenuSG, RegistrationSG -from dutylog.infrastructure.database.repositories.users_repository import UsersRepository -from dutylog.infrastructure.database.repositories.residents_repository import ResidentsRepository +from dutylog.application.bot.user_dialogs.states import ( + MainMenuSG, + AdminMenuSG, + RegistrationSG, +) +from dutylog.infrastructure.database.repositories.users_repository import ( + UsersRepository, +) +from dutylog.infrastructure.database.repositories.residents_repository import ( + ResidentsRepository, +) from dutylog.infrastructure.utils.config import Config router = Router() @@ -22,18 +30,20 @@ async def start_handler( ): assert message.from_user is not None user = await users_repository.get_user_by_id(message.from_user.id) - + is_creator = message.from_user.id == config.bot.creator_id is_admin = user.is_admin if user else False - + if is_admin or is_creator: await dialog_manager.start(AdminMenuSG.main, mode=StartMode.RESET_STACK) return - + if user: - resident = await residents_repository.get_resident_by_user_id(message.from_user.id) + resident = await residents_repository.get_resident_by_user_id( + message.from_user.id + ) if resident: await dialog_manager.start(MainMenuSG.main, mode=StartMode.RESET_STACK) return - + await dialog_manager.start(RegistrationSG.select_floor, mode=StartMode.RESET_STACK) diff --git a/src/dutylog/infrastructure/database/dao/floors_dao.py b/src/dutylog/infrastructure/database/dao/floors_dao.py index 9f6c749..1a36721 100644 --- a/src/dutylog/infrastructure/database/dao/floors_dao.py +++ b/src/dutylog/infrastructure/database/dao/floors_dao.py @@ -9,15 +9,11 @@ class FloorsDAO: self.session = session async def get_by_id(self, floor_id: int) -> Floor | None: - result = await self.session.execute( - select(Floor).where(Floor.id == floor_id) - ) + result = await self.session.execute(select(Floor).where(Floor.id == floor_id)) return result.scalar_one_or_none() async def get_by_number(self, number: int) -> Floor | None: - result = await self.session.execute( - select(Floor).where(Floor.number == number) - ) + result = await self.session.execute(select(Floor).where(Floor.number == number)) return result.scalar_one_or_none() async def get_all(self) -> list[Floor]: @@ -38,7 +34,5 @@ class FloorsDAO: return await self.get_by_id(floor_id) async def delete(self, floor_id: int) -> None: - await self.session.execute( - delete(Floor).where(Floor.id == floor_id) - ) + await self.session.execute(delete(Floor).where(Floor.id == floor_id)) await self.session.commit() diff --git a/src/dutylog/infrastructure/database/dao/residents_dao.py b/src/dutylog/infrastructure/database/dao/residents_dao.py index 8c58154..d605d3d 100644 --- a/src/dutylog/infrastructure/database/dao/residents_dao.py +++ b/src/dutylog/infrastructure/database/dao/residents_dao.py @@ -50,7 +50,5 @@ class ResidentsDAO: return await self.get_by_id(resident_id) async def delete(self, resident_id: int) -> None: - await self.session.execute( - delete(Resident).where(Resident.id == resident_id) - ) + await self.session.execute(delete(Resident).where(Resident.id == resident_id)) await self.session.commit() diff --git a/src/dutylog/infrastructure/database/dao/rooms_dao.py b/src/dutylog/infrastructure/database/dao/rooms_dao.py index 401494e..dd10148 100644 --- a/src/dutylog/infrastructure/database/dao/rooms_dao.py +++ b/src/dutylog/infrastructure/database/dao/rooms_dao.py @@ -9,15 +9,11 @@ class RoomsDAO: self.session = session async def get_by_id(self, room_id: int) -> Room | None: - result = await self.session.execute( - select(Room).where(Room.id == room_id) - ) + result = await self.session.execute(select(Room).where(Room.id == room_id)) return result.scalar_one_or_none() async def get_by_number(self, number: int) -> Room | None: - result = await self.session.execute( - select(Room).where(Room.number == number) - ) + result = await self.session.execute(select(Room).where(Room.number == number)) return result.scalar_one_or_none() async def get_by_floor(self, floor_id: int) -> list[Room]: @@ -44,7 +40,5 @@ class RoomsDAO: return await self.get_by_id(room_id) async def delete(self, room_id: int) -> None: - await self.session.execute( - delete(Room).where(Room.id == room_id) - ) + await self.session.execute(delete(Room).where(Room.id == room_id)) await self.session.commit() diff --git a/src/dutylog/infrastructure/database/dao/users_dao.py b/src/dutylog/infrastructure/database/dao/users_dao.py index af43e9f..ea0e320 100644 --- a/src/dutylog/infrastructure/database/dao/users_dao.py +++ b/src/dutylog/infrastructure/database/dao/users_dao.py @@ -9,9 +9,7 @@ class UsersDAO: self.session = session async def get_by_id(self, user_id: int) -> User | None: - result = await self.session.execute( - select(User).where(User.id == user_id) - ) + result = await self.session.execute(select(User).where(User.id == user_id)) return result.scalar_one_or_none() async def get_all(self) -> list[User]: @@ -32,7 +30,5 @@ class UsersDAO: return await self.get_by_id(user_id) async def delete(self, user_id: int) -> None: - await self.session.execute( - delete(User).where(User.id == user_id) - ) + await self.session.execute(delete(User).where(User.id == user_id)) await self.session.commit() diff --git a/src/dutylog/infrastructure/database/models/hours_transaction.py b/src/dutylog/infrastructure/database/models/hours_transaction.py index 3a4b959..3cf7eb4 100644 --- a/src/dutylog/infrastructure/database/models/hours_transaction.py +++ b/src/dutylog/infrastructure/database/models/hours_transaction.py @@ -17,8 +17,14 @@ class HoursTransaction(Base): __tablename__ = "hours_transactions" id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - resident_id: Mapped[int] = mapped_column(Integer, ForeignKey("residents.id", ondelete="CASCADE"), nullable=False) + resident_id: Mapped[int] = mapped_column( + Integer, ForeignKey("residents.id", ondelete="CASCADE"), nullable=False + ) transaction_type: Mapped[str] = mapped_column(String(50), nullable=False) amount: Mapped[int] = mapped_column(Integer, nullable=False) - admin_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=msk_now) + admin_id: Mapped[int] = mapped_column( + BigInteger, ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=msk_now + ) diff --git a/src/dutylog/infrastructure/database/models/resident.py b/src/dutylog/infrastructure/database/models/resident.py index dd2beab..cfbabf1 100644 --- a/src/dutylog/infrastructure/database/models/resident.py +++ b/src/dutylog/infrastructure/database/models/resident.py @@ -12,10 +12,23 @@ class Resident(Base): id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) real_name: Mapped[str | None] = mapped_column(String(255), nullable=True) - room: Mapped[int] = mapped_column(Integer, ForeignKey("rooms.id", ondelete="CASCADE"), nullable=False) - user_entity: Mapped[int | None] = mapped_column(BigInteger, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, unique=True) - is_busy: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") + room: Mapped[int] = mapped_column( + Integer, ForeignKey("rooms.id", ondelete="CASCADE"), nullable=False + ) + user_entity: Mapped[int | None] = mapped_column( + BigInteger, + ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + unique=True, + ) + is_busy: Mapped[bool] = mapped_column( + Boolean, default=False, server_default="false" + ) active_hours: Mapped[int] = mapped_column(Integer, default=0, server_default="0") inactive_hours: Mapped[int] = mapped_column(Integer, default=0, server_default="0") - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=msk_now) - updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=msk_now, onupdate=msk_now) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=msk_now + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=msk_now, onupdate=msk_now + ) diff --git a/src/dutylog/infrastructure/database/models/room.py b/src/dutylog/infrastructure/database/models/room.py index f4a4ccd..d19cae6 100644 --- a/src/dutylog/infrastructure/database/models/room.py +++ b/src/dutylog/infrastructure/database/models/room.py @@ -9,4 +9,6 @@ class Room(Base): id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) number: Mapped[int] = mapped_column(Integer, nullable=False, unique=True) - on_floor: Mapped[int] = mapped_column(Integer, ForeignKey("floors.id", ondelete="CASCADE"), nullable=False) + on_floor: Mapped[int] = mapped_column( + Integer, ForeignKey("floors.id", ondelete="CASCADE"), nullable=False + ) diff --git a/src/dutylog/infrastructure/database/models/user.py b/src/dutylog/infrastructure/database/models/user.py index ebc80b2..fbf4dce 100644 --- a/src/dutylog/infrastructure/database/models/user.py +++ b/src/dutylog/infrastructure/database/models/user.py @@ -14,6 +14,12 @@ class User(Base): username: Mapped[str | None] = mapped_column(String(255), nullable=True) first_name: Mapped[str | None] = mapped_column(String(255), nullable=True) last_name: Mapped[str | None] = mapped_column(String(255), nullable=True) - is_admin: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=msk_now) - updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=msk_now, onupdate=msk_now) + is_admin: Mapped[bool] = mapped_column( + Boolean, default=False, server_default="false" + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=msk_now + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=msk_now, onupdate=msk_now + ) diff --git a/src/dutylog/infrastructure/database/repositories/hours_transactions_repository.py b/src/dutylog/infrastructure/database/repositories/hours_transactions_repository.py index dc0b4dc..13561ec 100644 --- a/src/dutylog/infrastructure/database/repositories/hours_transactions_repository.py +++ b/src/dutylog/infrastructure/database/repositories/hours_transactions_repository.py @@ -1,6 +1,11 @@ -from dutylog.infrastructure.database.dao.hours_transactions_dao import HoursTransactionsDAO +from dutylog.infrastructure.database.dao.hours_transactions_dao import ( + HoursTransactionsDAO, +) from dutylog.infrastructure.database.dao.residents_dao import ResidentsDAO -from dutylog.infrastructure.database.models.hours_transaction import HoursTransaction, TransactionType +from dutylog.infrastructure.database.models.hours_transaction import ( + HoursTransaction, + TransactionType, +) from dutylog.infrastructure.database.models.resident import Resident @@ -32,10 +37,14 @@ class HoursTransactionsRepository: if resident: if is_active: new_hours = resident.active_hours + amount - resident = await self.residents_dao.update(resident_id, active_hours=new_hours) + resident = await self.residents_dao.update( + resident_id, active_hours=new_hours + ) else: new_hours = resident.inactive_hours + amount - resident = await self.residents_dao.update(resident_id, inactive_hours=new_hours) + resident = await self.residents_dao.update( + resident_id, inactive_hours=new_hours + ) return transaction, resident @@ -58,10 +67,14 @@ class HoursTransactionsRepository: if resident: if is_active: new_hours = max(0, resident.active_hours - amount) - resident = await self.residents_dao.update(resident_id, active_hours=new_hours) + resident = await self.residents_dao.update( + resident_id, active_hours=new_hours + ) else: new_hours = max(0, resident.inactive_hours - amount) - resident = await self.residents_dao.update(resident_id, inactive_hours=new_hours) + resident = await self.residents_dao.update( + resident_id, inactive_hours=new_hours + ) return transaction, resident @@ -71,5 +84,7 @@ class HoursTransactionsRepository: async def get_all_transactions(self) -> list[HoursTransaction]: return await self.transactions_dao.get_all() - async def get_transaction_by_id(self, transaction_id: int) -> HoursTransaction | None: + async def get_transaction_by_id( + self, transaction_id: int + ) -> HoursTransaction | None: return await self.transactions_dao.get_by_id(transaction_id) diff --git a/src/dutylog/infrastructure/database/repositories/residents_repository.py b/src/dutylog/infrastructure/database/repositories/residents_repository.py index d3469b6..e27d561 100644 --- a/src/dutylog/infrastructure/database/repositories/residents_repository.py +++ b/src/dutylog/infrastructure/database/repositories/residents_repository.py @@ -17,7 +17,9 @@ class ResidentsRepository: ) return await self.residents_dao.create(resident) - async def bind_user_to_resident(self, resident_id: int, user_id: int) -> Resident | None: + async def bind_user_to_resident( + self, resident_id: int, user_id: int + ) -> Resident | None: return await self.residents_dao.update( resident_id, user_entity=user_id, @@ -52,7 +54,9 @@ class ResidentsRepository: resident = await self.residents_dao.get_by_id(resident_id) if resident: new_hours = resident.inactive_hours + hours - return await self.residents_dao.update(resident_id, inactive_hours=new_hours) + return await self.residents_dao.update( + resident_id, inactive_hours=new_hours + ) return None async def get_resident_by_id(self, resident_id: int) -> Resident | None: diff --git a/src/dutylog/infrastructure/ioc.py b/src/dutylog/infrastructure/ioc.py index b4b69a6..868ce65 100644 --- a/src/dutylog/infrastructure/ioc.py +++ b/src/dutylog/infrastructure/ioc.py @@ -5,15 +5,27 @@ from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker from dutylog.infrastructure.database.config import create_engine, create_session_maker from dutylog.infrastructure.database.dao.users_dao import UsersDAO -from dutylog.infrastructure.database.dao.hours_transactions_dao import HoursTransactionsDAO +from dutylog.infrastructure.database.dao.hours_transactions_dao import ( + HoursTransactionsDAO, +) from dutylog.infrastructure.database.dao.rooms_dao import RoomsDAO from dutylog.infrastructure.database.dao.residents_dao import ResidentsDAO from dutylog.infrastructure.database.dao.floors_dao import FloorsDAO -from dutylog.infrastructure.database.repositories.users_repository import UsersRepository -from dutylog.infrastructure.database.repositories.hours_transactions_repository import HoursTransactionsRepository -from dutylog.infrastructure.database.repositories.rooms_repository import RoomsRepository -from dutylog.infrastructure.database.repositories.residents_repository import ResidentsRepository -from dutylog.infrastructure.database.repositories.floors_repository import FloorsRepository +from dutylog.infrastructure.database.repositories.users_repository import ( + UsersRepository, +) +from dutylog.infrastructure.database.repositories.hours_transactions_repository import ( + HoursTransactionsRepository, +) +from dutylog.infrastructure.database.repositories.rooms_repository import ( + RoomsRepository, +) +from dutylog.infrastructure.database.repositories.residents_repository import ( + ResidentsRepository, +) +from dutylog.infrastructure.database.repositories.floors_repository import ( + FloorsRepository, +) from dutylog.infrastructure.utils.config import Config, load_config @@ -29,7 +41,9 @@ class DatabaseProvider(Provider): return create_engine(config.database.url) @provide(scope=Scope.APP) - def get_session_maker(self, engine: AsyncEngine) -> async_sessionmaker[AsyncSession]: + def get_session_maker( + self, engine: AsyncEngine + ) -> async_sessionmaker[AsyncSession]: return create_session_maker(engine) @provide(scope=Scope.REQUEST) @@ -80,14 +94,11 @@ class RepositoryProvider(Provider): return RoomsRepository(rooms_dao) @provide(scope=Scope.REQUEST) - def get_residents_repository(self, residents_dao: ResidentsDAO) -> ResidentsRepository: + def get_residents_repository( + self, residents_dao: ResidentsDAO + ) -> ResidentsRepository: return ResidentsRepository(residents_dao) @provide(scope=Scope.REQUEST) def get_floors_repository(self, floors_dao: FloorsDAO) -> FloorsRepository: return FloorsRepository(floors_dao) - - - - - diff --git a/src/dutylog/infrastructure/utils/config.py b/src/dutylog/infrastructure/utils/config.py index 8422098..787802e 100644 --- a/src/dutylog/infrastructure/utils/config.py +++ b/src/dutylog/infrastructure/utils/config.py @@ -17,11 +17,11 @@ class DatabaseConfig: user: str password: str database: str - + @property def url(self) -> str: return f"postgresql+asyncpg://{self.user}:{self.password}@{self.host}:{self.port}/{self.database}" - + @dataclass class Config: @@ -31,30 +31,30 @@ class Config: def load_config() -> Config: config_path = Path("config.toml") - + if not config_path.exists(): raise FileNotFoundError(f"Config file not found: {config_path}") - + with open(config_path, "rb") as f: data = tomllib.load(f) - + if "bot" not in data: raise KeyError("Missing required section: bot") if "database" not in data: raise KeyError("Missing required section: database") - + bot_data = data["bot"] if "token" not in bot_data: raise KeyError("Missing required field: bot.token") if "creator_id" not in bot_data: raise KeyError("Missing required field: bot.creator_id") - + database_data = data["database"] required_db_fields = ["host", "port", "user", "password", "database"] for field in required_db_fields: if field not in database_data: raise KeyError(f"Missing required field: database.{field}") - + return Config( bot=BotConfig( token=bot_data["token"],