Compare commits

...

14 Commits

Author SHA1 Message Date
kolo 7a5e6cb7a8 add delete button to resident history detail window for creator only; dynamic back navigation from delete confirmation 2026-07-15 01:48:34 +03:00
kolo f775a34885 fix: delete button now shows for all transactions; reverse resident hours on transaction deletion 2026-07-15 01:43:17 +03:00
kolo f9d65b159a add transaction deletion for creator: delete button in detail window, confirmation window, delete_transaction repo method 2026-07-15 01:37:16 +03:00
kolo 78930ab920 sort transactions newest-first in resident and user history; 40 transactions in creator history 2026-07-15 01:34:33 +03:00
kolo 061c9c9ad6 update 2026-07-15 01:29:40 +03:00
kolo d8e6cead6e fix empty message text in detail windows for media groups; add emoji to add room hours button 2026-07-15 01:21:57 +03:00
kolo ae940a58e6 fix broken emoji in remove_hours_remark_window, remove unused json import, add input validation to on_room_remark_input 2026-07-15 01:14:14 +03:00
kolo 504c454afb keep back button in dialog window: single photo via DynamicMedia, media group via bot with caption 2026-07-15 01:05:02 +03:00
kolo 16e9d7f124 send photo/media group with detail text as caption, not separately 2026-07-15 01:02:54 +03:00
kolo c325586ef9 fix: move item_id before FromDishka params in Select on_click handlers 2026-07-15 01:00:24 +03:00
kolo ccd06c99b0 show all photos immediately on transaction detail, remove All Photos button, fix bot KeyError 2026-07-15 00:59:23 +03:00
kolo 6d5608343a refactor: use AlbumMiddleware instead of MediaGroupCollector, send photos with caption, add photo support for room hours 2026-07-15 00:55:40 +03:00
kolo 05b4a79b40 refactor: move MediaGroupCollector to dishka DI instead of module-level dicts 2026-07-15 00:48:31 +03:00
kolo 53f30c923b 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
2026-07-15 00:45:58 +03:00
17 changed files with 1141 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,
)
+3
View File
@@ -13,6 +13,7 @@ from dutylog.application.bot.user_dialogs import main_menu_dialog
from dutylog.application.bot.admin_dialogs import admin_menu_dialog
from dutylog.application.bot.user_dialogs.registration_dialog import registration_dialog
from dutylog.application.bot.user_dialogs.user_menu.feedback import feedback_router
from dutylog.application.bot.middlewares.album import AlbumMiddleware
from dutylog.infrastructure.ioc import (
ConfigProvider,
DatabaseProvider,
@@ -37,6 +38,8 @@ async def main():
dp = Dispatcher()
dp.message.outer_middleware(AlbumMiddleware())
container = make_async_container(
ConfigProvider(),
DatabaseProvider(),
@@ -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,8 @@ 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,
transactions_history_delete_confirm_window,
)
@@ -88,6 +94,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 +122,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 +151,6 @@ admin_menu_dialog = Dialog(
add_admin_select_user_window,
add_admin_confirm_window,
transactions_history_window,
transactions_history_detail_window,
transactions_history_delete_confirm_window,
)
@@ -1,6 +1,8 @@
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
@@ -101,11 +103,28 @@ 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("⚠️ Пожалуйста, введите причину добавления часов")
album = dialog_manager.middleware_data.get("album")
photo_file_ids = []
remark = None
if album:
for msg in album:
if msg.photo:
photo_file_ids.append(msg.photo[-1].file_id)
if msg.caption and msg.caption.strip():
remark = msg.caption.strip()
elif 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"] = message.text.strip()
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 +133,28 @@ 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
album = dialog_manager.middleware_data.get("album")
photo_file_ids = []
remark = None
if album:
for msg in album:
if msg.photo:
photo_file_ids.append(msg.photo[-1].file_id)
if msg.caption and msg.caption.strip():
remark = msg.caption.strip()
elif 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 +164,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 +174,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 +200,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)
@@ -176,7 +219,14 @@ async def on_add_hours_confirm(
notification_text = f"<blockquote> <b>Начислены часы</b></blockquote>\n\n<b>Количество:</b> <code>{hours}</code> ч\n<b>Причина:</b> {remark}\n<b>Администратор:</b> {admin_username}\n\n<b>Всего неотработанных часов:</b> <code>{resident.active_hours}</code> ч"
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], caption=notification_text)
else:
media = [InputMediaPhoto(media=fid, caption=notification_text if i == 0 else None) for i, fid in enumerate(photo_file_ids)]
await bot.send_media_group(resident.user_entity, media=media)
else:
await bot.send_message(resident.user_entity, notification_text)
except (TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter):
pass
@@ -208,11 +258,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)
@@ -232,7 +285,14 @@ async def on_remove_hours_confirm(
notification_text += f"<b>Администратор:</b> {admin_username}\n\n<b>Осталось неотработанных часов:</b> <code>{resident.active_hours}</code> ч"
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], caption=notification_text)
else:
media = [InputMediaPhoto(media=fid, caption=notification_text if i == 0 else None) for i, fid in enumerate(photo_file_ids)]
await bot.send_media_group(resident.user_entity, media=media)
else:
await bot.send_message(resident.user_entity, notification_text)
except (TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter):
pass
@@ -322,7 +382,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 +393,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 +409,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 +427,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, User
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
@@ -24,6 +28,7 @@ from dutylog.infrastructure.database.repositories.users_repository import (
from dutylog.infrastructure.database.repositories.hours_transactions_repository import (
HoursTransactionsRepository,
)
from dutylog.infrastructure.utils.config import Config
from dutylog.infrastructure.utils.datetime import msk_now
@@ -448,10 +453,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 +471,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,16 +579,16 @@ 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)
last_10 = transactions_sorted[-10:]
transactions_sorted = sorted(transactions, key=lambda x: x.created_at, reverse=True)
last_10 = transactions_sorted[:10]
resident_name = resident.real_name if resident.real_name else "Без имени"
@@ -590,18 +607,206 @@ async def get_resident_history_data(
<b>Резидент:</b> {resident_name}
"""
for tx in last_10:
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,
transactions_repository: FromDishka[HoursTransactionsRepository],
users_repository: FromDishka[UsersRepository],
):
dialog_manager.dialog_data["selected_transaction_id"] = int(item_id)
tx = await transactions_repository.get_transaction_by_id(int(item_id))
if tx:
photo_file_ids = HoursTransactionsRepository.get_photo_file_ids(tx)
if len(photo_file_ids) > 1:
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}"
caption = f"📋 <b>Детали транзакции</b>\n\n⚙️ <b>Операция:</b> {operation}\n🔢 <b>Количество:</b> {emoji}<code>{tx.amount}</code> ч\n📅 <b>Дата:</b> {date_str}{remark_text}{admin_info}"
bot: Bot = dialog_manager.middleware_data.get("bot")
try:
media = [InputMediaPhoto(media=fid, caption=caption if i == 0 else None) for i, fid in enumerate(photo_file_ids)]
await bot.send_media_group(callback.message.chat.id, media=media)
except Exception:
pass
await dialog_manager.switch_to(AdminMenuSG.resident_history_detail)
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 get_transaction_detail_data(
event_from_user: User,
dialog_manager: DialogManager,
transactions_repository: FromDishka[HoursTransactionsRepository],
residents_repository: FromDishka[ResidentsRepository],
users_repository: FromDishka[UsersRepository],
config: FromDishka[Config],
**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}
tx = await transactions_repository.get_transaction_by_id(int(tx_id))
if not tx:
return {"detail_content": "Ошибка: транзакция не найдена", "photo_media": None, "has_multiple_photos": False}
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
has_multiple_photos = len(photo_file_ids) > 1
if photo_file_ids and not has_multiple_photos:
photo_media = MediaAttachment(
type=ContentType.PHOTO,
file_id=MediaId(file_id=photo_file_ids[0]),
)
if has_multiple_photos:
detail_content = ""
else:
detail_content = content
is_creator = event_from_user.id == config.bot.creator_id
return {
"detail_content": detail_content,
"photo_media": photo_media,
"has_multiple_photos": has_multiple_photos,
"is_creator": is_creator,
}
async def on_search_transactions(
callback: CallbackQuery,
button: Button,
dialog_manager: DialogManager,
):
await dialog_manager.switch_to(AdminMenuSG.resident_history_search_input)
@inject
async def on_resident_delete_transaction_click(
callback: CallbackQuery,
button: Button,
dialog_manager: DialogManager,
):
dialog_manager.dialog_data["delete_source"] = "resident_history"
await dialog_manager.switch_to(AdminMenuSG.transactions_history_delete_confirm)
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 +859,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 +1129,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 +1155,63 @@ resident_history_window = Window(
state=AdminMenuSG.resident_history,
getter=get_resident_history_data,
)
resident_history_detail_window = Window(
DynamicMedia("photo_media", when=F["photo_media"]),
Format("{detail_content}", when=F["detail_content"]),
Row(
Button(
Const("🗑 Удалить"),
id="resident_delete_transaction_btn",
on_click=on_resident_delete_transaction_click,
when=F["is_creator"],
),
),
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,16 @@
import json
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 magic_filter import F
from dishka import FromDishka
from dishka.integrations.aiogram_dialog import inject
@@ -311,11 +317,28 @@ async def on_room_remark_input(
widget: MessageInput,
dialog_manager: DialogManager,
):
if message.text and message.text.strip():
dialog_manager.dialog_data["remark"] = message.text.strip()
else:
dialog_manager.dialog_data["remark"] = None
album = dialog_manager.middleware_data.get("album")
photo_file_ids = []
remark = None
if album:
for msg in album:
if msg.photo:
photo_file_ids.append(msg.photo[-1].file_id)
if msg.caption and msg.caption.strip():
remark = msg.caption.strip()
elif 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 message.text.strip():
remark = message.text.strip()
if not photo_file_ids and not remark:
await message.answer("⚠️ Пожалуйста, отправьте текст или фото с подписью")
return
dialog_manager.dialog_data["remark"] = remark
dialog_manager.dialog_data["photo_file_ids"] = photo_file_ids
await dialog_manager.switch_to(AdminMenuSG.room_add_hours_confirm)
@@ -325,6 +348,7 @@ async def on_room_skip_remark(
dialog_manager: DialogManager,
):
dialog_manager.dialog_data["remark"] = None
dialog_manager.dialog_data["photo_file_ids"] = []
await dialog_manager.switch_to(AdminMenuSG.room_add_hours_confirm)
@@ -335,10 +359,13 @@ async def get_room_hours_confirm_data(
hours = dialog_manager.dialog_data.get("selected_hours", 0)
remark = dialog_manager.dialog_data.get("remark", "")
remark_text = f"\n\n<b>Примечание:</b> {remark}" if remark else ""
photo_file_ids = dialog_manager.dialog_data.get("photo_file_ids", [])
has_photo = bool(photo_file_ids)
return {
"hours": hours,
"remark_text": remark_text,
"has_photo": f"✅ Да ({len(photo_file_ids)} шт.)" if has_photo else "❌ Нет",
}
@@ -360,12 +387,15 @@ async def on_room_add_hours_confirm(
admin_id = callback.from_user.id
if room_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
results = await transactions_repository.add_hours_to_room(
room_id=room_id,
amount=hours,
admin_id=admin_id,
is_active=True,
remark=remark,
photo_file_id=photo_file_id,
)
for transaction, resident in results:
@@ -374,11 +404,15 @@ async def on_room_add_hours_confirm(
if user:
try:
remark_text = f"\n💬 <i>{remark}</i>" if remark else ""
await bot.send_message(
user.id,
f"<blockquote> <b>Уведомление</b></blockq uote>\n\n"
f"Вашей комнате начислено <b>+{hours}</b> ч{remark_text}"
)
notification_text = f"<blockquote> <b>Уведомление</b></blockquote>\n\nВашей комнате начислено <b>+{hours}</b> ч{remark_text}"
if photo_file_ids:
if len(photo_file_ids) == 1:
await bot.send_photo(user.id, photo_file_ids[0], caption=notification_text)
else:
media = [InputMediaPhoto(media=fid, caption=notification_text if i == 0 else None) for i, fid in enumerate(photo_file_ids)]
await bot.send_media_group(user.id, media=media)
else:
await bot.send_message(user.id, notification_text)
except (TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter):
pass
@@ -444,12 +478,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 +492,116 @@ 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:
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,
transactions_repository: FromDishka[HoursTransactionsRepository],
users_repository: FromDishka[UsersRepository],
):
dialog_manager.dialog_data["selected_transaction_id"] = int(item_id)
tx = await transactions_repository.get_transaction_by_id(int(item_id))
if tx:
photo_file_ids = HoursTransactionsRepository.get_photo_file_ids(tx)
if len(photo_file_ids) > 1:
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}"
caption = f"📋 <b>Детали транзакции</b>\n\n⚙️ <b>Операция:</b> {operation}\n🔢 <b>Количество:</b> {emoji}<code>{tx.amount}</code> ч\n📅 <b>Дата:</b> {date_str}{remark_text}{admin_info}"
bot: Bot = dialog_manager.middleware_data.get("bot")
try:
media = [InputMediaPhoto(media=fid, caption=caption if i == 0 else None) for i, fid in enumerate(photo_file_ids)]
await bot.send_media_group(callback.message.chat.id, media=media)
except Exception:
pass
await dialog_manager.switch_to(AdminMenuSG.room_history_detail)
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"
@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")
return {"history_content": history_text}
if not tx_id:
return {"detail_content": "Ошибка: транзакция не выбрана", "photo_media": None, "has_multiple_photos": False}
tx = await transactions_repository.get_transaction_by_id(int(tx_id))
if not tx:
return {"detail_content": "Ошибка: транзакция не найдена", "photo_media": None, "has_multiple_photos": False}
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
has_multiple_photos = len(photo_file_ids) > 1
if photo_file_ids and not has_multiple_photos:
photo_media = MediaAttachment(
type=ContentType.PHOTO,
file_id=MediaId(file_id=photo_file_ids[0]),
)
if has_multiple_photos:
detail_content = ""
else:
detail_content = content
return {
"detail_content": detail_content,
"photo_media": photo_media,
"has_multiple_photos": has_multiple_photos,
}
rooms_select_floor_window = Window(
@@ -524,7 +656,7 @@ rooms_list_window = Window(
room_info_window = Window(
Format("{info_content}"),
Button(
Const("Добавить часы"),
Const(" Добавить часы"),
id="room_add_hours_btn",
on_click=on_room_add_hours_click,
),
@@ -603,7 +735,7 @@ room_add_hours_custom_window = Window(
)
room_add_hours_remark_window = Window(
Const("<blockquote>💬 <b>Примечание</b></blockquote>\n\nВведите примечание к операции (или пропустите):"),
Const("<blockquote>💬 <b>Примечание</b></blockquote>\n\nВведите примечание к операции или отправьте фото/медиагруппу с подписью (или пропустите):"),
MessageInput(on_room_remark_input),
Button(
Const("⏭ Пропустить"),
@@ -619,7 +751,7 @@ room_add_hours_remark_window = Window(
)
room_add_hours_confirm_window = Window(
Format("<blockquote> <b>Подтверждение</b></blockquote>\n\nВы уверены, что хотите добавить <code>{hours}</code> часов?{remark_text}"),
Format("<blockquote> <b>Подтверждение</b></blockquote>\n\nВы уверены, что хотите добавить <code>{hours}</code> часов?{remark_text}\n\n📷 <b>Фото:</b> {has_photo}"),
Row(
Button(
Const("✅ Да"),
@@ -688,6 +820,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 +841,15 @@ room_history_window = Window(
state=AdminMenuSG.room_history,
getter=get_room_history_data,
)
room_history_detail_window = Window(
DynamicMedia("photo_media"),
Format("{detail_content}", when=F["detail_content"]),
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,11 @@
from dutylog.application.bot.creator_dialogs.transactions_history import (
transactions_history_window,
transactions_history_detail_window,
transactions_history_delete_confirm_window,
)
__all__ = ["transactions_history_window"]
__all__ = [
"transactions_history_window",
"transactions_history_detail_window",
"transactions_history_delete_confirm_window",
]
@@ -1,6 +1,12 @@
from aiogram_dialog import Window
from aiogram import Bot
from aiogram.enums import ContentType
from aiogram.types import CallbackQuery, InputMediaPhoto
from aiogram_dialog import Window, DialogManager
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, Row
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
@@ -31,44 +37,207 @@ async def get_transactions_history_data(
all_transactions = await transactions_repository.get_all_transactions()
all_transactions.sort(key=lambda t: t.created_at, reverse=True)
recent_transactions = all_transactions[:8]
recent_transactions = all_transactions[:40]
if not recent_transactions:
content = "<blockquote>📜 <b>История транзакций</b></blockquote>\n\n<i>Нет транзакций</i>"
else:
content = "<blockquote>📜 <b>История транзакций</b></blockquote>\n\n<i>Последние 8 транзакций:</i>\n\n"
content = "<blockquote>📜 <b>История транзакций</b></blockquote>\n\n<i>Последние 40 транзакций:</i>\n\n"
for tx in recent_transactions:
resident = await residents_repository.get_by_id(tx.resident_id)
if not resident:
continue
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 "???"
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,
transactions_repository: FromDishka[HoursTransactionsRepository],
residents_repository: FromDishka[ResidentsRepository],
rooms_repository: FromDishka[RoomsRepository],
users_repository: FromDishka[UsersRepository],
):
dialog_manager.dialog_data["selected_transaction_id"] = int(item_id)
tx = await transactions_repository.get_transaction_by_id(int(item_id))
if tx:
photo_file_ids = HoursTransactionsRepository.get_photo_file_ids(tx)
if len(photo_file_ids) > 1:
operation = "Начислено" if tx.transaction_type == "increase" else "Списано"
emoji = "+" if tx.transaction_type == "increase" else ""
room_mark = " 🚪" if tx.per_room else ""
msk_time = tx.created_at.astimezone(msk_now().tzinfo).replace(tzinfo=None)
date_str = msk_time.strftime("%d.%m.%Y %H:%M")
resident = await residents_repository.get_by_id(tx.resident_id)
resident_name = resident.real_name if resident and resident.real_name else "Без имени"
room = await rooms_repository.get_by_id(resident.room) if resident else None
room_number = room.number if room else "???"
admin_info = ""
if tx.admin_id:
admin = await users_repository.get_user_by_id(tx.admin_id)
if admin:
admin_name = f"@{admin.username}" if admin.username else admin.first_name or f"ID: {admin.id}"
admin_info = f"\n👨‍💼 {admin_name}"
admin_info = f"\n👨‍💼 <b>Администратор:</b> {admin_name}"
remark_text = f"\n💬 <i>{tx.remark}</i>" if tx.remark else ""
caption = f"📋 <b>Детали транзакции</b>\n\n⚙️ <b>Операция:</b> {operation}\n🔢 <b>Количество:</b> {emoji}<code>{tx.amount}</code> ч{room_mark}\n👤 <b>Резидент:</b> {resident_name} (к. {room_number}){admin_info}\n📅 <b>Дата:</b> {date_str}{remark_text}"
bot: Bot = dialog_manager.middleware_data.get("bot")
try:
media = [InputMediaPhoto(media=fid, caption=caption if i == 0 else None) for i, fid in enumerate(photo_file_ids)]
await bot.send_media_group(callback.message.chat.id, media=media)
except Exception:
pass
await dialog_manager.switch_to(AdminMenuSG.transactions_history_detail)
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_delete_transaction_click(
callback: CallbackQuery,
button: Button,
dialog_manager: DialogManager,
):
dialog_manager.dialog_data["delete_source"] = "transactions_history"
await dialog_manager.switch_to(AdminMenuSG.transactions_history_delete_confirm)
@inject
async def on_delete_transaction_confirm(
callback: CallbackQuery,
button: Button,
dialog_manager: DialogManager,
transactions_repository: FromDishka[HoursTransactionsRepository],
):
tx_id = dialog_manager.dialog_data.get("selected_transaction_id")
if tx_id:
await transactions_repository.delete_transaction(int(tx_id))
await callback.answer("✅ Транзакция удалена", show_alert=True)
source = dialog_manager.dialog_data.pop("delete_source", "transactions_history")
if source == "resident_history":
await dialog_manager.switch_to(AdminMenuSG.resident_history)
else:
await dialog_manager.switch_to(AdminMenuSG.transactions_history)
@inject
async def on_delete_transaction_back(
callback: CallbackQuery,
button: Button,
dialog_manager: DialogManager,
):
source = dialog_manager.dialog_data.pop("delete_source", "transactions_history")
if source == "resident_history":
await dialog_manager.switch_to(AdminMenuSG.resident_history_detail)
else:
await dialog_manager.switch_to(AdminMenuSG.transactions_history_detail)
@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}
tx = await transactions_repository.get_transaction_by_id(int(tx_id))
if not tx:
return {"detail_content": "Ошибка: транзакция не найдена", "photo_media": None, "has_multiple_photos": False}
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
has_multiple_photos = len(photo_file_ids) > 1
if photo_file_ids and not has_multiple_photos:
photo_media = MediaAttachment(
type=ContentType.PHOTO,
file_id=MediaId(file_id=photo_file_ids[0]),
)
if has_multiple_photos:
detail_content = ""
else:
detail_content = content
return {
"detail_content": detail_content,
"photo_media": photo_media,
"has_multiple_photos": has_multiple_photos,
}
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 +246,39 @@ transactions_history_window = Window(
state=AdminMenuSG.transactions_history,
getter=get_transactions_history_data,
)
transactions_history_detail_window = Window(
DynamicMedia("photo_media", when=F["photo_media"]),
Format("{detail_content}", when=F["detail_content"]),
Row(
Button(
Const("🗑 Удалить"),
id="delete_transaction_btn",
on_click=on_delete_transaction_click,
),
),
SwitchTo(
Const("◀️ К списку"),
id="back_to_transactions_from_detail",
state=AdminMenuSG.transactions_history,
),
state=AdminMenuSG.transactions_history_detail,
getter=get_transaction_detail_data,
)
transactions_history_delete_confirm_window = Window(
Const("<blockquote>🗑 <b>Удаление транзакции</b></blockquote>\n\n⚠️ Вы уверены, что хотите удалить эту транзакцию?\nЭто действие нельзя отменить."),
Row(
Button(
Const("✅ Да, удалить"),
id="confirm_delete_transaction_btn",
on_click=on_delete_transaction_confirm,
),
),
Button(
Const("◀️ Назад"),
id="back_to_transaction_detail",
on_click=on_delete_transaction_back,
),
state=AdminMenuSG.transactions_history_delete_confirm,
)
@@ -0,0 +1,31 @@
import asyncio
from collections.abc import Awaitable, Callable
from typing import Any
from aiogram import BaseMiddleware
from aiogram.types import Message
class AlbumMiddleware(BaseMiddleware):
ALBUM_DATA: dict[str, list[Message]] = {}
def __init__(self, delay: float = 0.6) -> None:
self.delay = delay
async def __call__(
self,
handler: Callable[[Message, dict[str, Any]], Awaitable[Any]],
event: Message,
data: dict[str, Any],
) -> Any:
if not event.media_group_id:
return await handler(event, data)
try:
self.ALBUM_DATA[event.media_group_id].append(event)
return
except KeyError:
self.ALBUM_DATA[event.media_group_id] = [event]
await asyncio.sleep(self.delay)
data["album"] = self.ALBUM_DATA.pop(event.media_group_id)
return await handler(event, 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,8 @@ class AdminMenuSG(StatesGroup):
add_admin_select_user = State()
add_admin_confirm = State()
transactions_history = State()
transactions_history_detail = State()
transactions_history_delete_confirm = State()
class CreatorMenuSG(StatesGroup):
@@ -76,6 +83,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,12 @@
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 magic_filter import F
from dishka import FromDishka
from dishka.integrations.aiogram_dialog import inject
@@ -15,6 +20,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 +33,164 @@ 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>"
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, reverse=True)
last_10 = transactions_sorted[:10]
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,
}
@inject
async def on_user_transaction_selected(
callback: CallbackQuery,
widget: Select,
dialog_manager,
item_id: str,
transactions_repository: FromDishka[HoursTransactionsRepository],
users_repository: FromDishka[UsersRepository],
):
dialog_manager.dialog_data["selected_transaction_id"] = int(item_id)
tx = await transactions_repository.get_transaction_by_id(int(item_id))
if tx:
photo_file_ids = HoursTransactionsRepository.get_photo_file_ids(tx)
if len(photo_file_ids) > 1:
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}"
caption = f"📋 <b>Детали транзакции</b>\n\n⚙️ <b>Операция:</b> {operation}\n🔢 <b>Количество:</b> {emoji}<code>{tx.amount}</code> ч\n📅 <b>Дата:</b> {date_str}{remark_text}{admin_info}"
bot: Bot = dialog_manager.middleware_data.get("bot")
try:
media = [InputMediaPhoto(media=fid, caption=caption if i == 0 else None) for i, fid in enumerate(photo_file_ids)]
await bot.send_media_group(callback.message.chat.id, media=media)
except Exception:
pass
await dialog_manager.switch_to(MainMenuSG.history_detail)
@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}
tx = await transactions_repository.get_transaction_by_id(int(tx_id))
if not tx:
return {"detail_content": "Ошибка: транзакция не найдена", "photo_media": None, "has_multiple_photos": False}
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
has_multiple_photos = len(photo_file_ids) > 1
if photo_file_ids and not has_multiple_photos:
photo_media = MediaAttachment(
type=ContentType.PHOTO,
file_id=MediaId(file_id=photo_file_ids[0]),
)
if has_multiple_photos:
detail_content = ""
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:]
detail_content = content
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"
return {"history_content": history_text}
return {
"detail_content": detail_content,
"photo_media": photo_media,
"has_multiple_photos": has_multiple_photos,
}
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}", when=F["detail_content"]),
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()
@@ -131,6 +144,26 @@ class HoursTransactionsRepository:
) -> HoursTransaction | None:
return await self.transactions_dao.get_by_id(transaction_id)
async def delete_transaction(self, transaction_id: int) -> None:
tx = await self.transactions_dao.get_by_id(transaction_id)
if tx:
resident = await self.residents_dao.get_by_id(tx.resident_id)
if resident:
if tx.transaction_type == TransactionType.INCREASE.value:
new_active = max(0, resident.active_hours - tx.amount)
await self.residents_dao.update(
tx.resident_id, active_hours=new_active
)
elif tx.transaction_type == TransactionType.DECREASE.value:
new_active = resident.active_hours + tx.amount
new_inactive = max(0, resident.inactive_hours - tx.amount)
await self.residents_dao.update(
tx.resident_id,
active_hours=new_active,
inactive_hours=new_inactive,
)
await self.transactions_dao.delete(transaction_id)
async def get_by_period(self, start_date, end_date) -> list[HoursTransaction]:
return await self.transactions_dao.get_by_period(start_date, end_date)
@@ -141,6 +174,7 @@ class HoursTransactionsRepository:
admin_id: int | None = None,
is_active: bool = True,
remark: str | None = None,
photo_file_id: str | None = None,
) -> list[tuple[HoursTransaction, Resident | None]]:
residents = await self.residents_dao.get_by_room(room_id)
results = []
@@ -158,6 +192,7 @@ class HoursTransactionsRepository:
is_active=is_active,
remark=remark,
per_room=True,
photo_file_id=photo_file_id,
)
results.append(result)
@@ -174,3 +209,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]