diff --git a/alembic/versions/a1b2c3d4e5f6_add_photo_file_id_to_hours_transactions.py b/alembic/versions/a1b2c3d4e5f6_add_photo_file_id_to_hours_transactions.py new file mode 100644 index 0000000..9da84ac --- /dev/null +++ b/alembic/versions/a1b2c3d4e5f6_add_photo_file_id_to_hours_transactions.py @@ -0,0 +1,25 @@ +"""add_photo_file_id_to_hours_transactions + +Revision ID: a1b2c3d4e5f6 +Revises: 4fe7f71301e7 +Create Date: 2026-07-14 23:41:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = 'a1b2c3d4e5f6' +down_revision: Union[str, Sequence[str], None] = '4fe7f71301e7' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('hours_transactions', sa.Column('photo_file_id', sa.String(length=512), nullable=True)) + + +def downgrade() -> None: + op.drop_column('hours_transactions', 'photo_file_id') diff --git a/alembic/versions/b2c3d4e5f6g7_change_photo_file_id_to_text.py b/alembic/versions/b2c3d4e5f6g7_change_photo_file_id_to_text.py new file mode 100644 index 0000000..f432cae --- /dev/null +++ b/alembic/versions/b2c3d4e5f6g7_change_photo_file_id_to_text.py @@ -0,0 +1,37 @@ +"""change_photo_file_id_to_text + +Revision ID: b2c3d4e5f6g7 +Revises: a1b2c3d4e5f6 +Create Date: 2026-07-15 00:35:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = 'b2c3d4e5f6g7' +down_revision: Union[str, Sequence[str], None] = 'a1b2c3d4e5f6' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.alter_column( + 'hours_transactions', + 'photo_file_id', + existing_type=sa.String(length=512), + type_=sa.Text(), + existing_nullable=True, + ) + + +def downgrade() -> None: + op.alter_column( + 'hours_transactions', + 'photo_file_id', + existing_type=sa.Text(), + type_=sa.String(length=512), + existing_nullable=True, + ) diff --git a/src/dutylog/application/bot/admin_dialogs/admin_menu_dialog.py b/src/dutylog/application/bot/admin_dialogs/admin_menu_dialog.py index 6f499c8..2a7509a 100644 --- a/src/dutylog/application/bot/admin_dialogs/admin_menu_dialog.py +++ b/src/dutylog/application/bot/admin_dialogs/admin_menu_dialog.py @@ -8,6 +8,9 @@ from dutylog.application.bot.admin_dialogs.residents_management import ( residents_list_window, resident_info_window, resident_history_window, + resident_history_detail_window, + resident_history_search_input_window, + resident_history_search_results_window, resident_logout_confirm_window, resident_delete_confirm_window, resident_rebind_floor_window, @@ -36,6 +39,7 @@ from dutylog.application.bot.admin_dialogs.rooms_management import ( rooms_list_window, room_info_window, room_history_window, + room_history_detail_window, room_delete_confirm_window, room_add_hours_select_window, room_add_hours_custom_window, @@ -80,6 +84,7 @@ from dutylog.application.bot.creator_dialogs.admins_management import ( ) from dutylog.application.bot.creator_dialogs.transactions_history import ( transactions_history_window, + transactions_history_detail_window, ) @@ -88,6 +93,9 @@ admin_menu_dialog = Dialog( residents_list_window, resident_info_window, resident_history_window, + resident_history_detail_window, + resident_history_search_input_window, + resident_history_search_results_window, resident_logout_confirm_window, resident_delete_confirm_window, resident_rebind_floor_window, @@ -113,6 +121,7 @@ admin_menu_dialog = Dialog( rooms_list_window, room_info_window, room_history_window, + room_history_detail_window, room_delete_confirm_window, room_add_hours_select_window, room_add_hours_custom_window, @@ -141,4 +150,5 @@ admin_menu_dialog = Dialog( add_admin_select_user_window, add_admin_confirm_window, transactions_history_window, + transactions_history_detail_window, ) diff --git a/src/dutylog/application/bot/admin_dialogs/hours_management.py b/src/dutylog/application/bot/admin_dialogs/hours_management.py index 1c5389e..dd8a0b5 100644 --- a/src/dutylog/application/bot/admin_dialogs/hours_management.py +++ b/src/dutylog/application/bot/admin_dialogs/hours_management.py @@ -1,6 +1,9 @@ +import asyncio +import json + from aiogram import Bot from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter -from aiogram.types import Message, CallbackQuery +from aiogram.types import Message, CallbackQuery, InputMediaPhoto from aiogram_dialog import Window, DialogManager from aiogram_dialog.widgets.text import Format, Const from aiogram_dialog.widgets.kbd import Row, SwitchTo, Button, Select, Group @@ -8,6 +11,10 @@ from aiogram_dialog.widgets.input import MessageInput from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject +_media_group_photos: dict[tuple[int, str], list[str]] = {} +_media_group_remarks: dict[tuple[int, str], str] = {} +_media_group_counters: dict[tuple[int, str], int] = {} + from dutylog.application.bot.user_dialogs.states import AdminMenuSG from dutylog.infrastructure.database.repositories.residents_repository import ( ResidentsRepository, @@ -101,11 +108,40 @@ async def on_add_hours_remark_input( widget: MessageInput, dialog_manager: DialogManager, ): - if not message.text or len(message.text.strip()) < 1: - await message.answer("⚠️ Пожалуйста, введите причину добавления часов") + if message.media_group_id: + chat_id = message.chat.id + mgid = message.media_group_id + key = (chat_id, mgid) + _media_group_counters[key] = _media_group_counters.get(key, 0) + 1 + my_count = _media_group_counters[key] + _media_group_photos.setdefault(key, []).append(message.photo[-1].file_id) + if message.caption and message.caption.strip(): + _media_group_remarks[key] = message.caption.strip() + await asyncio.sleep(2.0) + if _media_group_counters.get(key, 0) == my_count: + remark = _media_group_remarks.pop(key, None) + file_ids = _media_group_photos.pop(key, []) + _media_group_counters.pop(key, None) + dialog_manager.dialog_data["hours_remark"] = remark + dialog_manager.dialog_data["photo_file_ids"] = file_ids + await dialog_manager.switch_to(AdminMenuSG.add_hours_confirm) return - - dialog_manager.dialog_data["hours_remark"] = message.text.strip() + + photo_file_ids = [] + remark = None + + if message.photo: + photo_file_ids = [message.photo[-1].file_id] + remark = message.caption.strip() if message.caption and message.caption.strip() else None + elif message.text and len(message.text.strip()) > 0: + remark = message.text.strip() + + if not photo_file_ids and not remark: + await message.answer("⚠️ Пожалуйста, отправьте текст или фото с подписью") + return + + dialog_manager.dialog_data["hours_remark"] = remark + dialog_manager.dialog_data["photo_file_ids"] = photo_file_ids await dialog_manager.switch_to(AdminMenuSG.add_hours_confirm) @@ -114,11 +150,40 @@ async def on_remove_hours_remark_input( widget: MessageInput, dialog_manager: DialogManager, ): - if message.text and len(message.text.strip()) > 0: - dialog_manager.dialog_data["hours_remark"] = message.text.strip() - else: - dialog_manager.dialog_data["hours_remark"] = None - + if message.media_group_id: + chat_id = message.chat.id + mgid = message.media_group_id + key = (chat_id, mgid) + _media_group_counters[key] = _media_group_counters.get(key, 0) + 1 + my_count = _media_group_counters[key] + _media_group_photos.setdefault(key, []).append(message.photo[-1].file_id) + if message.caption and message.caption.strip(): + _media_group_remarks[key] = message.caption.strip() + await asyncio.sleep(2.0) + if _media_group_counters.get(key, 0) == my_count: + remark = _media_group_remarks.pop(key, None) + file_ids = _media_group_photos.pop(key, []) + _media_group_counters.pop(key, None) + dialog_manager.dialog_data["hours_remark"] = remark + dialog_manager.dialog_data["photo_file_ids"] = file_ids + await dialog_manager.switch_to(AdminMenuSG.remove_hours_confirm) + return + + photo_file_ids = [] + remark = None + + if message.photo: + photo_file_ids = [message.photo[-1].file_id] + remark = message.caption.strip() if message.caption and message.caption.strip() else None + elif message.text and len(message.text.strip()) > 0: + remark = message.text.strip() + + if not photo_file_ids and not remark: + await message.answer("⚠️ Пожалуйста, отправьте текст или фото с подписью") + return + + dialog_manager.dialog_data["hours_remark"] = remark + dialog_manager.dialog_data["photo_file_ids"] = photo_file_ids await dialog_manager.switch_to(AdminMenuSG.remove_hours_confirm) @@ -128,6 +193,7 @@ async def on_skip_remark( dialog_manager: DialogManager, ): dialog_manager.dialog_data["hours_remark"] = None + dialog_manager.dialog_data["photo_file_ids"] = [] await dialog_manager.switch_to(AdminMenuSG.remove_hours_confirm) @@ -137,10 +203,13 @@ async def get_hours_confirm_data( ): hours = dialog_manager.dialog_data.get("selected_hours", 0) remark = dialog_manager.dialog_data.get("hours_remark") + photo_file_ids = dialog_manager.dialog_data.get("photo_file_ids", []) + has_photo = bool(photo_file_ids) return { "hours": hours, "remark": remark if remark else "Не указана", + "has_photo": f"✅ Да ({len(photo_file_ids)} шт.)" if has_photo else "❌ Нет", } @@ -160,12 +229,15 @@ async def on_add_hours_confirm( admin_id = callback.from_user.id if resident_id and hours: + photo_file_ids = dialog_manager.dialog_data.get("photo_file_ids", []) + photo_file_id = json.dumps(photo_file_ids) if photo_file_ids else None await transactions_repository.add_hours( resident_id=resident_id, amount=hours, admin_id=admin_id, is_active=True, remark=remark, + photo_file_id=photo_file_id, ) resident = await residents_repository.get_resident_by_id(resident_id) @@ -177,6 +249,12 @@ async def on_add_hours_confirm( try: await bot.send_message(resident.user_entity, notification_text) + if photo_file_ids: + if len(photo_file_ids) == 1: + await bot.send_photo(resident.user_entity, photo_file_ids[0]) + else: + media = [InputMediaPhoto(media=fid) for fid in photo_file_ids] + await bot.send_media_group(resident.user_entity, media=media) except (TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter): pass @@ -208,11 +286,14 @@ async def on_remove_hours_confirm( await dialog_manager.switch_to(AdminMenuSG.resident_info) return + photo_file_ids = dialog_manager.dialog_data.get("photo_file_ids", []) + photo_file_id = json.dumps(photo_file_ids) if photo_file_ids else None await transactions_repository.move_hours_to_completed( resident_id=resident_id, amount=hours, admin_id=admin_id, remark=remark, + photo_file_id=photo_file_id, ) resident = await residents_repository.get_resident_by_id(resident_id) @@ -233,6 +314,12 @@ async def on_remove_hours_confirm( try: await bot.send_message(resident.user_entity, notification_text) + if photo_file_ids: + if len(photo_file_ids) == 1: + await bot.send_photo(resident.user_entity, photo_file_ids[0]) + else: + media = [InputMediaPhoto(media=fid) for fid in photo_file_ids] + await bot.send_media_group(resident.user_entity, media=media) except (TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter): pass @@ -322,7 +409,7 @@ remove_hours_custom_window = Window( ) add_hours_remark_window = Window( - Const("
📝 Причина добавления часов
\n\n
Укажите причину добавления часов (обязательно).
"), + Const("
📝 Причина добавления часов
\n\n
Отправьте текст или фото с подписью.\nФото будет сохранено как описание операции.
"), MessageInput(on_add_hours_remark_input), SwitchTo( Const("◀️ Назад"), @@ -333,7 +420,7 @@ add_hours_remark_window = Window( ) remove_hours_remark_window = Window( - Const("
📝 Причина снятия часов
\n\n
Укажите причину снятия часов (необязательно).
"), + Const("
Причина снятия часов
\n\n
Отправьте текст или фото с подписью.\nФото будет сохранено как описание операции.\nМожно пропустить, нажав кнопку ниже.
"), MessageInput(on_remove_hours_remark_input), Button( Const("⏭ Пропустить"), @@ -349,7 +436,7 @@ remove_hours_remark_window = Window( ) add_hours_confirm_window = Window( - Format("
Подтверждение
\n\nВы уверены, что хотите добавить {hours} часов?\n\nПричина: {remark}"), + Format("
Подтверждение
\n\nВы уверены, что хотите добавить {hours} часов?\n\nПричина: {remark}\nФото: {has_photo}"), Row( Button( Const("✅ Да"), @@ -367,7 +454,7 @@ add_hours_confirm_window = Window( ) remove_hours_confirm_window = Window( - Format("
Подтверждение
\n\nВы уверены, что хотите отнять {hours} часов?\n\nПричина: {remark}"), + Format("
Подтверждение
\n\nВы уверены, что хотите отнять {hours} часов?\n\nПричина: {remark}\nФото: {has_photo}"), Row( Button( Const("✅ Да"), diff --git a/src/dutylog/application/bot/admin_dialogs/residents_management.py b/src/dutylog/application/bot/admin_dialogs/residents_management.py index 85c4241..69aa2f9 100644 --- a/src/dutylog/application/bot/admin_dialogs/residents_management.py +++ b/src/dutylog/application/bot/admin_dialogs/residents_management.py @@ -1,9 +1,13 @@ -from aiogram.types import Message, CallbackQuery +from aiogram import Bot +from aiogram.enums import ContentType +from aiogram.types import Message, CallbackQuery, InputMediaPhoto from aiogram.utils.markdown import html_decoration as hd from aiogram_dialog import Window, DialogManager from aiogram_dialog.widgets.text import Format, Const from aiogram_dialog.widgets.kbd import Row, SwitchTo, Button, ScrollingGroup, Select, Group from aiogram_dialog.widgets.input import MessageInput +from aiogram_dialog.widgets.media import DynamicMedia +from aiogram_dialog.api.entities.media import MediaAttachment, MediaId from magic_filter import F from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject @@ -448,10 +452,13 @@ async def on_create_resident_cancel( await dialog_manager.switch_to(AdminMenuSG.residents) +@inject async def on_search_input( message: Message, widget: MessageInput, dialog_manager: DialogManager, + residents_repository: FromDishka[ResidentsRepository], + users_repository: FromDishka[UsersRepository], ): if not message.text or len(message.text.strip()) < 1: await message.answer("⚠️ Пожалуйста, введите поисковый запрос") @@ -463,7 +470,16 @@ async def on_search_input( dialog_manager.dialog_data["search_query"] = query dialog_manager.dialog_data["is_search_active"] = True - await dialog_manager.switch_to(AdminMenuSG.residents_search_results) + + residents, _ = await residents_repository.search_residents(query, users_repository) + + if len(residents) == 1: + dialog_manager.dialog_data["selected_resident_id"] = residents[0].id + dialog_manager.dialog_data["from_search"] = True + dialog_manager.dialog_data["from_filter"] = False + await dialog_manager.switch_to(AdminMenuSG.resident_info) + else: + await dialog_manager.switch_to(AdminMenuSG.residents_search_results) @inject @@ -562,12 +578,12 @@ async def get_resident_history_data( resident_id = dialog_manager.dialog_data.get("selected_resident_id") if not resident_id: - return {"history_content": "Ошибка: резидент не выбран"} + return {"history_content": "Ошибка: резидент не выбран", "transactions": [], "has_transactions": False} resident = await residents_repository.get_resident_by_id(resident_id) if not resident: - return {"history_content": "Ошибка: резидент не найден"} + return {"history_content": "Ошибка: резидент не найден", "transactions": [], "has_transactions": False} transactions = await transactions_repository.get_resident_history(resident_id) transactions_sorted = sorted(transactions, key=lambda x: x.created_at) @@ -590,18 +606,190 @@ async def get_resident_history_data( Резидент: {resident_name} """ - for tx in last_10: - operation = "Начислено" if tx.transaction_type == "increase" else "Списано" - emoji = "+" if tx.transaction_type == "increase" else "−" - - msk_time = tx.created_at.astimezone(msk_now().tzinfo).replace(tzinfo=None) - date_str = msk_time.strftime("%d.%m.%Y %H:%M") - - remark_text = f"\n💬 {tx.remark}" if tx.remark else "" - - history_text += f"
{operation} {emoji}{tx.amount} ч\n📅 {date_str}{remark_text}
\n" - return {"history_content": history_text} + tx_items = [] + for tx in last_10: + emoji = "+" if tx.transaction_type == "increase" else "−" + msk_time = tx.created_at.astimezone(msk_now().tzinfo).replace(tzinfo=None) + date_str = msk_time.strftime("%d.%m.%Y %H:%M") + photo_mark = " 📷" if tx.photo_file_id else "" + label = f"{emoji}{tx.amount} ч | {date_str}{photo_mark}" + tx_items.append((label, str(tx.id))) + + return { + "history_content": history_text, + "transactions": tx_items, + "has_transactions": len(tx_items) > 0, + } + + +@inject +async def on_transaction_selected( + callback: CallbackQuery, + widget: Select, + dialog_manager: DialogManager, + item_id: str, +): + dialog_manager.dialog_data["selected_transaction_id"] = int(item_id) + await dialog_manager.switch_to(AdminMenuSG.resident_history_detail) + + +@inject +async def get_transaction_detail_data( + dialog_manager: DialogManager, + transactions_repository: FromDishka[HoursTransactionsRepository], + residents_repository: FromDishka[ResidentsRepository], + users_repository: FromDishka[UsersRepository], + **kwargs, +): + tx_id = dialog_manager.dialog_data.get("selected_transaction_id") + + if not tx_id: + return {"detail_content": "Ошибка: транзакция не выбрана", "photo_media": None, "has_multiple_photos": False, "photo_count": 0} + + tx = await transactions_repository.get_transaction_by_id(int(tx_id)) + + if not tx: + return {"detail_content": "Ошибка: транзакция не найдена", "photo_media": None, "has_multiple_photos": False, "photo_count": 0} + + operation = "Начислено" if tx.transaction_type == "increase" else "Списано" + emoji = "+" if tx.transaction_type == "increase" else "−" + msk_time = tx.created_at.astimezone(msk_now().tzinfo).replace(tzinfo=None) + date_str = msk_time.strftime("%d.%m.%Y %H:%M") + + remark_text = f"\n💬 {tx.remark}" if tx.remark else "" + + admin_info = "" + if tx.admin_id: + admin = await users_repository.get_user_by_id(tx.admin_id) + if admin: + admin_name = f"@{admin.username}" if admin.username else admin.first_name or f"ID: {admin.id}" + admin_info = f"\n👨‍💼 Администратор: {admin_name}" + + content = f""" +
📋 Детали транзакции
+ +⚙️ Операция: {operation} +🔢 Количество: {emoji}{tx.amount} ч +📅 Дата: {date_str}{remark_text}{admin_info} +""" + + photo_file_ids = HoursTransactionsRepository.get_photo_file_ids(tx) + photo_media = None + if photo_file_ids: + photo_media = MediaAttachment( + type=ContentType.PHOTO, + file_id=MediaId(file_id=photo_file_ids[0]), + ) + + return { + "detail_content": content, + "photo_media": photo_media, + "has_multiple_photos": len(photo_file_ids) > 1, + "photo_count": len(photo_file_ids), + } + + +@inject +async def on_show_all_photos( + callback: CallbackQuery, + button: Button, + dialog_manager: DialogManager, + transactions_repository: FromDishka[HoursTransactionsRepository], + **kwargs, +): + tx_id = dialog_manager.dialog_data.get("selected_transaction_id") + if not tx_id: + return + + tx = await transactions_repository.get_transaction_by_id(int(tx_id)) + if not tx: + return + + photo_file_ids = HoursTransactionsRepository.get_photo_file_ids(tx) + if len(photo_file_ids) <= 1: + return + + bot: Bot = kwargs["bot"] + media = [InputMediaPhoto(media=file_id) for file_id in photo_file_ids] + try: + await bot.send_media_group(callback.message.chat.id, media=media) + except Exception: + pass + + +async def on_search_transactions( + callback: CallbackQuery, + button: Button, + dialog_manager: DialogManager, +): + await dialog_manager.switch_to(AdminMenuSG.resident_history_search_input) + + +async def on_transaction_search_input( + message: Message, + widget: MessageInput, + dialog_manager: DialogManager, +): + if not message.text or len(message.text.strip()) < 1: + await message.answer("⚠️ Пожалуйста, введите поисковый запрос") + return + + dialog_manager.dialog_data["transaction_search_query"] = message.text.strip() + await dialog_manager.switch_to(AdminMenuSG.resident_history_search_results) + + +@inject +async def get_transaction_search_results_data( + dialog_manager: DialogManager, + residents_repository: FromDishka[ResidentsRepository], + transactions_repository: FromDishka[HoursTransactionsRepository], + **kwargs, +): + resident_id = dialog_manager.dialog_data.get("selected_resident_id") + query = dialog_manager.dialog_data.get("transaction_search_query", "") + + if not resident_id: + return {"content": "Ошибка: резидент не выбран", "search_transactions": [], "has_search_results": False} + + resident = await residents_repository.get_resident_by_id(resident_id) + resident_name = resident.real_name if resident and resident.real_name else "Без имени" + + transactions = await transactions_repository.search_resident_transactions(resident_id, query) + + if not transactions: + content = f""" +
🔍 Поиск по транзакциям
+ +Резидент: {resident_name} +Запрос: {query} + +❌ Ничего не найдено +""" + return {"content": content, "search_transactions": [], "has_search_results": False} + + content = f""" +
🔍 Поиск по транзакциям
+ +Резидент: {resident_name} +Запрос: {query} +Найдено: {len(transactions)} + +""" + search_transactions = [] + for tx in transactions: + emoji = "+" if tx.transaction_type == "increase" else "−" + msk_time = tx.created_at.astimezone(msk_now().tzinfo).replace(tzinfo=None) + date_str = msk_time.strftime("%d.%m.%Y %H:%M") + photo_mark = " 📷" if tx.photo_file_id else "" + label = f"{emoji}{tx.amount} ч | {date_str}{photo_mark}" + search_transactions.append((label, str(tx.id))) + + return { + "content": content, + "search_transactions": search_transactions, + "has_search_results": True, + } residents_list_window = Window( @@ -654,37 +842,42 @@ resident_info_window = Window( when=~F["is_admin"], ), Button( - Const("➖️ Отнять"), + Const("➖ Отнять"), id="remove_hours_btn", on_click=lambda c, b, m: m.switch_to(AdminMenuSG.remove_hours_select), when=~F["is_admin"], ), ), - Button( - Const("📜 История"), - id="resident_history_btn", - on_click=lambda c, b, m: m.switch_to(AdminMenuSG.resident_history), - when=~F["is_admin"], - ), Button( Const("🔄 Перепривязать к комнате"), id="rebind_resident_btn", on_click=on_rebind_resident, when=~F["is_admin"], ), + Button( + Const("🚪 Разлогинить"), + id="logout_resident_btn", + on_click=lambda c, b, m: m.switch_to(AdminMenuSG.resident_logout_confirm), + when=F["is_busy"] & ~F["is_admin"], + ), Row( Button( - Const("🚪 Разлогинить"), - id="logout_resident_btn", - on_click=lambda c, b, m: m.switch_to(AdminMenuSG.resident_logout_confirm), - when=F["is_busy"] & ~F["is_admin"], + Const("📜 История"), + id="resident_history_btn", + on_click=lambda c, b, m: m.switch_to(AdminMenuSG.resident_history), + when=~F["is_admin"], ), Button( Const("🗑 Удалить"), id="delete_resident_btn", on_click=lambda c, b, m: m.switch_to(AdminMenuSG.resident_delete_confirm), when=~F["is_admin"], - ) + ), + ), + Button( + Const("🔍 Поиск резидента"), + id="search_from_resident_info_btn", + on_click=on_search_residents, ), SwitchTo( Const("◀️ Назад к результатам поиска"), @@ -919,6 +1112,24 @@ resident_rebind_confirm_window = Window( resident_history_window = Window( Format("{history_content}"), + ScrollingGroup( + Select( + Format("{item[0]}"), + id="transactions_select", + item_id_getter=lambda x: x[1], + items="transactions", + on_click=on_transaction_selected, + ), + id="transactions_scroll", + width=1, + height=8, + when="has_transactions", + ), + Button( + Const("🔍 Поиск по транзакциям"), + id="search_transactions_btn", + on_click=on_search_transactions, + ), SwitchTo( Const("◀️ Назад"), id="back_to_resident_info", @@ -927,3 +1138,61 @@ resident_history_window = Window( state=AdminMenuSG.resident_history, getter=get_resident_history_data, ) + +resident_history_detail_window = Window( + DynamicMedia("photo_media"), + Format("{detail_content}"), + Button( + Format("📷 Все фото ({photo_count})"), + id="show_all_photos_btn", + on_click=on_show_all_photos, + when="has_multiple_photos", + ), + SwitchTo( + Const("◀️ К списку"), + id="back_to_history_from_detail", + state=AdminMenuSG.resident_history, + ), + state=AdminMenuSG.resident_history_detail, + getter=get_transaction_detail_data, +) + +resident_history_search_input_window = Window( + Const("
🔍 Поиск по транзакциям
\n\n
Введите поисковый запрос:\n• Текст для поиска по описанию\n• Число для поиска по количеству часов
"), + MessageInput(on_transaction_search_input), + SwitchTo( + Const("◀️ Назад к истории"), + id="back_to_history_from_search_input", + state=AdminMenuSG.resident_history, + ), + state=AdminMenuSG.resident_history_search_input, +) + +resident_history_search_results_window = Window( + Format("{content}"), + ScrollingGroup( + Select( + Format("{item[0]}"), + id="search_transactions_select", + item_id_getter=lambda x: x[1], + items="search_transactions", + on_click=on_transaction_selected, + ), + id="search_transactions_scroll", + width=1, + height=8, + when="has_search_results", + ), + SwitchTo( + Const("🔍 Новый поиск"), + id="new_transaction_search", + state=AdminMenuSG.resident_history_search_input, + ), + SwitchTo( + Const("◀️ К истории"), + id="back_to_history_from_search_results", + state=AdminMenuSG.resident_history, + ), + state=AdminMenuSG.resident_history_search_results, + getter=get_transaction_search_results_data, +) diff --git a/src/dutylog/application/bot/admin_dialogs/rooms_management.py b/src/dutylog/application/bot/admin_dialogs/rooms_management.py index 9433f78..439142f 100644 --- a/src/dutylog/application/bot/admin_dialogs/rooms_management.py +++ b/src/dutylog/application/bot/admin_dialogs/rooms_management.py @@ -1,10 +1,13 @@ from aiogram import Bot +from aiogram.enums import ContentType from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter -from aiogram.types import Message, CallbackQuery +from aiogram.types import Message, CallbackQuery, InputMediaPhoto from aiogram_dialog import Window, DialogManager from aiogram_dialog.widgets.text import Format, Const from aiogram_dialog.widgets.kbd import SwitchTo, Button, ScrollingGroup, Select, Row, Group from aiogram_dialog.widgets.input import MessageInput +from aiogram_dialog.widgets.media import DynamicMedia +from aiogram_dialog.api.entities.media import MediaAttachment, MediaId from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject @@ -444,12 +447,12 @@ async def get_room_history_data( room_id = dialog_manager.dialog_data.get("selected_room_id") if not room_id: - return {"history_content": "Ошибка: комната не выбрана"} + return {"history_content": "Ошибка: комната не выбрана", "transactions": [], "has_transactions": False} room = await rooms_repository.get_room_by_id(room_id) if not room: - return {"history_content": "Ошибка: комната не найдена"} + return {"history_content": "Ошибка: комната не найдена", "transactions": [], "has_transactions": False} transactions = await transactions_repository.get_room_transactions(room_id) last_10 = transactions[:10] @@ -458,18 +461,115 @@ async def get_room_history_data( history_text = f"
📜 История операций
\n\nКомната: {room.number}\n\nИстория операций пуста" else: history_text = f"
📜 История операций
\n\nКомната: {room.number}\n\n" - for tx in last_10: - operation = "Начислено" if tx.transaction_type == "increase" else "Списано" - emoji = "+" if tx.transaction_type == "increase" else "−" - - msk_time = tx.created_at.astimezone(msk_now().tzinfo).replace(tzinfo=None) - date_str = msk_time.strftime("%d.%m.%Y %H:%M") - - remark_text = f"\n💬 {tx.remark}" if tx.remark else "" - - history_text += f"
{operation} {emoji}{tx.amount} ч\n📅 {date_str}{remark_text}
\n" - return {"history_content": history_text} + tx_items = [] + for tx in last_10: + emoji = "+" if tx.transaction_type == "increase" else "−" + msk_time = tx.created_at.astimezone(msk_now().tzinfo).replace(tzinfo=None) + date_str = msk_time.strftime("%d.%m.%Y %H:%M") + photo_mark = " 📷" if tx.photo_file_id else "" + label = f"{emoji}{tx.amount} ч | {date_str}{photo_mark}" + tx_items.append((label, str(tx.id))) + + return { + "history_content": history_text, + "transactions": tx_items, + "has_transactions": len(tx_items) > 0, + } + + +@inject +async def on_room_transaction_selected( + callback: CallbackQuery, + widget: Select, + dialog_manager: DialogManager, + item_id: str, +): + dialog_manager.dialog_data["selected_transaction_id"] = int(item_id) + await dialog_manager.switch_to(AdminMenuSG.room_history_detail) + + +@inject +async def on_show_room_all_photos( + callback: CallbackQuery, + button: Button, + dialog_manager: DialogManager, + transactions_repository: FromDishka[HoursTransactionsRepository], + **kwargs, +): + tx_id = dialog_manager.dialog_data.get("selected_transaction_id") + if not tx_id: + return + + tx = await transactions_repository.get_transaction_by_id(int(tx_id)) + if not tx: + return + + photo_file_ids = HoursTransactionsRepository.get_photo_file_ids(tx) + if len(photo_file_ids) <= 1: + return + + bot: Bot = kwargs["bot"] + media = [InputMediaPhoto(media=file_id) for file_id in photo_file_ids] + try: + await bot.send_media_group(callback.message.chat.id, media=media) + except Exception: + pass + + +@inject +async def get_room_transaction_detail_data( + dialog_manager: DialogManager, + transactions_repository: FromDishka[HoursTransactionsRepository], + users_repository: FromDishka[UsersRepository], + **kwargs, +): + tx_id = dialog_manager.dialog_data.get("selected_transaction_id") + + if not tx_id: + return {"detail_content": "Ошибка: транзакция не выбрана", "photo_media": None, "has_multiple_photos": False, "photo_count": 0} + + tx = await transactions_repository.get_transaction_by_id(int(tx_id)) + + if not tx: + return {"detail_content": "Ошибка: транзакция не найдена", "photo_media": None, "has_multiple_photos": False, "photo_count": 0} + + operation = "Начислено" if tx.transaction_type == "increase" else "Списано" + emoji = "+" if tx.transaction_type == "increase" else "−" + msk_time = tx.created_at.astimezone(msk_now().tzinfo).replace(tzinfo=None) + date_str = msk_time.strftime("%d.%m.%Y %H:%M") + + remark_text = f"\n💬 {tx.remark}" if tx.remark else "" + + admin_info = "" + if tx.admin_id: + admin = await users_repository.get_user_by_id(tx.admin_id) + if admin: + admin_name = f"@{admin.username}" if admin.username else admin.first_name or f"ID: {admin.id}" + admin_info = f"\n👨‍💼 Администратор: {admin_name}" + + content = f""" +
📋 Детали транзакции
+ +⚙️ Операция: {operation} +🔢 Количество: {emoji}{tx.amount} ч +📅 Дата: {date_str}{remark_text}{admin_info} +""" + + photo_file_ids = HoursTransactionsRepository.get_photo_file_ids(tx) + photo_media = None + if photo_file_ids: + photo_media = MediaAttachment( + type=ContentType.PHOTO, + file_id=MediaId(file_id=photo_file_ids[0]), + ) + + return { + "detail_content": content, + "photo_media": photo_media, + "has_multiple_photos": len(photo_file_ids) > 1, + "photo_count": len(photo_file_ids), + } rooms_select_floor_window = Window( @@ -688,6 +788,19 @@ create_room_confirm_window = Window( room_history_window = Window( Format("{history_content}"), + ScrollingGroup( + Select( + Format("{item[0]}"), + id="room_transactions_select", + item_id_getter=lambda x: x[1], + items="transactions", + on_click=on_room_transaction_selected, + ), + id="room_transactions_scroll", + width=1, + height=8, + when="has_transactions", + ), SwitchTo( Const("◀️ Назад"), id="back_to_room_info", @@ -696,3 +809,21 @@ room_history_window = Window( state=AdminMenuSG.room_history, getter=get_room_history_data, ) + +room_history_detail_window = Window( + DynamicMedia("photo_media"), + Format("{detail_content}"), + Button( + Format("📷 Все фото ({photo_count})"), + id="show_room_all_photos_btn", + on_click=on_show_room_all_photos, + when="has_multiple_photos", + ), + SwitchTo( + Const("◀️ К списку"), + id="back_to_room_history_from_detail", + state=AdminMenuSG.room_history, + ), + state=AdminMenuSG.room_history_detail, + getter=get_room_transaction_detail_data, +) diff --git a/src/dutylog/application/bot/creator_dialogs/__init__.py b/src/dutylog/application/bot/creator_dialogs/__init__.py index f762690..0e49ba7 100644 --- a/src/dutylog/application/bot/creator_dialogs/__init__.py +++ b/src/dutylog/application/bot/creator_dialogs/__init__.py @@ -1,5 +1,6 @@ from dutylog.application.bot.creator_dialogs.transactions_history import ( transactions_history_window, + transactions_history_detail_window, ) -__all__ = ["transactions_history_window"] +__all__ = ["transactions_history_window", "transactions_history_detail_window"] diff --git a/src/dutylog/application/bot/creator_dialogs/transactions_history.py b/src/dutylog/application/bot/creator_dialogs/transactions_history.py index 64081b3..1baea96 100644 --- a/src/dutylog/application/bot/creator_dialogs/transactions_history.py +++ b/src/dutylog/application/bot/creator_dialogs/transactions_history.py @@ -1,6 +1,13 @@ +import json + +from aiogram import Bot +from aiogram.enums import ContentType +from aiogram.types import CallbackQuery, InputMediaPhoto from aiogram_dialog import Window from aiogram_dialog.widgets.text import Format, Const -from aiogram_dialog.widgets.kbd import SwitchTo +from aiogram_dialog.widgets.kbd import SwitchTo, ScrollingGroup, Select, Button +from aiogram_dialog.widgets.media import DynamicMedia +from aiogram_dialog.api.entities.media import MediaAttachment, MediaId from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject @@ -37,38 +44,152 @@ async def get_transactions_history_data( content = "
📜 История транзакций
\n\nНет транзакций" else: content = "
📜 История транзакций
\n\nПоследние 8 транзакций:\n\n" - - for tx in recent_transactions: - resident = await residents_repository.get_by_id(tx.resident_id) - if not resident: - continue - - room = await rooms_repository.get_by_id(resident.room) - room_number = room.number if room else "???" - - operation = "Начислено" if tx.transaction_type == "increase" else "Списано" - emoji = "+" if tx.transaction_type == "increase" else "−" - room_mark = " 🚪" if tx.per_room else "" - - msk_time = tx.created_at.astimezone(msk_now().tzinfo).replace(tzinfo=None) - date_str = msk_time.strftime("%d.%m.%Y %H:%M") - - admin_info = "" - if tx.admin_id: - admin = await users_repository.get_user_by_id(tx.admin_id) - if admin: - admin_name = f"@{admin.username}" if admin.username else admin.first_name or f"ID: {admin.id}" - admin_info = f"\n👨‍💼 {admin_name}" - - remark_text = f"\n💬 {tx.remark}" if tx.remark else "" - - content += f"
{operation} {emoji}{tx.amount} ч{room_mark}\n👤 {resident.real_name or 'Без имени'} (к. {room_number}){admin_info}\n📅 {date_str}{remark_text}
\n" - return {"content": content} + tx_items = [] + for tx in recent_transactions: + resident = await residents_repository.get_by_id(tx.resident_id) + if not resident: + continue + + room = await rooms_repository.get_by_id(resident.room) + room_number = room.number if room else "???" + + emoji = "+" if tx.transaction_type == "increase" else "−" + room_mark = " 🚪" if tx.per_room else "" + + msk_time = tx.created_at.astimezone(msk_now().tzinfo).replace(tzinfo=None) + date_str = msk_time.strftime("%d.%m.%Y %H:%M") + + photo_mark = " 📷" if tx.photo_file_id else "" + label = f"{emoji}{tx.amount} ч | {date_str}{photo_mark}" + tx_items.append((label, str(tx.id))) + + return { + "content": content, + "transactions": tx_items, + "has_transactions": len(tx_items) > 0, + } + + +@inject +async def on_creator_transaction_selected( + callback: CallbackQuery, + widget: Select, + dialog_manager, + item_id: str, +): + dialog_manager.dialog_data["selected_transaction_id"] = int(item_id) + await dialog_manager.switch_to(AdminMenuSG.transactions_history_detail) + + +@inject +async def on_show_creator_all_photos( + callback: CallbackQuery, + button: Button, + dialog_manager, + transactions_repository: FromDishka[HoursTransactionsRepository], + **kwargs, +): + tx_id = dialog_manager.dialog_data.get("selected_transaction_id") + if not tx_id: + return + + tx = await transactions_repository.get_transaction_by_id(int(tx_id)) + if not tx: + return + + photo_file_ids = HoursTransactionsRepository.get_photo_file_ids(tx) + if len(photo_file_ids) <= 1: + return + + bot: Bot = kwargs["bot"] + media = [InputMediaPhoto(media=file_id) for file_id in photo_file_ids] + try: + await bot.send_media_group(callback.message.chat.id, media=media) + except Exception: + pass + + +@inject +async def get_transaction_detail_data( + dialog_manager, + transactions_repository: FromDishka[HoursTransactionsRepository], + residents_repository: FromDishka[ResidentsRepository], + rooms_repository: FromDishka[RoomsRepository], + users_repository: FromDishka[UsersRepository], + **kwargs, +) -> dict: + tx_id = dialog_manager.dialog_data.get("selected_transaction_id") + + if not tx_id: + return {"detail_content": "Ошибка: транзакция не выбрана", "photo_media": None, "has_multiple_photos": False, "photo_count": 0} + + tx = await transactions_repository.get_transaction_by_id(int(tx_id)) + + if not tx: + return {"detail_content": "Ошибка: транзакция не найдена", "photo_media": None, "has_multiple_photos": False, "photo_count": 0} + + operation = "Начислено" if tx.transaction_type == "increase" else "Списано" + emoji = "+" if tx.transaction_type == "increase" else "−" + room_mark = " 🚪" if tx.per_room else "" + + msk_time = tx.created_at.astimezone(msk_now().tzinfo).replace(tzinfo=None) + date_str = msk_time.strftime("%d.%m.%Y %H:%M") + + resident = await residents_repository.get_by_id(tx.resident_id) + resident_name = resident.real_name if resident and resident.real_name else "Без имени" + room = await rooms_repository.get_by_id(resident.room) if resident else None + room_number = room.number if room else "???" + + admin_info = "" + if tx.admin_id: + admin = await users_repository.get_user_by_id(tx.admin_id) + if admin: + admin_name = f"@{admin.username}" if admin.username else admin.first_name or f"ID: {admin.id}" + admin_info = f"\n👨‍💼 Администратор: {admin_name}" + + remark_text = f"\n💬 {tx.remark}" if tx.remark else "" + + content = f""" +
📋 Детали транзакции
+ +⚙️ Операция: {operation} +🔢 Количество: {emoji}{tx.amount} ч{room_mark} +👤 Резидент: {resident_name} (к. {room_number}){admin_info} +📅 Дата: {date_str}{remark_text} +""" + + photo_file_ids = HoursTransactionsRepository.get_photo_file_ids(tx) + photo_media = None + if photo_file_ids: + photo_media = MediaAttachment( + type=ContentType.PHOTO, + file_id=MediaId(file_id=photo_file_ids[0]), + ) + + return { + "detail_content": content, + "photo_media": photo_media, + "has_multiple_photos": len(photo_file_ids) > 1, + "photo_count": len(photo_file_ids), + } transactions_history_window = Window( Format("{content}"), + ScrollingGroup( + Select( + Format("{item[0]}"), + id="creator_transactions_select", + item_id_getter=lambda x: x[1], + items="transactions", + on_click=on_creator_transaction_selected, + ), + id="creator_transactions_scroll", + width=1, + height=8, + when="has_transactions", + ), SwitchTo( Const("◀️ Назад"), id="back_to_main", @@ -77,3 +198,21 @@ transactions_history_window = Window( state=AdminMenuSG.transactions_history, getter=get_transactions_history_data, ) + +transactions_history_detail_window = Window( + DynamicMedia("photo_media"), + Format("{detail_content}"), + Button( + Format("📷 Все фото ({photo_count})"), + id="show_creator_all_photos_btn", + on_click=on_show_creator_all_photos, + when="has_multiple_photos", + ), + SwitchTo( + Const("◀️ К списку"), + id="back_to_transactions_from_detail", + state=AdminMenuSG.transactions_history, + ), + state=AdminMenuSG.transactions_history_detail, + getter=get_transaction_detail_data, +) 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 c66a4ea..8556395 100644 --- a/src/dutylog/application/bot/user_dialogs/main_menu_dialog.py +++ b/src/dutylog/application/bot/user_dialogs/main_menu_dialog.py @@ -1,7 +1,7 @@ from aiogram_dialog import Dialog from dutylog.application.bot.user_dialogs.user_menu.main_menu import main_menu_window -from dutylog.application.bot.user_dialogs.user_menu.history import history_window +from dutylog.application.bot.user_dialogs.user_menu.history import history_window, history_detail_window from dutylog.application.bot.user_dialogs.user_menu.top_residents import top_residents_window from dutylog.application.bot.user_dialogs.user_menu.faq import faq_window from dutylog.application.bot.user_dialogs.user_menu.feedback import feedback_window @@ -10,6 +10,7 @@ from dutylog.application.bot.user_dialogs.user_menu.feedback import feedback_win main_menu_dialog = Dialog( main_menu_window, history_window, + history_detail_window, top_residents_window, faq_window, feedback_window, diff --git a/src/dutylog/application/bot/user_dialogs/states.py b/src/dutylog/application/bot/user_dialogs/states.py index a86356b..9c32c66 100644 --- a/src/dutylog/application/bot/user_dialogs/states.py +++ b/src/dutylog/application/bot/user_dialogs/states.py @@ -4,6 +4,7 @@ from aiogram.fsm.state import State, StatesGroup class MainMenuSG(StatesGroup): main = State() history = State() + history_detail = State() top_residents = State() faq = State() feedback = State() @@ -19,6 +20,9 @@ class AdminMenuSG(StatesGroup): residents_filtered_results = State() resident_info = State() resident_history = State() + resident_history_search_input = State() + resident_history_search_results = State() + resident_history_detail = State() resident_logout_confirm = State() resident_delete_confirm = State() resident_rebind_floor = State() @@ -44,6 +48,7 @@ class AdminMenuSG(StatesGroup): rooms_list = State() room_info = State() room_history = State() + room_history_detail = State() room_delete_confirm = State() room_add_hours_select = State() room_add_hours_custom = State() @@ -67,6 +72,7 @@ class AdminMenuSG(StatesGroup): add_admin_select_user = State() add_admin_confirm = State() transactions_history = State() + transactions_history_detail = State() class CreatorMenuSG(StatesGroup): @@ -76,6 +82,7 @@ class CreatorMenuSG(StatesGroup): add_admin_select_user = State() add_admin_confirm = State() transactions_history = State() + transactions_history_detail = State() top_residents = State() diff --git a/src/dutylog/application/bot/user_dialogs/user_menu/history.py b/src/dutylog/application/bot/user_dialogs/user_menu/history.py index 786b33e..7a6b3ed 100644 --- a/src/dutylog/application/bot/user_dialogs/user_menu/history.py +++ b/src/dutylog/application/bot/user_dialogs/user_menu/history.py @@ -1,7 +1,11 @@ -from aiogram.types import User +from aiogram import Bot +from aiogram.enums import ContentType +from aiogram.types import User, CallbackQuery, InputMediaPhoto from aiogram_dialog import Window from aiogram_dialog.widgets.text import Format, Const -from aiogram_dialog.widgets.kbd import Back +from aiogram_dialog.widgets.kbd import Back, ScrollingGroup, Select, SwitchTo, Button +from aiogram_dialog.widgets.media import DynamicMedia +from aiogram_dialog.api.entities.media import MediaAttachment, MediaId from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject @@ -15,6 +19,9 @@ from dutylog.infrastructure.database.repositories.rooms_repository import ( from dutylog.infrastructure.database.repositories.hours_transactions_repository import ( HoursTransactionsRepository, ) +from dutylog.infrastructure.database.repositories.users_repository import ( + UsersRepository, +) from dutylog.infrastructure.utils.datetime import msk_now @@ -25,39 +32,169 @@ async def get_history_data( rooms_repository: FromDishka[RoomsRepository], transactions_repository: FromDishka[HoursTransactionsRepository], **kwargs, -) -> dict[str, str]: +) -> dict: resident = await residents_repository.get_resident_by_user_id(event_from_user.id) if not resident: history_text = "
📜 История операций
\n\n⚠️ Профиль не найден" - else: - transactions = await transactions_repository.get_resident_history(resident.id) - transactions_sorted = sorted(transactions, key=lambda x: x.created_at) - last_10 = transactions_sorted[-10:] + return {"history_content": history_text, "transactions": [], "has_transactions": False} + + transactions = await transactions_repository.get_resident_history(resident.id) + transactions_sorted = sorted(transactions, key=lambda x: x.created_at) + last_10 = transactions_sorted[-10:] - if not last_10: - history_text = "📜 История операций\n\n👤 Ваши операции:\nИстория операций пуста\n\n" - else: - history_text = "📜 История операций\n\n👤 Ваши операции:\n\n" - for tx in last_10: - operation = "Начислено" if tx.transaction_type == "increase" else "Списано" - emoji = "+" if tx.transaction_type == "increase" else "−" - - msk_time = tx.created_at.astimezone(msk_now().tzinfo).replace(tzinfo=None) - date_str = msk_time.strftime("%d.%m.%Y %H:%M") - - remark_text = f"\n💬 {tx.remark}" if tx.remark else "" - - room_mark = " 🚪" if tx.per_room else "" - - history_text += f"
{operation} {emoji}{tx.amount} ч{room_mark}\n📅 {date_str}{remark_text}
\n" + if not last_10: + history_text = "📜 История операций\n\n👤 Ваши операции:\nИстория операций пуста\n\n" + return {"history_content": history_text, "transactions": [], "has_transactions": False} + + history_text = "📜 История операций\n\n👤 Ваши операции:\n\n" + + tx_items = [] + for tx in last_10: + emoji = "+" if tx.transaction_type == "increase" else "−" + msk_time = tx.created_at.astimezone(msk_now().tzinfo).replace(tzinfo=None) + date_str = msk_time.strftime("%d.%m.%Y %H:%M") + photo_mark = " 📷" if tx.photo_file_id else "" + label = f"{emoji}{tx.amount} ч | {date_str}{photo_mark}" + tx_items.append((label, str(tx.id))) + + return { + "history_content": history_text, + "transactions": tx_items, + "has_transactions": len(tx_items) > 0, + } - return {"history_content": history_text} + +@inject +async def on_user_transaction_selected( + callback: CallbackQuery, + widget: Select, + dialog_manager, + item_id: str, +): + dialog_manager.dialog_data["selected_transaction_id"] = int(item_id) + await dialog_manager.switch_to(MainMenuSG.history_detail) + + +@inject +async def on_show_user_all_photos( + callback: CallbackQuery, + button: Button, + dialog_manager, + transactions_repository: FromDishka[HoursTransactionsRepository], + **kwargs, +): + tx_id = dialog_manager.dialog_data.get("selected_transaction_id") + if not tx_id: + return + + tx = await transactions_repository.get_transaction_by_id(int(tx_id)) + if not tx: + return + + photo_file_ids = HoursTransactionsRepository.get_photo_file_ids(tx) + if len(photo_file_ids) <= 1: + return + + bot: Bot = kwargs["bot"] + media = [InputMediaPhoto(media=file_id) for file_id in photo_file_ids] + try: + await bot.send_media_group(callback.message.chat.id, media=media) + except Exception: + pass + + +@inject +async def get_history_detail_data( + event_from_user: User, + dialog_manager, + transactions_repository: FromDishka[HoursTransactionsRepository], + residents_repository: FromDishka[ResidentsRepository], + users_repository: FromDishka[UsersRepository], + **kwargs, +) -> dict: + tx_id = dialog_manager.dialog_data.get("selected_transaction_id") + + if not tx_id: + return {"detail_content": "Ошибка: транзакция не выбрана", "photo_media": None, "has_multiple_photos": False, "photo_count": 0} + + tx = await transactions_repository.get_transaction_by_id(int(tx_id)) + + if not tx: + return {"detail_content": "Ошибка: транзакция не найдена", "photo_media": None, "has_multiple_photos": False, "photo_count": 0} + + operation = "Начислено" if tx.transaction_type == "increase" else "Списано" + emoji = "+" if tx.transaction_type == "increase" else "−" + msk_time = tx.created_at.astimezone(msk_now().tzinfo).replace(tzinfo=None) + date_str = msk_time.strftime("%d.%m.%Y %H:%M") + + remark_text = f"\n💬 {tx.remark}" if tx.remark else "" + + admin_info = "" + if tx.admin_id: + admin = await users_repository.get_user_by_id(tx.admin_id) + if admin: + admin_name = f"@{admin.username}" if admin.username else admin.first_name or f"ID: {admin.id}" + admin_info = f"\n👨‍💼 Администратор: {admin_name}" + + content = f""" +
📋 Детали транзакции
+ +⚙️ Операция: {operation} +🔢 Количество: {emoji}{tx.amount} ч +📅 Дата: {date_str}{remark_text}{admin_info} +""" + + photo_file_ids = HoursTransactionsRepository.get_photo_file_ids(tx) + photo_media = None + if photo_file_ids: + photo_media = MediaAttachment( + type=ContentType.PHOTO, + file_id=MediaId(file_id=photo_file_ids[0]), + ) + + return { + "detail_content": content, + "photo_media": photo_media, + "has_multiple_photos": len(photo_file_ids) > 1, + "photo_count": len(photo_file_ids), + } history_window = Window( Format("{history_content}"), + ScrollingGroup( + Select( + Format("{item[0]}"), + id="user_transactions_select", + item_id_getter=lambda x: x[1], + items="transactions", + on_click=on_user_transaction_selected, + ), + id="user_transactions_scroll", + width=1, + height=8, + when="has_transactions", + ), Back(Const("◀️ Назад")), state=MainMenuSG.history, getter=get_history_data, ) + +history_detail_window = Window( + DynamicMedia("photo_media"), + Format("{detail_content}"), + Button( + Format("📷 Все фото ({photo_count})"), + id="show_user_all_photos_btn", + on_click=on_show_user_all_photos, + when="has_multiple_photos", + ), + SwitchTo( + Const("◀️ К списку"), + id="back_to_history_from_detail", + state=MainMenuSG.history, + ), + state=MainMenuSG.history_detail, + getter=get_history_detail_data, +) diff --git a/src/dutylog/infrastructure/database/dao/hours_transactions_dao.py b/src/dutylog/infrastructure/database/dao/hours_transactions_dao.py index d27bba4..cc86bff 100644 --- a/src/dutylog/infrastructure/database/dao/hours_transactions_dao.py +++ b/src/dutylog/infrastructure/database/dao/hours_transactions_dao.py @@ -30,6 +30,23 @@ class HoursTransactionsDAO: ) return list(result.scalars().all()) + async def search_by_remark_or_amount( + self, resident_id: int, query: str + ) -> list[HoursTransaction]: + conditions = [HoursTransaction.resident_id == resident_id] + if query.isdigit(): + amount = int(query) + conditions.append(HoursTransaction.amount == amount) + else: + conditions.append(HoursTransaction.remark.ilike(f"%{query}%")) + + result = await self.session.execute( + select(HoursTransaction) + .where(*conditions) + .order_by(HoursTransaction.created_at.desc()) + ) + return list(result.scalars().all()) + async def create(self, transaction: HoursTransaction) -> HoursTransaction: self.session.add(transaction) 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 c7328cb..65570b2 100644 --- a/src/dutylog/infrastructure/database/models/hours_transaction.py +++ b/src/dutylog/infrastructure/database/models/hours_transaction.py @@ -1,7 +1,7 @@ from datetime import datetime from enum import Enum -from sqlalchemy import BigInteger, Boolean, Integer, String, DateTime, ForeignKey +from sqlalchemy import BigInteger, Boolean, Integer, String, DateTime, ForeignKey, Text from sqlalchemy.orm import Mapped, mapped_column from dutylog.infrastructure.database.models.base import Base @@ -26,6 +26,7 @@ class HoursTransaction(Base): BigInteger, ForeignKey("users.id", ondelete="SET NULL"), nullable=True ) remark: Mapped[str | None] = mapped_column(String(500), nullable=True) + photo_file_id: Mapped[str | None] = mapped_column(Text, nullable=True) per_room: Mapped[bool] = mapped_column( Boolean, default=False, server_default="false" ) diff --git a/src/dutylog/infrastructure/database/repositories/hours_transactions_repository.py b/src/dutylog/infrastructure/database/repositories/hours_transactions_repository.py index 10664eb..99e97ea 100644 --- a/src/dutylog/infrastructure/database/repositories/hours_transactions_repository.py +++ b/src/dutylog/infrastructure/database/repositories/hours_transactions_repository.py @@ -1,3 +1,5 @@ +import json + from dutylog.infrastructure.database.dao.hours_transactions_dao import ( HoursTransactionsDAO, ) @@ -29,6 +31,7 @@ class HoursTransactionsRepository: is_active: bool = True, remark: str | None = None, per_room: bool = False, + photo_file_id: str | None = None, ) -> tuple[HoursTransaction, Resident | None]: transaction = HoursTransaction( resident_id=resident_id, @@ -37,6 +40,7 @@ class HoursTransactionsRepository: admin_id=admin_id, remark=remark, per_room=per_room, + photo_file_id=photo_file_id, ) transaction = await self.transactions_dao.create(transaction) @@ -63,6 +67,7 @@ class HoursTransactionsRepository: is_active: bool = True, remark: str | None = None, per_room: bool = False, + photo_file_id: str | None = None, ) -> tuple[HoursTransaction, Resident | None]: transaction = HoursTransaction( resident_id=resident_id, @@ -71,6 +76,7 @@ class HoursTransactionsRepository: admin_id=admin_id, remark=remark, per_room=per_room, + photo_file_id=photo_file_id, ) transaction = await self.transactions_dao.create(transaction) @@ -96,6 +102,7 @@ class HoursTransactionsRepository: admin_id: int | None = None, remark: str | None = None, per_room: bool = False, + photo_file_id: str | None = None, ) -> tuple[HoursTransaction, Resident | None]: """Перемещает часы из неотработанных в отработанные""" transaction = HoursTransaction( @@ -105,6 +112,7 @@ class HoursTransactionsRepository: admin_id=admin_id, remark=remark, per_room=per_room, + photo_file_id=photo_file_id, ) transaction = await self.transactions_dao.create(transaction) @@ -123,6 +131,11 @@ class HoursTransactionsRepository: async def get_resident_history(self, resident_id: int) -> list[HoursTransaction]: return await self.transactions_dao.get_by_resident_id(resident_id) + async def search_resident_transactions( + self, resident_id: int, query: str + ) -> list[HoursTransaction]: + return await self.transactions_dao.search_by_remark_or_amount(resident_id, query) + async def get_all_transactions(self) -> list[HoursTransaction]: return await self.transactions_dao.get_all() @@ -174,3 +187,15 @@ class HoursTransactionsRepository: all_transactions.sort(key=lambda t: t.created_at, reverse=True) return all_transactions + + @staticmethod + def get_photo_file_ids(tx: HoursTransaction) -> list[str]: + if not tx.photo_file_id: + return [] + try: + ids = json.loads(tx.photo_file_id) + if isinstance(ids, list): + return ids + except (json.JSONDecodeError, TypeError): + pass + return [tx.photo_file_id]