From a4d8862a19742bdae816a62124376c9e515f6ce4 Mon Sep 17 00:00:00 2001 From: Vlad Date: Sat, 7 Jun 2025 15:35:13 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20=D0=BD=D0=BE=D0=B2=D1=8B=D0=B5=20=D0=BC=D0=B5?= =?UTF-8?q?=D1=82=D0=BE=D0=B4=D1=8B=20=D0=B4=D0=BB=D1=8F=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=BB=D1=83=D1=87=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B0=D0=BD=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D1=82=D0=B8=D0=BA=D0=B8=20=D0=BF=D0=BE=20=D0=B2?= =?UTF-8?q?=D0=BE=D0=B7=D0=B2=D1=80=D0=B0=D1=82=D0=B0=D0=BC=20=D0=B8=20?= =?UTF-8?q?=D1=83=D0=BB=D1=83=D1=87=D1=88=D0=B5=D0=BD=D0=B0=20=D0=BE=D0=B1?= =?UTF-8?q?=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=BA=D0=B0=20=D0=BE=D1=88=D0=B8?= =?UTF-8?q?=D0=B1=D0=BE=D0=BA=20=D0=B2=20=D0=BA=D0=BB=D0=B0=D1=81=D1=81?= =?UTF-8?q?=D0=B5=20OracleDatabase.=20=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B1=D0=BE=D1=82?= =?UTF-8?q?=D1=87=D0=B8=D0=BA=D0=B8=20=D0=BA=D0=BE=D0=BB=D0=B1=D0=B5=D0=BA?= =?UTF-8?q?=D0=BE=D0=B2=20=D0=B2=20main.py=20=D0=B4=D0=BB=D1=8F=20=D0=BE?= =?UTF-8?q?=D1=82=D0=BE=D0=B1=D1=80=D0=B0=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D1=81=D0=BE=D0=BE=D1=82=D0=B2=D0=B5=D1=82=D1=81=D1=82=D0=B2?= =?UTF-8?q?=D1=83=D1=8E=D1=89=D0=B8=D1=85=20=D0=BE=D1=82=D1=87=D0=B5=D1=82?= =?UTF-8?q?=D0=BE=D0=B2=20=D0=B2=20=D0=B0=D0=B4=D0=BC=D0=B8=D0=BD-=D0=BF?= =?UTF-8?q?=D0=B0=D0=BD=D0=B5=D0=BB=D0=B8.=20=D0=AD=D1=82=D0=B8=20=D0=B8?= =?UTF-8?q?=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=83=D0=BB?= =?UTF-8?q?=D1=83=D1=87=D1=88=D0=B0=D1=8E=D1=82=20=D1=84=D1=83=D0=BD=D0=BA?= =?UTF-8?q?=D1=86=D0=B8=D0=BE=D0=BD=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D1=8C=20=D0=B0=D0=BD=D0=B0=D0=BB=D0=B8=D1=82=D0=B8=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=B2=D0=BE=D0=B7=D0=B2=D1=80=D0=B0=D1=82=D0=BE=D0=B2?= =?UTF-8?q?=20=D0=B8=20=D0=BF=D1=80=D0=B5=D0=B4=D0=BE=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D1=8F=D1=8E=D1=82=20=D0=B0=D0=B4=D0=BC=D0=B8=D0=BD?= =?UTF-8?q?=D0=B8=D1=81=D1=82=D1=80=D0=B0=D1=82=D0=BE=D1=80=D0=B0=D0=BC=20?= =?UTF-8?q?=D0=B1=D0=BE=D0=BB=D0=B5=D0=B5=20=D0=BF=D0=BE=D0=BB=D0=BD=D0=BE?= =?UTF-8?q?=D0=B5=20=D0=BF=D1=80=D0=B5=D0=B4=D1=81=D1=82=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BE=20=D0=BF=D1=80=D0=BE=D0=B1?= =?UTF-8?q?=D0=BB=D0=B5=D0=BC=D0=B0=D1=85=20=D1=81=20=D0=B2=D0=BE=D0=B7?= =?UTF-8?q?=D0=B2=D1=80=D0=B0=D1=82=D0=B0=D0=BC=D0=B8.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/__init__.py | 1 + config/settings.py | 38 ++++ database/__init__.py | 35 ++++ database/analytics/__init__.py | 1 + database/analytics/users_stats.py | 327 ++++++++++++++++++++++++++++++ database/base.py | 91 +++++++++ database/core_queries.py | 244 ++++++++++++++++++++++ database/payment_tracking.py | 84 ++++++++ database/user_management.py | 215 ++++++++++++++++++++ handlers/__init__.py | 1 + handlers/admin/__init__.py | 1 + keyboards/__init__.py | 1 + utils/__init__.py | 1 + utils/formatting.py | 85 ++++++++ utils/logging_config.py | 123 +++++++++++ utils/photo_utils.py | 199 ++++++++++++++++++ utils/system_utils.py | 37 ++++ 17 files changed, 1484 insertions(+) create mode 100644 config/__init__.py create mode 100644 config/settings.py create mode 100644 database/__init__.py create mode 100644 database/analytics/__init__.py create mode 100644 database/analytics/users_stats.py create mode 100644 database/base.py create mode 100644 database/core_queries.py create mode 100644 database/payment_tracking.py create mode 100644 database/user_management.py create mode 100644 handlers/__init__.py create mode 100644 handlers/admin/__init__.py create mode 100644 keyboards/__init__.py create mode 100644 utils/__init__.py create mode 100644 utils/formatting.py create mode 100644 utils/logging_config.py create mode 100644 utils/photo_utils.py create mode 100644 utils/system_utils.py diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..59c671d --- /dev/null +++ b/config/__init__.py @@ -0,0 +1 @@ +# Конфигурация приложения \ No newline at end of file diff --git a/config/settings.py b/config/settings.py new file mode 100644 index 0000000..8e1ad15 --- /dev/null +++ b/config/settings.py @@ -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" \ No newline at end of file diff --git a/database/__init__.py b/database/__init__.py new file mode 100644 index 0000000..3cdf023 --- /dev/null +++ b/database/__init__.py @@ -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' +] \ No newline at end of file diff --git a/database/analytics/__init__.py b/database/analytics/__init__.py new file mode 100644 index 0000000..981e190 --- /dev/null +++ b/database/analytics/__init__.py @@ -0,0 +1 @@ +# Модули аналитики и статистики \ No newline at end of file diff --git a/database/analytics/users_stats.py b/database/analytics/users_stats.py new file mode 100644 index 0000000..baab98b --- /dev/null +++ b/database/analytics/users_stats.py @@ -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 {} \ No newline at end of file diff --git a/database/base.py b/database/base.py new file mode 100644 index 0000000..24a64db --- /dev/null +++ b/database/base.py @@ -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) \ No newline at end of file diff --git a/database/core_queries.py b/database/core_queries.py new file mode 100644 index 0000000..68d0157 --- /dev/null +++ b/database/core_queries.py @@ -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) \ No newline at end of file diff --git a/database/payment_tracking.py b/database/payment_tracking.py new file mode 100644 index 0000000..a3453b2 --- /dev/null +++ b/database/payment_tracking.py @@ -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 \ No newline at end of file diff --git a/database/user_management.py b/database/user_management.py new file mode 100644 index 0000000..5e26f66 --- /dev/null +++ b/database/user_management.py @@ -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 {} \ No newline at end of file diff --git a/handlers/__init__.py b/handlers/__init__.py new file mode 100644 index 0000000..b303a4c --- /dev/null +++ b/handlers/__init__.py @@ -0,0 +1 @@ +# Обработчики Telegram бота \ No newline at end of file diff --git a/handlers/admin/__init__.py b/handlers/admin/__init__.py new file mode 100644 index 0000000..ca1b0c7 --- /dev/null +++ b/handlers/admin/__init__.py @@ -0,0 +1 @@ +# Обработчики админ-панели \ No newline at end of file diff --git a/keyboards/__init__.py b/keyboards/__init__.py new file mode 100644 index 0000000..9d6b823 --- /dev/null +++ b/keyboards/__init__.py @@ -0,0 +1 @@ +# Клавиатуры Telegram бота \ No newline at end of file diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..5016d77 --- /dev/null +++ b/utils/__init__.py @@ -0,0 +1 @@ +# Утилиты и вспомогательные функции \ No newline at end of file diff --git a/utils/formatting.py b/utils/formatting.py new file mode 100644 index 0000000..0afd38d --- /dev/null +++ b/utils/formatting.py @@ -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 \ No newline at end of file diff --git a/utils/logging_config.py b/utils/logging_config.py new file mode 100644 index 0000000..bfb76fa --- /dev/null +++ b/utils/logging_config.py @@ -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})' \ No newline at end of file diff --git a/utils/photo_utils.py b/utils/photo_utils.py new file mode 100644 index 0000000..d09da1d --- /dev/null +++ b/utils/photo_utils.py @@ -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)}" + ) \ No newline at end of file diff --git a/utils/system_utils.py b/utils/system_utils.py new file mode 100644 index 0000000..89f21b5 --- /dev/null +++ b/utils/system_utils.py @@ -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' \ No newline at end of file