feat: Add A-Z filter for clients/suppliers in Telegram bot

- Add A-Z alphabetical filter keyboard for clients and suppliers lists
  (same pattern as company selection, without emoji)
- Increase clients/suppliers list pagination from 10 to 20 items per page
- Remove emoji from company A-Z filter button for consistency
- Add 6 new callback handlers: clients_alpha_menu, clients_alpha:LETTER,
  clients_alpha_page:PAGE:LETTER, and supplier equivalents
- Dashboard service and models updates
- Telegram bot: email handlers, auth, DB operations, internal API improvements
- Frontend: dashboard cards updates (CashFlow, Clienti, Furnizori, Treasury)
- Frontend: SolduriCompactCard and CollapsibleCard improvements
- DashboardView enhancements
- start.sh and run-with-restart.sh script updates
- IIS web.config and service worker updates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-02-21 14:34:15 +00:00
parent 1366dbc11c
commit 30f55cf18b
28 changed files with 1671 additions and 520 deletions

View File

@@ -14,6 +14,8 @@ import logging
from datetime import datetime
import asyncio
from typing import Optional
from backend.modules.telegram.auth.email_auth import (
is_valid_email_format,
verify_email_in_oracle,
@@ -22,6 +24,7 @@ from backend.modules.telegram.auth.email_auth import (
check_rate_limit,
clear_rate_limit
)
from shared.auth.email_server_cache import email_server_cache
from backend.modules.telegram.utils.email_service import get_email_service
from backend.modules.telegram.db.operations import (
create_email_auth_code,
@@ -39,7 +42,7 @@ from backend.modules.telegram.api.client import get_backend_client
logger = logging.getLogger(__name__)
# Conversation states
AWAITING_EMAIL, AWAITING_CODE, AWAITING_PASSWORD = range(3)
AWAITING_EMAIL, AWAITING_CODE, AWAITING_PASSWORD, AWAITING_SERVER_SELECTION = range(4)
# Constants
MAX_CODE_ATTEMPTS = 3
@@ -261,57 +264,25 @@ async def web_login_info_callback(update: Update, context: ContextTypes.DEFAULT_
# STATE: AWAITING_EMAIL
# ============================================================================
async def receive_email(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Handler pentru primirea email-ului"""
email = update.message.text.strip().lower()
user_id = update.effective_user.id
# ȘTERG mesajul utilizatorului imediat (chat curat)
async def _send_email_code(
context: ContextTypes.DEFAULT_TYPE,
chat_id: int,
email: str,
server_id: Optional[str],
user_id: int
) -> int:
"""
Generate and send email verification code on the specified server.
Returns AWAITING_CODE on success or ConversationHandler.END on failure.
"""
try:
await update.message.delete()
except Exception as e:
logger.warning(f"Could not delete email message: {e}")
# Validare format email
if not is_valid_email_format(email):
# Show error in main message
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
text="Email invalid\n\nIntrodu o adresă validă (nume@domeniu.ro)",
reply_markup=InlineKeyboardMarkup([
[InlineKeyboardButton("Anulează", callback_data="cancel")]
])
)
return AWAITING_EMAIL
# Check for existing pending code
existing_code = await get_pending_email_code(user_id)
if existing_code:
# Delete old pending code
await delete_user_email_codes(user_id)
logger.info(f"Deleted existing pending code for user {user_id}")
# EDIT login message to show loading
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
text="Verificare email...",
reply_markup=None
)
try:
# Verifică email în Oracle
username = await verify_email_in_oracle(email)
# Verify email in Oracle (on specific server if known)
username = await verify_email_in_oracle(email, server_id=server_id)
# IMPORTANT: Generic response to prevent email enumeration
# We always say "code sent" even if email doesn't exist
if username:
# Email exists - generate and send code
code = generate_email_code()
# Save code in database
code_saved = await create_email_auth_code(
code=code,
email=email,
@@ -323,27 +294,26 @@ async def receive_email(update: Update, context: ContextTypes.DEFAULT_TYPE):
if not code_saved:
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
chat_id=chat_id,
text="Eroare la salvarea codului.\n\nIncearcă din nou cu /login"
)
return ConversationHandler.END
# Send email (async with retry)
email_service = get_email_service()
email_sent = await email_service.send_auth_code(email, code, username)
if not email_sent:
logger.error(f"Failed to send email to {email}")
# Don't reveal this to user - they'll timeout naturally
if email_sent:
logger.info(f"[EMAIL-AUTH] ✅ Code sent for {email[:3]}***@*** (user {user_id}, server={server_id})")
else:
logger.error(f"[EMAIL-AUTH] ❌ Failed to send code (user {user_id}, server={server_id})")
# Wait 1 second for better UX (looks like verification happened)
# Wait 1 second for UX (looks like verification happened)
await asyncio.sleep(1)
# ALWAYS show this message (prevent enumeration)
# EDIT same message with success + buttons
# ALWAYS show success (prevent enumeration)
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
chat_id=chat_id,
text=f"Cod trimis pe {email}\n\nIntrodu codul primit pe email:",
reply_markup=InlineKeyboardMarkup([
[InlineKeyboardButton("Retrimite Cod", callback_data=f"resend:{email}")],
@@ -351,22 +321,135 @@ async def receive_email(update: Update, context: ContextTypes.DEFAULT_TYPE):
])
)
# Save email in context for resend functionality
context.user_data['pending_email'] = email
context.user_data['pending_username'] = username
return AWAITING_CODE
except Exception as e:
logger.error(f"Error in receive_email: {e}", exc_info=True)
logger.error(f"Error sending email code: {e}", exc_info=True)
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
chat_id=chat_id,
text="Eroare internă.\n\nIncearcă din nou mai târziu."
)
return ConversationHandler.END
async def receive_email(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Handler pentru primirea email-ului"""
email = update.message.text.strip().lower()
user_id = update.effective_user.id
chat_id = update.effective_chat.id
# ȘTERG mesajul utilizatorului imediat (chat curat)
try:
await update.message.delete()
except Exception as e:
logger.warning(f"Could not delete email message: {e}")
# Validare format email
if not is_valid_email_format(email):
await edit_login_message(
context=context,
chat_id=chat_id,
text="Email invalid\n\nIntrodu o adresă validă (nume@domeniu.ro)",
reply_markup=InlineKeyboardMarkup([
[InlineKeyboardButton("Anulează", callback_data="cancel")]
])
)
return AWAITING_EMAIL
# Clean up old pending codes
existing_code = await get_pending_email_code(user_id)
if existing_code:
await delete_user_email_codes(user_id)
logger.info(f"Deleted existing pending code for user {user_id}")
# Show loading
await edit_login_message(
context=context,
chat_id=chat_id,
text="Verificare email...",
reply_markup=None
)
# Check server cache for multi-server routing
try:
await email_server_cache.refresh_if_needed()
servers = email_server_cache.get_servers_for_email(email)
except Exception as e:
logger.warning(f"Could not check email server cache: {e}")
servers = []
if len(servers) > 1:
# Multiple servers — ask user to select before sending code
context.user_data['pending_email'] = email
try:
from backend.config import settings
keyboard = []
for srv_id in servers:
srv = settings.get_oracle_server(srv_id)
srv_name = srv.name if srv else srv_id
keyboard.append([InlineKeyboardButton(srv_name, callback_data=f"select_server:{srv_id}")])
except Exception:
# Fallback: use server IDs as labels
keyboard = [
[InlineKeyboardButton(srv_id, callback_data=f"select_server:{srv_id}")]
for srv_id in servers
]
keyboard.append([InlineKeyboardButton("Anulează", callback_data="cancel")])
await edit_login_message(
context=context,
chat_id=chat_id,
text="Email identificat pe mai multe servere.\n\nSelectează serverul pentru autentificare:",
reply_markup=InlineKeyboardMarkup(keyboard)
)
return AWAITING_SERVER_SELECTION
# Single server or no cache hit — proceed directly
server_id = servers[0] if servers else None
context.user_data['server_id'] = server_id
return await _send_email_code(context, chat_id, email, server_id, user_id)
# ============================================================================
# STATE: AWAITING_SERVER_SELECTION
# ============================================================================
async def handle_server_selected(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Callback pentru selectarea serverului Oracle (mod multi-server)"""
query = update.callback_query
user_id = update.effective_user.id
chat_id = update.effective_chat.id
await query.answer()
# Extract server_id from callback data: "select_server:<id>"
server_id = query.data.split(":", 1)[1]
email = context.user_data.get('pending_email')
if not email:
await edit_login_message(
context=context,
chat_id=chat_id,
text="Sesiune expirată\n\nIncearcă din nou cu /login"
)
return ConversationHandler.END
# Save selected server to context
context.user_data['server_id'] = server_id
logger.info(f"[EMAIL-AUTH] User {user_id} selected server '{server_id}' for {email[:3]}***")
# Clean up old pending codes then send code on selected server
await delete_user_email_codes(user_id)
return await _send_email_code(context, chat_id, email, server_id, user_id)
# ============================================================================
# STATE: AWAITING_CODE
# ============================================================================
@@ -593,12 +676,14 @@ async def receive_password(update: Update, context: ContextTypes.DEFAULT_TYPE):
try:
# Call backend endpoint pentru verificare parolă + JWT
backend_client = get_backend_client()
server_id = context.user_data.get('server_id')
response = await backend_client.login_with_email(
email=email,
password=password,
telegram_user_id=user_id,
session_token=session_token
session_token=session_token,
server_id=server_id
)
if not response.get('success'):
@@ -749,6 +834,9 @@ email_login_handler = ConversationHandler(
AWAITING_EMAIL: [
MessageHandler(filters.TEXT & ~filters.COMMAND, receive_email)
],
AWAITING_SERVER_SELECTION: [
CallbackQueryHandler(handle_server_selected, pattern='^select_server:')
],
AWAITING_CODE: [
MessageHandler(filters.TEXT & ~filters.COMMAND, receive_code),
CallbackQueryHandler(resend_code_callback, pattern='^resend:')

View File

@@ -20,7 +20,7 @@ from backend.modules.telegram.auth.linking import (
get_user_companies
)
from backend.modules.telegram.agent.session import get_session_manager
from backend.modules.telegram.db.operations import update_user_last_active
from backend.modules.telegram.db.operations import update_user_last_active, link_user_to_oracle
from backend.modules.telegram.api.client import get_backend_client
logger = logging.getLogger(__name__)
@@ -1279,9 +1279,82 @@ async def handle_text_message(update: Update, context: ContextTypes.DEFAULT_TYPE
try:
telegram_user = update.effective_user
telegram_user_id = telegram_user.id
text = update.message.text.strip().upper()
text = update.message.text.strip()
logger.info(f"Text message from user {telegram_user_id}: {text}")
logger.info(f"Text message from user {telegram_user_id}")
# Check if user is awaiting password for server switch
pending_server_id = context.user_data.get('pending_switch_server_id')
if pending_server_id:
# Șterge IMEDIAT mesajul cu parola (securitate)
try:
await update.message.delete()
except Exception as e:
logger.warning(f"Could not delete password message: {e}")
oracle_password = text
jwt_token = context.user_data.pop('pending_switch_jwt_token', None)
username = context.user_data.pop('pending_switch_username', None)
context.user_data.pop('pending_switch_server_id', None)
if not jwt_token or not username:
await update.effective_chat.send_message("Sesiune expirată. Încearcă din nou.")
return
await update.effective_chat.send_message("Se verifică parola și se schimbă serverul...")
client = get_backend_client()
async with client:
result = await client.switch_server(
jwt_token=jwt_token,
oracle_username=username,
new_server_id=pending_server_id,
oracle_password=oracle_password
)
if not result.get('success'):
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
await update.effective_chat.send_message(
f"{result.get('message', 'Eroare la schimbarea serverului')}\n\nReîncearcă cu /menu → Schimbă server.",
reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("« Meniu", callback_data="action:menu")]])
)
return
# Salvează noul JWT în SQLite
from datetime import datetime, timedelta
token_expires_at = datetime.now() + timedelta(minutes=30)
await link_user_to_oracle(
telegram_user_id=telegram_user_id,
oracle_username=result.get('username', username),
jwt_token=result['access_token'],
jwt_refresh_token=result['refresh_token'],
token_expires_at=token_expires_at
)
# Curăță compania din sesiune — aparținea serverului vechi
session_manager = get_session_manager()
session = await session_manager.get_or_create_session(telegram_user_id)
session.clear_active_company()
await session_manager.save_session(telegram_user_id)
try:
from backend.config import settings
srv = settings.get_oracle_server(pending_server_id)
srv_display = srv.name if srv else pending_server_id
except Exception:
srv_display = pending_server_id
await update.effective_chat.send_message(f"✅ Server schimbat: **{srv_display}**\nSelectează firma...", parse_mode=ParseMode.MARKDOWN)
await _handle_selectcompany_view(
query_or_update=update,
telegram_user_id=telegram_user_id,
jwt_token=result['access_token'],
is_callback=False,
page=0,
search_term=""
)
return
# Check if user is already linked
is_linked = await check_user_linked(telegram_user_id)
@@ -1291,6 +1364,8 @@ async def handle_text_message(update: Update, context: ContextTypes.DEFAULT_TYPE
# (could add natural language processing here in the future)
return
text = text.upper() # Only uppercase for linking code check
# User is NOT linked - check if text looks like a linking code
# Linking codes are exactly 8 alphanumeric characters
if len(text) == 8 and text.isalnum():
@@ -1740,6 +1815,43 @@ async def handle_menu_callback(query, telegram_user_id: int, callback_data: str)
search_term=""
)
elif action == "switch_server":
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
from shared.auth.email_server_cache import email_server_cache
from backend.modules.telegram.bot.menus import pad_message_for_wide_buttons
username = auth_data['username']
try:
servers = await email_server_cache.get_servers_for_username(username)
except Exception as e:
logger.error(f"Could not get servers for {username}: {e}")
await query.answer("Eroare la obținerea serverelor.", show_alert=True)
return
if len(servers) <= 1:
await query.answer("Ești pe singurul server disponibil.", show_alert=True)
return
# Build server selection keyboard
try:
from backend.config import settings
keyboard_rows = []
for srv_id in servers:
srv = settings.get_oracle_server(srv_id)
srv_name = srv.name if srv else srv_id
keyboard_rows.append([InlineKeyboardButton(srv_name, callback_data=f"switch_server_confirm:{srv_id}")])
except Exception:
keyboard_rows = [[InlineKeyboardButton(s, callback_data=f"switch_server_confirm:{s}")] for s in servers]
keyboard_rows.append([InlineKeyboardButton("« Înapoi", callback_data="action:menu")])
await query.edit_message_text(
pad_message_for_wide_buttons(f"Selectează serverul Oracle:\n\nUtilizator: {username}"),
reply_markup=InlineKeyboardMarkup(keyboard_rows),
parse_mode=ParseMode.MARKDOWN
)
async def handle_action_callback(query, telegram_user_id: int, callback_data: str):
"""
@@ -2099,6 +2211,241 @@ async def button_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
parse_mode=ParseMode.MARKDOWN
)
elif callback_data == "select_company_alpha_menu":
# Show A-Z letter filter keyboard
from backend.modules.telegram.bot.helpers import create_alpha_filter_keyboard
await query.edit_message_text(
"**Selectează litera**\n\nAlege prima literă a firmei:",
reply_markup=create_alpha_filter_keyboard(),
parse_mode=ParseMode.MARKDOWN
)
elif callback_data.startswith("select_company_alpha:"):
# Filter companies by starting letter and show page 0
letter = callback_data.split(":", 1)[1] # "A" or "ALL"
auth_data = await get_user_auth_data(telegram_user_id)
jwt_token = auth_data['jwt_token']
client = get_backend_client()
async with client:
all_companies = await client.get_user_companies(jwt_token=jwt_token)
if letter == "ALL":
filtered = all_companies
else:
filtered = [
c for c in all_companies
if c.get('name', c.get('nume_firma', '')).upper().startswith(letter)
]
if not filtered:
await query.answer(f"Nicio firmă cu litera {letter}.", show_alert=True)
return
from backend.modules.telegram.bot.helpers import create_company_selection_keyboard_paginated
keyboard = create_company_selection_keyboard_paginated(
filtered, page=0,
back_callback="select_company_alpha_menu",
page_callback_prefix="select_company_alpha_page",
page_callback_suffix=f":{letter}"
)
label = f"Firme cu litera {letter}" if letter != "ALL" else "Toate firmele"
await query.edit_message_text(
f"**{label}** ({len(filtered)}):",
reply_markup=keyboard,
parse_mode=ParseMode.MARKDOWN
)
elif callback_data.startswith("select_company_alpha_page:"):
# Paginate within an alpha-filtered company list
# Callback format: select_company_alpha_page:PAGE:LETTER
parts = callback_data.split(":")
page = int(parts[1])
letter = parts[2] # "A""Z" or "ALL"
auth_data = await get_user_auth_data(telegram_user_id)
jwt_token = auth_data['jwt_token']
client = get_backend_client()
async with client:
all_companies = await client.get_user_companies(jwt_token=jwt_token)
if letter == "ALL":
filtered = all_companies
else:
filtered = [
c for c in all_companies
if c.get('name', c.get('nume_firma', '')).upper().startswith(letter)
]
from backend.modules.telegram.bot.helpers import create_company_selection_keyboard_paginated
keyboard = create_company_selection_keyboard_paginated(
filtered, page=page,
back_callback="select_company_alpha_menu",
page_callback_prefix="select_company_alpha_page",
page_callback_suffix=f":{letter}"
)
label = f"Firme cu litera {letter}" if letter != "ALL" else "Toate firmele"
await query.edit_message_text(
f"**{label}** ({len(filtered)}):",
reply_markup=keyboard,
parse_mode=ParseMode.MARKDOWN
)
elif callback_data == "clients_alpha_menu":
# Show A-Z letter filter keyboard for clients
from backend.modules.telegram.bot.helpers import create_alpha_filter_keyboard_partner
await query.edit_message_text(
"**Selecteaza litera**\n\nAlege prima litera a clientului:",
reply_markup=create_alpha_filter_keyboard_partner("clients"),
parse_mode=ParseMode.MARKDOWN
)
elif callback_data.startswith("clients_alpha:"):
# Filter clients by starting letter and show page 0
letter = callback_data.split(":", 1)[1] # "A" or "ALL"
auth_data = await get_user_auth_data(telegram_user_id)
jwt_token = auth_data['jwt_token']
session_manager = get_session_manager()
session = await session_manager.get_or_create_session(telegram_user_id)
company = session.get_active_company()
from backend.modules.telegram.bot.helpers import get_clients_with_maturity
clients_data = await get_clients_with_maturity(company['id'], jwt_token)
all_clients = clients_data['clients']
if letter == "ALL":
filtered = all_clients
else:
filtered = [
c for c in all_clients
if c.get('name', '').upper().startswith(letter)
]
if not filtered:
await query.answer(f"Niciun client cu litera {letter}.", show_alert=True)
return
from backend.modules.telegram.bot.menus import create_client_list_keyboard, format_response_with_company
from backend.modules.telegram.bot.formatters import format_clients_balance_response
content = format_clients_balance_response(filtered, clients_data['maturity'])
label = f"Clienti cu litera {letter}" if letter != "ALL" else "Toti clientii"
response = format_response_with_company(f"**{label}** ({len(filtered)}):\n\n{content}", company['name'])
keyboard = create_client_list_keyboard(filtered, page=0, letter=letter)
await query.edit_message_text(response, reply_markup=keyboard, parse_mode=ParseMode.MARKDOWN)
elif callback_data.startswith("clients_alpha_page:"):
# Paginate within an alpha-filtered client list
# Callback format: clients_alpha_page:PAGE:LETTER
parts = callback_data.split(":")
page = int(parts[1])
letter = parts[2] # "A""Z" or "ALL"
auth_data = await get_user_auth_data(telegram_user_id)
jwt_token = auth_data['jwt_token']
session_manager = get_session_manager()
session = await session_manager.get_or_create_session(telegram_user_id)
company = session.get_active_company()
from backend.modules.telegram.bot.helpers import get_clients_with_maturity
clients_data = await get_clients_with_maturity(company['id'], jwt_token)
all_clients = clients_data['clients']
if letter == "ALL":
filtered = all_clients
else:
filtered = [
c for c in all_clients
if c.get('name', '').upper().startswith(letter)
]
from backend.modules.telegram.bot.menus import create_client_list_keyboard, format_response_with_company
from backend.modules.telegram.bot.formatters import format_clients_balance_response
content = format_clients_balance_response(filtered, clients_data['maturity'])
label = f"Clienti cu litera {letter}" if letter != "ALL" else "Toti clientii"
response = format_response_with_company(f"**{label}** ({len(filtered)}):\n\n{content}", company['name'])
keyboard = create_client_list_keyboard(filtered, page=page, letter=letter)
await query.edit_message_text(response, reply_markup=keyboard, parse_mode=ParseMode.MARKDOWN)
elif callback_data == "suppliers_alpha_menu":
# Show A-Z letter filter keyboard for suppliers
from backend.modules.telegram.bot.helpers import create_alpha_filter_keyboard_partner
await query.edit_message_text(
"**Selecteaza litera**\n\nAlege prima litera a furnizorului:",
reply_markup=create_alpha_filter_keyboard_partner("suppliers"),
parse_mode=ParseMode.MARKDOWN
)
elif callback_data.startswith("suppliers_alpha:"):
# Filter suppliers by starting letter and show page 0
letter = callback_data.split(":", 1)[1] # "A" or "ALL"
auth_data = await get_user_auth_data(telegram_user_id)
jwt_token = auth_data['jwt_token']
session_manager = get_session_manager()
session = await session_manager.get_or_create_session(telegram_user_id)
company = session.get_active_company()
from backend.modules.telegram.bot.helpers import get_suppliers_with_maturity
suppliers_data = await get_suppliers_with_maturity(company['id'], jwt_token)
all_suppliers = suppliers_data['suppliers']
if letter == "ALL":
filtered = all_suppliers
else:
filtered = [
s for s in all_suppliers
if s.get('name', '').upper().startswith(letter)
]
if not filtered:
await query.answer(f"Niciun furnizor cu litera {letter}.", show_alert=True)
return
from backend.modules.telegram.bot.menus import create_supplier_list_keyboard, format_response_with_company
from backend.modules.telegram.bot.formatters import format_suppliers_balance_response
content = format_suppliers_balance_response(filtered, suppliers_data['maturity'])
label = f"Furnizori cu litera {letter}" if letter != "ALL" else "Toti furnizorii"
response = format_response_with_company(f"**{label}** ({len(filtered)}):\n\n{content}", company['name'])
keyboard = create_supplier_list_keyboard(filtered, page=0, letter=letter)
await query.edit_message_text(response, reply_markup=keyboard, parse_mode=ParseMode.MARKDOWN)
elif callback_data.startswith("suppliers_alpha_page:"):
# Paginate within an alpha-filtered supplier list
# Callback format: suppliers_alpha_page:PAGE:LETTER
parts = callback_data.split(":")
page = int(parts[1])
letter = parts[2] # "A""Z" or "ALL"
auth_data = await get_user_auth_data(telegram_user_id)
jwt_token = auth_data['jwt_token']
session_manager = get_session_manager()
session = await session_manager.get_or_create_session(telegram_user_id)
company = session.get_active_company()
from backend.modules.telegram.bot.helpers import get_suppliers_with_maturity
suppliers_data = await get_suppliers_with_maturity(company['id'], jwt_token)
all_suppliers = suppliers_data['suppliers']
if letter == "ALL":
filtered = all_suppliers
else:
filtered = [
s for s in all_suppliers
if s.get('name', '').upper().startswith(letter)
]
from backend.modules.telegram.bot.menus import create_supplier_list_keyboard, format_response_with_company
from backend.modules.telegram.bot.formatters import format_suppliers_balance_response
content = format_suppliers_balance_response(filtered, suppliers_data['maturity'])
label = f"Furnizori cu litera {letter}" if letter != "ALL" else "Toti furnizorii"
response = format_response_with_company(f"**{label}** ({len(filtered)}):\n\n{content}", company['name'])
keyboard = create_supplier_list_keyboard(filtered, page=page, letter=letter)
await query.edit_message_text(response, reply_markup=keyboard, parse_mode=ParseMode.MARKDOWN)
elif callback_data.startswith("select_company:"):
# Handle company selection
company_id = int(callback_data.split(":")[1])
@@ -2151,6 +2498,44 @@ async def button_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
"Companie negasita sau nu ai acces la ea."
)
# ========== SWITCH SERVER CALLBACKS ==========
elif callback_data.startswith("switch_server_confirm:"):
new_server_id = callback_data.split(":", 1)[1]
telegram_user_id = update.effective_user.id
auth_data = await get_user_auth_data(telegram_user_id)
if not auth_data:
await query.edit_message_text("Sesiune expirată. Re-autentifică-te cu /login")
return
# Stochează serverul țintă și cere parola — servere diferite pot avea parole diferite
context.user_data['pending_switch_server_id'] = new_server_id
context.user_data['pending_switch_jwt_token'] = auth_data['jwt_token']
context.user_data['pending_switch_username'] = auth_data['username']
try:
from backend.config import settings
srv = settings.get_oracle_server(new_server_id)
srv_display = srv.name if srv else new_server_id
except Exception:
srv_display = new_server_id
from telegram import ForceReply
await query.edit_message_text(
f"🔐 **Schimbare server: {srv_display}**\n\n"
f"Introdu parola Oracle pentru acest server:\n"
f"_(Mesajul cu parola va fi șters automat)_",
parse_mode=ParseMode.MARKDOWN
)
# Trimite un mesaj separat cu ForceReply pentru a forța input-ul
await context.bot.send_message(
chat_id=update.effective_chat.id,
text="Parolă:",
reply_markup=ForceReply(selective=True, input_field_placeholder="Parola Oracle...")
)
return
# ========== LOGOUT CALLBACKS ==========
elif callback_data == "logout_confirm":
@@ -2741,6 +3126,40 @@ async def _handle_selectcompany_view(
)
return
# Auto-selectează dacă există exact o singură firmă
if len(companies) == 1:
selected = companies[0]
company_id = selected.get('id_firma', selected.get('id'))
company_name = selected.get('name', selected.get('nume_firma', 'N/A'))
company_cui = selected.get('fiscal_code', selected.get('cui'))
session_manager = get_session_manager()
session = await session_manager.get_or_create_session(telegram_user_id)
session.set_active_company(
company_id=company_id,
company_name=company_name,
company_cui=company_cui
)
await session_manager.save_session(telegram_user_id)
from backend.modules.telegram.bot.menus import create_main_menu, get_menu_message
keyboard = create_main_menu(company_name=company_name, company_cui=company_cui)
menu_text = f"✅ Firmă selectată automat: **{company_name}**\n\n" + get_menu_message(company_name, company_cui)
if is_callback:
await query_or_update.edit_message_text(
menu_text,
reply_markup=keyboard,
parse_mode=ParseMode.MARKDOWN
)
else:
await query_or_update.message.reply_text(
menu_text,
reply_markup=keyboard,
parse_mode=ParseMode.MARKDOWN
)
return
from backend.modules.telegram.bot.helpers import create_company_selection_keyboard_paginated
keyboard = create_company_selection_keyboard_paginated(companies, page=page)

View File

@@ -169,7 +169,10 @@ def create_company_selection_keyboard(
def create_company_selection_keyboard_paginated(
companies: List[Dict[str, Any]],
page: int = 0,
per_page: int = 10
per_page: int = 20,
back_callback: str = "action:menu",
page_callback_prefix: str = "select_company_page",
page_callback_suffix: str = ""
) -> InlineKeyboardMarkup:
"""
Create paginated inline keyboard for company selection.
@@ -180,7 +183,10 @@ def create_company_selection_keyboard_paginated(
Args:
companies: Full list of company dicts (with id, nume_firma, cui)
page: Current page number (0-indexed)
per_page: Number of companies per page (default: 10)
per_page: Number of companies per page (default: 20)
back_callback: Callback data for the back button (default: "action:menu")
page_callback_prefix: Prefix for pagination callbacks (default: "select_company_page")
page_callback_suffix: Suffix appended after page number in pagination callbacks
Returns:
InlineKeyboardMarkup with company buttons and pagination controls
@@ -221,8 +227,9 @@ def create_company_selection_keyboard_paginated(
# Previous button
if page > 0:
prev_cb = f"{page_callback_prefix}:{page-1}{page_callback_suffix}"
nav_buttons.append(
InlineKeyboardButton("< Anterior", callback_data=f"select_company_page:{page-1}")
InlineKeyboardButton("< Anterior", callback_data=prev_cb)
)
# Page indicator (non-clickable)
@@ -232,20 +239,76 @@ def create_company_selection_keyboard_paginated(
# Next button
if page < total_pages - 1:
next_cb = f"{page_callback_prefix}:{page+1}{page_callback_suffix}"
nav_buttons.append(
InlineKeyboardButton("Urmator >", callback_data=f"select_company_page:{page+1}")
InlineKeyboardButton("Urmator >", callback_data=next_cb)
)
keyboard.append(nav_buttons)
# Back to menu button
# A-Z filter + back button
keyboard.append([
InlineKeyboardButton("< Inapoi la Meniu", callback_data="action:menu")
InlineKeyboardButton("Filtrare A-Z", callback_data="select_company_alpha_menu")
])
keyboard.append([
InlineKeyboardButton("« Înapoi", callback_data=back_callback)
])
return InlineKeyboardMarkup(keyboard)
def create_alpha_filter_keyboard() -> InlineKeyboardMarkup:
"""
Create inline keyboard with AZ letter buttons for filtering companies.
Displays 26 letter buttons in rows of 6, plus a 'Toată lista' button
that shows all companies without filtering.
Returns:
InlineKeyboardMarkup with letter buttons and navigation
"""
letters = list("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
keyboard = []
row_size = 6
for i in range(0, len(letters), row_size):
row = [
InlineKeyboardButton(l, callback_data=f"select_company_alpha:{l}")
for l in letters[i:i + row_size]
]
keyboard.append(row)
keyboard.append([
InlineKeyboardButton("Toată lista", callback_data="select_company_alpha:ALL"),
InlineKeyboardButton("« Meniu", callback_data="action:menu")
])
return InlineKeyboardMarkup(keyboard)
def create_alpha_filter_keyboard_partner(partner_type: str) -> InlineKeyboardMarkup:
"""
Create inline keyboard with AZ letter buttons for filtering clients or suppliers.
Args:
partner_type: "clients" or "suppliers"
Returns:
InlineKeyboardMarkup with letter buttons and navigation
"""
letters = list("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
keyboard = []
row_size = 6
for i in range(0, len(letters), row_size):
row = [
InlineKeyboardButton(l, callback_data=f"{partner_type}_alpha:{l}")
for l in letters[i:i + row_size]
]
keyboard.append(row)
keyboard.append([
InlineKeyboardButton("Toata lista", callback_data=f"{partner_type}_alpha:ALL"),
InlineKeyboardButton("« Meniu", callback_data="action:menu")
])
return InlineKeyboardMarkup(keyboard)
def format_company_context_footer(company_name: str) -> str:
"""
Format discrete footer with company context.

View File

@@ -260,7 +260,13 @@ def create_main_menu(
InlineKeyboardButton("Clear Cache", callback_data="menu:clearcache")
])
# Row 6: Help/Logout buttons (authenticated) or Login button (non-authenticated)
# Row 6: Switch Server button (authenticated only)
if is_authenticated:
keyboard.append([
InlineKeyboardButton("Schimba Server", callback_data="menu:switch_server"),
])
# Row 7: Help/Logout buttons (authenticated) or Login button (non-authenticated)
if is_authenticated:
keyboard.append([
InlineKeyboardButton("Help", callback_data="action:help"),
@@ -334,7 +340,7 @@ def create_action_buttons(current_view: str, show_export: bool = True, show_back
return InlineKeyboardMarkup(keyboard)
def create_client_list_keyboard(clients: List[Dict], max_items: int = 10, page: int = 0) -> InlineKeyboardMarkup:
def create_client_list_keyboard(clients: List[Dict], max_items: int = 20, page: int = 0, letter: str = None) -> InlineKeyboardMarkup:
"""
Create client list keyboard (Level 2) with client buttons and pagination.
@@ -344,6 +350,7 @@ def create_client_list_keyboard(clients: List[Dict], max_items: int = 10, page:
clients: List of client dicts with keys: id, name, balance
max_items: Maximum number of clients per page (default: 10)
page: Current page number (0-indexed)
letter: Optional letter filter (e.g. "A", "ALL") - when set, uses alpha pagination
Returns:
InlineKeyboardMarkup with client list buttons and pagination
@@ -387,10 +394,18 @@ def create_client_list_keyboard(clients: List[Dict], max_items: int = 10, page:
if total_pages > 1:
nav_buttons = []
# Choose pagination callback based on whether letter filter is active
if letter:
prev_cb = f"clients_alpha_page:{page-1}:{letter}"
next_cb = f"clients_alpha_page:{page+1}:{letter}"
else:
prev_cb = f"clients_page:{page-1}"
next_cb = f"clients_page:{page+1}"
# Previous button
if page > 0:
nav_buttons.append(
InlineKeyboardButton("< Anterior", callback_data=f"clients_page:{page-1}")
InlineKeyboardButton("< Anterior", callback_data=prev_cb)
)
# Page indicator (non-clickable)
@@ -401,20 +416,26 @@ def create_client_list_keyboard(clients: List[Dict], max_items: int = 10, page:
# Next button
if page < total_pages - 1:
nav_buttons.append(
InlineKeyboardButton("Următor >", callback_data=f"clients_page:{page+1}")
InlineKeyboardButton("Urmator >", callback_data=next_cb)
)
keyboard.append(nav_buttons)
# Navigation row: Back button only
# Filtrare A-Z button
keyboard.append([
InlineKeyboardButton("< Înapoi", callback_data="action:menu")
InlineKeyboardButton("Filtrare A-Z", callback_data="clients_alpha_menu")
])
# Back button: to A-Z menu if filtering, otherwise to main menu
back_callback = "clients_alpha_menu" if letter else "action:menu"
keyboard.append([
InlineKeyboardButton("< Inapoi", callback_data=back_callback)
])
return InlineKeyboardMarkup(keyboard)
def create_supplier_list_keyboard(suppliers: List[Dict], max_items: int = 10, page: int = 0) -> InlineKeyboardMarkup:
def create_supplier_list_keyboard(suppliers: List[Dict], max_items: int = 20, page: int = 0, letter: str = None) -> InlineKeyboardMarkup:
"""
Create supplier list keyboard (Level 2) with supplier buttons and pagination.
@@ -424,6 +445,7 @@ def create_supplier_list_keyboard(suppliers: List[Dict], max_items: int = 10, pa
suppliers: List of supplier dicts with keys: id, name, balance
max_items: Maximum number of suppliers per page (default: 10)
page: Current page number (0-indexed)
letter: Optional letter filter (e.g. "A", "ALL") - when set, uses alpha pagination
Returns:
InlineKeyboardMarkup with supplier list buttons and pagination
@@ -467,10 +489,18 @@ def create_supplier_list_keyboard(suppliers: List[Dict], max_items: int = 10, pa
if total_pages > 1:
nav_buttons = []
# Choose pagination callback based on whether letter filter is active
if letter:
prev_cb = f"suppliers_alpha_page:{page-1}:{letter}"
next_cb = f"suppliers_alpha_page:{page+1}:{letter}"
else:
prev_cb = f"suppliers_page:{page-1}"
next_cb = f"suppliers_page:{page+1}"
# Previous button
if page > 0:
nav_buttons.append(
InlineKeyboardButton("< Anterior", callback_data=f"suppliers_page:{page-1}")
InlineKeyboardButton("< Anterior", callback_data=prev_cb)
)
# Page indicator (non-clickable)
@@ -481,14 +511,20 @@ def create_supplier_list_keyboard(suppliers: List[Dict], max_items: int = 10, pa
# Next button
if page < total_pages - 1:
nav_buttons.append(
InlineKeyboardButton("Următor >", callback_data=f"suppliers_page:{page+1}")
InlineKeyboardButton("Urmator >", callback_data=next_cb)
)
keyboard.append(nav_buttons)
# Navigation row: Back button only
# Filtrare A-Z button
keyboard.append([
InlineKeyboardButton("< Înapoi", callback_data="action:menu")
InlineKeyboardButton("Filtrare A-Z", callback_data="suppliers_alpha_menu")
])
# Back button: to A-Z menu if filtering, otherwise to main menu
back_callback = "suppliers_alpha_menu" if letter else "action:menu"
keyboard.append([
InlineKeyboardButton("< Inapoi", callback_data=back_callback)
])
return InlineKeyboardMarkup(keyboard)