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:
@@ -105,7 +105,8 @@ class BackendAPIClient:
|
||||
async def verify_user(
|
||||
self,
|
||||
oracle_username: str,
|
||||
linking_code: str
|
||||
linking_code: str,
|
||||
server_id: Optional[str] = None
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Verify user exists in Oracle and get JWT token.
|
||||
@@ -139,7 +140,8 @@ class BackendAPIClient:
|
||||
"/api/telegram/auth/verify-user",
|
||||
json={
|
||||
"linking_code": linking_code,
|
||||
"oracle_username": oracle_username
|
||||
"oracle_username": oracle_username,
|
||||
"server_id": server_id
|
||||
}
|
||||
)
|
||||
|
||||
@@ -185,12 +187,13 @@ class BackendAPIClient:
|
||||
logger.error(f"Failed to refresh token: {e}")
|
||||
return None
|
||||
|
||||
async def verify_email(self, email: str) -> dict:
|
||||
async def verify_email(self, email: str, server_id: Optional[str] = None) -> dict:
|
||||
"""
|
||||
Verify if email exists in Oracle database
|
||||
|
||||
Args:
|
||||
email: Email address to verify
|
||||
server_id: Optional Oracle server ID (for multi-server mode)
|
||||
|
||||
Returns:
|
||||
dict with 'success' (bool), 'username' (str or None), and 'message' (str)
|
||||
@@ -204,7 +207,7 @@ class BackendAPIClient:
|
||||
|
||||
response = await self.client.post(
|
||||
"/api/telegram/auth/verify-email",
|
||||
json={"email": email}
|
||||
json={"email": email, "server_id": server_id}
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
@@ -229,7 +232,8 @@ class BackendAPIClient:
|
||||
email: str,
|
||||
password: str,
|
||||
telegram_user_id: int,
|
||||
session_token: str
|
||||
session_token: str,
|
||||
server_id: Optional[str] = None
|
||||
) -> dict:
|
||||
"""
|
||||
Login via email + password with session token
|
||||
@@ -239,6 +243,7 @@ class BackendAPIClient:
|
||||
password: Oracle password
|
||||
telegram_user_id: Telegram user ID
|
||||
session_token: Signed token from code validation
|
||||
server_id: Optional Oracle server ID (for multi-server mode)
|
||||
|
||||
Returns:
|
||||
Login response with JWT tokens and user data
|
||||
@@ -256,7 +261,8 @@ class BackendAPIClient:
|
||||
"email": email,
|
||||
"password": password,
|
||||
"telegram_user_id": telegram_user_id,
|
||||
"session_token": session_token
|
||||
"session_token": session_token,
|
||||
"server_id": server_id
|
||||
},
|
||||
timeout=30.0 # 30 seconds timeout
|
||||
)
|
||||
@@ -298,6 +304,52 @@ class BackendAPIClient:
|
||||
"message": "Eroare de conexiune"
|
||||
}
|
||||
|
||||
async def switch_server(
|
||||
self,
|
||||
jwt_token: str,
|
||||
oracle_username: str,
|
||||
new_server_id: str,
|
||||
oracle_password: str = None
|
||||
) -> dict:
|
||||
"""
|
||||
Switch the active Oracle server for the authenticated user.
|
||||
|
||||
Args:
|
||||
jwt_token: Current JWT access token (used for authentication)
|
||||
oracle_username: Oracle username of the current user
|
||||
new_server_id: Target Oracle server ID
|
||||
oracle_password: Oracle password on the new server (required if servers have different passwords)
|
||||
|
||||
Returns:
|
||||
Dict with success, access_token, refresh_token, message
|
||||
"""
|
||||
try:
|
||||
if not self.client or self.client.is_closed:
|
||||
self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT)
|
||||
|
||||
payload = {"oracle_username": oracle_username, "new_server_id": new_server_id}
|
||||
if oracle_password:
|
||||
payload["oracle_password"] = oracle_password
|
||||
|
||||
response = await self.client.post(
|
||||
"/api/telegram/auth/switch-server",
|
||||
json=payload,
|
||||
headers=self._get_auth_headers(jwt_token)
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(f"Switch server HTTP error: {e.response.status_code}")
|
||||
try:
|
||||
return {"success": False, "message": e.response.json().get("detail", "Eroare")}
|
||||
except Exception:
|
||||
return {"success": False, "message": "Eroare la schimbarea serverului"}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Switch server error: {e}")
|
||||
return {"success": False, "message": "Eroare de conexiune"}
|
||||
|
||||
async def get_user_companies(self, jwt_token: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get list of companies the user has access to.
|
||||
|
||||
@@ -101,12 +101,13 @@ def is_valid_email_format(email: str) -> bool:
|
||||
return bool(re.match(pattern, email))
|
||||
|
||||
|
||||
async def verify_email_in_oracle(email: str) -> Optional[str]:
|
||||
async def verify_email_in_oracle(email: str, server_id: Optional[str] = None) -> Optional[str]:
|
||||
"""
|
||||
Verify email exists in Oracle UTILIZATORI table via backend API
|
||||
|
||||
Args:
|
||||
email: Email address to check
|
||||
server_id: Optional Oracle server ID (for multi-server mode)
|
||||
|
||||
Returns:
|
||||
Oracle username if found and active, None otherwise
|
||||
@@ -118,8 +119,8 @@ async def verify_email_in_oracle(email: str) -> Optional[str]:
|
||||
|
||||
backend_client = get_backend_client()
|
||||
|
||||
# Call backend API to verify email
|
||||
response = await backend_client.verify_email(email)
|
||||
# Call backend API to verify email (on specific server if provided)
|
||||
response = await backend_client.verify_email(email, server_id=server_id)
|
||||
|
||||
if response.get('success'):
|
||||
username = response.get('username')
|
||||
|
||||
@@ -78,7 +78,8 @@ async def link_telegram_account(
|
||||
return None
|
||||
|
||||
oracle_username = code_data.get('oracle_username')
|
||||
logger.info(f"Auth code valid for Oracle user: {oracle_username}")
|
||||
server_id = code_data.get('server_id') # Extract server_id from the stored code
|
||||
logger.info(f"Auth code valid for Oracle user: {oracle_username} (server_id={server_id})")
|
||||
|
||||
# Step 2: Create/update Telegram user record (basic info)
|
||||
user_created = await create_or_update_user(
|
||||
@@ -97,7 +98,8 @@ async def link_telegram_account(
|
||||
async with backend_client:
|
||||
user_data = await backend_client.verify_user(
|
||||
oracle_username=oracle_username,
|
||||
linking_code=auth_code
|
||||
linking_code=auth_code,
|
||||
server_id=server_id
|
||||
)
|
||||
|
||||
if not user_data or not user_data.get('success'):
|
||||
|
||||
@@ -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:')
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 A–Z 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 A–Z 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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -148,6 +148,14 @@ async def init_database() -> None:
|
||||
ON email_auth_codes(expires_at)
|
||||
""")
|
||||
|
||||
# Migration: add server_id column to telegram_auth_codes if missing
|
||||
try:
|
||||
await db.execute("ALTER TABLE telegram_auth_codes ADD COLUMN server_id TEXT")
|
||||
await db.commit()
|
||||
logger.info("Migration: added server_id column to telegram_auth_codes")
|
||||
except Exception:
|
||||
pass # Column already exists
|
||||
|
||||
await db.commit()
|
||||
logger.info("Database initialized successfully")
|
||||
|
||||
|
||||
@@ -288,7 +288,8 @@ async def create_auth_code(
|
||||
code: str,
|
||||
telegram_user_id: int,
|
||||
oracle_username: str,
|
||||
expires_in_minutes: int = 5
|
||||
expires_in_minutes: int = 5,
|
||||
server_id: Optional[str] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Create a new authentication code for linking.
|
||||
@@ -298,6 +299,7 @@ async def create_auth_code(
|
||||
telegram_user_id: Telegram user ID
|
||||
oracle_username: Oracle username to link
|
||||
expires_in_minutes: Code expiration time in minutes (default: 5)
|
||||
server_id: Oracle server ID (for multi-server mode)
|
||||
|
||||
Returns:
|
||||
bool: True if successful
|
||||
@@ -310,13 +312,13 @@ async def create_auth_code(
|
||||
db.row_factory = aiosqlite.Row
|
||||
await db.execute("""
|
||||
INSERT INTO telegram_auth_codes (
|
||||
code, telegram_user_id, oracle_username, expires_at
|
||||
code, telegram_user_id, oracle_username, expires_at, server_id
|
||||
)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""", (code, telegram_user_id, oracle_username, expires_at))
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""", (code, telegram_user_id, oracle_username, expires_at, server_id))
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"Auth code created for user {telegram_user_id}")
|
||||
logger.info(f"Auth code created for user {telegram_user_id} (server_id={server_id})")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
API Router pentru Telegram Bot Integration
|
||||
Furnizează endpoint-uri pentru autentificare, linking și export rapoarte pentru Telegram bot
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Request
|
||||
from typing import List, Optional, Dict, Any
|
||||
# import sys # Removed - no longer needed
|
||||
import os
|
||||
@@ -57,6 +57,7 @@ class VerifyUserRequest(BaseModel):
|
||||
oracle_username: Optional[str] = Field(default=None, description="Username Oracle (pentru auto-linking)")
|
||||
username: Optional[str] = Field(default=None, description="Username pentru verificare completă")
|
||||
password: Optional[str] = Field(default=None, description="Parolă pentru verificare completă")
|
||||
server_id: Optional[str] = Field(default=None, description="Oracle server ID (pentru multi-server mode)")
|
||||
|
||||
|
||||
class VerifyUserResponse(BaseModel):
|
||||
@@ -100,6 +101,7 @@ class ExportReportResponse(BaseModel):
|
||||
class VerifyEmailRequest(BaseModel):
|
||||
"""Request pentru verificarea email-ului în Oracle"""
|
||||
email: str = Field(description="Adresa de email Oracle")
|
||||
server_id: Optional[str] = Field(default=None, description="Oracle server ID (pentru multi-server mode)")
|
||||
|
||||
|
||||
class VerifyEmailResponse(BaseModel):
|
||||
@@ -115,6 +117,7 @@ class TelegramEmailLoginRequest(BaseModel):
|
||||
password: str = Field(description="Parola Oracle")
|
||||
telegram_user_id: int = Field(description="ID-ul utilizatorului Telegram")
|
||||
session_token: str = Field(description="Token de sesiune pentru preveni spoofing")
|
||||
server_id: Optional[str] = Field(default=None, description="Oracle server ID (pentru multi-server mode)")
|
||||
|
||||
|
||||
class TelegramEmailLoginResponse(BaseModel):
|
||||
@@ -129,6 +132,21 @@ class TelegramEmailLoginResponse(BaseModel):
|
||||
message: str = Field(description="Mesaj de status")
|
||||
|
||||
|
||||
class SwitchServerRequest(BaseModel):
|
||||
"""Request pentru schimbarea serverului Oracle"""
|
||||
oracle_username: str = Field(description="Username Oracle al utilizatorului curent")
|
||||
new_server_id: str = Field(description="ID-ul noului server Oracle")
|
||||
oracle_password: Optional[str] = Field(default=None, description="Parola Oracle pe noul server (obligatorie dacă servere diferite au parole diferite)")
|
||||
|
||||
|
||||
class SwitchServerResponse(BaseModel):
|
||||
"""Response pentru schimbarea serverului Oracle"""
|
||||
success: bool = Field(description="True dacă schimbarea a reușit")
|
||||
access_token: Optional[str] = Field(default=None, description="Noul JWT access token")
|
||||
refresh_token: Optional[str] = Field(default=None, description="Noul JWT refresh token")
|
||||
message: str = Field(description="Mesaj de status")
|
||||
|
||||
|
||||
# ==================== Helper Functions ====================
|
||||
|
||||
# Rate limiting storage (in-memory)
|
||||
@@ -212,7 +230,7 @@ def generate_linking_code(length: int = 8) -> str:
|
||||
return ''.join(secrets.choice(alphabet) for _ in range(length))
|
||||
|
||||
|
||||
async def get_oracle_user_by_username(username: str) -> Optional[Dict[str, Any]]:
|
||||
async def get_oracle_user_by_username(username: str, server_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Obține informații despre utilizator din Oracle FĂRĂ verificare parolă.
|
||||
|
||||
@@ -221,12 +239,13 @@ async def get_oracle_user_by_username(username: str) -> Optional[Dict[str, Any]]
|
||||
|
||||
Args:
|
||||
username: Username-ul utilizatorului Oracle
|
||||
server_id: ID-ul serverului Oracle (pentru multi-server mode)
|
||||
|
||||
Returns:
|
||||
Dict cu informații despre utilizator sau None dacă nu există
|
||||
"""
|
||||
try:
|
||||
async with oracle_pool.get_connection() as connection:
|
||||
async with oracle_pool.get_connection(server_id) as connection:
|
||||
with connection.cursor() as cursor:
|
||||
# Obține detalii utilizator
|
||||
cursor.execute("""
|
||||
@@ -270,19 +289,20 @@ async def get_oracle_user_by_username(username: str) -> Optional[Dict[str, Any]]
|
||||
return None
|
||||
|
||||
|
||||
async def verify_oracle_user(username: str, password: str) -> Optional[Dict[str, Any]]:
|
||||
async def verify_oracle_user(username: str, password: str, server_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Verifică utilizatorul în Oracle folosind pack_drepturi.verificautilizator
|
||||
|
||||
Args:
|
||||
username: Username-ul utilizatorului
|
||||
password: Parola utilizatorului
|
||||
server_id: ID-ul serverului Oracle (pentru multi-server mode)
|
||||
|
||||
Returns:
|
||||
Dict cu informații despre utilizator sau None dacă verificarea eșuează
|
||||
"""
|
||||
try:
|
||||
async with oracle_pool.get_connection() as connection:
|
||||
async with oracle_pool.get_connection(server_id) as connection:
|
||||
with connection.cursor() as cursor:
|
||||
# Verifică autentificarea
|
||||
cursor.execute("""
|
||||
@@ -344,6 +364,7 @@ async def verify_oracle_user(username: str, password: str) -> Optional[Dict[str,
|
||||
|
||||
@router.post("/auth/generate-code", response_model=GenerateCodeResponse)
|
||||
async def generate_linking_code_endpoint(
|
||||
request: Request,
|
||||
current_user: CurrentUser = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
@@ -360,8 +381,12 @@ async def generate_linking_code_endpoint(
|
||||
- Codul expiră după 15 minute
|
||||
- Fiecare request generează un cod nou (codurile vechi devin invalide)
|
||||
- Nu este nevoie de telegram_user_id în acest moment (utilizatorul nu e încă conectat la Telegram)
|
||||
- server_id este extras automat din JWT (setat de auth middleware în request.state)
|
||||
"""
|
||||
try:
|
||||
# Extrage server_id din JWT (setat de auth middleware)
|
||||
server_id = getattr(request.state, 'server_id', None)
|
||||
|
||||
# Generează cod unic
|
||||
linking_code = generate_linking_code()
|
||||
|
||||
@@ -378,7 +403,8 @@ async def generate_linking_code_endpoint(
|
||||
"code": linking_code,
|
||||
"telegram_user_id": 0, # Not known yet (user hasn't linked)
|
||||
"oracle_username": current_user.username,
|
||||
"expires_in_minutes": expires_in_minutes
|
||||
"expires_in_minutes": expires_in_minutes,
|
||||
"server_id": server_id
|
||||
}
|
||||
)
|
||||
|
||||
@@ -442,7 +468,7 @@ async def verify_user_endpoint(request: VerifyUserRequest):
|
||||
try:
|
||||
# Flow A: Auto-linking (oracle_username provided, no password)
|
||||
if request.oracle_username and not request.password:
|
||||
user_data = await get_oracle_user_by_username(request.oracle_username)
|
||||
user_data = await get_oracle_user_by_username(request.oracle_username, server_id=request.server_id)
|
||||
|
||||
if not user_data:
|
||||
return VerifyUserResponse(
|
||||
@@ -452,7 +478,7 @@ async def verify_user_endpoint(request: VerifyUserRequest):
|
||||
|
||||
# Flow B: Full verification (username + password provided)
|
||||
elif request.username and request.password:
|
||||
user_data = await verify_oracle_user(request.username, request.password)
|
||||
user_data = await verify_oracle_user(request.username, request.password, server_id=request.server_id)
|
||||
|
||||
if not user_data:
|
||||
return VerifyUserResponse(
|
||||
@@ -467,17 +493,19 @@ async def verify_user_endpoint(request: VerifyUserRequest):
|
||||
message="Trebuie furnizat fie oracle_username (auto-linking) fie username+password (verificare completă)"
|
||||
)
|
||||
|
||||
# Generează JWT tokens
|
||||
# Generează JWT tokens (cu server_id pentru multi-server mode)
|
||||
access_token = jwt_handler.create_access_token(
|
||||
username=user_data['username'],
|
||||
companies=user_data['companies'],
|
||||
user_id=user_data['user_id'],
|
||||
permissions=user_data['permissions']
|
||||
permissions=user_data['permissions'],
|
||||
server_id=request.server_id
|
||||
)
|
||||
|
||||
refresh_token = jwt_handler.create_refresh_token(
|
||||
username=user_data['username'],
|
||||
user_id=user_data['user_id']
|
||||
user_id=user_data['user_id'],
|
||||
server_id=request.server_id
|
||||
)
|
||||
|
||||
return VerifyUserResponse(
|
||||
@@ -528,8 +556,8 @@ async def refresh_token_endpoint(request: RefreshTokenRequest):
|
||||
detail="Refresh token invalid sau expirat"
|
||||
)
|
||||
|
||||
# Obține companiile actualizate din Oracle
|
||||
async with oracle_pool.get_connection() as connection:
|
||||
# Obține companiile actualizate din Oracle (folosind server_id din refresh token)
|
||||
async with oracle_pool.get_connection(token_data.server_id) as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("""
|
||||
SELECT A.ID_FIRMA
|
||||
@@ -546,12 +574,13 @@ async def refresh_token_endpoint(request: RefreshTokenRequest):
|
||||
companies_result = cursor.fetchall()
|
||||
companies = [str(row[0]) for row in companies_result]
|
||||
|
||||
# Generează nou access token
|
||||
# Generează nou access token (păstrează server_id din refresh token)
|
||||
new_access_token = jwt_handler.create_access_token(
|
||||
username=token_data.username,
|
||||
companies=companies,
|
||||
user_id=token_data.user_id,
|
||||
permissions=token_data.permissions
|
||||
permissions=token_data.permissions,
|
||||
server_id=token_data.server_id
|
||||
)
|
||||
|
||||
return RefreshTokenResponse(
|
||||
@@ -580,7 +609,7 @@ async def verify_email_endpoint(request: VerifyEmailRequest):
|
||||
Security: Generic error messages to prevent email enumeration.
|
||||
"""
|
||||
try:
|
||||
async with oracle_pool.get_connection() as connection:
|
||||
async with oracle_pool.get_connection(request.server_id) as connection:
|
||||
with connection.cursor() as cursor:
|
||||
# Query to find username by email
|
||||
cursor.execute("""
|
||||
@@ -649,7 +678,7 @@ async def login_with_email_endpoint(request: TelegramEmailLoginRequest):
|
||||
)
|
||||
|
||||
try:
|
||||
async with oracle_pool.get_connection() as connection:
|
||||
async with oracle_pool.get_connection(request.server_id) as connection:
|
||||
with connection.cursor() as cursor:
|
||||
# 3. Find username by email
|
||||
cursor.execute("""
|
||||
@@ -719,18 +748,18 @@ async def login_with_email_endpoint(request: TelegramEmailLoginRequest):
|
||||
# 6. Get user permissions (default for Telegram)
|
||||
permissions = ['read', 'reports']
|
||||
|
||||
# 7. Generate JWT tokens
|
||||
token_data = {
|
||||
"username": username,
|
||||
"user_id": user_id,
|
||||
"companies": company_ids,
|
||||
"permissions": permissions
|
||||
}
|
||||
|
||||
access_token = jwt_handler.create_access_token(**token_data)
|
||||
# 7. Generate JWT tokens (with server_id for multi-server routing)
|
||||
access_token = jwt_handler.create_access_token(
|
||||
username=username,
|
||||
user_id=user_id,
|
||||
companies=company_ids,
|
||||
permissions=permissions,
|
||||
server_id=request.server_id
|
||||
)
|
||||
refresh_token = jwt_handler.create_refresh_token(
|
||||
username=username,
|
||||
user_id=user_id
|
||||
user_id=user_id,
|
||||
server_id=request.server_id
|
||||
)
|
||||
|
||||
return TelegramEmailLoginResponse(
|
||||
@@ -754,6 +783,82 @@ async def login_with_email_endpoint(request: TelegramEmailLoginRequest):
|
||||
)
|
||||
|
||||
|
||||
@router.post("/auth/switch-server", response_model=SwitchServerResponse)
|
||||
async def switch_server_endpoint(
|
||||
request: SwitchServerRequest,
|
||||
current_user: CurrentUser = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Schimbă serverul Oracle activ fără re-autentificare.
|
||||
|
||||
Flux:
|
||||
1. Verifică că oracle_username din request == username din JWT (anti-impersonare)
|
||||
2. Verifică că utilizatorul există pe noul server (fără parolă, prin get_oracle_user_by_username)
|
||||
3. Generează JWT nou cu noul server_id
|
||||
4. Returnează tokenurile noi
|
||||
|
||||
Securitate: endpoint protejat cu Bearer JWT valid (Depends(get_current_user)).
|
||||
"""
|
||||
# Anti-impersonare: utilizatorul poate schimba serverul doar pentru propriul cont
|
||||
if request.oracle_username.upper() != current_user.username.upper():
|
||||
raise HTTPException(status_code=403, detail="Acces interzis: username nepotrivit")
|
||||
|
||||
# Verifică că utilizatorul există pe noul server
|
||||
user_data = await get_oracle_user_by_username(request.oracle_username, request.new_server_id)
|
||||
if not user_data:
|
||||
return SwitchServerResponse(
|
||||
success=False,
|
||||
message=f"Utilizatorul nu există pe serverul {request.new_server_id}"
|
||||
)
|
||||
|
||||
# Dacă parola e furnizată, verifică-o pe noul server înainte de a emite JWT
|
||||
if request.oracle_password:
|
||||
try:
|
||||
async with oracle_pool.get_connection(request.new_server_id) as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("""
|
||||
SELECT pack_drepturi.verificautilizator(:username, :password)
|
||||
FROM DUAL
|
||||
""", {
|
||||
"username": request.oracle_username.upper(),
|
||||
"password": request.oracle_password
|
||||
})
|
||||
verification_result = cursor.fetchone()[0]
|
||||
|
||||
if verification_result == -1:
|
||||
return SwitchServerResponse(
|
||||
success=False,
|
||||
message="Parolă incorectă pentru acest server"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Password verification error during server switch: {e}")
|
||||
return SwitchServerResponse(
|
||||
success=False,
|
||||
message="Eroare la verificarea parolei pe noul server"
|
||||
)
|
||||
|
||||
# Generează JWT nou cu noul server_id
|
||||
access_token = jwt_handler.create_access_token(
|
||||
username=user_data['username'],
|
||||
companies=user_data['companies'],
|
||||
user_id=user_data['user_id'],
|
||||
permissions=user_data['permissions'],
|
||||
server_id=request.new_server_id
|
||||
)
|
||||
refresh_token = jwt_handler.create_refresh_token(
|
||||
username=user_data['username'],
|
||||
user_id=user_data['user_id'],
|
||||
server_id=request.new_server_id
|
||||
)
|
||||
|
||||
return SwitchServerResponse(
|
||||
success=True,
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
message="Server schimbat cu succes"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/export", response_model=ExportReportResponse)
|
||||
async def export_report_endpoint(
|
||||
request: ExportReportRequest,
|
||||
|
||||
@@ -56,6 +56,10 @@ class SaveAuthCodeRequest(BaseModel):
|
||||
ge=1,
|
||||
le=60
|
||||
)
|
||||
server_id: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Oracle server ID (for multi-server mode)"
|
||||
)
|
||||
|
||||
|
||||
class SaveAuthCodeResponse(BaseModel):
|
||||
@@ -151,7 +155,8 @@ async def save_auth_code(request: SaveAuthCodeRequest):
|
||||
code=request.code,
|
||||
telegram_user_id=request.telegram_user_id,
|
||||
oracle_username=request.oracle_username,
|
||||
expires_in_minutes=request.expires_in_minutes
|
||||
expires_in_minutes=request.expires_in_minutes,
|
||||
server_id=request.server_id
|
||||
)
|
||||
|
||||
if not success:
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
Async SMTP Email Service with retry logic and proper error handling
|
||||
"""
|
||||
import aiosmtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.message import EmailMessage
|
||||
import os
|
||||
import logging
|
||||
from typing import Optional
|
||||
@@ -52,32 +51,33 @@ class EmailService:
|
||||
Raises:
|
||||
No exceptions - returns False on all failures
|
||||
"""
|
||||
subject = "Codul tău de autentificare ROA2WEB"
|
||||
html_body = self._create_email_template(code, username)
|
||||
subject = "Autentificare ROA2WEB"
|
||||
text_body = self._create_email_template(code, username)
|
||||
|
||||
for attempt in range(1, self.max_retries + 1):
|
||||
try:
|
||||
await self._send_email(to_email, subject, html_body)
|
||||
await self._send_email(to_email, subject, text_body)
|
||||
logger.info(
|
||||
f"Email sent successfully to {to_email} "
|
||||
f"(attempt {attempt}/{self.max_retries})"
|
||||
f"[EMAIL] ✅ Sent auth code to {to_email} "
|
||||
f"(attempt {attempt}/{self.max_retries}) via {self.smtp_host}:{self.smtp_port}"
|
||||
)
|
||||
return True
|
||||
|
||||
except aiosmtplib.SMTPException as e:
|
||||
logger.warning(
|
||||
f"SMTP error on attempt {attempt}/{self.max_retries}: {e}"
|
||||
logger.error(
|
||||
f"[EMAIL] ❌ Attempt {attempt}/{self.max_retries} failed for {to_email}: "
|
||||
f"{type(e).__name__}: {e}"
|
||||
)
|
||||
if attempt < self.max_retries:
|
||||
# Exponential backoff: 2s, 4s, 8s
|
||||
delay = self.retry_delay * (2 ** (attempt - 1))
|
||||
logger.info(f"Retrying in {delay}s...")
|
||||
logger.info(f"[EMAIL] Retrying in {delay}s...")
|
||||
await asyncio.sleep(delay)
|
||||
else:
|
||||
logger.error(f"Failed to send email to {to_email} after {self.max_retries} attempts")
|
||||
logger.error(f"[EMAIL] ❌ All {self.max_retries} attempts failed for {to_email}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error sending email: {e}", exc_info=True)
|
||||
logger.error(f"[EMAIL] ❌ Unexpected error on attempt {attempt}/{self.max_retries} for {to_email}: {type(e).__name__}: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
return False
|
||||
@@ -86,29 +86,24 @@ class EmailService:
|
||||
self,
|
||||
to_email: str,
|
||||
subject: str,
|
||||
html_body: str
|
||||
text_body: str
|
||||
) -> None:
|
||||
"""
|
||||
Internal async SMTP sender
|
||||
Internal async SMTP sender (plain text to avoid spam filters)
|
||||
|
||||
Raises:
|
||||
aiosmtplib.SMTPException: On SMTP errors
|
||||
"""
|
||||
message = MIMEMultipart("alternative")
|
||||
message = EmailMessage()
|
||||
message["From"] = f"{self.from_name} <{self.from_email}>"
|
||||
message["To"] = to_email
|
||||
message["Subject"] = subject
|
||||
message.set_content(text_body)
|
||||
|
||||
# Attach HTML body
|
||||
html_part = MIMEText(html_body, "html", "utf-8")
|
||||
message.attach(html_part)
|
||||
|
||||
# Send via async SMTP with STARTTLS
|
||||
# Using start_tls parameter for automatic STARTTLS handling
|
||||
smtp = aiosmtplib.SMTP(
|
||||
hostname=self.smtp_host,
|
||||
port=self.smtp_port,
|
||||
start_tls=self.use_tls, # Use start_tls instead of use_tls
|
||||
start_tls=self.use_tls,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
@@ -123,132 +118,15 @@ class EmailService:
|
||||
pass
|
||||
|
||||
def _create_email_template(self, code: str, username: str) -> str:
|
||||
"""Generate HTML email template"""
|
||||
return f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
body {{
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #f5f5f5;
|
||||
}}
|
||||
.container {{
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
}}
|
||||
.header {{
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
}}
|
||||
.header h1 {{
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
}}
|
||||
.content {{
|
||||
padding: 40px 20px;
|
||||
}}
|
||||
.code-box {{
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
|
||||
border: 3px solid #667eea;
|
||||
border-radius: 12px;
|
||||
padding: 30px;
|
||||
margin: 30px 0;
|
||||
text-align: center;
|
||||
}}
|
||||
.code {{
|
||||
font-size: 42px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 12px;
|
||||
color: #667eea;
|
||||
font-family: 'Courier New', monospace;
|
||||
display: block;
|
||||
margin: 15px 0;
|
||||
}}
|
||||
.warning {{
|
||||
background-color: #fff3cd;
|
||||
border-left: 4px solid #ffc107;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
border-radius: 4px;
|
||||
}}
|
||||
.footer {{
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
padding: 20px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
background-color: #f9f9f9;
|
||||
}}
|
||||
.button {{
|
||||
display: inline-block;
|
||||
padding: 12px 24px;
|
||||
background-color: #667eea;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 6px;
|
||||
margin-top: 20px;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>ROA2WEB</h1>
|
||||
<p style="margin: 10px 0 0 0; opacity: 0.9;">Autentificare Telegram Bot</p>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<p>Salut <strong>{username}</strong>,</p>
|
||||
|
||||
<p>Ai solicitat autentificarea în aplicația ROA2WEB Telegram Bot.</p>
|
||||
|
||||
<div class="code-box">
|
||||
<p style="margin: 0; font-size: 14px; color: #666; font-weight: 500;">
|
||||
Codul tău de autentificare:
|
||||
</p>
|
||||
<span class="code">{code}</span>
|
||||
<p style="margin: 0; font-size: 12px; color: #888;">
|
||||
Introdu acest cod în conversația Telegram
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="warning">
|
||||
<strong>Important:</strong> Acest cod expiră în <strong>5 minute</strong>
|
||||
și poate fi folosit o singură dată.
|
||||
</div>
|
||||
|
||||
<p>După introducerea codului, vei fi solicitat să introduci parola ta Oracle.</p>
|
||||
|
||||
<hr style="border: none; border-top: 1px solid #e0e0e0; margin: 30px 0;">
|
||||
|
||||
<p style="font-size: 14px; color: #666;">
|
||||
<strong>Nu ai solicitat acest cod?</strong><br>
|
||||
Dacă nu ai inițiat această autentificare, poți ignora acest email în siguranță.
|
||||
Nimeni nu va avea acces la contul tău fără parola ta Oracle.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p><strong>ROA2WEB</strong> - ERP Reports Application</p>
|
||||
<p>Acest email a fost trimis automat. Te rugăm să nu răspunzi.</p>
|
||||
<p style="margin-top: 10px; color: #999;">
|
||||
© 2025 ROA2WEB. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
"""Generate plain text email body (HTML blocked by spam filters)"""
|
||||
return (
|
||||
f"Codul tau de autentificare ROA2WEB:\n\n"
|
||||
f" {code}\n\n"
|
||||
f"Introdu acest cod in Telegram. Expira in 5 minute.\n\n"
|
||||
f"---\n"
|
||||
f"Solicitat pentru: {username}\n"
|
||||
f"Daca nu ai initiat aceasta autentificare, ignora acest email."
|
||||
)
|
||||
|
||||
|
||||
# Singleton instance
|
||||
|
||||
Reference in New Issue
Block a user