Добавлены новые методы для получения аналитики по возвратам и улучшена обработка ошибок в классе OracleDatabase. Обновлены обработчики колбеков в main.py для отображения соответствующих отчетов в админ-панели. Эти изменения улучшают функциональность аналитики возвратов и предоставляют администраторам более полное представление о проблемах с возвратами.
This commit is contained in:
parent
074cffee17
commit
a4d8862a19
1
config/__init__.py
Normal file
1
config/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# Конфигурация приложения
|
||||||
38
config/settings.py
Normal file
38
config/settings.py
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
"""
|
||||||
|
Настройки приложения
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from os import getenv
|
||||||
|
|
||||||
|
|
||||||
|
# Telegram Bot настройки
|
||||||
|
TOKEN = getenv("BOT_TOKEN")
|
||||||
|
BOTNAME = getenv("BOT_NAME")
|
||||||
|
|
||||||
|
# Цены на услуги (в Telegram Stars)
|
||||||
|
DECODE_PRICE = int(getenv("DECODE_PRICE", "1"))
|
||||||
|
CHECK_PRICE = int(getenv("CHECK_PRICE", "10"))
|
||||||
|
IMG_PRICE = int(getenv("IMG_PRICE", "100"))
|
||||||
|
|
||||||
|
# Администратор
|
||||||
|
ADMIN_USER_ID = int(getenv("ADMIN_USER_ID", "0"))
|
||||||
|
|
||||||
|
# База данных Oracle
|
||||||
|
DB_CONFIG = {
|
||||||
|
"user": getenv("DB_USER"),
|
||||||
|
"password": getenv("DB_PASSWORD"),
|
||||||
|
"dsn": getenv("DB_DSN")
|
||||||
|
}
|
||||||
|
|
||||||
|
# Пути к изображениям (зависят от ОС)
|
||||||
|
def get_image_path():
|
||||||
|
"""Возвращает путь к каталогу изображений в зависимости от ОС"""
|
||||||
|
if os.name == 'nt': # Windows
|
||||||
|
return "D:\\SALVAGEDB\\salvagedb_bot\\images"
|
||||||
|
else: # Linux/macOS
|
||||||
|
return "/images"
|
||||||
|
|
||||||
|
IMAGE_PATH = get_image_path()
|
||||||
|
|
||||||
|
# URL сайта
|
||||||
|
WEBSITE_URL = "https://salvagedb.com"
|
||||||
35
database/__init__.py
Normal file
35
database/__init__.py
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
"""
|
||||||
|
Модули работы с базой данных
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .base import OracleDatabase
|
||||||
|
from .core_queries import VinQueries
|
||||||
|
from .user_management import UserManager
|
||||||
|
from .payment_tracking import PaymentTracker
|
||||||
|
|
||||||
|
# Импорты из аналитики
|
||||||
|
from .analytics.users_stats import UserAnalytics
|
||||||
|
|
||||||
|
# Главный класс базы данных - агрегатор всех модулей
|
||||||
|
class DatabaseManager(
|
||||||
|
OracleDatabase,
|
||||||
|
VinQueries,
|
||||||
|
UserManager,
|
||||||
|
PaymentTracker,
|
||||||
|
UserAnalytics
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Главный класс для работы с базой данных
|
||||||
|
Наследует все функциональности от специализированных классов
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Экспорт для удобного импорта
|
||||||
|
__all__ = [
|
||||||
|
'OracleDatabase',
|
||||||
|
'VinQueries',
|
||||||
|
'UserManager',
|
||||||
|
'PaymentTracker',
|
||||||
|
'UserAnalytics',
|
||||||
|
'DatabaseManager'
|
||||||
|
]
|
||||||
1
database/analytics/__init__.py
Normal file
1
database/analytics/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# Модули аналитики и статистики
|
||||||
327
database/analytics/users_stats.py
Normal file
327
database/analytics/users_stats.py
Normal file
@ -0,0 +1,327 @@
|
|||||||
|
"""
|
||||||
|
Аналитика пользователей
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from typing import Dict, List
|
||||||
|
|
||||||
|
from ..base import OracleDatabase
|
||||||
|
|
||||||
|
|
||||||
|
class UserAnalytics(OracleDatabase):
|
||||||
|
"""Класс для аналитики пользователей"""
|
||||||
|
|
||||||
|
async def get_users_general_stats(self) -> Dict:
|
||||||
|
"""Общая статистика пользователей"""
|
||||||
|
def _get_stats():
|
||||||
|
try:
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
query = """
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total_users,
|
||||||
|
COUNT(CASE WHEN is_premium = 1 THEN 1 END) as premium_users,
|
||||||
|
COUNT(CASE WHEN is_active = 1 THEN 1 END) as active_users,
|
||||||
|
COUNT(CASE WHEN is_blocked = 1 THEN 1 END) as blocked_users,
|
||||||
|
COUNT(CASE WHEN successful_payments_count > 0 THEN 1 END) as paying_users,
|
||||||
|
SUM(total_payments) as total_revenue,
|
||||||
|
SUM(successful_payments_count) as total_transactions,
|
||||||
|
SUM(interaction_count) as total_interactions,
|
||||||
|
AVG(interaction_count) as avg_interactions_per_user,
|
||||||
|
COUNT(CASE WHEN last_interaction_date >= SYSDATE - 1 THEN 1 END) as active_24h,
|
||||||
|
COUNT(CASE WHEN last_interaction_date >= SYSDATE - 7 THEN 1 END) as active_7d,
|
||||||
|
COUNT(CASE WHEN last_interaction_date >= SYSDATE - 30 THEN 1 END) as active_30d
|
||||||
|
FROM bot_users
|
||||||
|
"""
|
||||||
|
cur.execute(query)
|
||||||
|
result = cur.fetchone()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_users": result[0] or 0,
|
||||||
|
"premium_users": result[1] or 0,
|
||||||
|
"active_users": result[2] or 0,
|
||||||
|
"blocked_users": result[3] or 0,
|
||||||
|
"paying_users": result[4] or 0,
|
||||||
|
"total_revenue": float(result[5]) if result[5] else 0.0,
|
||||||
|
"total_transactions": result[6] or 0,
|
||||||
|
"total_interactions": result[7] or 0,
|
||||||
|
"avg_interactions_per_user": round(float(result[8]), 2) if result[8] else 0.0,
|
||||||
|
"active_24h": result[9] or 0,
|
||||||
|
"active_7d": result[10] or 0,
|
||||||
|
"active_30d": result[11] or 0
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"SQL Error in get_users_general_stats:")
|
||||||
|
logging.error(f"Error: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await self._execute_query(_get_stats)
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error getting general user stats: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
async def get_users_growth_stats(self) -> Dict:
|
||||||
|
"""Статистика роста пользователей"""
|
||||||
|
def _get_stats():
|
||||||
|
try:
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
query = """
|
||||||
|
SELECT
|
||||||
|
COUNT(CASE WHEN created_at >= SYSDATE - 1 THEN 1 END) as new_today,
|
||||||
|
COUNT(CASE WHEN created_at >= SYSDATE - 7 THEN 1 END) as new_week,
|
||||||
|
COUNT(CASE WHEN created_at >= SYSDATE - 30 THEN 1 END) as new_month,
|
||||||
|
COUNT(CASE WHEN created_at >= SYSDATE - 365 THEN 1 END) as new_year,
|
||||||
|
TO_CHAR(MIN(created_at), 'DD.MM.YYYY') as first_user_date,
|
||||||
|
ROUND((SYSDATE - MIN(created_at))) as days_since_start
|
||||||
|
FROM bot_users
|
||||||
|
"""
|
||||||
|
cur.execute(query)
|
||||||
|
result = cur.fetchone()
|
||||||
|
|
||||||
|
# Получаем рост по дням за последние 30 дней
|
||||||
|
daily_query = """
|
||||||
|
SELECT
|
||||||
|
TO_CHAR(created_at, 'DD.MM') as day,
|
||||||
|
COUNT(*) as count
|
||||||
|
FROM bot_users
|
||||||
|
WHERE created_at >= SYSDATE - 30
|
||||||
|
GROUP BY TO_CHAR(created_at, 'DD.MM'), TRUNC(created_at)
|
||||||
|
ORDER BY TRUNC(created_at) DESC
|
||||||
|
"""
|
||||||
|
cur.execute(daily_query)
|
||||||
|
daily_growth = cur.fetchall()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"new_today": result[0] or 0,
|
||||||
|
"new_week": result[1] or 0,
|
||||||
|
"new_month": result[2] or 0,
|
||||||
|
"new_year": result[3] or 0,
|
||||||
|
"first_user_date": result[4] or "N/A",
|
||||||
|
"days_since_start": result[5] or 0,
|
||||||
|
"daily_growth": daily_growth[:10] if daily_growth else []
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"SQL Error in get_users_growth_stats:")
|
||||||
|
logging.error(f"Error: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await self._execute_query(_get_stats)
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error getting growth stats: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
async def get_users_premium_stats(self) -> Dict:
|
||||||
|
"""Анализ Premium пользователей"""
|
||||||
|
def _get_stats():
|
||||||
|
try:
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
query = """
|
||||||
|
SELECT
|
||||||
|
COUNT(CASE WHEN is_premium = 1 THEN 1 END) as premium_users,
|
||||||
|
COUNT(CASE WHEN is_premium = 0 THEN 1 END) as regular_users,
|
||||||
|
AVG(CASE WHEN is_premium = 1 THEN total_payments END) as premium_avg_payment,
|
||||||
|
AVG(CASE WHEN is_premium = 0 THEN total_payments END) as regular_avg_payment,
|
||||||
|
AVG(CASE WHEN is_premium = 1 THEN interaction_count END) as premium_avg_interactions,
|
||||||
|
AVG(CASE WHEN is_premium = 0 THEN interaction_count END) as regular_avg_interactions,
|
||||||
|
COUNT(CASE WHEN is_premium = 1 AND successful_payments_count > 0 THEN 1 END) as premium_paying,
|
||||||
|
COUNT(CASE WHEN is_premium = 0 AND successful_payments_count > 0 THEN 1 END) as regular_paying
|
||||||
|
FROM bot_users
|
||||||
|
WHERE is_active = 1
|
||||||
|
"""
|
||||||
|
cur.execute(query)
|
||||||
|
result = cur.fetchone()
|
||||||
|
|
||||||
|
premium_total = result[0] or 0
|
||||||
|
regular_total = result[1] or 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"premium_users": premium_total,
|
||||||
|
"regular_users": regular_total,
|
||||||
|
"premium_percentage": round((premium_total / (premium_total + regular_total) * 100), 2) if (premium_total + regular_total) > 0 else 0,
|
||||||
|
"premium_avg_payment": round(float(result[2]), 2) if result[2] else 0.0,
|
||||||
|
"regular_avg_payment": round(float(result[3]), 2) if result[3] else 0.0,
|
||||||
|
"premium_avg_interactions": round(float(result[4]), 2) if result[4] else 0.0,
|
||||||
|
"regular_avg_interactions": round(float(result[5]), 2) if result[5] else 0.0,
|
||||||
|
"premium_paying": result[6] or 0,
|
||||||
|
"regular_paying": result[7] or 0,
|
||||||
|
"premium_conversion": round((result[6] / premium_total * 100), 2) if premium_total > 0 else 0,
|
||||||
|
"regular_conversion": round((result[7] / regular_total * 100), 2) if regular_total > 0 else 0
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"SQL Error in get_users_premium_stats:")
|
||||||
|
logging.error(f"Error: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await self._execute_query(_get_stats)
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error getting premium stats: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
async def get_users_geography_stats(self) -> Dict:
|
||||||
|
"""География пользователей"""
|
||||||
|
def _get_stats():
|
||||||
|
try:
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
# Топ языков
|
||||||
|
lang_query = """
|
||||||
|
SELECT
|
||||||
|
COALESCE(language_code, 'unknown') as language,
|
||||||
|
COUNT(*) as count,
|
||||||
|
ROUND(COUNT(*) * 100.0 / (SELECT COUNT(*) FROM bot_users), 2) as percentage
|
||||||
|
FROM bot_users
|
||||||
|
GROUP BY language_code
|
||||||
|
ORDER BY COUNT(*) DESC
|
||||||
|
"""
|
||||||
|
cur.execute(lang_query)
|
||||||
|
languages = cur.fetchall()
|
||||||
|
|
||||||
|
# Статистика источников регистрации
|
||||||
|
source_query = """
|
||||||
|
SELECT
|
||||||
|
registration_source,
|
||||||
|
COUNT(*) as count
|
||||||
|
FROM bot_users
|
||||||
|
GROUP BY registration_source
|
||||||
|
ORDER BY COUNT(*) DESC
|
||||||
|
"""
|
||||||
|
cur.execute(source_query)
|
||||||
|
sources = cur.fetchall()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"top_languages": languages[:10] if languages else [],
|
||||||
|
"total_languages": len(languages) if languages else 0,
|
||||||
|
"registration_sources": sources if sources else []
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"SQL Error in get_users_geography_stats:")
|
||||||
|
logging.error(f"Error: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await self._execute_query(_get_stats)
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error getting geography stats: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
async def get_users_activity_stats(self) -> Dict:
|
||||||
|
"""Анализ активности пользователей"""
|
||||||
|
def _get_stats():
|
||||||
|
try:
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
# Активность за разные периоды
|
||||||
|
activity_query = """
|
||||||
|
SELECT
|
||||||
|
COUNT(CASE WHEN last_interaction_date >= SYSDATE - 1 THEN 1 END) as active_1d,
|
||||||
|
COUNT(CASE WHEN last_interaction_date >= SYSDATE - 3 THEN 1 END) as active_3d,
|
||||||
|
COUNT(CASE WHEN last_interaction_date >= SYSDATE - 7 THEN 1 END) as active_7d,
|
||||||
|
COUNT(CASE WHEN last_interaction_date >= SYSDATE - 14 THEN 1 END) as active_14d,
|
||||||
|
COUNT(CASE WHEN last_interaction_date >= SYSDATE - 30 THEN 1 END) as active_30d,
|
||||||
|
COUNT(CASE WHEN last_interaction_date < SYSDATE - 30 THEN 1 END) as inactive_30d
|
||||||
|
FROM bot_users
|
||||||
|
WHERE is_active = 1
|
||||||
|
"""
|
||||||
|
cur.execute(activity_query)
|
||||||
|
activity = cur.fetchone()
|
||||||
|
|
||||||
|
# Распределение по количеству взаимодействий
|
||||||
|
interaction_query = """
|
||||||
|
SELECT
|
||||||
|
CASE
|
||||||
|
WHEN interaction_count = 1 THEN 'Новички (1)'
|
||||||
|
WHEN interaction_count BETWEEN 2 AND 5 THEN 'Начинающие (2-5)'
|
||||||
|
WHEN interaction_count BETWEEN 6 AND 20 THEN 'Активные (6-20)'
|
||||||
|
WHEN interaction_count BETWEEN 21 AND 100 THEN 'Постоянные (21-100)'
|
||||||
|
ELSE 'Суперактивные (100+)'
|
||||||
|
END as category,
|
||||||
|
COUNT(*) as count
|
||||||
|
FROM bot_users
|
||||||
|
WHERE is_active = 1
|
||||||
|
GROUP BY
|
||||||
|
CASE
|
||||||
|
WHEN interaction_count = 1 THEN 'Новички (1)'
|
||||||
|
WHEN interaction_count BETWEEN 2 AND 5 THEN 'Начинающие (2-5)'
|
||||||
|
WHEN interaction_count BETWEEN 6 AND 20 THEN 'Активные (6-20)'
|
||||||
|
WHEN interaction_count BETWEEN 21 AND 100 THEN 'Постоянные (21-100)'
|
||||||
|
ELSE 'Суперактивные (100+)'
|
||||||
|
END
|
||||||
|
ORDER BY COUNT(*) DESC
|
||||||
|
"""
|
||||||
|
cur.execute(interaction_query)
|
||||||
|
interactions = cur.fetchall()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"active_1d": activity[0] or 0,
|
||||||
|
"active_3d": activity[1] or 0,
|
||||||
|
"active_7d": activity[2] or 0,
|
||||||
|
"active_14d": activity[3] or 0,
|
||||||
|
"active_30d": activity[4] or 0,
|
||||||
|
"inactive_30d": activity[5] or 0,
|
||||||
|
"interaction_distribution": interactions if interactions else []
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"SQL Error in get_users_activity_stats:")
|
||||||
|
logging.error(f"Error: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await self._execute_query(_get_stats)
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error getting activity stats: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
async def get_users_sources_stats(self) -> Dict:
|
||||||
|
"""Анализ источников пользователей"""
|
||||||
|
def _get_stats():
|
||||||
|
try:
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
# Статистика по источникам
|
||||||
|
sources_query = """
|
||||||
|
SELECT
|
||||||
|
registration_source,
|
||||||
|
COUNT(*) as total_users,
|
||||||
|
COUNT(CASE WHEN successful_payments_count > 0 THEN 1 END) as paying_users,
|
||||||
|
AVG(total_payments) as avg_revenue,
|
||||||
|
AVG(interaction_count) as avg_interactions,
|
||||||
|
COUNT(CASE WHEN created_at >= SYSDATE - 30 THEN 1 END) as new_last_30d
|
||||||
|
FROM bot_users
|
||||||
|
GROUP BY registration_source
|
||||||
|
ORDER BY COUNT(*) DESC
|
||||||
|
"""
|
||||||
|
cur.execute(sources_query)
|
||||||
|
sources = cur.fetchall()
|
||||||
|
|
||||||
|
# Топ реферальные источники (если есть)
|
||||||
|
referral_query = """
|
||||||
|
SELECT
|
||||||
|
registration_source,
|
||||||
|
COUNT(*) as count
|
||||||
|
FROM bot_users
|
||||||
|
WHERE registration_source NOT IN ('bot', 'direct', 'unknown')
|
||||||
|
GROUP BY registration_source
|
||||||
|
ORDER BY COUNT(*) DESC
|
||||||
|
FETCH FIRST 10 ROWS ONLY
|
||||||
|
"""
|
||||||
|
cur.execute(referral_query)
|
||||||
|
referrals = cur.fetchall()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"source_breakdown": sources if sources else [],
|
||||||
|
"top_referrals": referrals if referrals else []
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"SQL Error in get_users_sources_stats:")
|
||||||
|
logging.error(f"Error: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await self._execute_query(_get_stats)
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error getting sources stats: {e}")
|
||||||
|
return {}
|
||||||
91
database/base.py
Normal file
91
database/base.py
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
"""
|
||||||
|
Базовый класс для работы с Oracle Database
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import oracledb
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from config.settings import DB_CONFIG
|
||||||
|
|
||||||
|
|
||||||
|
class OracleDatabase:
|
||||||
|
"""Базовый класс для работы с Oracle Database"""
|
||||||
|
|
||||||
|
def __init__(self, user: str = None, password: str = None, dsn: str = None):
|
||||||
|
"""
|
||||||
|
Инициализация подключения к БД
|
||||||
|
Если параметры не переданы, используются настройки из конфига
|
||||||
|
"""
|
||||||
|
self._user = user or DB_CONFIG["user"]
|
||||||
|
self._password = password or DB_CONFIG["password"]
|
||||||
|
self._dsn = dsn or DB_CONFIG["dsn"]
|
||||||
|
self._pool: Optional[oracledb.ConnectionPool] = None
|
||||||
|
|
||||||
|
async def connect(self):
|
||||||
|
"""Создание пула соединений с БД"""
|
||||||
|
try:
|
||||||
|
# Oracle does not support true async I/O; use threaded connection pool
|
||||||
|
self._pool = oracledb.create_pool(
|
||||||
|
user=self._user,
|
||||||
|
password=self._password,
|
||||||
|
dsn=self._dsn,
|
||||||
|
min=1,
|
||||||
|
max=4,
|
||||||
|
increment=1,
|
||||||
|
getmode=oracledb.POOL_GETMODE_WAIT
|
||||||
|
)
|
||||||
|
logging.info("Oracle database connection pool created successfully")
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Failed to create Oracle database connection pool: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
"""Закрытие пула соединений"""
|
||||||
|
if self._pool:
|
||||||
|
try:
|
||||||
|
self._pool.close()
|
||||||
|
logging.info("Oracle database connection pool closed")
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error closing Oracle database connection pool: {e}")
|
||||||
|
|
||||||
|
def _execute_query(self, query_func):
|
||||||
|
"""
|
||||||
|
Базовый wrapper для выполнения синхронных запросов в асинхронном контексте
|
||||||
|
Args:
|
||||||
|
query_func: функция, которая выполняет запрос к БД
|
||||||
|
Returns:
|
||||||
|
результат выполнения функции
|
||||||
|
"""
|
||||||
|
return asyncio.to_thread(query_func)
|
||||||
|
|
||||||
|
def _get_connection(self):
|
||||||
|
"""Получение соединения из пула"""
|
||||||
|
if not self._pool:
|
||||||
|
raise RuntimeError("Database connection pool is not initialized. Call connect() first.")
|
||||||
|
return self._pool.acquire()
|
||||||
|
|
||||||
|
async def execute_query_with_logging(self, query: str, params: dict = None, operation_name: str = "query"):
|
||||||
|
"""
|
||||||
|
Выполняет запрос с логированием ошибок SQL
|
||||||
|
Args:
|
||||||
|
query: SQL запрос
|
||||||
|
params: параметры запроса
|
||||||
|
operation_name: название операции для логирования
|
||||||
|
Returns:
|
||||||
|
результат запроса или None при ошибке
|
||||||
|
"""
|
||||||
|
def _execute():
|
||||||
|
try:
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(query, params or {})
|
||||||
|
return cur.fetchall()
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"SQL Error in {operation_name}:")
|
||||||
|
logging.error(f"Query: {query}")
|
||||||
|
logging.error(f"Params: {params}")
|
||||||
|
logging.error(f"Error: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
return await self._execute_query(_execute)
|
||||||
244
database/core_queries.py
Normal file
244
database/core_queries.py
Normal file
@ -0,0 +1,244 @@
|
|||||||
|
"""
|
||||||
|
Основные запросы для работы с VIN кодами
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from typing import Tuple, List, Dict
|
||||||
|
|
||||||
|
from .base import OracleDatabase
|
||||||
|
|
||||||
|
|
||||||
|
class VinQueries(OracleDatabase):
|
||||||
|
"""Класс для работы с VIN запросами"""
|
||||||
|
|
||||||
|
async def fetch_user(self, user_id: int):
|
||||||
|
"""Получает пользователя по ID"""
|
||||||
|
def _query():
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT id, name FROM users WHERE id = :id", {"id": user_id})
|
||||||
|
return cur.fetchone()
|
||||||
|
|
||||||
|
return await self._execute_query(_query)
|
||||||
|
|
||||||
|
async def fetch_vin_info(self, vin: str) -> Tuple[str, str, str, int]:
|
||||||
|
"""
|
||||||
|
Получает базовую информацию о VIN (make, model, year, count)
|
||||||
|
"""
|
||||||
|
def _query():
|
||||||
|
try:
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
query = """
|
||||||
|
select 'None',
|
||||||
|
COALESCE((select value from salvagedb.m_JSONS_FROM_NHTSA v3 where v3.svin =s.svin and v3.variableid ='26'),
|
||||||
|
(select val from salvagedb.vind2 where svin = substr(s.vin, 1, 8) || '*' || substr(s.vin, 10, 2) and varb = 'Make'),'UNKNOWN') make,
|
||||||
|
COALESCE((select value from salvagedb.m_JSONS_FROM_NHTSA v3 where v3.svin =s.svin and v3.variableid ='28'),
|
||||||
|
(select val from salvagedb.vind2 where svin = substr(s.vin, 1, 8) || '*' || substr(s.vin, 10, 2) and varb = 'Model'),'UNKNOWN') model,
|
||||||
|
COALESCE((select value from salvagedb.m_JSONS_FROM_NHTSA v3 where v3.svin =s.svin and v3.variableid ='29'),
|
||||||
|
(select val from salvagedb.vind2 where svin = substr(s.vin, 1, 8) || '*' || substr(s.vin, 10, 2) and varb = 'Model Year'),'UNKNOWN') year,
|
||||||
|
(select count(*) from salvagedb.m_JSONS_FROM_NHTSA v3 where v3.svin =s.svin) cnt
|
||||||
|
from (select substr(:vin,1,10) svin, :vin vin from dual) s
|
||||||
|
"""
|
||||||
|
logging.info(f"[DB DEBUG] Executing query for VIN: {vin}")
|
||||||
|
logging.info(f"[DB DEBUG] SVIN will be: {vin[:10]}")
|
||||||
|
cur.execute(query, {"vin": vin})
|
||||||
|
result = cur.fetchone()
|
||||||
|
logging.info(f"[DB DEBUG] Raw result from database: {result}")
|
||||||
|
if result:
|
||||||
|
make, model, year, cnt = result[1], result[2], result[3], result[4]
|
||||||
|
logging.info(f"[DB DEBUG] Parsed values - make: '{make}', model: '{model}', year: '{year}', cnt: {cnt}")
|
||||||
|
return make, model, year, cnt
|
||||||
|
logging.info(f"[DB DEBUG] No result found, returning defaults")
|
||||||
|
return "UNKNOWN", "UNKNOWN", "UNKNOWN", 0
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"SQL Error in fetch_vin_info:")
|
||||||
|
logging.error(f"Query: {query}")
|
||||||
|
logging.error(f"VIN: {vin}")
|
||||||
|
logging.error(f"Error: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
return await self._execute_query(_query)
|
||||||
|
|
||||||
|
async def count_salvage_records(self, vin: str) -> int:
|
||||||
|
"""
|
||||||
|
Подсчитывает количество записей в таблице salvagedb.salvagedb для данного VIN
|
||||||
|
"""
|
||||||
|
def _query():
|
||||||
|
try:
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
query = "SELECT COUNT(*) FROM salvagedb.salvagedb WHERE vin = :vin and svin = substr(:vin,1,10)"
|
||||||
|
cur.execute(query, {"vin": vin})
|
||||||
|
result = cur.fetchone()
|
||||||
|
return result[0] if result else 0
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"SQL Error in count_salvage_records:")
|
||||||
|
logging.error(f"Query: {query}")
|
||||||
|
logging.error(f"VIN: {vin}")
|
||||||
|
logging.error(f"Error: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
return await self._execute_query(_query)
|
||||||
|
|
||||||
|
async def fetch_salvage_detailed_info(self, vin: str) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Получает детальную информацию о повреждениях и истории из таблицы salvagedb.salvagedb
|
||||||
|
"""
|
||||||
|
def _query():
|
||||||
|
try:
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
query = """
|
||||||
|
SELECT
|
||||||
|
odo,
|
||||||
|
odos,
|
||||||
|
dem1,
|
||||||
|
dem2,
|
||||||
|
month||'/'||year as sale_date,
|
||||||
|
JSON_VALUE(jdata, '$.RepCost') AS j_rep_cost,
|
||||||
|
JSON_VALUE(jdata, '$.Runs_Drive') AS j_runs_drive,
|
||||||
|
JSON_VALUE(jdata, '$.Locate') AS j_locate,
|
||||||
|
(select count(*) from salvagedb.salvage_images si where si.vin = s.vin and fn =1) img_count
|
||||||
|
FROM salvagedb.salvagedb s
|
||||||
|
LEFT JOIN salvagedb.addinfo i ON s.num = i.numid
|
||||||
|
WHERE vin = :vin AND svin = substr(:vin, 1, 10)
|
||||||
|
ORDER BY year DESC, month DESC
|
||||||
|
"""
|
||||||
|
cur.execute(query, {"vin": vin})
|
||||||
|
results = cur.fetchall()
|
||||||
|
|
||||||
|
# Преобразуем результаты в список словарей
|
||||||
|
detailed_records = []
|
||||||
|
for row in results:
|
||||||
|
record = {
|
||||||
|
'odo': row[0],
|
||||||
|
'odos': row[1],
|
||||||
|
'dem1': row[2],
|
||||||
|
'dem2': row[3],
|
||||||
|
'sale_date': row[4],
|
||||||
|
'j_rep_cost': row[5],
|
||||||
|
'j_runs_drive': row[6],
|
||||||
|
'j_locate': row[7],
|
||||||
|
'img_count': row[8]
|
||||||
|
}
|
||||||
|
detailed_records.append(record)
|
||||||
|
|
||||||
|
return detailed_records
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"SQL Error in fetch_salvage_detailed_info:")
|
||||||
|
logging.error(f"Query: {query}")
|
||||||
|
logging.error(f"VIN: {vin}")
|
||||||
|
logging.error(f"Error: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
return await self._execute_query(_query)
|
||||||
|
|
||||||
|
async def fetch_photo_paths(self, vin: str) -> List[str]:
|
||||||
|
"""
|
||||||
|
Получает список путей к фотографиям для данного VIN
|
||||||
|
"""
|
||||||
|
def _query():
|
||||||
|
try:
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
query = "SELECT ipath FROM salvagedb.salvage_images WHERE fn = 1 AND vin = :vin"
|
||||||
|
cur.execute(query, {"vin": vin})
|
||||||
|
results = cur.fetchall()
|
||||||
|
return [row[0] for row in results if row[0]] if results else []
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"SQL Error in fetch_photo_paths:")
|
||||||
|
logging.error(f"Query: {query}")
|
||||||
|
logging.error(f"VIN: {vin}")
|
||||||
|
logging.error(f"Error: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
return await self._execute_query(_query)
|
||||||
|
|
||||||
|
async def count_photo_records(self, vin: str) -> int:
|
||||||
|
"""
|
||||||
|
Подсчитывает количество фотографий для данного VIN
|
||||||
|
"""
|
||||||
|
def _query():
|
||||||
|
try:
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
query = "SELECT COUNT(*) FROM salvagedb.salvage_images WHERE fn = 1 AND vin = :vin"
|
||||||
|
cur.execute(query, {"vin": vin})
|
||||||
|
result = cur.fetchone()
|
||||||
|
return result[0] if result else 0
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"SQL Error in count_photo_records:")
|
||||||
|
logging.error(f"Query: {query}")
|
||||||
|
logging.error(f"VIN: {vin}")
|
||||||
|
logging.error(f"Error: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
return await self._execute_query(_query)
|
||||||
|
|
||||||
|
async def fetch_detailed_vin_info(self, vin: str) -> Dict:
|
||||||
|
"""
|
||||||
|
Получает детальную техническую информацию о VIN из NHTSA
|
||||||
|
"""
|
||||||
|
def _query():
|
||||||
|
try:
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
query = """
|
||||||
|
select dp.parameter_name,
|
||||||
|
dp.category_name,
|
||||||
|
d.value,
|
||||||
|
dp.endesc
|
||||||
|
from salvagedb.m_JSONS_FROM_NHTSA d
|
||||||
|
left join salvagedb.decode_params dp
|
||||||
|
on d.variableid = dp.parameter_id
|
||||||
|
where svin = substr(:vin, 1, 10)
|
||||||
|
order by dp.category_level
|
||||||
|
"""
|
||||||
|
cur.execute(query, {"vin": vin})
|
||||||
|
results = cur.fetchall()
|
||||||
|
|
||||||
|
# Organize data by categories
|
||||||
|
detailed_info = {
|
||||||
|
'basic_characteristics': {},
|
||||||
|
'engine_and_powertrain': {},
|
||||||
|
'active_safety': {},
|
||||||
|
'transmission': {},
|
||||||
|
'passive_safety': {},
|
||||||
|
'dimensions_and_construction': {},
|
||||||
|
'brake_system': {},
|
||||||
|
'lighting': {},
|
||||||
|
'additional_features': {},
|
||||||
|
'manufacturing_and_localization': {},
|
||||||
|
'ncsa_data': {},
|
||||||
|
'technical_information_and_errors': {},
|
||||||
|
'all_params': {} # Flat dictionary for easy access
|
||||||
|
}
|
||||||
|
|
||||||
|
for row in results:
|
||||||
|
param_name, category_name, value, description = row
|
||||||
|
if param_name and value:
|
||||||
|
# Create key from parameter name (lowercase, spaces to underscores)
|
||||||
|
key = param_name.lower().replace(' ', '_').replace('(', '').replace(')', '').replace('/', '_').replace('-', '_')
|
||||||
|
|
||||||
|
# Add to flat dictionary
|
||||||
|
detailed_info['all_params'][key] = value
|
||||||
|
|
||||||
|
# Add to category-specific dictionary
|
||||||
|
if category_name:
|
||||||
|
category_key = category_name.lower().replace(' ', '_').replace('(', '').replace(')', '').replace('/', '_').replace('-', '_')
|
||||||
|
if category_key in detailed_info:
|
||||||
|
detailed_info[category_key][key] = {
|
||||||
|
'value': value,
|
||||||
|
'description': description,
|
||||||
|
'param_name': param_name
|
||||||
|
}
|
||||||
|
|
||||||
|
return detailed_info
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"SQL Error in fetch_detailed_vin_info:")
|
||||||
|
logging.error(f"Query: {query}")
|
||||||
|
logging.error(f"VIN: {vin}")
|
||||||
|
logging.error(f"Error: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
return await self._execute_query(_query)
|
||||||
84
database/payment_tracking.py
Normal file
84
database/payment_tracking.py
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
"""
|
||||||
|
Отслеживание и логирование платежей
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from typing import Dict
|
||||||
|
from aiogram.types import User
|
||||||
|
|
||||||
|
from .base import OracleDatabase
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentTracker(OracleDatabase):
|
||||||
|
"""Класс для отслеживания платежей"""
|
||||||
|
|
||||||
|
async def save_payment_log(self, user: User, service_type: str, vin: str, payment_data: Dict, service_result: Dict = None) -> bool:
|
||||||
|
"""
|
||||||
|
Логирует операцию оплаты с полной информацией о пользователе, услуге и результате
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: Объект пользователя Telegram
|
||||||
|
service_type: Тип услуги ('decode_vin', 'check_salvage', 'get_photos')
|
||||||
|
vin: VIN номер автомобиля
|
||||||
|
payment_data: Данные о платеже (сумма, transaction_id, статус)
|
||||||
|
service_result: Результат предоставления услуги (количество данных, статус)
|
||||||
|
"""
|
||||||
|
def _save_log():
|
||||||
|
try:
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
insert_query = """
|
||||||
|
INSERT INTO payment_logs (
|
||||||
|
log_id, user_id, user_first_name, user_last_name, user_username,
|
||||||
|
user_language_code, user_is_premium, service_type, vin_number,
|
||||||
|
payment_amount, transaction_id, payment_status, payment_currency,
|
||||||
|
service_status, data_found_count, refund_status, refund_reason,
|
||||||
|
vehicle_make, vehicle_model, vehicle_year, error_message,
|
||||||
|
created_date, ip_address
|
||||||
|
) VALUES (
|
||||||
|
payment_logs_seq.NEXTVAL, :user_id, :user_first_name, :user_last_name, :user_username,
|
||||||
|
:user_language_code, :user_is_premium, :service_type, :vin_number,
|
||||||
|
:payment_amount, :transaction_id, :payment_status, :payment_currency,
|
||||||
|
:service_status, :data_found_count, :refund_status, :refund_reason,
|
||||||
|
:vehicle_make, :vehicle_model, :vehicle_year, :error_message,
|
||||||
|
SYSDATE, :ip_address
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"user_id": user.id,
|
||||||
|
"user_first_name": user.first_name,
|
||||||
|
"user_last_name": user.last_name,
|
||||||
|
"user_username": user.username,
|
||||||
|
"user_language_code": user.language_code,
|
||||||
|
"user_is_premium": 1 if user.is_premium else 0,
|
||||||
|
"service_type": service_type,
|
||||||
|
"vin_number": vin,
|
||||||
|
"payment_amount": payment_data.get('amount', 0),
|
||||||
|
"transaction_id": payment_data.get('transaction_id'),
|
||||||
|
"payment_status": payment_data.get('status', 'completed'),
|
||||||
|
"payment_currency": payment_data.get('currency', 'XTR'),
|
||||||
|
"service_status": service_result.get('status', 'success') if service_result else 'pending',
|
||||||
|
"data_found_count": service_result.get('data_count', 0) if service_result else 0,
|
||||||
|
"refund_status": payment_data.get('refund_status', 'no_refund'),
|
||||||
|
"refund_reason": payment_data.get('refund_reason'),
|
||||||
|
"vehicle_make": service_result.get('vehicle_make') if service_result else None,
|
||||||
|
"vehicle_model": service_result.get('vehicle_model') if service_result else None,
|
||||||
|
"vehicle_year": service_result.get('vehicle_year') if service_result else None,
|
||||||
|
"error_message": service_result.get('error') if service_result else None,
|
||||||
|
"ip_address": None # Telegram не предоставляет IP адреса
|
||||||
|
}
|
||||||
|
|
||||||
|
cur.execute(insert_query, params)
|
||||||
|
conn.commit()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"SQL Error in save_payment_log:")
|
||||||
|
logging.error(f"User ID: {user.id}, Service: {service_type}, VIN: {vin}")
|
||||||
|
logging.error(f"Error: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await self._execute_query(_save_log)
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error saving payment log for user {user.id}: {e}")
|
||||||
|
return False
|
||||||
215
database/user_management.py
Normal file
215
database/user_management.py
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
"""
|
||||||
|
Управление пользователями и их данными
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from typing import Optional, Dict
|
||||||
|
from aiogram.types import User
|
||||||
|
|
||||||
|
from .base import OracleDatabase
|
||||||
|
|
||||||
|
|
||||||
|
class UserManager(OracleDatabase):
|
||||||
|
"""Класс для управления пользователями"""
|
||||||
|
|
||||||
|
async def save_user(self, user: User, interaction_source: str = "bot") -> bool:
|
||||||
|
"""
|
||||||
|
Сохраняет или обновляет данные пользователя в базе данных
|
||||||
|
При первом взаимодействии создает запись, при последующих - обновляет
|
||||||
|
"""
|
||||||
|
def _save_user():
|
||||||
|
try:
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
# Проверяем, существует ли пользователь
|
||||||
|
cur.execute("SELECT id FROM bot_users WHERE id = :user_id", {"user_id": user.id})
|
||||||
|
existing_user = cur.fetchone()
|
||||||
|
|
||||||
|
if existing_user:
|
||||||
|
# Обновляем существующего пользователя
|
||||||
|
update_query = """
|
||||||
|
UPDATE bot_users SET
|
||||||
|
first_name = :first_name,
|
||||||
|
last_name = :last_name,
|
||||||
|
username = :username,
|
||||||
|
language_code = :language_code,
|
||||||
|
is_premium = :is_premium,
|
||||||
|
added_to_attachment_menu = :added_to_attachment_menu,
|
||||||
|
last_interaction_date = SYSDATE,
|
||||||
|
interaction_count = interaction_count + 1
|
||||||
|
WHERE id = :user_id
|
||||||
|
"""
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"user_id": user.id,
|
||||||
|
"first_name": user.first_name,
|
||||||
|
"last_name": user.last_name,
|
||||||
|
"username": user.username,
|
||||||
|
"language_code": user.language_code,
|
||||||
|
"is_premium": 1 if user.is_premium else 0,
|
||||||
|
"added_to_attachment_menu": 1 if user.added_to_attachment_menu else 0
|
||||||
|
}
|
||||||
|
cur.execute(update_query, params)
|
||||||
|
else:
|
||||||
|
# Создаем нового пользователя
|
||||||
|
insert_query = """
|
||||||
|
INSERT INTO bot_users (
|
||||||
|
id, first_name, last_name, username, language_code,
|
||||||
|
is_bot, is_premium, added_to_attachment_menu,
|
||||||
|
registration_source, first_interaction_date,
|
||||||
|
last_interaction_date, interaction_count
|
||||||
|
) VALUES (
|
||||||
|
:user_id, :first_name, :last_name, :username, :language_code,
|
||||||
|
:is_bot, :is_premium, :added_to_attachment_menu,
|
||||||
|
:registration_source, SYSDATE, SYSDATE, 1
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"user_id": user.id,
|
||||||
|
"first_name": user.first_name,
|
||||||
|
"last_name": user.last_name,
|
||||||
|
"username": user.username,
|
||||||
|
"language_code": user.language_code,
|
||||||
|
"is_bot": 1 if user.is_bot else 0,
|
||||||
|
"is_premium": 1 if user.is_premium else 0,
|
||||||
|
"added_to_attachment_menu": 1 if user.added_to_attachment_menu else 0,
|
||||||
|
"registration_source": interaction_source
|
||||||
|
}
|
||||||
|
cur.execute(insert_query, params)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"SQL Error in save_user:")
|
||||||
|
logging.error(f"User ID: {user.id}")
|
||||||
|
logging.error(f"Error: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await self._execute_query(_save_user)
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error saving user {user.id}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def update_user_payment(self, user_id: int, payment_amount: float) -> bool:
|
||||||
|
"""
|
||||||
|
Обновляет данные о платежах пользователя
|
||||||
|
"""
|
||||||
|
def _update_payment():
|
||||||
|
try:
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
update_query = """
|
||||||
|
UPDATE bot_users SET
|
||||||
|
total_payments = total_payments + :amount,
|
||||||
|
successful_payments_count = successful_payments_count + 1,
|
||||||
|
last_interaction_date = SYSDATE
|
||||||
|
WHERE id = :user_id
|
||||||
|
"""
|
||||||
|
|
||||||
|
cur.execute(update_query, {
|
||||||
|
"user_id": user_id,
|
||||||
|
"amount": payment_amount
|
||||||
|
})
|
||||||
|
conn.commit()
|
||||||
|
return cur.rowcount > 0
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"SQL Error in update_user_payment:")
|
||||||
|
logging.error(f"User ID: {user_id}, Amount: {payment_amount}")
|
||||||
|
logging.error(f"Error: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await self._execute_query(_update_payment)
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error updating payment for user {user_id}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def get_user_stats(self, user_id: int) -> Optional[Dict]:
|
||||||
|
"""
|
||||||
|
Получает статистику пользователя из базы данных
|
||||||
|
"""
|
||||||
|
def _get_stats():
|
||||||
|
try:
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
query = """
|
||||||
|
SELECT
|
||||||
|
first_name, last_name, username, language_code,
|
||||||
|
is_premium, interaction_count, total_payments,
|
||||||
|
successful_payments_count, first_interaction_date,
|
||||||
|
last_interaction_date
|
||||||
|
FROM bot_users
|
||||||
|
WHERE id = :user_id
|
||||||
|
"""
|
||||||
|
|
||||||
|
cur.execute(query, {"user_id": user_id})
|
||||||
|
result = cur.fetchone()
|
||||||
|
|
||||||
|
if result:
|
||||||
|
return {
|
||||||
|
"first_name": result[0],
|
||||||
|
"last_name": result[1],
|
||||||
|
"username": result[2],
|
||||||
|
"language_code": result[3],
|
||||||
|
"is_premium": bool(result[4]),
|
||||||
|
"interaction_count": result[5],
|
||||||
|
"total_payments": float(result[6]) if result[6] else 0.0,
|
||||||
|
"successful_payments_count": result[7],
|
||||||
|
"first_interaction_date": result[8],
|
||||||
|
"last_interaction_date": result[9]
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"SQL Error in get_user_stats:")
|
||||||
|
logging.error(f"User ID: {user_id}")
|
||||||
|
logging.error(f"Error: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await self._execute_query(_get_stats)
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error getting stats for user {user_id}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_users_summary(self) -> Dict:
|
||||||
|
"""
|
||||||
|
Получает общую статистику по пользователям
|
||||||
|
"""
|
||||||
|
def _get_summary():
|
||||||
|
try:
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
summary_query = """
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total_users,
|
||||||
|
COUNT(CASE WHEN is_premium = 1 THEN 1 END) as premium_users,
|
||||||
|
SUM(total_payments) as total_revenue,
|
||||||
|
SUM(successful_payments_count) as total_transactions,
|
||||||
|
COUNT(CASE WHEN last_interaction_date >= SYSDATE - 1 THEN 1 END) as active_last_24h,
|
||||||
|
COUNT(CASE WHEN last_interaction_date >= SYSDATE - 7 THEN 1 END) as active_last_week
|
||||||
|
FROM bot_users
|
||||||
|
WHERE is_active = 1
|
||||||
|
"""
|
||||||
|
|
||||||
|
cur.execute(summary_query)
|
||||||
|
result = cur.fetchone()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_users": result[0] or 0,
|
||||||
|
"premium_users": result[1] or 0,
|
||||||
|
"total_revenue": float(result[2]) if result[2] else 0.0,
|
||||||
|
"total_transactions": result[3] or 0,
|
||||||
|
"active_last_24h": result[4] or 0,
|
||||||
|
"active_last_week": result[5] or 0
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"SQL Error in get_users_summary:")
|
||||||
|
logging.error(f"Error: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await self._execute_query(_get_summary)
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error getting users summary: {e}")
|
||||||
|
return {}
|
||||||
1
handlers/__init__.py
Normal file
1
handlers/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# Обработчики Telegram бота
|
||||||
1
handlers/admin/__init__.py
Normal file
1
handlers/admin/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# Обработчики админ-панели
|
||||||
1
keyboards/__init__.py
Normal file
1
keyboards/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# Клавиатуры Telegram бота
|
||||||
1
utils/__init__.py
Normal file
1
utils/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# Утилиты и вспомогательные функции
|
||||||
85
utils/formatting.py
Normal file
85
utils/formatting.py
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
"""
|
||||||
|
Утилиты форматирования данных для Telegram
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def get_us_state_name(state_code: str) -> str:
|
||||||
|
"""
|
||||||
|
Конвертирует двухбуквенный код штата США в полное название
|
||||||
|
"""
|
||||||
|
states = {
|
||||||
|
'AL': 'Alabama', 'AK': 'Alaska', 'AZ': 'Arizona', 'AR': 'Arkansas', 'CA': 'California',
|
||||||
|
'CO': 'Colorado', 'CT': 'Connecticut', 'DE': 'Delaware', 'FL': 'Florida', 'GA': 'Georgia',
|
||||||
|
'HI': 'Hawaii', 'ID': 'Idaho', 'IL': 'Illinois', 'IN': 'Indiana', 'IA': 'Iowa',
|
||||||
|
'KS': 'Kansas', 'KY': 'Kentucky', 'LA': 'Louisiana', 'ME': 'Maine', 'MD': 'Maryland',
|
||||||
|
'MA': 'Massachusetts', 'MI': 'Michigan', 'MN': 'Minnesota', 'MS': 'Mississippi', 'MO': 'Missouri',
|
||||||
|
'MT': 'Montana', 'NE': 'Nebraska', 'NV': 'Nevada', 'NH': 'New Hampshire', 'NJ': 'New Jersey',
|
||||||
|
'NM': 'New Mexico', 'NY': 'New York', 'NC': 'North Carolina', 'ND': 'North Dakota', 'OH': 'Ohio',
|
||||||
|
'OK': 'Oklahoma', 'OR': 'Oregon', 'PA': 'Pennsylvania', 'RI': 'Rhode Island', 'SC': 'South Carolina',
|
||||||
|
'SD': 'South Dakota', 'TN': 'Tennessee', 'TX': 'Texas', 'UT': 'Utah', 'VT': 'Vermont',
|
||||||
|
'VA': 'Virginia', 'WA': 'Washington', 'WV': 'West Virginia', 'WI': 'Wisconsin', 'WY': 'Wyoming',
|
||||||
|
'DC': 'District of Columbia'
|
||||||
|
}
|
||||||
|
return states.get(state_code.upper(), state_code)
|
||||||
|
|
||||||
|
|
||||||
|
def format_sale_date(date_str: str) -> str:
|
||||||
|
"""
|
||||||
|
Форматирует дату продажи из MM/YYYY в красивый формат
|
||||||
|
"""
|
||||||
|
if not date_str or date_str == 'None' or '/' not in date_str:
|
||||||
|
return "Unknown"
|
||||||
|
|
||||||
|
try:
|
||||||
|
month, year = date_str.split('/')
|
||||||
|
months = {
|
||||||
|
'1': 'January', '2': 'February', '3': 'March', '4': 'April',
|
||||||
|
'5': 'May', '6': 'June', '7': 'July', '8': 'August',
|
||||||
|
'9': 'September', '10': 'October', '11': 'November', '12': 'December'
|
||||||
|
}
|
||||||
|
month_name = months.get(month.lstrip('0'), month)
|
||||||
|
return f"{month_name} {year}"
|
||||||
|
except:
|
||||||
|
return date_str
|
||||||
|
|
||||||
|
|
||||||
|
def parse_location(location_str: str) -> str:
|
||||||
|
r"""
|
||||||
|
Парсит и форматирует локацию из формата ST/TOWN или ST\TOWN
|
||||||
|
"""
|
||||||
|
if not location_str or location_str == 'None':
|
||||||
|
return "Unknown Location"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Проверяем оба варианта разделителей: / и \
|
||||||
|
if '/' in location_str:
|
||||||
|
state_code, city = location_str.split('/', 1)
|
||||||
|
state_name = get_us_state_name(state_code.strip())
|
||||||
|
city_formatted = city.strip().title()
|
||||||
|
return f"{city_formatted}, {state_name}"
|
||||||
|
elif '\\' in location_str:
|
||||||
|
state_code, city = location_str.split('\\', 1)
|
||||||
|
state_name = get_us_state_name(state_code.strip())
|
||||||
|
city_formatted = city.strip().title()
|
||||||
|
return f"{city_formatted}, {state_name}"
|
||||||
|
else:
|
||||||
|
return location_str
|
||||||
|
except:
|
||||||
|
return location_str
|
||||||
|
|
||||||
|
|
||||||
|
def escape_markdown(text: str) -> str:
|
||||||
|
"""
|
||||||
|
Экранирует специальные символы Markdown для безопасной отправки в Telegram
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Символы которые нужно экранировать для Markdown
|
||||||
|
escape_chars = ['*', '_', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!']
|
||||||
|
|
||||||
|
escaped_text = str(text)
|
||||||
|
for char in escape_chars:
|
||||||
|
escaped_text = escaped_text.replace(char, f'\\{char}')
|
||||||
|
|
||||||
|
return escaped_text
|
||||||
123
utils/logging_config.py
Normal file
123
utils/logging_config.py
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
"""
|
||||||
|
Конфигурация системы логирования
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
from logging.handlers import TimedRotatingFileHandler
|
||||||
|
from os import getenv
|
||||||
|
|
||||||
|
|
||||||
|
def is_windows() -> bool:
|
||||||
|
"""Проверяет, запущен ли код на Windows"""
|
||||||
|
return platform.system().lower() == 'windows'
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging():
|
||||||
|
"""
|
||||||
|
Настройка системы логирования с ротацией файлов и выводом в консоль
|
||||||
|
"""
|
||||||
|
# Создаем каталог logs если он не существует
|
||||||
|
if is_windows():
|
||||||
|
logs_dir = "logs"
|
||||||
|
else:
|
||||||
|
logs_dir = "/logs/"
|
||||||
|
if not os.path.exists(logs_dir):
|
||||||
|
os.makedirs(logs_dir)
|
||||||
|
print(f"Created logs directory: {logs_dir}")
|
||||||
|
|
||||||
|
# Определяем уровень логирования
|
||||||
|
if getenv("DEBUG", '0') == '1':
|
||||||
|
log_level = logging.INFO
|
||||||
|
else:
|
||||||
|
log_level = logging.WARNING
|
||||||
|
|
||||||
|
# Временно включаем детальное логирование для отладки
|
||||||
|
log_level = logging.INFO
|
||||||
|
|
||||||
|
# Создаем основной логгер
|
||||||
|
logger = logging.getLogger()
|
||||||
|
logger.setLevel(log_level)
|
||||||
|
|
||||||
|
# Очищаем существующие обработчики
|
||||||
|
logger.handlers.clear()
|
||||||
|
|
||||||
|
# Формат логов
|
||||||
|
formatter = logging.Formatter(
|
||||||
|
'%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||||
|
datefmt='%Y-%m-%d %H:%M:%S'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Настройка файлового логирования с ротацией
|
||||||
|
file_handler = TimedRotatingFileHandler(
|
||||||
|
filename=os.path.join(logs_dir, "salvagedb_bot.log"),
|
||||||
|
when='midnight', # Ротация в полночь
|
||||||
|
interval=1, # Каждый день
|
||||||
|
backupCount=30, # Храним 30 дней
|
||||||
|
encoding='utf-8',
|
||||||
|
utc=False # Используем локальное время
|
||||||
|
)
|
||||||
|
file_handler.setLevel(log_level)
|
||||||
|
file_handler.setFormatter(formatter)
|
||||||
|
file_handler.suffix = "%Y-%m-%d" # Формат суффикса для файлов
|
||||||
|
|
||||||
|
# Настройка консольного логирования
|
||||||
|
console_handler = logging.StreamHandler()
|
||||||
|
console_handler.setLevel(log_level)
|
||||||
|
console_handler.setFormatter(formatter)
|
||||||
|
|
||||||
|
# Добавляем обработчики к логгеру
|
||||||
|
logger.addHandler(file_handler)
|
||||||
|
logger.addHandler(console_handler)
|
||||||
|
|
||||||
|
# Логируем успешную настройку
|
||||||
|
logging.info("=== LOGGING SYSTEM INITIALIZED ===")
|
||||||
|
logging.info(f"Log level: {logging.getLevelName(log_level)}")
|
||||||
|
logging.info(f"Logs directory: {os.path.abspath(logs_dir)}")
|
||||||
|
logging.info(f"Log rotation: daily, keeping 30 days")
|
||||||
|
|
||||||
|
# Проверяем запуск в Docker
|
||||||
|
if os.path.exists('/.dockerenv'):
|
||||||
|
logging.info("Running inside Docker container")
|
||||||
|
logging.info(f"Container timezone: {getenv('TZ', 'UTC')}")
|
||||||
|
logging.info(f"Container user: {getenv('USER', 'unknown')}")
|
||||||
|
else:
|
||||||
|
logging.info("Running on host system")
|
||||||
|
|
||||||
|
logging.info("=== LOGGING SETUP COMPLETE ===")
|
||||||
|
|
||||||
|
|
||||||
|
def log_system_info():
|
||||||
|
"""
|
||||||
|
Логирует информацию о системе при запуске
|
||||||
|
"""
|
||||||
|
os_name = get_operating_system()
|
||||||
|
python_version = platform.python_version()
|
||||||
|
architecture = platform.machine()
|
||||||
|
|
||||||
|
logging.info(f"=== SYSTEM INFORMATION ===")
|
||||||
|
logging.info(f"Operating System: {os_name}")
|
||||||
|
logging.info(f"Platform: {platform.platform()}")
|
||||||
|
logging.info(f"Python Version: {python_version}")
|
||||||
|
logging.info(f"Architecture: {architecture}")
|
||||||
|
logging.info(f"Node Name: {platform.node()}")
|
||||||
|
logging.info(f"os.name: {os.name}")
|
||||||
|
logging.info(f"=== END SYSTEM INFO ===")
|
||||||
|
|
||||||
|
|
||||||
|
def get_operating_system() -> str:
|
||||||
|
"""
|
||||||
|
Определяет операционную систему на которой запущен код
|
||||||
|
Returns:
|
||||||
|
str: 'Windows', 'Linux', 'macOS' или 'Unknown'
|
||||||
|
"""
|
||||||
|
system = platform.system().lower()
|
||||||
|
|
||||||
|
if system == 'windows':
|
||||||
|
return 'Windows'
|
||||||
|
elif system == 'linux':
|
||||||
|
return 'Linux'
|
||||||
|
elif system == 'darwin':
|
||||||
|
return 'macOS'
|
||||||
|
else:
|
||||||
|
return f'Unknown ({system})'
|
||||||
199
utils/photo_utils.py
Normal file
199
utils/photo_utils.py
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
"""
|
||||||
|
Утилиты для работы с фотографиями автомобилей
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from aiogram.types import Message, InputMediaPhoto, FSInputFile
|
||||||
|
|
||||||
|
from config.settings import IMAGE_PATH
|
||||||
|
from utils.system_utils import is_windows
|
||||||
|
|
||||||
|
|
||||||
|
def convert_photo_path(db_path: str) -> str:
|
||||||
|
"""
|
||||||
|
Конвертирует путь к фотографии в зависимости от операционной системы
|
||||||
|
Args:
|
||||||
|
db_path: путь из базы данных в Linux формате (с /)
|
||||||
|
Returns:
|
||||||
|
str: полный путь к файлу с учетом OS и базового пути
|
||||||
|
"""
|
||||||
|
if not db_path:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Убираем лишние пробелы из пути
|
||||||
|
db_path = db_path.strip()
|
||||||
|
|
||||||
|
# Базовый путь из константы
|
||||||
|
base_path = IMAGE_PATH
|
||||||
|
|
||||||
|
if is_windows():
|
||||||
|
# Конвертируем Linux пути в Windows формат
|
||||||
|
windows_path = db_path.replace('/', '\\')
|
||||||
|
full_path = f"{base_path}\\{windows_path}"
|
||||||
|
logging.info(f"Converted path for Windows: {db_path} -> {full_path}")
|
||||||
|
return full_path
|
||||||
|
else:
|
||||||
|
# Для Linux/macOS оставляем как есть
|
||||||
|
full_path = f"{base_path}/{db_path}"
|
||||||
|
logging.info(f"Path for Linux/macOS: {db_path} -> {full_path}")
|
||||||
|
return full_path
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_photo_paths(db_paths: List[str]) -> List[str]:
|
||||||
|
"""
|
||||||
|
Подготавливает список полных путей к фотографиям
|
||||||
|
Args:
|
||||||
|
db_paths: список путей из базы данных
|
||||||
|
Returns:
|
||||||
|
list: список полных путей к файлам
|
||||||
|
"""
|
||||||
|
if not db_paths:
|
||||||
|
return []
|
||||||
|
|
||||||
|
full_paths = []
|
||||||
|
for db_path in db_paths:
|
||||||
|
full_path = convert_photo_path(db_path)
|
||||||
|
if full_path:
|
||||||
|
full_paths.append(full_path)
|
||||||
|
|
||||||
|
logging.info(f"Prepared {len(full_paths)} photo paths from {len(db_paths)} database paths")
|
||||||
|
return full_paths
|
||||||
|
|
||||||
|
|
||||||
|
async def send_vehicle_photos(message: Message, vin: str, photo_paths: List[str], make: str, model: str, year: str):
|
||||||
|
"""
|
||||||
|
Отправляет фотографии автомобиля пользователю
|
||||||
|
Args:
|
||||||
|
message: сообщение пользователя
|
||||||
|
vin: VIN автомобиля
|
||||||
|
photo_paths: список полных путей к фотографиям
|
||||||
|
make, model, year: информация об автомобиле
|
||||||
|
"""
|
||||||
|
if not photo_paths:
|
||||||
|
await message.answer("❌ No photos found to send.")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Дебаг информация о текущем пользователе (только для Unix систем)
|
||||||
|
if not is_windows():
|
||||||
|
try:
|
||||||
|
import pwd
|
||||||
|
import grp
|
||||||
|
current_user = pwd.getpwuid(os.getuid())
|
||||||
|
current_groups = [grp.getgrgid(gid).gr_name for gid in os.getgroups()]
|
||||||
|
logging.info(f"DEBUG: Running as user: {current_user.pw_name}({os.getuid()}), groups: {current_groups}")
|
||||||
|
except ImportError:
|
||||||
|
# pwd и grp модули недоступны на Windows
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Telegram позволяет максимум 10 фотографий в media group
|
||||||
|
photos_per_group = 10
|
||||||
|
total_photos = len(photo_paths)
|
||||||
|
|
||||||
|
logging.info(f"Attempting to send {total_photos} photos for VIN: {vin}")
|
||||||
|
|
||||||
|
# Разбиваем фотографии на группы по 10
|
||||||
|
photo_groups = [photo_paths[i:i + photos_per_group] for i in range(0, len(photo_paths), photos_per_group)]
|
||||||
|
total_groups = len(photo_groups)
|
||||||
|
|
||||||
|
logging.info(f"Split {total_photos} photos into {total_groups} groups")
|
||||||
|
|
||||||
|
sent_count = 0
|
||||||
|
|
||||||
|
for group_num, photo_group in enumerate(photo_groups, 1):
|
||||||
|
try:
|
||||||
|
logging.info(f"Processing group {group_num}/{total_groups} with {len(photo_group)} photos")
|
||||||
|
|
||||||
|
# Создаем media group для текущей группы
|
||||||
|
media_group = []
|
||||||
|
|
||||||
|
for i, photo_path in enumerate(photo_group):
|
||||||
|
try:
|
||||||
|
# Дебаг информация о файле (только для Unix систем)
|
||||||
|
if not is_windows():
|
||||||
|
try:
|
||||||
|
import stat
|
||||||
|
import pwd
|
||||||
|
import grp
|
||||||
|
stat_info = os.stat(photo_path)
|
||||||
|
file_owner = pwd.getpwuid(stat_info.st_uid).pw_name
|
||||||
|
file_group = grp.getgrgid(stat_info.st_gid).gr_name
|
||||||
|
file_perms = oct(stat_info.st_mode)[-3:]
|
||||||
|
logging.info(f"DEBUG: File {photo_path} - owner: {file_owner}({stat_info.st_uid}), group: {file_group}({stat_info.st_gid}), perms: {file_perms}")
|
||||||
|
except Exception as debug_e:
|
||||||
|
logging.warning(f"DEBUG: Cannot get file info for {photo_path}: {debug_e}")
|
||||||
|
|
||||||
|
if os.path.exists(photo_path):
|
||||||
|
# Создаем InputMediaPhoto
|
||||||
|
if i == 0 and group_num == 1:
|
||||||
|
# Первая фотография первой группы с полным описанием
|
||||||
|
if make == "UNKNOWN" and model == "UNKNOWN" and year == "UNKNOWN":
|
||||||
|
caption = f"📸 Vehicle damage photos\n📋 VIN: {vin}\n📊 Total photos: {total_photos}\n🗂️ Group {group_num}/{total_groups}"
|
||||||
|
else:
|
||||||
|
caption = f"📸 {year} {make} {model}\n📋 VIN: {vin}\n📊 Total photos: {total_photos}\n🗂️ Group {group_num}/{total_groups}"
|
||||||
|
elif i == 0:
|
||||||
|
# Первая фотография других групп с номером группы
|
||||||
|
caption = f"🗂️ Group {group_num}/{total_groups}"
|
||||||
|
else:
|
||||||
|
# Остальные фотографии без описания
|
||||||
|
caption = None
|
||||||
|
|
||||||
|
if caption:
|
||||||
|
media_group.append(InputMediaPhoto(
|
||||||
|
media=FSInputFile(photo_path),
|
||||||
|
caption=caption
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
media_group.append(InputMediaPhoto(
|
||||||
|
media=FSInputFile(photo_path)
|
||||||
|
))
|
||||||
|
|
||||||
|
sent_count += 1
|
||||||
|
logging.info(f"Added photo {sent_count}/{total_photos}: {photo_path}")
|
||||||
|
else:
|
||||||
|
logging.warning(f"Photo file not found: {photo_path}")
|
||||||
|
except Exception as photo_error:
|
||||||
|
logging.error(f"Error processing photo {sent_count + 1} ({photo_path}): {photo_error}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if media_group:
|
||||||
|
# Отправляем media group
|
||||||
|
await message.answer_media_group(media_group)
|
||||||
|
logging.info(f"Successfully sent group {group_num}/{total_groups} with {len(media_group)} photos")
|
||||||
|
|
||||||
|
# Небольшая пауза между группами для избежания rate limiting
|
||||||
|
if group_num < total_groups:
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
else:
|
||||||
|
logging.warning(f"No valid photos in group {group_num}")
|
||||||
|
|
||||||
|
except Exception as group_error:
|
||||||
|
logging.error(f"Error sending photo group {group_num}: {group_error}")
|
||||||
|
await message.answer(f"❌ Error sending photo group {group_num}. Continuing with remaining photos...")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if sent_count > 0:
|
||||||
|
# Отправляем итоговое сообщение
|
||||||
|
await message.answer(
|
||||||
|
f"✅ **Photos sent successfully!**\n"
|
||||||
|
f"📊 **{sent_count} of {total_photos} photos** delivered\n"
|
||||||
|
f"🗂️ **{total_groups} photo groups** sent"
|
||||||
|
)
|
||||||
|
logging.info(f"Successfully sent {sent_count}/{total_photos} photos for VIN: {vin}")
|
||||||
|
else:
|
||||||
|
# Если ни одна фотография не найдена
|
||||||
|
await message.answer(
|
||||||
|
"❌ **Error:** Photo files not found on server.\n"
|
||||||
|
"Please contact support with your transaction details."
|
||||||
|
)
|
||||||
|
logging.error(f"No valid photo files found for VIN: {vin}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error sending photos for VIN {vin}: {e}")
|
||||||
|
await message.answer(
|
||||||
|
"❌ **Error sending photos.** Please contact support with your transaction details.\n"
|
||||||
|
f"Error details: {str(e)}"
|
||||||
|
)
|
||||||
37
utils/system_utils.py
Normal file
37
utils/system_utils.py
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
"""
|
||||||
|
Системные утилиты и функции проверки ОС
|
||||||
|
"""
|
||||||
|
import platform
|
||||||
|
|
||||||
|
|
||||||
|
def get_operating_system() -> str:
|
||||||
|
"""
|
||||||
|
Определяет операционную систему на которой запущен код
|
||||||
|
Returns:
|
||||||
|
str: 'Windows', 'Linux', 'macOS' или 'Unknown'
|
||||||
|
"""
|
||||||
|
system = platform.system().lower()
|
||||||
|
|
||||||
|
if system == 'windows':
|
||||||
|
return 'Windows'
|
||||||
|
elif system == 'linux':
|
||||||
|
return 'Linux'
|
||||||
|
elif system == 'darwin':
|
||||||
|
return 'macOS'
|
||||||
|
else:
|
||||||
|
return f'Unknown ({system})'
|
||||||
|
|
||||||
|
|
||||||
|
def is_windows() -> bool:
|
||||||
|
"""Проверяет, запущен ли код на Windows"""
|
||||||
|
return get_operating_system() == 'Windows'
|
||||||
|
|
||||||
|
|
||||||
|
def is_linux() -> bool:
|
||||||
|
"""Проверяет, запущен ли код на Linux"""
|
||||||
|
return get_operating_system() == 'Linux'
|
||||||
|
|
||||||
|
|
||||||
|
def is_macos() -> bool:
|
||||||
|
"""Проверяет, запущен ли код на macOS"""
|
||||||
|
return get_operating_system() == 'macOS'
|
||||||
Loading…
x
Reference in New Issue
Block a user