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}
{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]