diff --git a/bot/config.py b/bot/config.py index 7f2842a..106ff22 100644 --- a/bot/config.py +++ b/bot/config.py @@ -29,7 +29,6 @@ class Config: migration_script_location: Path = Path(__file__).parent / 'migrations' alembic_config_path: Path = Path(__file__).parent / 'alembic.ini' tz = pytz.timezone('Asia/Yerevan') - last: int = 10 replenishment_name: str = '_replenishemnt' diff --git a/bot/handlers/_responses.py b/bot/handlers/_responses.py index 9780f35..63bf2b7 100644 --- a/bot/handlers/_responses.py +++ b/bot/handlers/_responses.py @@ -17,7 +17,6 @@ 'choose': 'Выбирай', 'select_new_type': 'Выбери тип расхода', 'write_new_type_name': 'Напиши название для нового типа расходов', - 'custom_date': 'Введи дату в формате YYYY-MM-DD для получения отчета', 'current_balance': ( 'Изменить баланс на ЧИСЛО - /balance ЧИСЛО. ' + _custom_eval_msg + '\n\n' diff --git a/bot/handlers/report.py b/bot/handlers/report.py index 2dd02c1..ad81a1e 100644 --- a/bot/handlers/report.py +++ b/bot/handlers/report.py @@ -1,25 +1,14 @@ import datetime as dt -from typing import Sequence -from typing import Union import keyboards from aiogram import F from aiogram import Router from aiogram.filters.command import Command -from aiogram.fsm.context import FSMContext -from aiogram.fsm.state import State -from aiogram.fsm.state import StatesGroup from aiogram.types import CallbackQuery from aiogram.types import Message -from config import config -from data.models import Expense -from handlers._responses import RESPONSES -from services import expense_service -from services import item_service from services import report_service from services import user_service from sqlalchemy.orm import Session -from utils import try_datetime def _month_year_add(month_year: str, delta_months: int) -> str: @@ -39,123 +28,65 @@ def _month_display_name(month_year: str) -> str: return f'{months_ru[m - 1]} {y}' -class Report(StatesGroup): - writing_date = State() +def _has_prev_next(month_year: str, _months_set: set) -> tuple: + return (True, True) router = Router() -def _prepare_report_text(expenses: Sequence[Expense], title: str) -> str: - if not expenses: - return f'{title}\n\nЗдесь пока пусто.' - - report_lines = [f'{title}\n'] - for expense in expenses: - cdate_tz_formatted = ( - expense.cdate_tz.strftime('%d.%m %H:%M') if expense.cdate_tz else '' - ) - txt = ( - f'{expense.user.first_name} {expense.item_name} ' - f'{expense.price} {expense.unit} {cdate_tz_formatted}' - ) - if expense.comment: - txt += f' {expense.comment}' - report_lines.append(txt.strip()) - - return '\n'.join(report_lines) - - -@router.message(Command('report')) -async def cmd_report(message: Message): - kb = keyboards.reports_kb() - await message.answer( - text='Выбери тип отчета', - reply_markup=kb, - ) - - -@router.callback_query(F.data == 'reports_menu') -async def cb_reports_menu(cb: CallbackQuery): - await cb.answer() - if not cb.message: - return - kb = keyboards.reports_kb() - await cb.message.edit_text( - text='Выбери тип отчета', - reply_markup=kb, - ) - - -@router.callback_query(F.data == 'last_n') -@router.message(Command('last')) -async def report_last(update: Union[CallbackQuery, Message], session: Session): - if isinstance(update, CallbackQuery): - await update.answer() - m = update.message - else: - m = update - - if not m: - return None - - expenses = expense_service.get_expenses( - user_id=m.chat.id, - session=session, - ) - - if len(expenses) > config.last: - expenses = expenses[-config.last:] - - text = _prepare_report_text(expenses, title=f'Последние {config.last} записей:') - kb = keyboards.back_button_kb('reports_menu') - - if isinstance(update, CallbackQuery) and update.message: - await update.message.edit_text(text=text, reply_markup=kb) - elif m: - await m.answer(text=text, reply_markup=kb) - - -@router.callback_query(F.data == 'by_month') -async def group_by_month(cb: CallbackQuery, session: Session): - await cb.answer() - if not cb.message: - return +async def _show_month( + target: Message | CallbackQuery, + user_id: int, + month_year: str, + session: Session, +) -> None: user = user_service.get_user_by_id( - user_id=cb.from_user.id, + user_id=user_id, session=session, ) - current_month_year = dt.date.today().strftime('%Y-%m') + months_data = report_service.get_months_summary(user=user, session=session) + months_set = {m[0] for m in months_data} + has_prev, has_next = _has_prev_next(month_year, months_set) total, unit = report_service.get_month_total( user=user, - month_year=current_month_year, + month_year=month_year, session=session, ) categories = report_service.get_month_categories( user=user, - month_year=current_month_year, + month_year=month_year, session=session, ) - title = _month_display_name(current_month_year) - lines = [f'📅 {title}', f'💰 Всего: {total:,} {unit}'] - text = '\n'.join(lines) - has_prev, has_next = _has_prev_next(current_month_year, set()) + title = _month_display_name(month_year) + text = '\n'.join([f'📅 {title}', f'💰 Всего: {total:,} {unit}']) kb = keyboards.month_categories_kb( - month_year=current_month_year, + month_year=month_year, categories_data=categories, has_prev=has_prev, has_next=has_next, ) - await cb.message.edit_text(text=text, reply_markup=kb) + if isinstance(target, CallbackQuery) and target.message: + await target.message.edit_text(text=text, reply_markup=kb) + elif isinstance(target, Message): + await target.answer(text=text, reply_markup=kb) -def _has_prev_next(month_year: str, _months_set: set) -> tuple: - return (True, True) + +@router.message(Command('report')) +async def cmd_report(message: Message, session: Session): + current_month_year = dt.date.today().strftime('%Y-%m') + await _show_month( + target=message, + user_id=message.chat.id, + month_year=current_month_year, + session=session, + ) @router.callback_query(F.data.startswith('report:month:list')) async def show_month_list(cb: CallbackQuery, session: Session): - if not cb.data or not cb.message: + if not cb.data or not cb.message or not cb.from_user: return await cb.answer() user = user_service.get_user_by_id( @@ -192,7 +123,7 @@ async def dispatch_month_callbacks(cb: CallbackQuery, session: Session): async def show_category_details(cb: CallbackQuery, session: Session): - if not cb.data or ':cat:' not in cb.data or not cb.message: + if not cb.data or ':cat:' not in cb.data or not cb.message or not cb.from_user: return await cb.answer() parts = cb.data.split(':') @@ -246,7 +177,7 @@ async def show_category_details(cb: CallbackQuery, session: Session): async def navigate_month(cb: CallbackQuery, session: Session): - if not cb.data or ':nav:' not in cb.data or not cb.message: + if not cb.data or ':nav:' not in cb.data or not cb.message or not cb.from_user: return await cb.answer() parts = cb.data.split(':') @@ -263,37 +194,16 @@ async def navigate_month(cb: CallbackQuery, session: Session): month_year = _month_year_add(month_year, delta) else: return - user = user_service.get_user_by_id( + await _show_month( + target=cb, user_id=cb.from_user.id, - session=session, - ) - months_data = report_service.get_months_summary(user=user, session=session) - months_set = {m[0] for m in months_data} - has_prev, has_next = _has_prev_next(month_year, months_set) - total, unit = report_service.get_month_total( - user=user, - month_year=month_year, - session=session, - ) - categories = report_service.get_month_categories( - user=user, month_year=month_year, session=session, ) - title = _month_display_name(month_year) - lines = [f'📅 {title}', f'💰 Всего: {total:,} {unit}'] - text = '\n'.join(lines) - kb = keyboards.month_categories_kb( - month_year=month_year, - categories_data=categories, - has_prev=has_prev, - has_next=has_next, - ) - await cb.message.edit_text(text=text, reply_markup=kb) async def show_month_categories(cb: CallbackQuery, session: Session): - if not cb.data or not cb.message: + if not cb.data or not cb.message or not cb.from_user: return if 'report:month:list' in cb.data or ':nav:' in cb.data or ':cat:' in cb.data: return @@ -302,116 +212,9 @@ async def show_month_categories(cb: CallbackQuery, session: Session): return await cb.answer() month_year = parts[2] - user = user_service.get_user_by_id( + await _show_month( + target=cb, user_id=cb.from_user.id, - session=session, - ) - months_data = report_service.get_months_summary(user=user, session=session) - months_set = {m[0] for m in months_data} - has_prev, has_next = _has_prev_next(month_year, months_set) - total, unit = report_service.get_month_total( - user=user, - month_year=month_year, - session=session, - ) - categories = report_service.get_month_categories( - user=user, month_year=month_year, session=session, ) - title = _month_display_name(month_year) - lines = [f'📅 {title}', f'💰 Всего: {total:,} {unit}'] - text = '\n'.join(lines) - kb = keyboards.month_categories_kb( - month_year=month_year, - categories_data=categories, - has_prev=has_prev, - has_next=has_next, - ) - await cb.message.edit_text(text=text, reply_markup=kb) - - -@router.callback_query(F.data == 'by_category') -async def group_by_category(cb: CallbackQuery, session: Session): - await cb.answer() - - if not cb.message: - return - - user = user_service.get_user_by_id( - user_id=cb.from_user.id, - session=session, - ) - - rows = report_service.get_report_by_category( - user=user, - session=session, - ) - - if not rows: - text = 'Расходы по категориям:\n\nЗдесь пока пусто.' - else: - text = 'Расходы по категориям (за всё время):\n\n' + '\n'.join(rows) - - kb = keyboards.back_button_kb('reports_menu') - await cb.message.edit_text(text=text, reply_markup=kb) - - -@router.callback_query(F.data == 'custom_day') -async def group_by_custom_day(cb: CallbackQuery, state: FSMContext): - await cb.answer() - await state.set_state(Report.writing_date) - - if cb.message and cb.message.from_user: - await cb.message.answer(RESPONSES['custom_date']) - - -@router.message(Report.writing_date) -async def report_by_day(m: Message, state: FSMContext, session: Session): - if not m.text or not m.from_user: - return - - user_dt = try_datetime(m.text.strip()) - if not user_dt: - return await m.answer(RESPONSES['custom_date']) - - expenses = expense_service.get_expenses_by_date( - user_id=m.from_user.id, - custom_date=user_dt, - session=session, - ) - - title = f"Записи за {user_dt.strftime('%d.%m.%Y')}:" - text = _prepare_report_text(expenses, title=title) - kb = keyboards.back_button_kb('reports_menu') - await m.answer(text=text, reply_markup=kb) - await state.clear() - - -@router.callback_query(F.data == 'full_report_by_item') -async def report_by_items_kb(cb: CallbackQuery, session: Session): - items = item_service.get_list(cb.from_user.id, session) - kb = keyboards.report_by_item(items) - await cb.answer() - if cb.message: - await cb.message.answer( - text='Выбери категорию по которой хочешь получить отчет', - reply_markup=kb, - ) - - -@router.callback_query(F.data.startswith('report')) -async def full_report_by_item(cb: CallbackQuery, session: Session): - await cb.answer() - if cb.data and cb.message: - item_name = cb.data.split(':')[-1] - expenses = expense_service.get_expenses_by_item( - item_name=item_name, - user_id=cb.from_user.id, - session=session, - ) - - title = f'Записи для категории {item_name}:' - text = _prepare_report_text(expenses, title=title) - kb = keyboards.back_button_kb('by_category') - await cb.message.edit_text(text=text, reply_markup=kb) diff --git a/bot/keyboards.py b/bot/keyboards.py index ad1c625..aab7b2b 100644 --- a/bot/keyboards.py +++ b/bot/keyboards.py @@ -3,8 +3,6 @@ from aiogram.types import InlineKeyboardMarkup from aiogram.utils.keyboard import InlineKeyboardBuilder -from config import config -from data.models import Item def _month_display(month_year: str, total: int, unit: str) -> str: @@ -16,16 +14,8 @@ def _month_display(month_year: str, total: int, unit: str) -> str: return f'{months_ru[m - 1]} {y} — {total:,} {unit}' -REPORS_CALLBACKS = { - f'Последние {config.last} записей': 'last_n', - 'Список расходов за выбранный день': 'custom_day', - 'Группировка по месяцу': 'by_month', - 'Группировка по категории': 'by_category', -} - - def get_items_kb( - items: Sequence[Item], + items: Sequence, ) -> InlineKeyboardMarkup: kb = InlineKeyboardBuilder() for c in items: @@ -36,26 +26,6 @@ def get_items_kb( return kb.as_markup() -def reports_kb() -> InlineKeyboardMarkup: - kb = InlineKeyboardBuilder() - for title, callback in REPORS_CALLBACKS.items(): - kb.button(text=title, callback_data=callback) - - kb.adjust(1) - return kb.as_markup() - - -def report_by_item( - items: Sequence[Item], -) -> InlineKeyboardMarkup: - kb = InlineKeyboardBuilder() - for c in items: - kb.button(text=c.name, callback_data=f'report:{c.name}') - - kb.adjust(1) - return kb.as_markup() - - def month_list_kb( months_data: Sequence[Tuple[str, int, str]], page: int = 0, @@ -83,37 +53,10 @@ def month_list_kb( callback_data=f'report:month:list:{page + 1}', ) - kb.button(text='Назад', callback_data='reports_menu') if total_pages > 1: - kb.adjust(*([1] * len(slice_)), 2 if page and page < total_pages - 1 else 1, 1) + kb.adjust(*([1] * len(slice_)), 2 if page and page < total_pages - 1 else 1) else: - kb.adjust(*([1] * len(slice_)), 1) - return kb.as_markup() - - -def month_navigation_kb( - month_year: str, - has_prev: bool, - has_next: bool, -) -> InlineKeyboardMarkup: - kb = InlineKeyboardBuilder() - if has_prev: - kb.button(text='<<', callback_data=f'report:month:{month_year}:nav:prev') - kb.button(text='Выбрать месяц', callback_data='report:month:list:0') - if has_next: - kb.button(text='>>', callback_data=f'report:month:{month_year}:nav:next') - kb.adjust(3) - return kb.as_markup() - - -def report_month_jump_kb(month_year: str) -> InlineKeyboardMarkup: - kb = InlineKeyboardBuilder() - for label, delta in [('-6 мес', -6), ('-3 мес', -3), ('+3 мес', 3), ('+6 мес', 6)]: - kb.button( - text=label, - callback_data=f'report:month:{month_year}:nav:jump:{delta}', - ) - kb.adjust(2) + kb.adjust(*([1] * len(slice_))) return kb.as_markup() diff --git a/bot/services/expense_service.py b/bot/services/expense_service.py index f80d17f..8a81f62 100644 --- a/bot/services/expense_service.py +++ b/bot/services/expense_service.py @@ -2,10 +2,8 @@ from typing import Optional from typing import Sequence -import sqlalchemy as sa from config import config from data.models import Expense -from sqlalchemy import func from sqlalchemy import select from sqlalchemy.orm import Session @@ -46,33 +44,3 @@ def get_expenses( Expense.user_id == user_id, ) ).all() - - -def get_expenses_by_date( - custom_date: dt.date, - user_id: int, - session: Session, -) -> Sequence[Expense]: - stmt = select(Expense).where( - sa.and_( - Expense.user_id == user_id, - ~Expense.is_replenishment, - func.date(Expense.cdate_tz) == custom_date, - ) - ) - return session.scalars(stmt).all() - - -def get_expenses_by_item( - item_name: str, - user_id: int, - session: Session, -) -> Sequence[Expense]: - stmt = select(Expense).where( - sa.and_( - Expense.user_id == user_id, - ~Expense.is_replenishment, - Expense.item_name == item_name, - ) - ) - return session.scalars(stmt).all() diff --git a/bot/services/report_service.py b/bot/services/report_service.py index b6733eb..6854c05 100644 --- a/bot/services/report_service.py +++ b/bot/services/report_service.py @@ -10,81 +10,6 @@ from sqlalchemy.orm import Session -def get_report_by_day( - user: User, - session: Session, -) -> List[str]: - stmt = ( - select( - Expense.user_id, - func.date(Expense.cdate_tz), - Expense.unit, - func.sum(Expense.price).label('total'), - ) - .where( - Expense.user_id == user.id, - ~Expense.is_replenishment, - ) - .group_by(Expense.user_id, func.date(Expense.cdate_tz), Expense.unit) - ) - - rows = session.execute(stmt) - return [f'{row.tuple()[1]} | {row.tuple()[3]} {row.tuple()[2]}' for row in rows] - - -def get_report_by_month( - user: User, - session: Session, -) -> List[str]: - by_month_year = 'month_year' - stmt = ( - select( - Expense.user_id, - func.strftime('%m-%Y', Expense.cdate_tz).label(by_month_year), - Expense.unit, - func.sum(Expense.price).label('total'), - ) - .where( - Expense.user_id == user.id, - ~Expense.is_replenishment, - ) - .group_by(Expense.user_id, by_month_year, Expense.unit) - .order_by(Expense.cdate_tz) - .subquery() - ) - - return [ - f'{getattr(r, by_month_year)} | {r.total} {r.unit}' for - r in - session.execute(select(stmt)) - ] - - -def get_report_by_category( - user: User, - session: Session, -) -> List[str]: - stmt = ( - select( - Expense.item_name, - Expense.unit, - func.sum(Expense.price).label('total'), - ) - .where( - Expense.user_id == user.id, - ~Expense.is_replenishment, - ) - .group_by(Expense.item_name, Expense.unit) - .order_by(Expense.item_name) - ) - - return [ - f'{r.item_name} | {r.total} {r.unit}' for - r in - session.execute(stmt) - ] - - def get_months_summary( user: User, session: Session, @@ -207,20 +132,3 @@ def get_category_by_day( by_date[d] = [0, r.unit] by_date[d][0] += r.total return [(d, t[0], t[1]) for d, t in sorted(by_date.items())] - - -def get_day_details( - user: User, - date: dt.date, - session: Session, -) -> Sequence[Expense]: - stmt = ( - select(Expense) - .where( - Expense.user_id == user.id, - ~Expense.is_replenishment, - func.date(Expense.cdate_tz) == date, - ) - .order_by(Expense.cdate_tz) - ) - return session.scalars(stmt).scalars().all() diff --git a/bot/utils.py b/bot/utils.py index 4958003..880d434 100644 --- a/bot/utils.py +++ b/bot/utils.py @@ -1,4 +1,3 @@ -import datetime as dt import logging import re from pathlib import Path @@ -24,21 +23,10 @@ BotCommand(command='report', description='Отчетность по расходам'), BotCommand(command='balance', description='Работа с балансом'), BotCommand(command='currency', description='Работа с валютой'), - BotCommand( - command='last', - description=f'Показать последние {config.last} расходов', - ), BotCommand(command='help', description='Показать подсказку'), ] -def try_datetime(string_dt: str) -> Optional[dt.date]: - try: - return dt.datetime.strptime(string_dt, '%Y-%m-%d').date() - except ValueError: - return None - - def custom_eval(equation: str) -> Optional[int]: MATH_SIGNS = ('-', '+') components = re.findall(r'\d+|\S', equation) diff --git a/tests/report_ui_test.py b/tests/report_ui_test.py index 6c69ae3..e06a4df 100644 --- a/tests/report_ui_test.py +++ b/tests/report_ui_test.py @@ -4,10 +4,9 @@ import pytest from aiogram.types import CallbackQuery -from handlers.report import cb_reports_menu -from handlers.report import group_by_category -from handlers.report import group_by_month -from handlers.report import report_last +from aiogram.types import Message +from handlers.report import cmd_report +from handlers.report import show_month_list from testing.util import fill_in_db from testing.util import get_random_user @@ -33,52 +32,37 @@ def mock_callback_query(): return cb -def test_reports_menu_renders_correctly(mock_callback_query): - # Test that the reports menu handler uses edit_text - asyncio.run(cb_reports_menu(mock_callback_query)) - - mock_callback_query.message.edit_text.assert_called_once() - kwargs = mock_callback_query.message.edit_text.call_args.kwargs - assert kwargs['text'] == 'Выбери тип отчета' - assert kwargs['reply_markup'] is not None +@pytest.fixture +def mock_message(): + message = MagicMock(spec=Message) + message.chat = DummyObj() + message.chat.id = 1 + message.answer = AsyncMock() + return message -def test_last_n_renders_interactive_text(mock_callback_query, db): +def test_cmd_report_renders_current_month(mock_message, db): fill_in_db(session=db, users_number=1, items_number=1, expenses_number=5) user = get_random_user(db) - mock_callback_query.from_user.id = user.id - mock_callback_query.message.chat.id = user.id - - asyncio.run(report_last(update=mock_callback_query, session=db)) - - mock_callback_query.message.edit_text.assert_called_once() - kwargs = mock_callback_query.message.edit_text.call_args.kwargs - assert 'Последние' in kwargs['text'] - assert 'записей:' in kwargs['text'] - assert kwargs['reply_markup'] is not None # Should have the back button - - -def test_group_by_category_renders_interactive_text(mock_callback_query, db): - fill_in_db(session=db, users_number=1, items_number=2, expenses_number=5) - user = get_random_user(db) - mock_callback_query.from_user.id = user.id + mock_message.chat.id = user.id - asyncio.run(group_by_category(cb=mock_callback_query, session=db)) + asyncio.run(cmd_report(message=mock_message, session=db)) - mock_callback_query.message.edit_text.assert_called_once() - kwargs = mock_callback_query.message.edit_text.call_args.kwargs - assert 'Расходы по категориям' in kwargs['text'] + mock_message.answer.assert_called_once() + kwargs = mock_message.answer.call_args.kwargs + assert 'Всего:' in kwargs['text'] assert kwargs['reply_markup'] is not None -def test_group_by_month_renders_interactive_text(mock_callback_query, db): +def test_show_month_list_renders_interactive_text(mock_callback_query, db): fill_in_db(session=db, users_number=1, items_number=1, expenses_number=5) user = get_random_user(db) mock_callback_query.from_user.id = user.id + mock_callback_query.data = 'report:month:list:0' - asyncio.run(group_by_month(cb=mock_callback_query, session=db)) + asyncio.run(show_month_list(cb=mock_callback_query, session=db)) mock_callback_query.message.edit_text.assert_called_once() kwargs = mock_callback_query.message.edit_text.call_args.kwargs - assert 'Всего:' in kwargs['text'] + assert 'Выбери месяц' in kwargs['text'] assert kwargs['reply_markup'] is not None