Skip to content

Implement payment confirmation flow and improve order navigation - #8

Open
soloveyska1 wants to merge 3 commits into
codex/update-order-process-and-notificationsfrom
codex/implement-payment-confirmation-system
Open

Implement payment confirmation flow and improve order navigation#8
soloveyska1 wants to merge 3 commits into
codex/update-order-process-and-notificationsfrom
codex/implement-payment-confirmation-system

Conversation

@soloveyska1

@soloveyska1 soloveyska1 commented Sep 17, 2025

Copy link
Copy Markdown
Owner

Summary

  • add bonus tracking storage and admin payment confirmation actions releasing prepayment/full payment bonuses
  • allow users to navigate backwards through order steps without losing data and update prompts accordingly
  • surface bonus balances and payment states in the profile and order views

Testing

  • python -m compileall bot.py

https://chatgpt.com/codex/tasks/task_e_68ca3725e308832f8482a8783e3e1c64

Сводка от Sourcery

Реализовать процесс подтверждения платежей с начислением бонусов, добавить отслеживание бонусов пользователей, улучшить навигацию при создании заказа и отобразить информацию о платежах и бонусах.

Новые возможности:

  • Добавить хранение данных для отслеживания бонусов и логику их начисления при предоплате и полной оплате.
  • Реализовать действия администратора для подтверждения платежей и уведомления пользователей о начисленных бонусах.
  • Включить обратную навигацию в многошаговом процессе создания заказа без потери данных.

Улучшения:

  • Отображать статус платежа и баланс бонусов в профиле пользователя и в представлениях заказа.
  • Инициализировать отсутствующие поля платежей и бонусов для существующих заказов при запуске.
  • Обновить подсказки в процессе оформления заказа, чтобы они включали инструкции по использованию кнопки «назад» и предварительный просмотр текущего выбора.
Original summary in English

Summary by Sourcery

Implement payment confirmation flow with bonus release, add user bonus tracking, improve order creation navigation, and surface payment and bonus info

New Features:

  • Add bonus tracking storage and release logic on prepayment and full payment
  • Implement admin actions to confirm payments and notify users with bonus credits
  • Enable backward navigation in multi-step order creation without losing data

Enhancements:

  • Display payment state and bonus balances in user profile and order views
  • Initialize missing payment and bonus fields for existing orders on startup
  • Update order flow prompts to include back-button instructions and current selection previews

@sourcery-ai

sourcery-ai Bot commented Sep 17, 2025

Copy link
Copy Markdown

Руководство для ревьюера

Этот PR интегрирует полный процесс отслеживания бонусов и подтверждения платежей — хранение и выдачу предоплатных/полных бонусов, расширение административных действий для подтверждения платежей и уведомления пользователей — а также перерабатывает диалог создания заказа, вводя механизм навигации назад на каждом шаге, при этом отображая информацию о платежах и бонусах в профиле, списках заказов и детальных представлениях.

Диаграмма последовательности подтверждения платежа администратором и выдачи бонусов

sequenceDiagram
actor Admin
participant Bot
participant "ORDERS (storage)"
participant "BONUSES (storage)"
participant User
Admin->>Bot: Confirm prepayment/full payment for order
Bot->>"ORDERS (storage)": Update order payment fields
Bot->>"BONUSES (storage)": Release bonus for user
Bot->>User: Notify about payment confirmation and bonus credited
Bot->>"ORDERS (storage)": Save updated order
Bot->>"BONUSES (storage)": Save updated bonus
Loading

Диаграмма сущность-связь для бонусов и заказов

erDiagram
    USER ||--o{ ORDER : places
    USER ||--o{ BONUS : has
    ORDER {
      string order_id
      string payment_state
      boolean prepayment_confirmed
      boolean full_payment_confirmed
      datetime prepayment_confirmed_at
      datetime full_payment_confirmed_at
      int bonus_total
      int bonus_released_prepaid
      int bonus_released_full
    }
    BONUS {
      int balance
      string[] history
    }
Loading

Диаграмма классов для логики подтверждения бонусов и платежей

classDiagram
    class Order {
      +order_id: str
      +payment_state: str
      +prepayment_confirmed: bool
      +full_payment_confirmed: bool
      +prepayment_confirmed_at: datetime
      +full_payment_confirmed_at: datetime
      +bonus_total: int
      +bonus_released_prepaid: int
      +bonus_released_full: int
    }
    class BonusEntry {
      +balance: int
      +history: list
    }
    class Bot {
      +ensure_order_payment_fields(order: dict): bool
      +release_bonus(user_id: str, order: dict, stage: str): int
      +get_user_bonus_balance(user_id: str): int
      +get_pending_bonus(user_id: str): int
    }
    Order "1" -- "*" BonusEntry : releases bonus
    Bot "1" -- "*" Order : manages
    Bot "1" -- "*" BonusEntry : manages
Loading

Изменения на уровне файлов

Изменение Детали Файлы
Реализация отслеживания бонусов, полей состояния платежа и действий администратора по подтверждению
  • загрузка и сохранение bonuses.json наряду с существующими хранилищами данных
  • добавление утилит ensure_order_payment_fields, release_bonus, get_user_bonus_balance, get_pending_bonus
  • обновление инициализации для исправления существующих заказов полями платежа и бонусов
  • расширение административных потоков с помощью build_admin_order_keyboard и обработчиков для confirm_prepay, confirm_full (обновление состояния, выдача бонусов, уведомление пользователей)
bot.py
Введение обратной навигации в процессе создания заказа
  • определение BACK_BUTTON_TEXT и добавление клавиатур ответа к подсказкам
  • рефакторинг шагов topic, deadline, requirements, file upload и contact в вспомогательные функции ask_/show_/prompt_, поддерживающие via_callback и back
  • добавление обработчиков back_from_* и привязка команд /back в состояниях ConversationHandler
bot.py
Отображение статуса платежа и баланса бонусов в пользовательских представлениях
  • обновление build_order_details для включения payment_state, детализации бонусов, отметок времени подтверждения
  • улучшение show_profile и show_orders для отображения текущих и ожидающих балансов бонусов и состояния платежа
  • обеспечение того, чтобы заказы были помечены полями платежа перед рендерингом
bot.py
Унификация обработки обратных вызовов (callback queries) и прямых сообщений
  • добавление условных путей в select_order_type и других обработчиках для ответа через query.edit_message_text или message.reply_text
  • удаление дублирующих процедур расчета и объединение логики сообщений/обратных вызовов
bot.py

Советы и команды

Взаимодействие с Sourcery

  • Запустить новый обзор: Прокомментируйте @sourcery-ai review в запросе на слияние (pull request).
  • Продолжить обсуждения: Отвечайте непосредственно на комментарии Sourcery.
  • Сгенерировать проблему GitHub из комментария к обзору: Попросите Sourcery создать проблему из комментария к обзору, ответив на него. Вы также можете ответить на комментарий к обзору с @sourcery-ai issue, чтобы создать из него проблему.
  • Сгенерировать заголовок запроса на слияние: Напишите @sourcery-ai в любом месте заголовка запроса на слияние, чтобы сгенерировать заголовок в любое время. Вы также можете прокомментировать @sourcery-ai title в запросе на слияние, чтобы (повторно) сгенерировать заголовок в любое время.
  • Сгенерировать сводку запроса на слияние: Напишите @sourcery-ai summary в любом месте тела запроса на слияние, чтобы сгенерировать сводку PR в любое время именно там, где вы хотите. Вы также можете прокомментировать @sourcery-ai summary в запросе на слияние, чтобы (повторно) сгенерировать сводку в любое время.
  • Сгенерировать руководство для ревьюера: Прокомментируйте @sourcery-ai guide в запросе на слияние, чтобы (повторно) сгенерировать руководство для ревьюера в любое время.
  • Разрешить все комментарии Sourcery: Прокомментируйте @sourcery-ai resolve в запросе на слияние, чтобы разрешить все комментарии Sourcery. Полезно, если вы уже решили все комментарии и не хотите больше их видеть.
  • Отклонить все обзоры Sourcery: Прокомментируйте @sourcery-ai dismiss в запросе на слияние, чтобы отклонить все существующие обзоры Sourcery. Особенно полезно, если вы хотите начать заново с новым обзором — не забудьте прокомментировать @sourcery-ai review, чтобы запустить новый обзор!

Настройка вашего опыта

Получите доступ к вашей панели управления, чтобы:

  • Включить или отключить функции обзора, такие как сгенерированная Sourcery сводка запроса на слияние, руководство для ревьюера и другие.
  • Изменить язык обзора.
  • Добавить, удалить или отредактировать пользовательские инструкции по обзору.
  • Настроить другие параметры обзора.

Получение помощи

Original review guide in English

Reviewer's Guide

This PR integrates a full bonus tracking and payment confirmation flow—storing and releasing prepayment/full bonuses, extending admin actions to confirm payments and notify users—and overhauls the order creation conversation by introducing a back-navigation mechanism at each step, while surfacing payment and bonus information in profile, order listings, and detail views.

Sequence diagram for admin payment confirmation and bonus release

sequenceDiagram
actor Admin
participant Bot
participant "ORDERS (storage)"
participant "BONUSES (storage)"
participant User
Admin->>Bot: Confirm prepayment/full payment for order
Bot->>"ORDERS (storage)": Update order payment fields
Bot->>"BONUSES (storage)": Release bonus for user
Bot->>User: Notify about payment confirmation and bonus credited
Bot->>"ORDERS (storage)": Save updated order
Bot->>"BONUSES (storage)": Save updated bonus
Loading

Entity relationship diagram for bonuses and orders

erDiagram
    USER ||--o{ ORDER : places
    USER ||--o{ BONUS : has
    ORDER {
      string order_id
      string payment_state
      boolean prepayment_confirmed
      boolean full_payment_confirmed
      datetime prepayment_confirmed_at
      datetime full_payment_confirmed_at
      int bonus_total
      int bonus_released_prepaid
      int bonus_released_full
    }
    BONUS {
      int balance
      string[] history
    }
Loading

Class diagram for bonus and payment confirmation logic

classDiagram
    class Order {
      +order_id: str
      +payment_state: str
      +prepayment_confirmed: bool
      +full_payment_confirmed: bool
      +prepayment_confirmed_at: datetime
      +full_payment_confirmed_at: datetime
      +bonus_total: int
      +bonus_released_prepaid: int
      +bonus_released_full: int
    }
    class BonusEntry {
      +balance: int
      +history: list
    }
    class Bot {
      +ensure_order_payment_fields(order: dict): bool
      +release_bonus(user_id: str, order: dict, stage: str): int
      +get_user_bonus_balance(user_id: str): int
      +get_pending_bonus(user_id: str): int
    }
    Order "1" -- "*" BonusEntry : releases bonus
    Bot "1" -- "*" Order : manages
    Bot "1" -- "*" BonusEntry : manages
Loading

File-Level Changes

Change Details Files
Implement bonus tracking, payment state fields, and admin confirmation actions
  • load and persist bonuses.json alongside existing data stores
  • add ensure_order_payment_fields, release_bonus, get_user_bonus_balance, get_pending_bonus utilities
  • update initialization to patch existing orders with payment and bonus fields
  • extend admin flows with build_admin_order_keyboard and handlers for confirm_prepay, confirm_full (update state, release bonuses, notify users)
bot.py
Introduce backward navigation in order creation flow
  • define BACK_BUTTON_TEXT and add reply keyboards to prompts
  • refactor topic, deadline, requirements, file upload, and contact steps into ask_/show_/prompt_ helpers supporting via_callback and back
  • add back_from_* handlers and bind '/back' commands in ConversationHandler states
bot.py
Surface payment status and bonus balances in user views
  • update build_order_details to include payment_state, bonus breakdown, confirmation timestamps
  • enhance show_profile and show_orders to display current and pending bonus balances and payment state
  • ensure orders are stamped with payment fields before rendering
bot.py
Unify handling of callback queries and direct messages
  • add conditional paths in select_order_type and other handlers to reply via query.edit_message_text or message.reply_text
  • remove duplicate calculation routines and merge message/callback logic
bot.py

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Привет! Я ознакомился с твоими изменениями, и они выглядят отлично!

Подсказка для AI-агентов
Пожалуйста, учти комментарии из этого обзора кода:

## Индивидуальные комментарии

### Comment 1
<location> `bot.py:656-667` </location>
<code_context>
+        return await prompt_contact_input(update, context, via_callback=True)
+    return await prompt_contact_input(update, context)

 async def input_contact(update: Update, context: ContextTypes.DEFAULT_TYPE):
     contact = update.message.text.strip()
+    if contact == BACK_BUTTON_TEXT:
+        await update.message.reply_text("Возвращаемся к файлам.", reply_markup=ReplyKeyboardRemove())
+        return await prompt_file_upload(update, context, from_back=True)
     if not contact:
         await update.message.reply_text("Контакт обязателен. Пожалуйста, укажите, куда менеджеру написать.")
         return INPUT_CONTACT
     context.user_data['current_contact'] = contact
     context.user_data['last_contact'] = contact
+    await update.message.reply_text("Контакт сохранен.", reply_markup=ReplyKeyboardRemove())
     return await add_upsell(update, context)

+async def back_from_topic(update: Update, context: ContextTypes.DEFAULT_TYPE):
</code_context>

<issue_to_address>
**предложение:** Валидация контактов может быть строже, чтобы избежать неверных записей.

Рассмотри возможность реализации валидации, чтобы убедиться, что контакты соответствуют ожидаемым форматам, таким как ники Telegram, адреса электронной почты или ссылки VK.

```suggestion
import re

def is_valid_contact(contact: str) -> bool:
    # Telegram handle: starts with @, 5-32 chars, letters, numbers, underscores
    telegram_pattern = r"^@[A-Za-z0-9_]{5,32}$"
    # Email address
    email_pattern = r"^[\w\.-]+@[\w\.-]+\.\w{2,}$"
    # VK link: vk.com/id123456 or vk.com/username
    vk_pattern = r"^(https?://)?(www\.)?vk\.com/(id\d+|[A-Za-z0-9_\.]+)$"
    return (
        re.match(telegram_pattern, contact)
        or re.match(email_pattern, contact)
        or re.match(vk_pattern, contact)
    )

async def input_contact(update: Update, context: ContextTypes.DEFAULT_TYPE):
    contact = update.message.text.strip()
    if contact == BACK_BUTTON_TEXT:
        await update.message.reply_text("Возвращаемся к файлам.", reply_markup=ReplyKeyboardRemove())
        return await prompt_file_upload(update, context, from_back=True)
    if not contact:
        await update.message.reply_text("Контакт обязателен. Пожалуйста, укажите, куда менеджеру написать.")
        return INPUT_CONTACT
    if not is_valid_contact(contact):
        await update.message.reply_text(
            "Пожалуйста, укажите корректный контакт: Telegram (@username), email, или ссылку на VK.",
            reply_markup=ReplyKeyboardRemove()
        )
        return INPUT_CONTACT
    context.user_data['current_contact'] = contact
    context.user_data['last_contact'] = contact
    await update.message.reply_text("Контакт сохранен.", reply_markup=ReplyKeyboardRemove())
    return await add_upsell(update, context)
```
</issue_to_address>

### Comment 2
<location> `bot.py:1220-1229` </location>
<code_context>
+    return InlineKeyboardMarkup(buttons)
+
 # Обработчик админ-меню
 async def admin_menu_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
     query = update.callback_query
</code_context>

<issue_to_address>
**проблема (риск_бага):** Обработчикам подтверждения оплаты администратором не хватает защиты от параллельного доступа при записи файлов.

Одновременные действия администраторов могут привести к повреждению данных из-за параллельных записей. Реализуйте блокировку файлов или атомарные операции для обеспечения безопасного доступа.
</issue_to_address>

### Comment 3
<location> `bot.py:488-498` </location>
<code_context>
+    keyboard = [[InlineKeyboardButton(f"{val['icon']} {val['name']}", callback_data=f'type_{key}')] for key, val in ORDER_TYPES.items()]
+    keyboard.append([InlineKeyboardButton("⬅️ Меню", callback_data='back_to_main')])
+    reply_markup = InlineKeyboardMarkup(keyboard)
+    if query:
+        try:
+            await query.edit_message_text(text, reply_markup=reply_markup)
+        except TelegramError as e:
+            if "message is not modified" in str(e).lower():
+                pass
+            else:
+                raise
+    else:
+        await update.message.reply_text(text, reply_markup=reply_markup)
+    return SELECT_ORDER_TYPE

 # Подробности о типе заказа
</code_context>

<issue_to_address>
**предложение:** Обработчик `select_order_type` должен обрабатывать неожиданные типы обновлений.

Рассмотрите возможность добавления обработки ошибок для случаев, когда отсутствует как `callback query`, так и `message`, чтобы предотвратить неожиданные сбои.

```suggestion
    if query:
        try:
            await query.edit_message_text(text, reply_markup=reply_markup)
        except TelegramError as e:
            if "message is not modified" in str(e).lower():
                pass
            else:
                raise
    elif getattr(update, "message", None):
        await update.message.reply_text(text, reply_markup=reply_markup)
    else:
        # Handle unexpected update type
        logger.error("select_order_type: Neither callback_query nor message present in update: %s", update)
        return ConversationHandler.END
    return SELECT_ORDER_TYPE
```
</issue_to_address>

### Comment 4
<location> `bot.py:119` </location>
<code_context>
def release_bonus(user_id: str, order: dict, stage: str) -> int:
    ensure_order_payment_fields(order)
    user_key = str(user_id)
    bonus_entry = BONUSES.setdefault(user_key, {'balance': 0, 'history': []})
    amount = 0
    if stage == 'prepayment':
        if order.get('bonus_released_prepaid'):
            return 0
        amount = order.get('bonus_total', 0) // 2
        order['bonus_released_prepaid'] = amount
    elif stage == 'full':
        if order.get('bonus_released_full'):
            return 0
        already = order.get('bonus_released_prepaid', 0)
        amount = max(order.get('bonus_total', 0) - already, 0)
        order['bonus_released_full'] = amount
    if amount <= 0:
        return 0
    bonus_entry['balance'] = bonus_entry.get('balance', 0) + amount
    history = bonus_entry.setdefault('history', [])
    history.append({
        'order_id': order.get('order_id'),
        'amount': amount,
        'stage': stage,
        'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S')
    })
    return amount

</code_context>

<issue_to_address>
**проблема (качество_кода):** Обнаружены следующие проблемы:

- Удалите ненужные приведения к int, str, float или bool ([`remove-unnecessary-cast`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/remove-unnecessary-cast/))
- Упростите условное выражение до формы switch [×2] ([`switch`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/switch/))
</issue_to_address>

### Comment 5
<location> `bot.py:146` </location>
<code_context>
def get_user_bonus_balance(user_id: str) -> int:
    entry = BONUSES.get(str(user_id), {})
    return int(entry.get('balance', 0))

</code_context>

<issue_to_address>
**предложение (качество_кода):** Удалите ненужные приведения к int, str, float или bool ([`remove-unnecessary-cast`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/remove-unnecessary-cast/))

```suggestion
    entry = BONUSES.get(user_id, {})
```
</issue_to_address>

### Comment 6
<location> `bot.py:152` </location>
<code_context>
def get_pending_bonus(user_id: str) -> int:
    total = 0
    changed = False
    for order in ORDERS.get(str(user_id), []):
        if ensure_order_payment_fields(order):
            changed = True
        credited = order.get('bonus_released_prepaid', 0) + order.get('bonus_released_full', 0)
        total += max(order.get('bonus_total', 0) - credited, 0)
    if changed:
        save_json(ORDERS_FILE, ORDERS)
    return int(total)

</code_context>

<issue_to_address>
**предложение (качество_кода):** Удалите ненужные приведения к int, str, float или bool ([`remove-unnecessary-cast`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/remove-unnecessary-cast/))

```suggestion
    for order in ORDERS.get(user_id, []):
```
</issue_to_address>

### Comment 7
<location> `bot.py:192` </location>
<code_context>
def build_order_details(uid: str, order: dict) -> str:
    changed = ensure_order_payment_fields(order)
    if changed:
        save_json(ORDERS_FILE, ORDERS)
    order_id = order.get('order_id', 'N/A')
    order_name = ORDER_TYPES.get(order.get('type'), {}).get('name', order.get('type', 'Неизвестно'))
    user_link = escape(f"tg://user?id={uid}", quote=True)
    lines = [
        f"<b>Заказ #{order_id}</b>",
        f"Пользователь: <a href=\"{user_link}\">{escape(str(uid))}</a>",
        f"Тип: {escape(order_name)}",
        f"Тема: {escape(order.get('topic', 'Без темы'))}",
        f"Срок: {order.get('deadline_days', 'N/A')} дней",
        f"Статус: {escape(order.get('status', 'неизвестно'))}",
        f"Оплата: {escape(order.get('payment_state', 'не оплачен'))}",
        f"Контакт: {format_contact_link(order.get('contact'))}",
        f"Требования: {escape(order.get('requirements', 'Нет'))}",
    ]
    if order.get('upsells'):
        upsells_readable = ', '.join(UPSELL_TITLES.get(code, code) for code in order['upsells'])
        lines.append(f"Допы: {escape(upsells_readable)}")
    else:
        lines.append("Допы: нет")
    lines.append(f"Файлов: {len(order.get('attachments') or [])}")
    bonus_total = order.get('bonus_total', 0)
    if bonus_total:
        lines.append(
            f"Бонусы: всего {bonus_total} ₽ | предоплата {order.get('bonus_released_prepaid', 0)} ₽ | оплата {order.get('bonus_released_full', 0)} ₽"
        )
    if order.get('prepayment_confirmed_at'):
        lines.append(f"Предоплата подтверждена: {escape(order['prepayment_confirmed_at'])}")
    if order.get('full_payment_confirmed_at'):
        lines.append(f"Оплата подтверждена: {escape(order['full_payment_confirmed_at'])}")
    if order.get('created_at'):
        lines.append(f"Создан: {escape(order['created_at'])}")
    return '<br>'.join(lines)

</code_context>

<issue_to_address>
**проблема (качество_кода):** Обнаружены следующие проблемы:

- Используйте именованные выражения для упрощения присваивания и условного выражения [×2] ([`use-named-expression`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/use-named-expression/))
- Удалите ненужные приведения к int, str, float или bool ([`remove-unnecessary-cast`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/remove-unnecessary-cast/))
</issue_to_address>

### Comment 8
<location> `bot.py:492-494` </location>
<code_context>
async def select_order_type(update: Update, context: ContextTypes.DEFAULT_TYPE):
    query = update.callback_query
    await answer_callback(query)
    data = query.data if query else None
    user = update.effective_user
    log_user_action(user.id, user.username, "Выбор типа заказа")
    if data == 'back_to_main':
        return await main_menu(update, context)
    text = "Выберите тип работы (добавьте несколько в корзину для скидки!):"
    keyboard = [[InlineKeyboardButton(f"{val['icon']} {val['name']}", callback_data=f'type_{key}')] for key, val in ORDER_TYPES.items()]
    keyboard.append([InlineKeyboardButton("⬅️ Меню", callback_data='back_to_main')])
    reply_markup = InlineKeyboardMarkup(keyboard)
    if query:
        try:
            await query.edit_message_text(text, reply_markup=reply_markup)
        except TelegramError as e:
            if "message is not modified" in str(e).lower():
                pass
            else:
                raise
    else:
        await update.message.reply_text(text, reply_markup=reply_markup)
    return SELECT_ORDER_TYPE

</code_context>

<issue_to_address>
**предложение (качество_кода):** Поменяйте местами if/else, чтобы убрать пустой блок if ([`remove-pass-body`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/remove-pass-body/))

```suggestion
            if "message is not modified" not in str(e).lower():
```
</issue_to_address>

### Comment 9
<location> `bot.py:1070` </location>
<code_context>
async def show_orders(update: Update, context: ContextTypes.DEFAULT_TYPE):
    query = update.callback_query
    await answer_callback(query)
    data = query.data
    if data == 'profile':
        return await show_profile(update, context)
    user_id = str(update.effective_user.id)
    user_orders = ORDERS.get(user_id, [])
    if not user_orders:
        text = "Пока нет заказов. Сделайте заказ сейчас!"
    else:
        text = "Ваши заказы:\n"
        changed = False
        for order in user_orders:
            name = ORDER_TYPES.get(order.get('type'), {}).get('name', 'Неизвестно')
            if ensure_order_payment_fields(order):
                changed = True
            payment_state = order.get('payment_state', 'не оплачен')
            text += f"#{order.get('order_id', 'N/A')}: {name} - {order.get('status', 'новый')} | Оплата: {payment_state}\n"
        if changed:
            save_json(ORDERS_FILE, ORDERS)
    keyboard = [[InlineKeyboardButton("Назад", callback_data='profile')]]
    await query.edit_message_text(text, reply_markup=InlineKeyboardMarkup(keyboard))
    return SHOW_ORDERS

</code_context>

<issue_to_address>
**проблема (качество_кода):** Обнаружены следующие проблемы:

- Используйте именованное выражение для упрощения присваивания и условного выражения ([`use-named-expression`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/use-named-expression/))
- Поменяйте местами ветви if/else ([`swap-if-else-branches`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/swap-if-else-branches/))
</issue_to_address>

### Comment 10
<location> `bot.py:1157-1160` </location>
<code_context>
def build_admin_order_keyboard(uid: str, order_id_str: str, order: dict) -> InlineKeyboardMarkup:
    buttons = []
    if not order.get('prepayment_confirmed'):
        buttons.append([
            InlineKeyboardButton("Подтвердить предоплату", callback_data=f'admin_confirm_prepay|{uid}|{order_id_str}')
        ])
    if not order.get('full_payment_confirmed'):
        buttons.append([
            InlineKeyboardButton("Подтвердить оплату", callback_data=f'admin_confirm_full|{uid}|{order_id_str}')
        ])
    buttons.append([InlineKeyboardButton("Отменить заказ", callback_data=f'admin_cancel|{uid}|{order_id_str}')])
    buttons.append([InlineKeyboardButton("Удалить заказ", callback_data=f'admin_delete|{uid}|{order_id_str}')])
    buttons.append([InlineKeyboardButton("👤 Открыть профиль", url=f"tg://user?id={uid}")])
    buttons.append([InlineKeyboardButton("⬅️ К списку", callback_data='admin_orders')])
    return InlineKeyboardMarkup(buttons)

</code_context>

<issue_to_address>
**предложение (качество_кода):** Объедините последовательные добавления в список в один extend [×3] ([`merge-list-appends-into-extend`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/merge-list-appends-into-extend/))

```suggestion
    buttons.extend(
        (
            [
                InlineKeyboardButton(
                    "Отменить заказ",
                    callback_data=f'admin_cancel|{uid}|{order_id_str}',
                )
            ],
            [
                InlineKeyboardButton(
                    "Удалить заказ",
                    callback_data=f'admin_delete|{uid}|{order_id_str}',
                )
            ],
            [
                InlineKeyboardButton(
                    "👤 Открыть профиль", url=f"tg://user?id={uid}"
                )
            ],
            [InlineKeyboardButton("⬅️ К списку", callback_data='admin_orders')],
        )
    )
```
</issue_to_address>

### Comment 11
<location> `bot.py:1164` </location>
<code_context>
async def admin_menu_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
    query = update.callback_query
    data = query.data
    shared_routes = {
        'back_to_main': main_menu,
        'make_order': select_order_type,
        'price_list': show_price_list,
        'price_calculator': price_calculator,
        'profile': show_profile,
        'faq': show_faq,
    }
    if data in shared_routes:
        context.user_data.pop('admin_state', None)
        return await shared_routes[data](update, context)
    if data == 'admin_menu':
        return await show_admin_menu(update, context)
    await answer_callback(query)
    if data == 'admin_orders':
        text_lines = []
        buttons = []
        updated = False
        for uid, ords in ORDERS.items():
            for ord_data in ords:
                if ensure_order_payment_fields(ord_data):
                    updated = True
                order_id = ord_data.get('order_id', 'N/A')
                status = escape(ord_data.get('status', 'новый'))
                payment_state = escape(ord_data.get('payment_state', 'не оплачен'))
                user_href = escape(f"tg://user?id={uid}", quote=True)
                text_lines.append(
                    f"#{order_id}: {status} | Оплата: {payment_state} — <a href=\"{user_href}\">{escape(str(uid))}</a>"
                )
                buttons.append([
                    InlineKeyboardButton(
                        f"#{order_id} ({uid})",
                        callback_data=f"admin_order|{uid}|{order_id}"
                    ),
                    InlineKeyboardButton("👤", url=f"tg://user?id={uid}")
                ])
        if updated:
            save_json(ORDERS_FILE, ORDERS)
        if not text_lines:
            text = "Заказы отсутствуют."
        else:
            text = "📋 Заказы:\n" + "\n".join(text_lines[:20])
        buttons.append([InlineKeyboardButton("Назад", callback_data='admin_menu')])
        await query.edit_message_text(text, reply_markup=InlineKeyboardMarkup(buttons), parse_mode=ParseMode.HTML)
        return ADMIN_MENU
    if data.startswith('admin_order|'):
        try:
            _, uid, order_id_str = data.split('|', 2)
        except ValueError:
            await query.edit_message_text("Некорректный идентификатор заказа.", reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("Назад", callback_data='admin_orders')]]))
            return ADMIN_MENU
        order = next((o for o in ORDERS.get(uid, []) if str(o.get('order_id')) == order_id_str), None)
        if not order:
            await query.edit_message_text("Заказ не найден.", reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("Назад", callback_data='admin_orders')]]))
            return ADMIN_MENU
        text = build_order_details(uid, order)
        reply_markup = build_admin_order_keyboard(uid, order_id_str, order)
        await query.edit_message_text(text, reply_markup=reply_markup, parse_mode=ParseMode.HTML)
        return ADMIN_MENU
    if data.startswith('admin_confirm_prepay|'):
        try:
            _, uid, order_id_str = data.split('|', 2)
        except ValueError:
            await query.edit_message_text("Некорректный идентификатор заказа.", reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("Назад", callback_data='admin_orders')]]))
            return ADMIN_MENU
        order = next((o for o in ORDERS.get(uid, []) if str(o.get('order_id')) == order_id_str), None)
        if not order:
            await query.edit_message_text("Заказ не найден.", reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("Назад", callback_data='admin_orders')]]))
            return ADMIN_MENU
        changed = ensure_order_payment_fields(order)
        if order.get('prepayment_confirmed'):
            if changed:
                save_json(ORDERS_FILE, ORDERS)
            info_prefix = "<b>ℹ️ Предоплата уже подтверждена.</b>"
        else:
            order['prepayment_confirmed'] = True
            order['prepayment_confirmed_at'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
            if not order.get('full_payment_confirmed'):
                order['payment_state'] = 'предоплата подтверждена'
            credited = release_bonus(uid, order, 'prepayment')
            save_json(ORDERS_FILE, ORDERS)
            save_json(BONUSES_FILE, BONUSES)
            info_prefix = "<b>✅ Предоплата подтверждена.</b>"
            try:
                balance = get_user_bonus_balance(uid)
                credited_text = f"Начислено бонусов: {credited} ₽." if credited else "Бонусы будут начислены после полной оплаты."
                message_text = (
                    f"Ваша предоплата по заказу #{order.get('order_id')} подтверждена. {credited_text}\n"
                    f"Текущий баланс бонусов: {balance} ₽."
                )
                await context.bot.send_message(int(uid), message_text)
            except (TelegramError, ValueError) as exc:
                logger.warning("Не удалось уведомить пользователя %s о предоплате: %s", uid, exc)
        text = build_order_details(uid, order)
        reply_markup = build_admin_order_keyboard(uid, order_id_str, order)
        await query.edit_message_text(f"{info_prefix}<br><br>{text}", reply_markup=reply_markup, parse_mode=ParseMode.HTML)
        return ADMIN_MENU
    if data.startswith('admin_confirm_full|'):
        try:
            _, uid, order_id_str = data.split('|', 2)
        except ValueError:
            await query.edit_message_text("Некорректный идентификатор заказа.", reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("Назад", callback_data='admin_orders')]]))
            return ADMIN_MENU
        order = next((o for o in ORDERS.get(uid, []) if str(o.get('order_id')) == order_id_str), None)
        if not order:
            await query.edit_message_text("Заказ не найден.", reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("Назад", callback_data='admin_orders')]]))
            return ADMIN_MENU
        changed = ensure_order_payment_fields(order)
        if order.get('full_payment_confirmed'):
            if changed:
                save_json(ORDERS_FILE, ORDERS)
            info_prefix = "<b>ℹ️ Полная оплата уже подтверждена.</b>"
        else:
            order['full_payment_confirmed'] = True
            order['full_payment_confirmed_at'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
            order['payment_state'] = 'оплачен'
            credited = release_bonus(uid, order, 'full')
            save_json(ORDERS_FILE, ORDERS)
            save_json(BONUSES_FILE, BONUSES)
            info_prefix = "<b>✅ Оплата подтверждена.</b>"
            try:
                balance = get_user_bonus_balance(uid)
                credited_text = f"Начислено бонусов: {credited} ₽." if credited else "Дополнительные бонусы не начислены."
                message_text = (
                    f"Полная оплата по заказу #{order.get('order_id')} подтверждена. {credited_text}\n"
                    f"Текущий баланс бонусов: {balance} ₽."
                )
                await context.bot.send_message(int(uid), message_text)
            except (TelegramError, ValueError) as exc:
                logger.warning("Не удалось уведомить пользователя %s об оплате: %s", uid, exc)
        text = build_order_details(uid, order)
        reply_markup = build_admin_order_keyboard(uid, order_id_str, order)
        await query.edit_message_text(f"{info_prefix}<br><br>{text}", reply_markup=reply_markup, parse_mode=ParseMode.HTML)
        return ADMIN_MENU
    if data.startswith('admin_cancel|'):
        try:
            _, uid, order_id_str = data.split('|', 2)
        except ValueError:
            await query.edit_message_text("Некорректный идентификатор заказа.", reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("Назад", callback_data='admin_orders')]]))
            return ADMIN_MENU
        order = next((o for o in ORDERS.get(uid, []) if str(o.get('order_id')) == order_id_str), None)
        if not order:
            await query.edit_message_text("Заказ не найден.", reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("Назад", callback_data='admin_orders')]]))
            return ADMIN_MENU
        order['status'] = 'отменен'
        save_json(ORDERS_FILE, ORDERS)
        text = f"Статус заказа #{order_id_str} обновлен на 'отменен'."
        keyboard = [
            [InlineKeyboardButton("Посмотреть заказ", callback_data=f'admin_order|{uid}|{order_id_str}')],
            [InlineKeyboardButton("⬅️ К списку", callback_data='admin_orders')]
        ]
        await query.edit_message_text(text, reply_markup=InlineKeyboardMarkup(keyboard))
        return ADMIN_MENU
    if data.startswith('admin_delete|'):
        try:
            _, uid, order_id_str = data.split('|', 2)
        except ValueError:
            await query.edit_message_text("Некорректный идентификатор заказа.", reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("Назад", callback_data='admin_orders')]]))
            return ADMIN_MENU
        orders_list = ORDERS.get(uid, [])
        new_list = [o for o in orders_list if str(o.get('order_id')) != order_id_str]
        if len(new_list) == len(orders_list):
            await query.edit_message_text("Заказ не найден.", reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("Назад", callback_data='admin_orders')]]))
            return ADMIN_MENU
        if new_list:
            ORDERS[uid] = new_list
        else:
            ORDERS.pop(uid, None)
        save_json(ORDERS_FILE, ORDERS)
        text = f"Заказ #{order_id_str} удален."
        keyboard = [
            [InlineKeyboardButton("⬅️ К списку", callback_data='admin_orders')],
            [InlineKeyboardButton("Админ-меню", callback_data='admin_menu')]
        ]
        await query.edit_message_text(text, reply_markup=InlineKeyboardMarkup(keyboard))
        return ADMIN_MENU
    text = ""
    keyboard = [[InlineKeyboardButton("Назад", callback_data='admin_menu')]]
    if data == 'admin_users':
        text = "👥 Пользователи:\n" + "\n".join(f"ID: {uid}" for uid in ORDERS.keys())
    elif data == 'admin_logs':
        text = "📊 Логи (последние 10):\n"
        for uid, logs in list(USER_LOGS.items())[-10:]:
            if logs:
                text += f"Пользователь {uid}: {logs[-1]['action']}\n"
    elif data == 'admin_prices':
        text = f"Текущий режим: {current_pricing_mode}\nВведите новый режим (hard/light):"
        context.user_data['admin_state'] = 'change_mode'
    elif data == 'admin_export':
        df = pd.DataFrame([{'user_id': uid, **ord} for uid, ords in ORDERS.items() for ord in ords])
        export_file = os.path.join(DATA_DIR, 'orders_export.csv')
        df.to_csv(export_file, index=False)
        await context.bot.send_document(ADMIN_CHAT_ID, open(export_file, 'rb'))
        os.remove(export_file)
        text = "📤 Экспорт отправлен!"
    await query.edit_message_text(text or "Неизвестная команда. Возвращаюсь в админ-меню.", reply_markup=InlineKeyboardMarkup(keyboard))
    return ADMIN_MENU

</code_context>

<issue_to_address>
**проблема (качество_кода):** Обнаружено низкое качество кода в `admin_menu_handler` - 4% ([`low-code-quality`](https://docs.sourcery.ai/Reference/Default-Rules/comments/low-code-quality/))

<br/><details><summary>Объяснение</summary>Показатель качества для этой функции ниже порогового значения качества в 25%.
Этот показатель представляет собой комбинацию длины метода, когнитивной сложности и рабочей памяти.

Как это можно исправить?

Возможно, стоит рефакторить эту функцию, чтобы сделать ее короче и читабельнее.

- Уменьшите длину функции, вынося части функциональности в отдельные функции. Это самое важное, что вы можете сделать — в идеале функция должна быть менее 10 строк.
- Уменьшите вложенность, возможно, путем введения защитных выражений для раннего возврата.
- Убедитесь, что переменные имеют узкую область видимости, чтобы код, использующий связанные концепции, располагался вместе внутри функции, а не был разбросан.</details>
</issue_to_address>

Sourcery бесплатен для проектов с открытым исходным кодом — если вам нравятся наши обзоры, пожалуйста, рассмотрите возможность поделиться ими ✨
Помогите мне быть более полезным! Пожалуйста, нажмите 👍 или 👎 на каждом комментарии, и я использую эту обратную связь для улучшения ваших обзоров.
Original comment in English

Hey there - I've reviewed your changes and they look great!

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location> `bot.py:656-667` </location>
<code_context>
+        return await prompt_contact_input(update, context, via_callback=True)
+    return await prompt_contact_input(update, context)

 async def input_contact(update: Update, context: ContextTypes.DEFAULT_TYPE):
     contact = update.message.text.strip()
+    if contact == BACK_BUTTON_TEXT:
+        await update.message.reply_text("Возвращаемся к файлам.", reply_markup=ReplyKeyboardRemove())
+        return await prompt_file_upload(update, context, from_back=True)
     if not contact:
         await update.message.reply_text("Контакт обязателен. Пожалуйста, укажите, куда менеджеру написать.")
         return INPUT_CONTACT
     context.user_data['current_contact'] = contact
     context.user_data['last_contact'] = contact
+    await update.message.reply_text("Контакт сохранен.", reply_markup=ReplyKeyboardRemove())
     return await add_upsell(update, context)

+async def back_from_topic(update: Update, context: ContextTypes.DEFAULT_TYPE):
</code_context>

<issue_to_address>
**suggestion:** Contact validation could be stricter to avoid invalid entries.

Consider implementing validation to ensure contacts match expected formats, such as Telegram handles, email addresses, or VK links.

```suggestion
import re

def is_valid_contact(contact: str) -> bool:
    # Telegram handle: starts with @, 5-32 chars, letters, numbers, underscores
    telegram_pattern = r"^@[A-Za-z0-9_]{5,32}$"
    # Email address
    email_pattern = r"^[\w\.-]+@[\w\.-]+\.\w{2,}$"
    # VK link: vk.com/id123456 or vk.com/username
    vk_pattern = r"^(https?://)?(www\.)?vk\.com/(id\d+|[A-Za-z0-9_\.]+)$"
    return (
        re.match(telegram_pattern, contact)
        or re.match(email_pattern, contact)
        or re.match(vk_pattern, contact)
    )

async def input_contact(update: Update, context: ContextTypes.DEFAULT_TYPE):
    contact = update.message.text.strip()
    if contact == BACK_BUTTON_TEXT:
        await update.message.reply_text("Возвращаемся к файлам.", reply_markup=ReplyKeyboardRemove())
        return await prompt_file_upload(update, context, from_back=True)
    if not contact:
        await update.message.reply_text("Контакт обязателен. Пожалуйста, укажите, куда менеджеру написать.")
        return INPUT_CONTACT
    if not is_valid_contact(contact):
        await update.message.reply_text(
            "Пожалуйста, укажите корректный контакт: Telegram (@username), email, или ссылку на VK.",
            reply_markup=ReplyKeyboardRemove()
        )
        return INPUT_CONTACT
    context.user_data['current_contact'] = contact
    context.user_data['last_contact'] = contact
    await update.message.reply_text("Контакт сохранен.", reply_markup=ReplyKeyboardRemove())
    return await add_upsell(update, context)
```
</issue_to_address>

### Comment 2
<location> `bot.py:1220-1229` </location>
<code_context>
+    return InlineKeyboardMarkup(buttons)
+
 # Обработчик админ-меню
 async def admin_menu_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
     query = update.callback_query
</code_context>

<issue_to_address>
**issue (bug_risk):** Admin payment confirmation handlers lack concurrency protection for file writes.

Simultaneous admin actions may cause data corruption due to concurrent writes. Implement file locking or atomic operations to ensure safe access.
</issue_to_address>

### Comment 3
<location> `bot.py:488-498` </location>
<code_context>
+    keyboard = [[InlineKeyboardButton(f"{val['icon']} {val['name']}", callback_data=f'type_{key}')] for key, val in ORDER_TYPES.items()]
+    keyboard.append([InlineKeyboardButton("⬅️ Меню", callback_data='back_to_main')])
+    reply_markup = InlineKeyboardMarkup(keyboard)
+    if query:
+        try:
+            await query.edit_message_text(text, reply_markup=reply_markup)
+        except TelegramError as e:
+            if "message is not modified" in str(e).lower():
+                pass
+            else:
+                raise
+    else:
+        await update.message.reply_text(text, reply_markup=reply_markup)
+    return SELECT_ORDER_TYPE

 # Подробности о типе заказа
</code_context>

<issue_to_address>
**suggestion:** select_order_type handler should handle unexpected update types.

Consider adding error handling for cases where neither a callback query nor a message is present to prevent unexpected failures.

```suggestion
    if query:
        try:
            await query.edit_message_text(text, reply_markup=reply_markup)
        except TelegramError as e:
            if "message is not modified" in str(e).lower():
                pass
            else:
                raise
    elif getattr(update, "message", None):
        await update.message.reply_text(text, reply_markup=reply_markup)
    else:
        # Handle unexpected update type
        logger.error("select_order_type: Neither callback_query nor message present in update: %s", update)
        return ConversationHandler.END
    return SELECT_ORDER_TYPE
```
</issue_to_address>

### Comment 4
<location> `bot.py:119` </location>
<code_context>
def release_bonus(user_id: str, order: dict, stage: str) -> int:
    ensure_order_payment_fields(order)
    user_key = str(user_id)
    bonus_entry = BONUSES.setdefault(user_key, {'balance': 0, 'history': []})
    amount = 0
    if stage == 'prepayment':
        if order.get('bonus_released_prepaid'):
            return 0
        amount = order.get('bonus_total', 0) // 2
        order['bonus_released_prepaid'] = amount
    elif stage == 'full':
        if order.get('bonus_released_full'):
            return 0
        already = order.get('bonus_released_prepaid', 0)
        amount = max(order.get('bonus_total', 0) - already, 0)
        order['bonus_released_full'] = amount
    if amount <= 0:
        return 0
    bonus_entry['balance'] = bonus_entry.get('balance', 0) + amount
    history = bonus_entry.setdefault('history', [])
    history.append({
        'order_id': order.get('order_id'),
        'amount': amount,
        'stage': stage,
        'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S')
    })
    return amount

</code_context>

<issue_to_address>
**issue (code-quality):** We've found these issues:

- Remove unnecessary casts to int, str, float or bool ([`remove-unnecessary-cast`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/remove-unnecessary-cast/))
- Simplify conditional into switch-like form [×2] ([`switch`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/switch/))
</issue_to_address>

### Comment 5
<location> `bot.py:146` </location>
<code_context>
def get_user_bonus_balance(user_id: str) -> int:
    entry = BONUSES.get(str(user_id), {})
    return int(entry.get('balance', 0))

</code_context>

<issue_to_address>
**suggestion (code-quality):** Remove unnecessary casts to int, str, float or bool ([`remove-unnecessary-cast`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/remove-unnecessary-cast/))

```suggestion
    entry = BONUSES.get(user_id, {})
```
</issue_to_address>

### Comment 6
<location> `bot.py:152` </location>
<code_context>
def get_pending_bonus(user_id: str) -> int:
    total = 0
    changed = False
    for order in ORDERS.get(str(user_id), []):
        if ensure_order_payment_fields(order):
            changed = True
        credited = order.get('bonus_released_prepaid', 0) + order.get('bonus_released_full', 0)
        total += max(order.get('bonus_total', 0) - credited, 0)
    if changed:
        save_json(ORDERS_FILE, ORDERS)
    return int(total)

</code_context>

<issue_to_address>
**suggestion (code-quality):** Remove unnecessary casts to int, str, float or bool ([`remove-unnecessary-cast`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/remove-unnecessary-cast/))

```suggestion
    for order in ORDERS.get(user_id, []):
```
</issue_to_address>

### Comment 7
<location> `bot.py:192` </location>
<code_context>
def build_order_details(uid: str, order: dict) -> str:
    changed = ensure_order_payment_fields(order)
    if changed:
        save_json(ORDERS_FILE, ORDERS)
    order_id = order.get('order_id', 'N/A')
    order_name = ORDER_TYPES.get(order.get('type'), {}).get('name', order.get('type', 'Неизвестно'))
    user_link = escape(f"tg://user?id={uid}", quote=True)
    lines = [
        f"<b>Заказ #{order_id}</b>",
        f"Пользователь: <a href=\"{user_link}\">{escape(str(uid))}</a>",
        f"Тип: {escape(order_name)}",
        f"Тема: {escape(order.get('topic', 'Без темы'))}",
        f"Срок: {order.get('deadline_days', 'N/A')} дней",
        f"Статус: {escape(order.get('status', 'неизвестно'))}",
        f"Оплата: {escape(order.get('payment_state', 'не оплачен'))}",
        f"Контакт: {format_contact_link(order.get('contact'))}",
        f"Требования: {escape(order.get('requirements', 'Нет'))}",
    ]
    if order.get('upsells'):
        upsells_readable = ', '.join(UPSELL_TITLES.get(code, code) for code in order['upsells'])
        lines.append(f"Допы: {escape(upsells_readable)}")
    else:
        lines.append("Допы: нет")
    lines.append(f"Файлов: {len(order.get('attachments') or [])}")
    bonus_total = order.get('bonus_total', 0)
    if bonus_total:
        lines.append(
            f"Бонусы: всего {bonus_total} ₽ | предоплата {order.get('bonus_released_prepaid', 0)} ₽ | оплата {order.get('bonus_released_full', 0)} ₽"
        )
    if order.get('prepayment_confirmed_at'):
        lines.append(f"Предоплата подтверждена: {escape(order['prepayment_confirmed_at'])}")
    if order.get('full_payment_confirmed_at'):
        lines.append(f"Оплата подтверждена: {escape(order['full_payment_confirmed_at'])}")
    if order.get('created_at'):
        lines.append(f"Создан: {escape(order['created_at'])}")
    return '<br>'.join(lines)

</code_context>

<issue_to_address>
**issue (code-quality):** We've found these issues:

- Use named expression to simplify assignment and conditional [×2] ([`use-named-expression`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/use-named-expression/))
- Remove unnecessary casts to int, str, float or bool ([`remove-unnecessary-cast`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/remove-unnecessary-cast/))
</issue_to_address>

### Comment 8
<location> `bot.py:492-494` </location>
<code_context>
async def select_order_type(update: Update, context: ContextTypes.DEFAULT_TYPE):
    query = update.callback_query
    await answer_callback(query)
    data = query.data if query else None
    user = update.effective_user
    log_user_action(user.id, user.username, "Выбор типа заказа")
    if data == 'back_to_main':
        return await main_menu(update, context)
    text = "Выберите тип работы (добавьте несколько в корзину для скидки!):"
    keyboard = [[InlineKeyboardButton(f"{val['icon']} {val['name']}", callback_data=f'type_{key}')] for key, val in ORDER_TYPES.items()]
    keyboard.append([InlineKeyboardButton("⬅️ Меню", callback_data='back_to_main')])
    reply_markup = InlineKeyboardMarkup(keyboard)
    if query:
        try:
            await query.edit_message_text(text, reply_markup=reply_markup)
        except TelegramError as e:
            if "message is not modified" in str(e).lower():
                pass
            else:
                raise
    else:
        await update.message.reply_text(text, reply_markup=reply_markup)
    return SELECT_ORDER_TYPE

</code_context>

<issue_to_address>
**suggestion (code-quality):** Swap if/else to remove empty if body ([`remove-pass-body`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/remove-pass-body/))

```suggestion
            if "message is not modified" not in str(e).lower():
```
</issue_to_address>

### Comment 9
<location> `bot.py:1070` </location>
<code_context>
async def show_orders(update: Update, context: ContextTypes.DEFAULT_TYPE):
    query = update.callback_query
    await answer_callback(query)
    data = query.data
    if data == 'profile':
        return await show_profile(update, context)
    user_id = str(update.effective_user.id)
    user_orders = ORDERS.get(user_id, [])
    if not user_orders:
        text = "Пока нет заказов. Сделайте заказ сейчас!"
    else:
        text = "Ваши заказы:\n"
        changed = False
        for order in user_orders:
            name = ORDER_TYPES.get(order.get('type'), {}).get('name', 'Неизвестно')
            if ensure_order_payment_fields(order):
                changed = True
            payment_state = order.get('payment_state', 'не оплачен')
            text += f"#{order.get('order_id', 'N/A')}: {name} - {order.get('status', 'новый')} | Оплата: {payment_state}\n"
        if changed:
            save_json(ORDERS_FILE, ORDERS)
    keyboard = [[InlineKeyboardButton("Назад", callback_data='profile')]]
    await query.edit_message_text(text, reply_markup=InlineKeyboardMarkup(keyboard))
    return SHOW_ORDERS

</code_context>

<issue_to_address>
**issue (code-quality):** We've found these issues:

- Use named expression to simplify assignment and conditional ([`use-named-expression`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/use-named-expression/))
- Swap if/else branches ([`swap-if-else-branches`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/swap-if-else-branches/))
</issue_to_address>

### Comment 10
<location> `bot.py:1157-1160` </location>
<code_context>
def build_admin_order_keyboard(uid: str, order_id_str: str, order: dict) -> InlineKeyboardMarkup:
    buttons = []
    if not order.get('prepayment_confirmed'):
        buttons.append([
            InlineKeyboardButton("Подтвердить предоплату", callback_data=f'admin_confirm_prepay|{uid}|{order_id_str}')
        ])
    if not order.get('full_payment_confirmed'):
        buttons.append([
            InlineKeyboardButton("Подтвердить оплату", callback_data=f'admin_confirm_full|{uid}|{order_id_str}')
        ])
    buttons.append([InlineKeyboardButton("Отменить заказ", callback_data=f'admin_cancel|{uid}|{order_id_str}')])
    buttons.append([InlineKeyboardButton("Удалить заказ", callback_data=f'admin_delete|{uid}|{order_id_str}')])
    buttons.append([InlineKeyboardButton("👤 Открыть профиль", url=f"tg://user?id={uid}")])
    buttons.append([InlineKeyboardButton("⬅️ К списку", callback_data='admin_orders')])
    return InlineKeyboardMarkup(buttons)

</code_context>

<issue_to_address>
**suggestion (code-quality):** Merge consecutive list appends into a single extend [×3] ([`merge-list-appends-into-extend`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/merge-list-appends-into-extend/))

```suggestion
    buttons.extend(
        (
            [
                InlineKeyboardButton(
                    "Отменить заказ",
                    callback_data=f'admin_cancel|{uid}|{order_id_str}',
                )
            ],
            [
                InlineKeyboardButton(
                    "Удалить заказ",
                    callback_data=f'admin_delete|{uid}|{order_id_str}',
                )
            ],
            [
                InlineKeyboardButton(
                    "👤 Открыть профиль", url=f"tg://user?id={uid}"
                )
            ],
            [InlineKeyboardButton("⬅️ К списку", callback_data='admin_orders')],
        )
    )
```
</issue_to_address>

### Comment 11
<location> `bot.py:1164` </location>
<code_context>
async def admin_menu_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
    query = update.callback_query
    data = query.data
    shared_routes = {
        'back_to_main': main_menu,
        'make_order': select_order_type,
        'price_list': show_price_list,
        'price_calculator': price_calculator,
        'profile': show_profile,
        'faq': show_faq,
    }
    if data in shared_routes:
        context.user_data.pop('admin_state', None)
        return await shared_routes[data](update, context)
    if data == 'admin_menu':
        return await show_admin_menu(update, context)
    await answer_callback(query)
    if data == 'admin_orders':
        text_lines = []
        buttons = []
        updated = False
        for uid, ords in ORDERS.items():
            for ord_data in ords:
                if ensure_order_payment_fields(ord_data):
                    updated = True
                order_id = ord_data.get('order_id', 'N/A')
                status = escape(ord_data.get('status', 'новый'))
                payment_state = escape(ord_data.get('payment_state', 'не оплачен'))
                user_href = escape(f"tg://user?id={uid}", quote=True)
                text_lines.append(
                    f"#{order_id}: {status} | Оплата: {payment_state} — <a href=\"{user_href}\">{escape(str(uid))}</a>"
                )
                buttons.append([
                    InlineKeyboardButton(
                        f"#{order_id} ({uid})",
                        callback_data=f"admin_order|{uid}|{order_id}"
                    ),
                    InlineKeyboardButton("👤", url=f"tg://user?id={uid}")
                ])
        if updated:
            save_json(ORDERS_FILE, ORDERS)
        if not text_lines:
            text = "Заказы отсутствуют."
        else:
            text = "📋 Заказы:\n" + "\n".join(text_lines[:20])
        buttons.append([InlineKeyboardButton("Назад", callback_data='admin_menu')])
        await query.edit_message_text(text, reply_markup=InlineKeyboardMarkup(buttons), parse_mode=ParseMode.HTML)
        return ADMIN_MENU
    if data.startswith('admin_order|'):
        try:
            _, uid, order_id_str = data.split('|', 2)
        except ValueError:
            await query.edit_message_text("Некорректный идентификатор заказа.", reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("Назад", callback_data='admin_orders')]]))
            return ADMIN_MENU
        order = next((o for o in ORDERS.get(uid, []) if str(o.get('order_id')) == order_id_str), None)
        if not order:
            await query.edit_message_text("Заказ не найден.", reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("Назад", callback_data='admin_orders')]]))
            return ADMIN_MENU
        text = build_order_details(uid, order)
        reply_markup = build_admin_order_keyboard(uid, order_id_str, order)
        await query.edit_message_text(text, reply_markup=reply_markup, parse_mode=ParseMode.HTML)
        return ADMIN_MENU
    if data.startswith('admin_confirm_prepay|'):
        try:
            _, uid, order_id_str = data.split('|', 2)
        except ValueError:
            await query.edit_message_text("Некорректный идентификатор заказа.", reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("Назад", callback_data='admin_orders')]]))
            return ADMIN_MENU
        order = next((o for o in ORDERS.get(uid, []) if str(o.get('order_id')) == order_id_str), None)
        if not order:
            await query.edit_message_text("Заказ не найден.", reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("Назад", callback_data='admin_orders')]]))
            return ADMIN_MENU
        changed = ensure_order_payment_fields(order)
        if order.get('prepayment_confirmed'):
            if changed:
                save_json(ORDERS_FILE, ORDERS)
            info_prefix = "<b>ℹ️ Предоплата уже подтверждена.</b>"
        else:
            order['prepayment_confirmed'] = True
            order['prepayment_confirmed_at'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
            if not order.get('full_payment_confirmed'):
                order['payment_state'] = 'предоплата подтверждена'
            credited = release_bonus(uid, order, 'prepayment')
            save_json(ORDERS_FILE, ORDERS)
            save_json(BONUSES_FILE, BONUSES)
            info_prefix = "<b>✅ Предоплата подтверждена.</b>"
            try:
                balance = get_user_bonus_balance(uid)
                credited_text = f"Начислено бонусов: {credited} ₽." if credited else "Бонусы будут начислены после полной оплаты."
                message_text = (
                    f"Ваша предоплата по заказу #{order.get('order_id')} подтверждена. {credited_text}\n"
                    f"Текущий баланс бонусов: {balance} ₽."
                )
                await context.bot.send_message(int(uid), message_text)
            except (TelegramError, ValueError) as exc:
                logger.warning("Не удалось уведомить пользователя %s о предоплате: %s", uid, exc)
        text = build_order_details(uid, order)
        reply_markup = build_admin_order_keyboard(uid, order_id_str, order)
        await query.edit_message_text(f"{info_prefix}<br><br>{text}", reply_markup=reply_markup, parse_mode=ParseMode.HTML)
        return ADMIN_MENU
    if data.startswith('admin_confirm_full|'):
        try:
            _, uid, order_id_str = data.split('|', 2)
        except ValueError:
            await query.edit_message_text("Некорректный идентификатор заказа.", reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("Назад", callback_data='admin_orders')]]))
            return ADMIN_MENU
        order = next((o for o in ORDERS.get(uid, []) if str(o.get('order_id')) == order_id_str), None)
        if not order:
            await query.edit_message_text("Заказ не найден.", reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("Назад", callback_data='admin_orders')]]))
            return ADMIN_MENU
        changed = ensure_order_payment_fields(order)
        if order.get('full_payment_confirmed'):
            if changed:
                save_json(ORDERS_FILE, ORDERS)
            info_prefix = "<b>ℹ️ Полная оплата уже подтверждена.</b>"
        else:
            order['full_payment_confirmed'] = True
            order['full_payment_confirmed_at'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
            order['payment_state'] = 'оплачен'
            credited = release_bonus(uid, order, 'full')
            save_json(ORDERS_FILE, ORDERS)
            save_json(BONUSES_FILE, BONUSES)
            info_prefix = "<b>✅ Оплата подтверждена.</b>"
            try:
                balance = get_user_bonus_balance(uid)
                credited_text = f"Начислено бонусов: {credited} ₽." if credited else "Дополнительные бонусы не начислены."
                message_text = (
                    f"Полная оплата по заказу #{order.get('order_id')} подтверждена. {credited_text}\n"
                    f"Текущий баланс бонусов: {balance} ₽."
                )
                await context.bot.send_message(int(uid), message_text)
            except (TelegramError, ValueError) as exc:
                logger.warning("Не удалось уведомить пользователя %s об оплате: %s", uid, exc)
        text = build_order_details(uid, order)
        reply_markup = build_admin_order_keyboard(uid, order_id_str, order)
        await query.edit_message_text(f"{info_prefix}<br><br>{text}", reply_markup=reply_markup, parse_mode=ParseMode.HTML)
        return ADMIN_MENU
    if data.startswith('admin_cancel|'):
        try:
            _, uid, order_id_str = data.split('|', 2)
        except ValueError:
            await query.edit_message_text("Некорректный идентификатор заказа.", reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("Назад", callback_data='admin_orders')]]))
            return ADMIN_MENU
        order = next((o for o in ORDERS.get(uid, []) if str(o.get('order_id')) == order_id_str), None)
        if not order:
            await query.edit_message_text("Заказ не найден.", reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("Назад", callback_data='admin_orders')]]))
            return ADMIN_MENU
        order['status'] = 'отменен'
        save_json(ORDERS_FILE, ORDERS)
        text = f"Статус заказа #{order_id_str} обновлен на 'отменен'."
        keyboard = [
            [InlineKeyboardButton("Посмотреть заказ", callback_data=f'admin_order|{uid}|{order_id_str}')],
            [InlineKeyboardButton("⬅️ К списку", callback_data='admin_orders')]
        ]
        await query.edit_message_text(text, reply_markup=InlineKeyboardMarkup(keyboard))
        return ADMIN_MENU
    if data.startswith('admin_delete|'):
        try:
            _, uid, order_id_str = data.split('|', 2)
        except ValueError:
            await query.edit_message_text("Некорректный идентификатор заказа.", reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("Назад", callback_data='admin_orders')]]))
            return ADMIN_MENU
        orders_list = ORDERS.get(uid, [])
        new_list = [o for o in orders_list if str(o.get('order_id')) != order_id_str]
        if len(new_list) == len(orders_list):
            await query.edit_message_text("Заказ не найден.", reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("Назад", callback_data='admin_orders')]]))
            return ADMIN_MENU
        if new_list:
            ORDERS[uid] = new_list
        else:
            ORDERS.pop(uid, None)
        save_json(ORDERS_FILE, ORDERS)
        text = f"Заказ #{order_id_str} удален."
        keyboard = [
            [InlineKeyboardButton("⬅️ К списку", callback_data='admin_orders')],
            [InlineKeyboardButton("Админ-меню", callback_data='admin_menu')]
        ]
        await query.edit_message_text(text, reply_markup=InlineKeyboardMarkup(keyboard))
        return ADMIN_MENU
    text = ""
    keyboard = [[InlineKeyboardButton("Назад", callback_data='admin_menu')]]
    if data == 'admin_users':
        text = "👥 Пользователи:\n" + "\n".join(f"ID: {uid}" for uid in ORDERS.keys())
    elif data == 'admin_logs':
        text = "📊 Логи (последние 10):\n"
        for uid, logs in list(USER_LOGS.items())[-10:]:
            if logs:
                text += f"Пользователь {uid}: {logs[-1]['action']}\n"
    elif data == 'admin_prices':
        text = f"Текущий режим: {current_pricing_mode}\nВведите новый режим (hard/light):"
        context.user_data['admin_state'] = 'change_mode'
    elif data == 'admin_export':
        df = pd.DataFrame([{'user_id': uid, **ord} for uid, ords in ORDERS.items() for ord in ords])
        export_file = os.path.join(DATA_DIR, 'orders_export.csv')
        df.to_csv(export_file, index=False)
        await context.bot.send_document(ADMIN_CHAT_ID, open(export_file, 'rb'))
        os.remove(export_file)
        text = "📤 Экспорт отправлен!"
    await query.edit_message_text(text or "Неизвестная команда. Возвращаюсь в админ-меню.", reply_markup=InlineKeyboardMarkup(keyboard))
    return ADMIN_MENU

</code_context>

<issue_to_address>
**issue (code-quality):** Low code quality found in admin\_menu\_handler - 4% ([`low-code-quality`](https://docs.sourcery.ai/Reference/Default-Rules/comments/low-code-quality/))

<br/><details><summary>Explanation</summary>The quality score for this function is below the quality threshold of 25%.
This score is a combination of the method length, cognitive complexity and working memory.

How can you solve this?

It might be worth refactoring this function to make it shorter and more readable.

- Reduce the function length by extracting pieces of functionality out into
  their own functions. This is the most important thing you can do - ideally a
  function should be less than 10 lines.
- Reduce nesting, perhaps by introducing guard clauses to return early.
- Ensure that variables are tightly scoped, so that code using related concepts
  sits together within the function rather than being scattered.</details>
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread bot.py
Comment on lines +488 to +498
if query:
try:
await query.edit_message_text(text, reply_markup=reply_markup)
except TelegramError as e:
if "message is not modified" in str(e).lower():
pass
else:
raise
else:
await update.message.reply_text(text, reply_markup=reply_markup)
return SELECT_ORDER_TYPE

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

предложение: Обработчик select_order_type должен обрабатывать неожиданные типы обновлений.

Рассмотрите возможность добавления обработки ошибок для случаев, когда отсутствует как callback query, так и message, чтобы предотвратить неожиданные сбои.

Suggested change
if query:
try:
await query.edit_message_text(text, reply_markup=reply_markup)
except TelegramError as e:
if "message is not modified" in str(e).lower():
pass
else:
raise
else:
await update.message.reply_text(text, reply_markup=reply_markup)
return SELECT_ORDER_TYPE
if query:
try:
await query.edit_message_text(text, reply_markup=reply_markup)
except TelegramError as e:
if "message is not modified" in str(e).lower():
pass
else:
raise
elif getattr(update, "message", None):
await update.message.reply_text(text, reply_markup=reply_markup)
else:
# Handle unexpected update type
logger.error("select_order_type: Neither callback_query nor message present in update: %s", update)
return ConversationHandler.END
return SELECT_ORDER_TYPE
Original comment in English

suggestion: select_order_type handler should handle unexpected update types.

Consider adding error handling for cases where neither a callback query nor a message is present to prevent unexpected failures.

Suggested change
if query:
try:
await query.edit_message_text(text, reply_markup=reply_markup)
except TelegramError as e:
if "message is not modified" in str(e).lower():
pass
else:
raise
else:
await update.message.reply_text(text, reply_markup=reply_markup)
return SELECT_ORDER_TYPE
if query:
try:
await query.edit_message_text(text, reply_markup=reply_markup)
except TelegramError as e:
if "message is not modified" in str(e).lower():
pass
else:
raise
elif getattr(update, "message", None):
await update.message.reply_text(text, reply_markup=reply_markup)
else:
# Handle unexpected update type
logger.error("select_order_type: Neither callback_query nor message present in update: %s", update)
return ConversationHandler.END
return SELECT_ORDER_TYPE

Comment thread bot.py
Comment on lines +492 to +494
if "message is not modified" in str(e).lower():
pass
else:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

предложение (качество_кода): Поменяйте местами if/else, чтобы убрать пустой блок if (remove-pass-body)

Suggested change
if "message is not modified" in str(e).lower():
pass
else:
if "message is not modified" not in str(e).lower():
Original comment in English

suggestion (code-quality): Swap if/else to remove empty if body (remove-pass-body)

Suggested change
if "message is not modified" in str(e).lower():
pass
else:
if "message is not modified" not in str(e).lower():

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant