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
@@ -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,
)
@@ -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("<blockquote>📝 <b>Причина добавления часов</b></blockquote>\n\n<blockquote>Укажите причину добавления часов (обязательно).</blockquote>"),
Const("<blockquote>📝 <b>Причина добавления часов</b></blockquote>\n\n<blockquote>Отправьте текст или фото с подписью.\nФото будет сохранено как описание операции.</blockquote>"),
MessageInput(on_add_hours_remark_input),
SwitchTo(
Const("◀️ Назад"),
@@ -333,7 +420,7 @@ add_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),
Button(
Const("⏭ Пропустить"),
@@ -349,7 +436,7 @@ remove_hours_remark_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(
Button(
Const("✅ Да"),
@@ -367,7 +454,7 @@ add_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(
Button(
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_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(
<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)
date_str = msk_time.strftime("%d.%m.%Y %H:%M")
remark_text = f"\n💬 <i>{tx.remark}</i>" if tx.remark else ""
history_text += f"<blockquote><b>{operation}</b> {emoji}<code>{tx.amount}</code> ч\n📅 {date_str}{remark_text}</blockquote>\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💬 <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(
@@ -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("<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.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"<blockquote>📜 <b>История операций</b></blockquote>\n\n<b>Комната:</b> {room.number}\n\n<i>История операций пуста</i>"
else:
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)
date_str = msk_time.strftime("%d.%m.%Y %H:%M")
remark_text = f"\n💬 <i>{tx.remark}<t/i>" if tx.remark else ""
history_text += f"<blockquote><b>{operation}</b> {emoji}<code>{tx.amount}</code> ч\n📅 {date_str}{remark_text}</blockquote>\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💬 <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(
@@ -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,
)
@@ -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"]
@@ -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 = "<blockquote>📜 <b>История транзакций</b></blockquote>\n\n<i>Нет транзакций</i>"
else:
content = "<blockquote>📜 <b>История транзакций</b></blockquote>\n\n<i>Последние 8 транзакций:</i>\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💬 <i>{tx.remark}</i>" if tx.remark else ""
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}
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👨‍💼 <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(
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,
)
@@ -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,
@@ -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()
@@ -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 = "<blockquote>📜 <b>История операций</b></blockquote>\n\n⚠️ <i>Профиль не найден</i>"
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 = "📜 <b>История операций</b>\n\n<b>👤 Ваши операции:</b>\n<i>История операций пуста</i>\n\n"
else:
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)
date_str = msk_time.strftime("%d.%m.%Y %H:%M")
remark_text = f"\n💬 <i>{tx.remark}</i>" if tx.remark else ""
room_mark = " 🚪" if tx.per_room else ""
history_text += f"<blockquote><b>{operation}</b> {emoji}<code>{tx.amount}</code> ч{room_mark}\n📅 {date_str}{remark_text}</blockquote>\n"
if not last_10:
history_text = "📜 <b>История операций</b>\n\n<b>👤 Ваши операции:</b>\n<i>История операций пуста</i>\n\n"
return {"history_content": history_text, "transactions": [], "has_transactions": False}
history_text = "📜 <b>История операций</b>\n\n<b>👤 Ваши операции:</b>\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💬 <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(
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,
)
@@ -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()
@@ -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"
)
@@ -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]