fix: media group photos saving, add photos to user notifications, admin info in transaction details, remove deep link fallback

- Fix media group collection: use module-level dicts instead of dialog_data
  to avoid race conditions between concurrent handler calls
- Add photo/media group sending in user notifications (add/remove hours)
- Add admin username with emoji to all transaction detail windows
- Add emoji prefixes to detail content fields
- Support multiple photos via JSON array in photo_file_id column
- Change photo_file_id column from String(512) to Text
- Add get_photo_file_ids() helper with backward compat for single file_id
- Add 'All photos' button for media groups in detail views
- Remove /start photo_ deep link fallback handler
- Add Alembic migration for column type change
This commit is contained in:
2026-07-15 00:45:58 +03:00
parent 8526440d14
commit 53f30c923b
14 changed files with 997 additions and 110 deletions
@@ -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')
@@ -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,
)
@@ -8,6 +8,9 @@ from dutylog.application.bot.admin_dialogs.residents_management import (
residents_list_window, residents_list_window,
resident_info_window, resident_info_window,
resident_history_window, resident_history_window,
resident_history_detail_window,
resident_history_search_input_window,
resident_history_search_results_window,
resident_logout_confirm_window, resident_logout_confirm_window,
resident_delete_confirm_window, resident_delete_confirm_window,
resident_rebind_floor_window, resident_rebind_floor_window,
@@ -36,6 +39,7 @@ from dutylog.application.bot.admin_dialogs.rooms_management import (
rooms_list_window, rooms_list_window,
room_info_window, room_info_window,
room_history_window, room_history_window,
room_history_detail_window,
room_delete_confirm_window, room_delete_confirm_window,
room_add_hours_select_window, room_add_hours_select_window,
room_add_hours_custom_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 ( from dutylog.application.bot.creator_dialogs.transactions_history import (
transactions_history_window, transactions_history_window,
transactions_history_detail_window,
) )
@@ -88,6 +93,9 @@ admin_menu_dialog = Dialog(
residents_list_window, residents_list_window,
resident_info_window, resident_info_window,
resident_history_window, resident_history_window,
resident_history_detail_window,
resident_history_search_input_window,
resident_history_search_results_window,
resident_logout_confirm_window, resident_logout_confirm_window,
resident_delete_confirm_window, resident_delete_confirm_window,
resident_rebind_floor_window, resident_rebind_floor_window,
@@ -113,6 +121,7 @@ admin_menu_dialog = Dialog(
rooms_list_window, rooms_list_window,
room_info_window, room_info_window,
room_history_window, room_history_window,
room_history_detail_window,
room_delete_confirm_window, room_delete_confirm_window,
room_add_hours_select_window, room_add_hours_select_window,
room_add_hours_custom_window, room_add_hours_custom_window,
@@ -141,4 +150,5 @@ admin_menu_dialog = Dialog(
add_admin_select_user_window, add_admin_select_user_window,
add_admin_confirm_window, add_admin_confirm_window,
transactions_history_window, transactions_history_window,
transactions_history_detail_window,
) )
@@ -1,6 +1,9 @@
import asyncio
import json
from aiogram import Bot from aiogram import Bot
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter 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 import Window, DialogManager
from aiogram_dialog.widgets.text import Format, Const from aiogram_dialog.widgets.text import Format, Const
from aiogram_dialog.widgets.kbd import Row, SwitchTo, Button, Select, Group 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 import FromDishka
from dishka.integrations.aiogram_dialog import inject 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.application.bot.user_dialogs.states import AdminMenuSG
from dutylog.infrastructure.database.repositories.residents_repository import ( from dutylog.infrastructure.database.repositories.residents_repository import (
ResidentsRepository, ResidentsRepository,
@@ -101,11 +108,40 @@ async def on_add_hours_remark_input(
widget: MessageInput, widget: MessageInput,
dialog_manager: DialogManager, dialog_manager: DialogManager,
): ):
if not message.text or len(message.text.strip()) < 1: if message.media_group_id:
await message.answer("⚠️ Пожалуйста, введите причину добавления часов") 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 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) await dialog_manager.switch_to(AdminMenuSG.add_hours_confirm)
@@ -114,11 +150,40 @@ async def on_remove_hours_remark_input(
widget: MessageInput, widget: MessageInput,
dialog_manager: DialogManager, dialog_manager: DialogManager,
): ):
if message.text and len(message.text.strip()) > 0: if message.media_group_id:
dialog_manager.dialog_data["hours_remark"] = message.text.strip() chat_id = message.chat.id
else: mgid = message.media_group_id
dialog_manager.dialog_data["hours_remark"] = None 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) await dialog_manager.switch_to(AdminMenuSG.remove_hours_confirm)
@@ -128,6 +193,7 @@ async def on_skip_remark(
dialog_manager: DialogManager, dialog_manager: DialogManager,
): ):
dialog_manager.dialog_data["hours_remark"] = None dialog_manager.dialog_data["hours_remark"] = None
dialog_manager.dialog_data["photo_file_ids"] = []
await dialog_manager.switch_to(AdminMenuSG.remove_hours_confirm) 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) hours = dialog_manager.dialog_data.get("selected_hours", 0)
remark = dialog_manager.dialog_data.get("hours_remark") 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 { return {
"hours": hours, "hours": hours,
"remark": remark if remark else "Не указана", "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 admin_id = callback.from_user.id
if resident_id and hours: 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( await transactions_repository.add_hours(
resident_id=resident_id, resident_id=resident_id,
amount=hours, amount=hours,
admin_id=admin_id, admin_id=admin_id,
is_active=True, is_active=True,
remark=remark, remark=remark,
photo_file_id=photo_file_id,
) )
resident = await residents_repository.get_resident_by_id(resident_id) resident = await residents_repository.get_resident_by_id(resident_id)
@@ -177,6 +249,12 @@ async def on_add_hours_confirm(
try: try:
await bot.send_message(resident.user_entity, notification_text) 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): except (TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter):
pass pass
@@ -208,11 +286,14 @@ async def on_remove_hours_confirm(
await dialog_manager.switch_to(AdminMenuSG.resident_info) await dialog_manager.switch_to(AdminMenuSG.resident_info)
return 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( await transactions_repository.move_hours_to_completed(
resident_id=resident_id, resident_id=resident_id,
amount=hours, amount=hours,
admin_id=admin_id, admin_id=admin_id,
remark=remark, remark=remark,
photo_file_id=photo_file_id,
) )
resident = await residents_repository.get_resident_by_id(resident_id) resident = await residents_repository.get_resident_by_id(resident_id)
@@ -233,6 +314,12 @@ async def on_remove_hours_confirm(
try: try:
await bot.send_message(resident.user_entity, notification_text) 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): except (TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter):
pass pass
@@ -322,7 +409,7 @@ remove_hours_custom_window = Window(
) )
add_hours_remark_window = Window( add_hours_remark_window = Window(
Const("<blockquote>📝 <b>Причина добавления часов</b></blockquote>\n\n<blockquote>Укажите причину добавления часов (обязательно).</blockquote>"), Const("<blockquote>📝 <b>Причина добавления часов</b></blockquote>\n\n<blockquote>Отправьте текст или фото с подписью.\nФото будет сохранено как описание операции.</blockquote>"),
MessageInput(on_add_hours_remark_input), MessageInput(on_add_hours_remark_input),
SwitchTo( SwitchTo(
Const("◀️ Назад"), Const("◀️ Назад"),
@@ -333,7 +420,7 @@ add_hours_remark_window = Window(
) )
remove_hours_remark_window = Window( remove_hours_remark_window = Window(
Const("<blockquote>📝 <b>Причина снятия часов</b></blockquote>\n\n<blockquote>Укажите причину снятия часов (необязательно).</blockquote>"), Const("<blockquote> <b>Причина снятия часов</b></blockquote>\n\n<blockquote>Отправьте текст или фото с подписью.\nФото будет сохранено как описание операции.\nМожно пропустить, нажав кнопку ниже.</blockquote>"),
MessageInput(on_remove_hours_remark_input), MessageInput(on_remove_hours_remark_input),
Button( Button(
Const("⏭ Пропустить"), Const("⏭ Пропустить"),
@@ -349,7 +436,7 @@ remove_hours_remark_window = Window(
) )
add_hours_confirm_window = Window( add_hours_confirm_window = Window(
Format("<blockquote> <b>Подтверждение</b></blockquote>\n\nВы уверены, что хотите добавить <code>{hours}</code> часов?\n\n<b>Причина:</b> {remark}"), Format("<blockquote> <b>Подтверждение</b></blockquote>\n\nВы уверены, что хотите добавить <code>{hours}</code> часов?\n\n<b>Причина:</b> {remark}\n<b>Фото:</b> {has_photo}"),
Row( Row(
Button( Button(
Const("✅ Да"), Const("✅ Да"),
@@ -367,7 +454,7 @@ add_hours_confirm_window = Window(
) )
remove_hours_confirm_window = Window( remove_hours_confirm_window = Window(
Format("<blockquote> <b>Подтверждение</b></blockquote>\n\nВы уверены, что хотите отнять <code>{hours}</code> часов?\n\n<b>Причина:</b> {remark}"), Format("<blockquote> <b>Подтверждение</b></blockquote>\n\nВы уверены, что хотите отнять <code>{hours}</code> часов?\n\n<b>Причина:</b> {remark}\n<b>Фото:</b> {has_photo}"),
Row( Row(
Button( Button(
Const("✅ Да"), Const("✅ Да"),
@@ -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.utils.markdown import html_decoration as hd
from aiogram_dialog import Window, DialogManager from aiogram_dialog import Window, DialogManager
from aiogram_dialog.widgets.text import Format, Const from aiogram_dialog.widgets.text import Format, Const
from aiogram_dialog.widgets.kbd import Row, SwitchTo, Button, ScrollingGroup, Select, Group from aiogram_dialog.widgets.kbd import Row, SwitchTo, Button, ScrollingGroup, Select, Group
from aiogram_dialog.widgets.input import MessageInput 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 magic_filter import F
from dishka import FromDishka from dishka import FromDishka
from dishka.integrations.aiogram_dialog import inject from dishka.integrations.aiogram_dialog import inject
@@ -448,10 +452,13 @@ async def on_create_resident_cancel(
await dialog_manager.switch_to(AdminMenuSG.residents) await dialog_manager.switch_to(AdminMenuSG.residents)
@inject
async def on_search_input( async def on_search_input(
message: Message, message: Message,
widget: MessageInput, widget: MessageInput,
dialog_manager: DialogManager, dialog_manager: DialogManager,
residents_repository: FromDishka[ResidentsRepository],
users_repository: FromDishka[UsersRepository],
): ):
if not message.text or len(message.text.strip()) < 1: if not message.text or len(message.text.strip()) < 1:
await message.answer("⚠️ Пожалуйста, введите поисковый запрос") await message.answer("⚠️ Пожалуйста, введите поисковый запрос")
@@ -463,7 +470,16 @@ async def on_search_input(
dialog_manager.dialog_data["search_query"] = query dialog_manager.dialog_data["search_query"] = query
dialog_manager.dialog_data["is_search_active"] = True 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 @inject
@@ -562,12 +578,12 @@ async def get_resident_history_data(
resident_id = dialog_manager.dialog_data.get("selected_resident_id") resident_id = dialog_manager.dialog_data.get("selected_resident_id")
if not 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) resident = await residents_repository.get_resident_by_id(resident_id)
if not resident: if not resident:
return {"history_content": "Ошибка: резидент не найден"} return {"history_content": "Ошибка: резидент не найден", "transactions": [], "has_transactions": False}
transactions = await transactions_repository.get_resident_history(resident_id) transactions = await transactions_repository.get_resident_history(resident_id)
transactions_sorted = sorted(transactions, key=lambda x: x.created_at) transactions_sorted = sorted(transactions, key=lambda x: x.created_at)
@@ -590,18 +606,190 @@ async def get_resident_history_data(
<b>Резидент:</b> {resident_name} <b>Резидент:</b> {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) tx_items = []
date_str = msk_time.strftime("%d.%m.%Y %H:%M") 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)))
remark_text = f"\n💬 <i>{tx.remark}</i>" if tx.remark else "" return {
"history_content": history_text,
"transactions": tx_items,
"has_transactions": len(tx_items) > 0,
}
history_text += f"<blockquote><b>{operation}</b> {emoji}<code>{tx.amount}</code> ч\n📅 {date_str}{remark_text}</blockquote>\n"
return {"history_content": history_text} @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💬 <i>{tx.remark}</i>" 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👨‍💼 <b>Администратор:</b> {admin_name}"
content = f"""
<blockquote>📋 <b>Детали транзакции</b></blockquote>
⚙️ <b>Операция:</b> {operation}
🔢 <b>Количество:</b> {emoji}<code>{tx.amount}</code> ч
📅 <b>Дата:</b> {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"""
<blockquote>🔍 <b>Поиск по транзакциям</b></blockquote>
<b>Резидент:</b> {resident_name}
<b>Запрос:</b> <code>{query}</code>
❌ Ничего не найдено
"""
return {"content": content, "search_transactions": [], "has_search_results": False}
content = f"""
<blockquote>🔍 <b>Поиск по транзакциям</b></blockquote>
<b>Резидент:</b> {resident_name}
<b>Запрос:</b> <code>{query}</code>
<b>Найдено:</b> <code>{len(transactions)}</code>
"""
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( residents_list_window = Window(
@@ -654,37 +842,42 @@ resident_info_window = Window(
when=~F["is_admin"], when=~F["is_admin"],
), ),
Button( Button(
Const(" Отнять"), Const(" Отнять"),
id="remove_hours_btn", id="remove_hours_btn",
on_click=lambda c, b, m: m.switch_to(AdminMenuSG.remove_hours_select), on_click=lambda c, b, m: m.switch_to(AdminMenuSG.remove_hours_select),
when=~F["is_admin"], 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( Button(
Const("🔄 Перепривязать к комнате"), Const("🔄 Перепривязать к комнате"),
id="rebind_resident_btn", id="rebind_resident_btn",
on_click=on_rebind_resident, on_click=on_rebind_resident,
when=~F["is_admin"], 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( Row(
Button( Button(
Const("🚪 Разлогинить"), Const("📜 История"),
id="logout_resident_btn", id="resident_history_btn",
on_click=lambda c, b, m: m.switch_to(AdminMenuSG.resident_logout_confirm), on_click=lambda c, b, m: m.switch_to(AdminMenuSG.resident_history),
when=F["is_busy"] & ~F["is_admin"], when=~F["is_admin"],
), ),
Button( Button(
Const("🗑 Удалить"), Const("🗑 Удалить"),
id="delete_resident_btn", id="delete_resident_btn",
on_click=lambda c, b, m: m.switch_to(AdminMenuSG.resident_delete_confirm), on_click=lambda c, b, m: m.switch_to(AdminMenuSG.resident_delete_confirm),
when=~F["is_admin"], when=~F["is_admin"],
) ),
),
Button(
Const("🔍 Поиск резидента"),
id="search_from_resident_info_btn",
on_click=on_search_residents,
), ),
SwitchTo( SwitchTo(
Const("◀️ Назад к результатам поиска"), Const("◀️ Назад к результатам поиска"),
@@ -919,6 +1112,24 @@ resident_rebind_confirm_window = Window(
resident_history_window = Window( resident_history_window = Window(
Format("{history_content}"), 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( SwitchTo(
Const("◀️ Назад"), Const("◀️ Назад"),
id="back_to_resident_info", id="back_to_resident_info",
@@ -927,3 +1138,61 @@ resident_history_window = Window(
state=AdminMenuSG.resident_history, state=AdminMenuSG.resident_history,
getter=get_resident_history_data, 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("<blockquote>🔍 <b>Поиск по транзакциям</b></blockquote>\n\n<blockquote>Введите поисковый запрос:\n• Текст для поиска по описанию\n• Число для поиска по количеству часов</blockquote>"),
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,
)
@@ -1,10 +1,13 @@
from aiogram import Bot from aiogram import Bot
from aiogram.enums import ContentType
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter 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 import Window, DialogManager
from aiogram_dialog.widgets.text import Format, Const from aiogram_dialog.widgets.text import Format, Const
from aiogram_dialog.widgets.kbd import SwitchTo, Button, ScrollingGroup, Select, Row, Group from aiogram_dialog.widgets.kbd import SwitchTo, Button, ScrollingGroup, Select, Row, Group
from aiogram_dialog.widgets.input import MessageInput 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 import FromDishka
from dishka.integrations.aiogram_dialog import inject 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") room_id = dialog_manager.dialog_data.get("selected_room_id")
if not 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) room = await rooms_repository.get_room_by_id(room_id)
if not room: if not room:
return {"history_content": "Ошибка: комната не найдена"} return {"history_content": "Ошибка: комната не найдена", "transactions": [], "has_transactions": False}
transactions = await transactions_repository.get_room_transactions(room_id) transactions = await transactions_repository.get_room_transactions(room_id)
last_10 = transactions[:10] last_10 = transactions[:10]
@@ -458,18 +461,115 @@ async def get_room_history_data(
history_text = f"<blockquote>📜 <b>История операций</b></blockquote>\n\n<b>Комната:</b> {room.number}\n\n<i>История операций пуста</i>" history_text = f"<blockquote>📜 <b>История операций</b></blockquote>\n\n<b>Комната:</b> {room.number}\n\n<i>История операций пуста</i>"
else: else:
history_text = f"<blockquote>📜 <b>История операций</b></blockquote>\n\n<b>Комната:</b> {room.number}\n\n" history_text = f"<blockquote>📜 <b>История операций</b></blockquote>\n\n<b>Комната:</b> {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) tx_items = []
date_str = msk_time.strftime("%d.%m.%Y %H:%M") 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)))
remark_text = f"\n💬 <i>{tx.remark}<t/i>" if tx.remark else "" return {
"history_content": history_text,
"transactions": tx_items,
"has_transactions": len(tx_items) > 0,
}
history_text += f"<blockquote><b>{operation}</b> {emoji}<code>{tx.amount}</code> ч\n📅 {date_str}{remark_text}</blockquote>\n"
return {"history_content": history_text} @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💬 <i>{tx.remark}</i>" 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👨‍💼 <b>Администратор:</b> {admin_name}"
content = f"""
<blockquote>📋 <b>Детали транзакции</b></blockquote>
⚙️ <b>Операция:</b> {operation}
🔢 <b>Количество:</b> {emoji}<code>{tx.amount}</code> ч
📅 <b>Дата:</b> {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( rooms_select_floor_window = Window(
@@ -688,6 +788,19 @@ create_room_confirm_window = Window(
room_history_window = Window( room_history_window = Window(
Format("{history_content}"), 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( SwitchTo(
Const("◀️ Назад"), Const("◀️ Назад"),
id="back_to_room_info", id="back_to_room_info",
@@ -696,3 +809,21 @@ room_history_window = Window(
state=AdminMenuSG.room_history, state=AdminMenuSG.room_history,
getter=get_room_history_data, 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,
)
@@ -1,5 +1,6 @@
from dutylog.application.bot.creator_dialogs.transactions_history import ( from dutylog.application.bot.creator_dialogs.transactions_history import (
transactions_history_window, transactions_history_window,
transactions_history_detail_window,
) )
__all__ = ["transactions_history_window"] __all__ = ["transactions_history_window", "transactions_history_detail_window"]
@@ -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 import Window
from aiogram_dialog.widgets.text import Format, Const 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 import FromDishka
from dishka.integrations.aiogram_dialog import inject from dishka.integrations.aiogram_dialog import inject
@@ -38,37 +45,151 @@ async def get_transactions_history_data(
else: else:
content = "<blockquote>📜 <b>История транзакций</b></blockquote>\n\n<i>Последние 8 транзакций:</i>\n\n" content = "<blockquote>📜 <b>История транзакций</b></blockquote>\n\n<i>Последние 8 транзакций:</i>\n\n"
for tx in recent_transactions: tx_items = []
resident = await residents_repository.get_by_id(tx.resident_id) for tx in recent_transactions:
if not resident: resident = await residents_repository.get_by_id(tx.resident_id)
continue if not resident:
continue
room = await rooms_repository.get_by_id(resident.room) room = await rooms_repository.get_by_id(resident.room)
room_number = room.number if room else "???" room_number = room.number if room else "???"
operation = "Начислено" if tx.transaction_type == "increase" else "Списано" emoji = "+" if tx.transaction_type == "increase" else ""
emoji = "+" if tx.transaction_type == "increase" else "" room_mark = " 🚪" if tx.per_room else ""
room_mark = " 🚪" if tx.per_room else ""
msk_time = tx.created_at.astimezone(msk_now().tzinfo).replace(tzinfo=None) msk_time = tx.created_at.astimezone(msk_now().tzinfo).replace(tzinfo=None)
date_str = msk_time.strftime("%d.%m.%Y %H:%M") date_str = msk_time.strftime("%d.%m.%Y %H:%M")
admin_info = "" photo_mark = " 📷" if tx.photo_file_id else ""
if tx.admin_id: label = f"{emoji}{tx.amount} ч | {date_str}{photo_mark}"
admin = await users_repository.get_user_by_id(tx.admin_id) tx_items.append((label, str(tx.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💬 <i>{tx.remark}</i>" if tx.remark else "" return {
"content": content,
"transactions": tx_items,
"has_transactions": len(tx_items) > 0,
}
content += f"<blockquote><b>{operation}</b> {emoji}<code>{tx.amount}</code> ч{room_mark}\n👤 {resident.real_name or 'Без имени'} (к. {room_number}){admin_info}\n📅 {date_str}{remark_text}</blockquote>\n"
return {"content": content} @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👨‍💼 <b>Администратор:</b> {admin_name}"
remark_text = f"\n💬 <i>{tx.remark}</i>" if tx.remark else ""
content = f"""
<blockquote>📋 <b>Детали транзакции</b></blockquote>
⚙️ <b>Операция:</b> {operation}
🔢 <b>Количество:</b> {emoji}<code>{tx.amount}</code> ч{room_mark}
👤 <b>Резидент:</b> {resident_name} (к. {room_number}){admin_info}
📅 <b>Дата:</b> {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( transactions_history_window = Window(
Format("{content}"), 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( SwitchTo(
Const("◀️ Назад"), Const("◀️ Назад"),
id="back_to_main", id="back_to_main",
@@ -77,3 +198,21 @@ transactions_history_window = Window(
state=AdminMenuSG.transactions_history, state=AdminMenuSG.transactions_history,
getter=get_transactions_history_data, 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,
)
@@ -1,7 +1,7 @@
from aiogram_dialog import Dialog 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.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.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.faq import faq_window
from dutylog.application.bot.user_dialogs.user_menu.feedback import feedback_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_dialog = Dialog(
main_menu_window, main_menu_window,
history_window, history_window,
history_detail_window,
top_residents_window, top_residents_window,
faq_window, faq_window,
feedback_window, feedback_window,
@@ -4,6 +4,7 @@ from aiogram.fsm.state import State, StatesGroup
class MainMenuSG(StatesGroup): class MainMenuSG(StatesGroup):
main = State() main = State()
history = State() history = State()
history_detail = State()
top_residents = State() top_residents = State()
faq = State() faq = State()
feedback = State() feedback = State()
@@ -19,6 +20,9 @@ class AdminMenuSG(StatesGroup):
residents_filtered_results = State() residents_filtered_results = State()
resident_info = State() resident_info = State()
resident_history = State() resident_history = State()
resident_history_search_input = State()
resident_history_search_results = State()
resident_history_detail = State()
resident_logout_confirm = State() resident_logout_confirm = State()
resident_delete_confirm = State() resident_delete_confirm = State()
resident_rebind_floor = State() resident_rebind_floor = State()
@@ -44,6 +48,7 @@ class AdminMenuSG(StatesGroup):
rooms_list = State() rooms_list = State()
room_info = State() room_info = State()
room_history = State() room_history = State()
room_history_detail = State()
room_delete_confirm = State() room_delete_confirm = State()
room_add_hours_select = State() room_add_hours_select = State()
room_add_hours_custom = State() room_add_hours_custom = State()
@@ -67,6 +72,7 @@ class AdminMenuSG(StatesGroup):
add_admin_select_user = State() add_admin_select_user = State()
add_admin_confirm = State() add_admin_confirm = State()
transactions_history = State() transactions_history = State()
transactions_history_detail = State()
class CreatorMenuSG(StatesGroup): class CreatorMenuSG(StatesGroup):
@@ -76,6 +82,7 @@ class CreatorMenuSG(StatesGroup):
add_admin_select_user = State() add_admin_select_user = State()
add_admin_confirm = State() add_admin_confirm = State()
transactions_history = State() transactions_history = State()
transactions_history_detail = State()
top_residents = State() top_residents = State()
@@ -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 import Window
from aiogram_dialog.widgets.text import Format, Const 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 import FromDishka
from dishka.integrations.aiogram_dialog import inject 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 ( from dutylog.infrastructure.database.repositories.hours_transactions_repository import (
HoursTransactionsRepository, HoursTransactionsRepository,
) )
from dutylog.infrastructure.database.repositories.users_repository import (
UsersRepository,
)
from dutylog.infrastructure.utils.datetime import msk_now from dutylog.infrastructure.utils.datetime import msk_now
@@ -25,39 +32,169 @@ async def get_history_data(
rooms_repository: FromDishka[RoomsRepository], rooms_repository: FromDishka[RoomsRepository],
transactions_repository: FromDishka[HoursTransactionsRepository], transactions_repository: FromDishka[HoursTransactionsRepository],
**kwargs, **kwargs,
) -> dict[str, str]: ) -> dict:
resident = await residents_repository.get_resident_by_user_id(event_from_user.id) resident = await residents_repository.get_resident_by_user_id(event_from_user.id)
if not resident: if not resident:
history_text = "<blockquote>📜 <b>История операций</b></blockquote>\n\n⚠️ <i>Профиль не найден</i>" history_text = "<blockquote>📜 <b>История операций</b></blockquote>\n\n⚠️ <i>Профиль не найден</i>"
else: 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: transactions = await transactions_repository.get_resident_history(resident.id)
history_text = "📜 <b>История операций</b>\n\n<b>👤 Ваши операции:</b>\n<i>История операций пуста</i>\n\n" transactions_sorted = sorted(transactions, key=lambda x: x.created_at)
else: last_10 = transactions_sorted[-10:]
history_text = "📜 <b>История операций</b>\n\n<b>👤 Ваши операции:</b>\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) if not last_10:
date_str = msk_time.strftime("%d.%m.%Y %H:%M") history_text = "📜 <b>История операций</b>\n\n<b>👤 Ваши операции:</b>\n<i>История операций пуста</i>\n\n"
return {"history_content": history_text, "transactions": [], "has_transactions": False}
remark_text = f"\n💬 <i>{tx.remark}</i>" if tx.remark else "" history_text = "📜 <b>История операций</b>\n\n<b>👤 Ваши операции:</b>\n\n"
room_mark = " 🚪" if tx.per_room else "" 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)))
history_text += f"<blockquote><b>{operation}</b> {emoji}<code>{tx.amount}</code> ч{room_mark}\n📅 {date_str}{remark_text}</blockquote>\n" 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💬 <i>{tx.remark}</i>" 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👨‍💼 <b>Администратор:</b> {admin_name}"
content = f"""
<blockquote>📋 <b>Детали транзакции</b></blockquote>
⚙️ <b>Операция:</b> {operation}
🔢 <b>Количество:</b> {emoji}<code>{tx.amount}</code> ч
📅 <b>Дата:</b> {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( history_window = Window(
Format("{history_content}"), 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("◀️ Назад")), Back(Const("◀️ Назад")),
state=MainMenuSG.history, state=MainMenuSG.history,
getter=get_history_data, 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,
)
@@ -30,6 +30,23 @@ class HoursTransactionsDAO:
) )
return list(result.scalars().all()) 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: async def create(self, transaction: HoursTransaction) -> HoursTransaction:
self.session.add(transaction) self.session.add(transaction)
await self.session.commit() await self.session.commit()
@@ -1,7 +1,7 @@
from datetime import datetime from datetime import datetime
from enum import Enum 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 sqlalchemy.orm import Mapped, mapped_column
from dutylog.infrastructure.database.models.base import Base from dutylog.infrastructure.database.models.base import Base
@@ -26,6 +26,7 @@ class HoursTransaction(Base):
BigInteger, ForeignKey("users.id", ondelete="SET NULL"), nullable=True BigInteger, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
) )
remark: Mapped[str | None] = mapped_column(String(500), 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( per_room: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false" Boolean, default=False, server_default="false"
) )
@@ -1,3 +1,5 @@
import json
from dutylog.infrastructure.database.dao.hours_transactions_dao import ( from dutylog.infrastructure.database.dao.hours_transactions_dao import (
HoursTransactionsDAO, HoursTransactionsDAO,
) )
@@ -29,6 +31,7 @@ class HoursTransactionsRepository:
is_active: bool = True, is_active: bool = True,
remark: str | None = None, remark: str | None = None,
per_room: bool = False, per_room: bool = False,
photo_file_id: str | None = None,
) -> tuple[HoursTransaction, Resident | None]: ) -> tuple[HoursTransaction, Resident | None]:
transaction = HoursTransaction( transaction = HoursTransaction(
resident_id=resident_id, resident_id=resident_id,
@@ -37,6 +40,7 @@ class HoursTransactionsRepository:
admin_id=admin_id, admin_id=admin_id,
remark=remark, remark=remark,
per_room=per_room, per_room=per_room,
photo_file_id=photo_file_id,
) )
transaction = await self.transactions_dao.create(transaction) transaction = await self.transactions_dao.create(transaction)
@@ -63,6 +67,7 @@ class HoursTransactionsRepository:
is_active: bool = True, is_active: bool = True,
remark: str | None = None, remark: str | None = None,
per_room: bool = False, per_room: bool = False,
photo_file_id: str | None = None,
) -> tuple[HoursTransaction, Resident | None]: ) -> tuple[HoursTransaction, Resident | None]:
transaction = HoursTransaction( transaction = HoursTransaction(
resident_id=resident_id, resident_id=resident_id,
@@ -71,6 +76,7 @@ class HoursTransactionsRepository:
admin_id=admin_id, admin_id=admin_id,
remark=remark, remark=remark,
per_room=per_room, per_room=per_room,
photo_file_id=photo_file_id,
) )
transaction = await self.transactions_dao.create(transaction) transaction = await self.transactions_dao.create(transaction)
@@ -96,6 +102,7 @@ class HoursTransactionsRepository:
admin_id: int | None = None, admin_id: int | None = None,
remark: str | None = None, remark: str | None = None,
per_room: bool = False, per_room: bool = False,
photo_file_id: str | None = None,
) -> tuple[HoursTransaction, Resident | None]: ) -> tuple[HoursTransaction, Resident | None]:
"""Перемещает часы из неотработанных в отработанные""" """Перемещает часы из неотработанных в отработанные"""
transaction = HoursTransaction( transaction = HoursTransaction(
@@ -105,6 +112,7 @@ class HoursTransactionsRepository:
admin_id=admin_id, admin_id=admin_id,
remark=remark, remark=remark,
per_room=per_room, per_room=per_room,
photo_file_id=photo_file_id,
) )
transaction = await self.transactions_dao.create(transaction) transaction = await self.transactions_dao.create(transaction)
@@ -123,6 +131,11 @@ class HoursTransactionsRepository:
async def get_resident_history(self, resident_id: int) -> list[HoursTransaction]: async def get_resident_history(self, resident_id: int) -> list[HoursTransaction]:
return await self.transactions_dao.get_by_resident_id(resident_id) 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]: async def get_all_transactions(self) -> list[HoursTransaction]:
return await self.transactions_dao.get_all() return await self.transactions_dao.get_all()
@@ -174,3 +187,15 @@ class HoursTransactionsRepository:
all_transactions.sort(key=lambda t: t.created_at, reverse=True) all_transactions.sort(key=lambda t: t.created_at, reverse=True)
return all_transactions 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]