Migrating to Postgres from sqlite3

This commit is contained in:
machka
2025-05-20 18:41:32 +00:00
parent ac2f83c061
commit a56d804b58
38 changed files with 5017 additions and 4389 deletions
+86 -65
View File
@@ -2,95 +2,115 @@ import logging
import asyncio
from aiogram import Bot
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from datetime import datetime
from datetime import datetime, timezone
from config.settings import Settings
from .subscription_service import SubscriptionService
from db.database import get_db_connection_manager
from sqlalchemy.orm import sessionmaker
from bot.middlewares.i18n import JsonI18n
from .panel_api_service import PanelApiService
from bot.services.panel_api_service import PanelApiService
from bot.services.subscription_service import SubscriptionService
async def send_expiration_warnings(bot: Bot, settings: Settings,
i18n: JsonI18n,
panel_service: PanelApiService):
"""
Sends expiration warnings to users whose subscriptions are ending soon.
This function is called by the scheduler.
"""
panel_service: PanelApiService,
async_session_factory: sessionmaker):
logging.info(
f"Scheduler job: Checking for expiring subscriptions at {datetime.now()}..."
f"Scheduler job 'send_expiration_warnings' started at {datetime.now(timezone.utc)} UTC."
)
sub_service = SubscriptionService(get_db_connection_manager, settings,
panel_service)
expiring_subs = await sub_service.get_subscriptions_ending_soon(
settings.SUBSCRIPTION_EXPIRATION_NOTIFICATION_DAYS)
if not expiring_subs:
logging.info("No subscriptions found ending soon for notification.")
if async_session_factory is None:
logging.error(
"NotificationService: AsyncSessionFactory not provided to send_expiration_warnings!"
)
return
logging.info(
f"Found {len(expiring_subs)} subscriptions ending soon for notification."
)
for sub_info in expiring_subs:
user_id = sub_info['user_id']
if not user_id:
logging.warning(
f"Skipping notification for subscription without user_id: {sub_info}"
)
continue
user_lang = sub_info.get('language_code') if sub_info.get(
'language_code') else getattr(settings, 'DEFAULT_LANGUAGE', 'en')
first_name = sub_info.get('first_name', 'User')
end_date_str = sub_info['end_date_str']
days_left_float = sub_info.get('days_left')
days_left_display = 'N/A'
if days_left_float is not None:
days_left_display = max(0, int(round(days_left_float)))
_ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs)
message_text = _("subscription_ending_soon_notification",
user_name=first_name,
end_date=end_date_str,
days_left=days_left_display)
async with async_session_factory() as session:
try:
await bot.send_message(user_id, message_text)
await sub_service.update_last_notification_sent(
user_id, end_date_str)
sub_service = SubscriptionService(settings, panel_service)
expiring_subs_details_list = await sub_service.get_subscriptions_ending_soon(
session, settings.SUBSCRIPTION_EXPIRATION_NOTIFICATION_DAYS)
if not expiring_subs_details_list:
logging.info(
"No subscriptions found ending soon for notification.")
return
logging.info(
f"Sent expiration warning to user {user_id} for subscription ending {end_date_str}"
f"Found {len(expiring_subs_details_list)} subscriptions for expiration warning."
)
except Exception as e:
for sub_details in expiring_subs_details_list:
user_id = sub_details['user_id']
user_lang = sub_details.get('language_code',
settings.DEFAULT_LANGUAGE)
first_name = sub_details.get('first_name', f"User {user_id}")
end_date_str_for_msg = sub_details.get('end_date_str', "N/A")
days_left_display = sub_details.get('days_left', "N/A")
subscription_actual_end_date_obj: Optional[
datetime] = sub_details.get(
'subscription_end_date_iso_for_update')
_ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs
)
message_text = _("subscription_ending_soon_notification",
user_name=first_name,
end_date=end_date_str_for_msg,
days_left=days_left_display)
try:
await bot.send_message(user_id, message_text)
logging.info(
f"Sent expiration warning to user {user_id} for subscription ending {end_date_str_for_msg}."
)
if subscription_actual_end_date_obj:
await sub_service.update_last_notification_sent(
session, user_id, subscription_actual_end_date_obj)
else:
logging.warning(
f"Could not find exact subscription end_date_obj for user {user_id} to update notification time."
)
except Exception as e:
logging.error(
f"Failed to send expiration warning or update notification status for user {user_id}: {e}",
exc_info=True)
await asyncio.sleep(0.1)
await session.commit()
logging.info(
"Finished processing expiration warnings. Session committed.")
except Exception as e_session:
logging.error(
f"Failed to send expiration warning to user {user_id}: {e}")
await asyncio.sleep(0.1)
f"Error during send_expiration_warnings session: {e_session}",
exc_info=True)
await session.rollback()
logging.info(
"Session rolled back due to error in send_expiration_warnings."
)
async def schedule_subscription_notifications(bot: Bot, settings: Settings,
i18n: JsonI18n,
scheduler: AsyncIOScheduler):
"""Schedules the daily job to send expiration warnings."""
async def schedule_subscription_notifications(
bot: Bot, settings: Settings, i18n: JsonI18n,
scheduler: AsyncIOScheduler, panel_service: PanelApiService,
async_session_factory: sessionmaker):
async def job_wrapper():
panel_service = PanelApiService(settings)
try:
await send_expiration_warnings(bot, settings, i18n, panel_service)
try:
await send_expiration_warnings(bot, settings, i18n, panel_service,
async_session_factory)
except Exception as e:
logging.error(
f"Error in scheduled job 'send_expiration_warnings': {e}",
f"Unhandled error in scheduled job 'send_expiration_warnings' (job_wrapper): {e}",
exc_info=True)
finally:
await panel_service.close_session()
try:
notification_hour = int(settings.SUBSCRIPTION_NOTIFICATION_HOUR_UTC)
@@ -98,7 +118,7 @@ async def schedule_subscription_notifications(bot: Bot, settings: Settings,
settings.SUBSCRIPTION_NOTIFICATION_MINUTE_UTC)
except (ValueError, TypeError):
logging.warning(
"SUBSCRIPTION_NOTIFICATION_HOUR_UTC or MINUTE_UTC is invalid. Defaulting to 9:00 UTC."
"SUBSCRIPTION_NOTIFICATION_HOUR_UTC or MINUTE_UTC is invalid in settings. Defaulting to 9:00 UTC."
)
notification_hour = 9
notification_minute = 0
@@ -107,8 +127,9 @@ async def schedule_subscription_notifications(bot: Bot, settings: Settings,
'cron',
hour=notification_hour,
minute=notification_minute,
name="daily_subscription_expiration_warnings",
misfire_grace_time=60 * 15)
name="daily_subscription_expiration_warnings_v2",
misfire_grace_time=60 * 15,
replace_existing=True)
logging.info(
f"Subscription expiration warning job scheduled daily at {notification_hour:02d}:{notification_minute:02d} UTC."
)
+322 -141
View File
@@ -1,9 +1,16 @@
import aiohttp
import logging
import json
from typing import Optional, List, Dict, Any
from datetime import datetime, timedelta, timezone
import asyncio
from urllib.parse import urlencode
from sqlalchemy.ext.asyncio import AsyncSession
from config.settings import Settings
from db.dal import panel_sync_dal
from db.models import PanelSyncStatus
class PanelApiService:
@@ -17,153 +24,304 @@ class PanelApiService:
async def _get_session(self) -> aiohttp.ClientSession:
if self._session is None or self._session.closed:
self._session = aiohttp.ClientSession()
timeout = aiohttp.ClientTimeout(total=30)
self._session = aiohttp.ClientSession(timeout=timeout)
return self._session
async def close_session(self):
if self._session and not self._session.closed:
await self._session.close()
self._session = None
logging.info("Panel API service session closed.")
logging.info("Panel API service HTTP session closed.")
async def _prepare_headers(self) -> Dict[str, str]:
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
"X-Forwarded-Proto": "https",
"X-Forwarded-For": self.default_client_ip,
"X-Real-IP": self.default_client_ip,
}
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
return headers
async def _request(self, method: str, endpoint: str,
async def _request(self,
method: str,
endpoint: str,
log_full_response: bool = False,
**kwargs) -> Optional[Dict[str, Any]]:
if not self.base_url:
logging.error("Panel API URL not configured.")
logging.error(
"Panel API URL (PANEL_API_URL) not configured in settings.")
return {
"error": True,
"status_code": 0,
"message": "Panel API URL not configured."
}
session = await self._get_session()
aiohttp_session = await self._get_session()
headers = await self._prepare_headers()
if "Authorization" not in headers and self.api_key:
logging.warning(
f"Authorization header missing for panel endpoint {endpoint} despite API key being set."
)
url_for_request = f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}"
url = f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}"
json_payload_for_log = kwargs.get('json') if method in [
current_params = kwargs.get("params")
url_with_params_for_log = url_for_request
if current_params:
try:
url_with_params_for_log += "?" + urlencode(current_params)
except Exception:
pass
json_payload_for_log = kwargs.get('json') if method.upper() in [
"POST", "PATCH", "PUT"
] else None
log_prefix = f"Panel API {method} {url}"
log_prefix = f"Panel API Req: {method.upper()} {url_with_params_for_log}"
if json_payload_for_log:
log_prefix += f" Payload: {json_payload_for_log}"
try:
payload_str = json.dumps(json_payload_for_log)
log_prefix += f" | Payload: {payload_str[:300]}{'...' if len(payload_str) > 300 else ''}"
except Exception:
log_prefix += f" | Payload: {str(json_payload_for_log)[:300]}..."
try:
async with session.request(method, url, headers=headers,
**kwargs) as response:
if 200 <= response.status < 300:
async with aiohttp_session.request(method.upper(),
url_for_request,
headers=headers,
**kwargs) as response:
response_status = response.status
response_text = await response.text()
log_suffix = f"| Status: {response_status}"
if log_full_response or not (200 <= response_status < 300):
try:
data = await response.json()
logging.debug(
f"{log_prefix} - Success ({response.status})")
return data
except aiohttp.ContentTypeError:
logging.debug(
f"{log_prefix} - Success ({response.status}) with non-JSON response."
parsed_json_for_log = json.loads(response_text)
pretty_response_text = json.dumps(parsed_json_for_log,
indent=2,
ensure_ascii=False)
logging.info(
f"{log_prefix} {log_suffix} | Full Response Body:\n{pretty_response_text}"
)
except json.JSONDecodeError:
logging.info(
f"{log_prefix} {log_suffix} | Full Response Text (not JSON):\n{response_text[:2000]}{'...' if len(response_text) > 2000 else ''}"
)
else:
logging.debug(
f"{log_prefix} {log_suffix} | OK. Response Body Preview: {response_text[:200]}{'...' if len(response_text) > 200 else ''}"
)
if 200 <= response_status < 300:
try:
if 'application/json' in response.headers.get(
'Content-Type', '').lower():
data = json.loads(response_text)
return data
else:
return {
"status": "success",
"code": response_status,
"data_text": response_text
}
except json.JSONDecodeError as e_json_ok:
logging.error(
f"{log_prefix} {log_suffix} | OK but JSON Parse Error. Error: {e_json_ok}. Body was logged above."
)
return {
"status": "success",
"code": response.status,
"data_text": await response.text()
"status": "success_parse_error",
"code": response_status,
"data_text": response_text,
"parse_error": str(e_json_ok)
}
else:
error_details = {
"message":
f"Request failed with status {response_status}",
"raw_response_text": response_text
}
try:
error_json = await response.json()
logging.error(
f"{log_prefix} - Failed ({response.status}): {error_json}"
)
return {
"error": True,
"status_code": response.status,
"response": error_json,
"message": error_json.get("message"),
"errorCode": error_json.get("errorCode")
}
except aiohttp.ContentTypeError:
error_text = await response.text()
logging.error(
f"{log_prefix} - Failed ({response.status}): {error_text}"
)
return {
"error": True,
"status_code": response.status,
"message": error_text
}
if 'application/json' in response.headers.get(
'Content-Type', '').lower():
error_json_data = json.loads(response_text)
error_details.update(error_json_data)
except json.JSONDecodeError:
pass
return {
"error": True,
"status_code": response_status,
"details": error_details
}
except aiohttp.ClientConnectorError as e:
logging.error(
f"Panel API ClientConnectorError to {url_for_request}: {e}")
return {
"error": True,
"status_code": -1,
"message": f"Connection error: {str(e)}"
}
except aiohttp.ClientError as e:
logging.error(f"Panel API client request error to {url}: {e}")
return {"error": True, "status_code": -1, "message": str(e)}
except Exception as e:
logging.error(f"Unexpected Panel API request error to {url}: {e}",
exc_info=True)
logging.error(f"Panel API ClientError to {url_for_request}: {e}")
return {
"error": True,
"status_code": -2,
"message": f"Client error: {str(e)}"
}
except asyncio.TimeoutError:
logging.error(f"Panel API request to {url_for_request} timed out.")
return {
"error": True,
"status_code": -3,
"message": "Request timed out"
}
except Exception as e:
logging.error(
f"Unexpected Panel API request error to {url_for_request}: {e}",
exc_info=True)
return {
"error": True,
"status_code": -4,
"message": f"Unexpected error: {str(e)}"
}
async def get_all_panel_users(
self,
page_size: int = 100,
log_responses: bool = False) -> Optional[List[Dict[str, Any]]]:
all_users = []
start_offset = 0
while True:
params = {"size": page_size, "start": start_offset}
response_data = await self._request(
"GET",
"/users",
params=params,
log_full_response=log_responses)
if not response_data or response_data.get("error"):
logging.error(
f"Failed to fetch panel users batch (start: {start_offset}). Response: {response_data}"
)
return None
users_batch = response_data.get("response", {}).get("users", [])
if not users_batch: break
all_users.extend(users_batch)
if len(users_batch) < page_size: break
start_offset += page_size
await asyncio.sleep(0.1)
logging.info(f"Fetched {len(all_users)} users from panel API.")
return all_users
async def get_user_by_uuid(
self,
user_uuid: str,
log_response: bool = True) -> Optional[Dict[str, Any]]:
endpoint = f"/users/{user_uuid}"
full_response = await self._request("GET",
endpoint,
log_full_response=log_response)
if full_response and not full_response.get(
"error") and "response" in full_response:
return full_response.get("response")
return None
async def get_users_by_filter(
self,
username: Optional[str] = None) -> Optional[List[Dict[str, Any]]]:
"""Fetches users from panel by username."""
if not username:
logging.warning("get_users_by_filter called without username.")
return None
telegram_id: Optional[int] = None,
username: Optional[str] = None,
email: Optional[str] = None,
log_response: bool = True) -> Optional[List[Dict[str, Any]]]:
params = {"username": username}
response_data = await self._request("GET", "/users", params=params)
response_data = None
filter_used_log = "No filter specified"
if response_data and not response_data.get("error"):
users_list = response_data.get("response", {}).get("users", [])
logging.info(
f"Found {len(users_list)} panel users matching filter: {params}"
if telegram_id is not None:
filter_used_log = f"telegramId={telegram_id}"
endpoint = f"/users/by-telegram-id/{telegram_id}"
response_data = await self._request("GET",
endpoint,
log_full_response=log_response)
if response_data and not response_data.get(
"error") and "response" in response_data and isinstance(
response_data["response"], list):
return response_data["response"]
elif response_data and response_data.get("errorCode") == "A062":
logging.info(
f"Panel API: Users not found for {filter_used_log}")
return []
elif username is not None:
filter_used_log = f"username={username}"
endpoint = f"/users/by-username/{username}"
response_data = await self._request("GET",
endpoint,
log_full_response=log_response)
if response_data and not response_data.get(
"error") and "response" in response_data and isinstance(
response_data["response"], dict):
return [response_data["response"]]
elif response_data and response_data.get("errorCode") == "A062":
logging.info(
f"Panel API: User not found for {filter_used_log}")
return []
elif email is not None:
filter_used_log = f"email={email}"
endpoint = f"/users/by-email/{email}"
response_data = await self._request("GET",
endpoint,
log_full_response=log_response)
if response_data and not response_data.get(
"error") and "response" in response_data and isinstance(
response_data["response"], list):
return response_data["response"]
elif response_data and response_data.get("errorCode") == "A062":
logging.info(
f"Panel API: Users not found for {filter_used_log}")
return []
if not telegram_id and not username and not email:
logging.warning(
"get_users_by_filter called without any specific filter criteria."
)
return users_list
return []
logging.error(
f"Failed to fetch panel users with filter {params}. Response: {response_data}"
f"Failed to fetch panel users with filter ({filter_used_log}). Last API response: {response_data if not log_response else '(logged above)'}"
)
return None
async def create_panel_user(
self,
username: str,
telegram_id: Optional[int] = None,
email: Optional[str] = None,
default_expire_days: int = 1,
default_traffic_limit_bytes: int = 0,
default_traffic_limit_strategy: str = "NO_RESET",
specific_inbound_uuids: Optional[List[str]] = None,
activate_all_inbounds_default_flag: bool = True
) -> Optional[Dict[str, Any]]:
self,
username_on_panel: str,
telegram_id: Optional[int] = None,
email: Optional[str] = None,
default_expire_days: int = 1,
default_traffic_limit_bytes: int = 0,
default_traffic_limit_strategy: str = "NO_RESET",
specific_inbound_uuids: Optional[List[str]] = None,
activate_all_inbounds_default_flag: bool = True,
description: Optional[str] = None,
tag: Optional[str] = None,
status: str = "ACTIVE",
log_response: bool = True) -> Optional[Dict[str, Any]]:
if not (6 <= len(username) <= 34
and username.replace('_', '').replace('-', '').isalnum()):
msg = f"Username '{username}' for panel does not meet requirements (6-34 chars, alphanumeric, _, -)."
logging.error(msg)
return {
"error": True,
"status_code": 400,
"message": msg,
"response": {
if not (6 <= len(username_on_panel) <= 34 and
username_on_panel.replace('_', '').replace('-', '').isalnum()):
if not (username_on_panel.startswith("tg_")
and username_on_panel.split("tg_")[-1].isdigit()):
msg = f"Panel username '{username_on_panel}' does not meet panel requirements."
logging.error(msg)
return {
"error": True,
"status_code": 400,
"message": msg,
"errorCode": "VALIDATION_ERROR"
"errorCode": "VALIDATION_ERROR_USERNAME"
}
}
now = datetime.now(timezone.utc)
expire_at_dt = now + timedelta(days=default_expire_days)
@@ -171,83 +329,86 @@ class PanelApiService:
timespec='milliseconds').replace('+00:00', 'Z')
payload: Dict[str, Any] = {
"username": username,
"username": username_on_panel,
"status": status.upper(),
"expireAt": expire_at_iso,
"trafficLimitStrategy": default_traffic_limit_strategy,
"trafficLimitStrategy": default_traffic_limit_strategy.upper(),
"trafficLimitBytes": default_traffic_limit_bytes,
}
if specific_inbound_uuids:
payload["activeUserInbounds"] = specific_inbound_uuids
payload["activateAllInbounds"] = False
else:
payload["activateAllInbounds"] = activate_all_inbounds_default_flag
if telegram_id is not None: payload["telegramId"] = telegram_id
if email: payload["email"] = email
if description: payload["description"] = description
if tag: payload["tag"] = tag
return await self._request("POST", "/users", json=payload)
response = await self._request("POST",
"/users",
json=payload,
log_full_response=log_response)
if response and not response.get("error") and "response" in response:
logging.info(
f"Panel user '{username_on_panel}' created successfully (UUID: {response.get('response',{}).get('uuid')})."
)
return response
logging.error(
f"Failed to create panel user '{username_on_panel}'. Payload: {payload}, Response: {response if not log_response else '(full response logged above)'}"
)
return response
async def update_user_details_on_panel(
self, user_uuid: str,
update_payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
if 'uuid' not in update_payload: update_payload['uuid'] = user_uuid
update_payload.pop('activateAllInbounds', None)
self,
user_uuid: str,
update_payload: Dict[str, Any],
log_response: bool = True) -> Optional[Dict[str, Any]]:
if 'uuid' not in update_payload:
update_payload['uuid'] = user_uuid
full_response = await self._request("PATCH",
"/users",
json=update_payload)
json=update_payload,
log_full_response=log_response)
if full_response and not full_response.get(
"error") and full_response.get("response"):
"error") and "response" in full_response:
logging.info(f"User {user_uuid} details updated on panel.")
return full_response.get("response")
logging.error(
f"Failed to update user {user_uuid} details on panel. Payload: {update_payload}, Resp: {full_response}"
f"Failed to update user {user_uuid} details on panel. Payload: {update_payload}, Response: {full_response if not log_response else '(logged above)'}"
)
return None
async def get_all_panel_users(self,
page_size: int = 100
) -> Optional[List[Dict[str, Any]]]:
all_users = []
start_offset = 0
while True:
params = {"size": page_size, "start": start_offset}
response_data = await self._request("GET", "/users", params=params)
if not response_data or response_data.get("error"):
logging.error(
f"Failed to fetch panel users batch: {response_data}")
return None
users_batch = response_data.get("response", {}).get("users", [])
if not users_batch: break
all_users.extend(users_batch)
if len(users_batch) < page_size: break
start_offset += page_size
logging.info(f"Fetched {len(all_users)} users from panel API.")
return all_users
async def update_user_status_on_panel(self,
user_uuid: str,
enable: bool,
log_response: bool = True) -> bool:
action = "enable" if enable else "disable"
endpoint = f"/users/{user_uuid}/actions/{action}"
response_data = await self._request("POST",
endpoint,
log_full_response=log_response)
async def get_user_by_uuid(self,
user_uuid: str) -> Optional[Dict[str, Any]]:
full_response = await self._request("GET", f"/users/{user_uuid}")
if full_response and not full_response.get(
"error") and full_response.get("response"):
return full_response.get("response")
return None
if response_data and not response_data.get(
"error") and "response" in response_data:
actual_status = response_data.get("response", {}).get("status")
expected_status = "ACTIVE" if enable else "DISABLED"
if actual_status == expected_status:
logging.info(
f"User {user_uuid} status on panel successfully set to {action} (Actual: {actual_status})."
)
return True
else:
logging.warning(
f"User {user_uuid} status on panel action '{action}' called, but final status is '{actual_status}'."
)
return False
async def update_user_status_on_panel(self, user_uuid: str,
enable: bool) -> bool:
endpoint = f"/users/{user_uuid}/actions/{'enable' if enable else 'disable'}"
response_data = await self._request("POST", endpoint)
if response_data and not response_data.get("error") and (
response_data.get("response")
or response_data.get("status") == "success"):
logging.info(
f"User {user_uuid} status on panel -> {'enabled' if enable else 'disabled'}."
)
return True
logging.error(
f"Failed to update user {user_uuid} status on panel. Resp: {response_data}"
f"Failed to {action} user {user_uuid} on panel. Response: {response_data if not log_response else '(logged above)'}"
)
return False
@@ -255,5 +416,25 @@ class PanelApiService:
self,
short_uuid_or_sub_uuid: str,
client_type: Optional[str] = None) -> Optional[str]:
if not self.settings.PANEL_API_URL: return None
return f"{self.settings.PANEL_API_URL.rstrip('/')}/sub/{short_uuid_or_sub_uuid}"
if not self.settings.PANEL_API_URL:
logging.error(
"PANEL_API_URL not set, cannot generate subscription link.")
return None
base_sub_url = f"{self.settings.PANEL_API_URL.rstrip('/')}/sub/{short_uuid_or_sub_uuid}"
if client_type:
return f"{base_sub_url}/{client_type.lower()}"
return base_sub_url
async def update_bot_db_sync_status(self,
session: AsyncSession,
status: str,
details: str,
users_processed: int = 0,
subs_synced: int = 0):
await panel_sync_dal.update_panel_sync_status(session, status, details,
users_processed,
subs_synced)
async def get_bot_db_last_sync_status(
self, session: AsyncSession) -> Optional[PanelSyncStatus]:
return await panel_sync_dal.get_panel_sync_status(session)
+137 -77
View File
@@ -1,10 +1,12 @@
import uuid
import logging
from yookassa import Configuration, Payment
from yookassa.domain.request.payment_request_builder import PaymentRequestBuilder
import asyncio
from typing import Optional, Dict, Any, List
from yookassa import Configuration, Payment as YooKassaPayment
from yookassa.domain.request.payment_request_builder import PaymentRequestBuilder
from yookassa.domain.common.confirmation_type import ConfirmationType
from config.settings import Settings
@@ -14,61 +16,84 @@ class YooKassaService:
shop_id: Optional[str],
secret_key: Optional[str],
configured_return_url: Optional[str],
bot_username_for_default: Optional[str] = None,
bot_username_for_default_return: Optional[str] = None,
settings_obj: Optional[Settings] = None):
self.settings = settings_obj
if not shop_id or not secret_key:
logging.warning(
"YooKassa SHOP_ID or SECRET_KEY not configured. Payment functionality will be disabled."
)
"YooKassa SHOP_ID or SECRET_KEY not configured in settings. "
"Payment functionality will be DISABLED.")
self.configured = False
else:
Configuration.account_id = shop_id
Configuration.secret_key = secret_key
self.configured = True
logging.info(f"YooKassa configured for shop_id: {shop_id}")
try:
Configuration.configure(shop_id, secret_key)
self.configured = True
logging.info(
f"YooKassa SDK configured for shop_id: {shop_id[:5]}...")
except Exception as e:
logging.error(f"Failed to configure YooKassa SDK: {e}",
exc_info=True)
self.configured = False
if configured_return_url:
self.return_url = configured_return_url
elif bot_username_for_default:
self.return_url = f"https://t.me/{bot_username_for_default}"
elif bot_username_for_default_return:
self.return_url = f"https://t.me/{bot_username_for_default_return}"
logging.info(
f"YOOKASSA_RETURN_URL not set, using dynamic default: {self.return_url}"
f"YOOKASSA_RETURN_URL not set, using dynamic default based on bot username: {self.return_url}"
)
else:
self.return_url = "https://example.com/payment_error_no_return_url"
self.return_url = "https://example.com/payment_error_no_return_url_configured"
logging.warning(
f"YOOKASSA_RETURN_URL not set AND bot username not provided. Using placeholder: {self.return_url}"
f"CRITICAL: YOOKASSA_RETURN_URL not set AND bot username not provided. "
f"Using placeholder: {self.return_url}. Payments may not complete correctly."
)
logging.info(
f"YooKassa Service effective return_url: {self.return_url}")
f"YooKassa Service effective return_url for payments: {self.return_url}"
)
async def create_payment(
self, amount: float, currency: str, description: str,
metadata: Dict[str, Any]) -> Optional[Dict[str, Any]]:
self,
amount: float,
currency: str,
description: str,
metadata: Dict[str, Any],
receipt_email: Optional[str] = None,
receipt_phone: Optional[str] = None) -> Optional[Dict[str, Any]]:
if not self.configured:
logging.error("YooKassa is not configured. Cannot create payment.")
return None
if not self.settings:
logging.error(
"YooKassaService: Settings object not available for receipt creation."
)
return {
"error": True,
"internal_message": "Service settings not initialized."
}
if not self.settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL:
logging.error(
"CRITICAL: YOOKASSA_DEFAULT_RECEIPT_EMAIL is not configured. YooKassa payment will fail due to missing receipt customer contact."
"YooKassaService: Settings object not available. Cannot create payment with receipt details."
)
return {
"error":
True,
"internal_message":
"YooKassa receipt email not configured by admin."
"Service settings (Settings object) not initialized."
}
customer_contact_for_receipt = {}
if receipt_email:
customer_contact_for_receipt["email"] = receipt_email
elif receipt_phone:
customer_contact_for_receipt["phone"] = receipt_phone
elif self.settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL:
customer_contact_for_receipt[
"email"] = self.settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL
else:
logging.error(
"CRITICAL: No email/phone for YooKassa receipt provided and YOOKASSA_DEFAULT_RECEIPT_EMAIL is not set."
)
return {
"error":
True,
"internal_message":
"YooKassa receipt customer contact (email/phone) missing and no default email configured."
}
try:
@@ -79,15 +104,15 @@ class YooKassaService:
})
builder.set_capture(True)
builder.set_confirmation({
"type": "redirect",
"type": ConfirmationType.REDIRECT,
"return_url": self.return_url
})
builder.set_description(description)
builder.set_metadata(metadata)
receipt_items: List[Dict[str, Any]] = [{
receipt_items_list: List[Dict[str, Any]] = [{
"description":
description,
description[:128],
"quantity":
"1.00",
"amount": {
@@ -102,87 +127,122 @@ class YooKassaService:
self.settings.YOOKASSA_PAYMENT_SUBJECT
}]
receipt_customer: Dict[str, str] = {}
if self.settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL:
receipt_customer[
"email"] = self.settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL
if not receipt_customer:
logging.error(
"YooKassa: No customer contact (email/phone) for receipt.")
return {
"error": True,
"internal_message": "Receipt customer contact missing."
}
receipt_payload: Dict[str, Any] = {
"customer": receipt_customer,
"items": receipt_items
receipt_data_dict: Dict[str, Any] = {
"customer": customer_contact_for_receipt,
"items": receipt_items_list
}
builder.set_receipt(receipt_payload)
builder.set_receipt(receipt_data_dict)
idempotence_key = str(uuid.uuid4())
payment_request = builder.build()
logging.info(
f"Creating YooKassa payment (IDK: {idempotence_key}) with receipt. Email: {self.settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL}"
f"Creating YooKassa payment (Idempotence-Key: {idempotence_key}). "
f"Amount: {amount} {currency}. Metadata: {metadata}. Receipt: {receipt_data_dict}"
)
res = Payment.create(payment_request, idempotence_key)
loop = asyncio.get_event_loop()
response = await loop.run_in_executor(
None, lambda: YooKassaPayment.create(payment_request,
idempotence_key))
logging.info(
f"YooKassa Payment.create response: ID={res.id}, Status={res.status}"
f"YooKassa Payment.create response: ID={response.id}, Status={response.status}, Paid={response.paid}"
)
return {
"id":
res.id,
response.id,
"confirmation_url":
res.confirmation.confirmation_url
if res.confirmation else None,
response.confirmation.confirmation_url
if response.confirmation else None,
"status":
res.status,
response.status,
"metadata":
res.metadata,
response.metadata,
"amount_value":
float(res.amount.value),
float(response.amount.value),
"amount_currency":
res.amount.currency,
"idempotence_key":
response.amount.currency,
"idempotence_key_used":
idempotence_key,
"paid":
res.paid,
response.paid,
"refundable":
res.refundable,
response.refundable,
"created_at":
res.created_at.isoformat() if hasattr(
res.created_at, 'isoformat') else str(res.created_at)
response.created_at.isoformat() if hasattr(
response.created_at, 'isoformat') else str(
response.created_at),
"description_from_yk":
response.description,
"test_mode":
response.test if hasattr(response, 'test') else None
}
except Exception as e:
logging.error(f"YooKassa payment creation failed: {e}",
exc_info=True)
return None
async def get_payment_info(self,
payment_id: str) -> Optional[Dict[str, Any]]:
async def get_payment_info(
self, payment_id_in_yookassa: str) -> Optional[Dict[str, Any]]:
if not self.configured:
logging.error(
"YooKassa is not configured. Cannot get payment info.")
return None
try:
payment_info = Payment.find_one(payment_id)
if payment_info:
logging.info(
f"Fetching payment info from YooKassa for ID: {payment_id_in_yookassa}"
)
loop = asyncio.get_event_loop()
payment_info_yk = await loop.run_in_executor(
None, lambda: YooKassaPayment.find_one(payment_id_in_yookassa))
if payment_info_yk:
logging.info(
f"YooKassa payment info for {payment_id_in_yookassa}: Status={payment_info_yk.status}, Paid={payment_info_yk.paid}"
)
return {
"id": payment_info.id,
"status": payment_info.status,
"paid": payment_info.paid,
"amount_value": float(payment_info.amount.value),
"amount_currency": payment_info.amount.currency,
"metadata": payment_info.metadata,
"description": payment_info.description,
"id":
payment_info_yk.id,
"status":
payment_info_yk.status,
"paid":
payment_info_yk.paid,
"amount_value":
float(payment_info_yk.amount.value),
"amount_currency":
payment_info_yk.amount.currency,
"metadata":
payment_info_yk.metadata,
"description":
payment_info_yk.description,
"refundable":
payment_info_yk.refundable,
"created_at":
payment_info_yk.created_at.isoformat() if hasattr(
payment_info_yk.created_at, 'isoformat') else str(
payment_info_yk.created_at),
"captured_at":
payment_info_yk.captured_at.isoformat()
if payment_info_yk.captured_at and hasattr(
payment_info_yk.captured_at, 'isoformat') else None,
"payment_method_type":
payment_info_yk.payment_method.type
if payment_info_yk.payment_method else None,
"test_mode":
payment_info_yk.test
if hasattr(payment_info_yk, 'test') else None
}
return None
else:
logging.warning(
f"No payment info found in YooKassa for ID: {payment_id_in_yookassa}"
)
return None
except Exception as e:
logging.error(
f"YooKassa get payment info for {payment_id} failed: {e}")
f"YooKassa get payment info for {payment_id_in_yookassa} failed: {e}",
exc_info=True)
return None
+47 -58
View File
@@ -1,88 +1,77 @@
import logging
import aiosqlite
from datetime import datetime, timezone
from typing import Optional, Dict, Callable, Any, Tuple
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Optional, Tuple, Dict
from aiogram import Bot
from config.settings import Settings
from db.database import get_promo_code_by_code, increment_promo_activation, get_db_connection_manager, _setup_db_connection
from db.dal import promo_code_dal, user_dal, subscription_dal
from db.models import PromoCode, User
from .subscription_service import SubscriptionService
from bot.middlewares.i18n import JsonI18n
class PromoCodeService:
def __init__(self, db_conn_provider: Callable[[], Any], settings: Settings,
def __init__(self, settings: Settings,
subscription_service: SubscriptionService, bot: Bot,
i18n: JsonI18n):
self.db_conn_provider = db_conn_provider
self.settings = settings
self.subscription_service = subscription_service
self.bot = bot
self.i18n = i18n
async def apply_promo_code(self, user_id: int, code_input: str,
async def apply_promo_code(self, session: AsyncSession, user_id: int,
code_input: str,
user_lang: str) -> Tuple[bool, str]:
"""
Applies a promo code for a user.
Returns: (success_status: bool, message_text_for_user: str)
"""
_ = lambda k, **kw: self.i18n.gettext(user_lang, k, **kw)
code_input_upper = code_input.strip().upper()
async with self.db_conn_provider() as db:
await _setup_db_connection(db)
try:
promo_data = await get_promo_code_by_code(code_input_upper,
db_conn=db)
promo_data = await promo_code_dal.get_active_promo_code_by_code_str(
session, code_input_upper)
if not promo_data:
return False, _("promo_code_not_found",
code=code_input_upper)
if not promo_data:
return False, _("promo_code_not_found", code=code_input_upper)
if promo_data['current_activations'] >= promo_data[
'max_activations']:
return False, _("promo_code_max_activations_reached",
code=code_input_upper)
existing_activation = await promo_code_dal.get_user_activation_for_promo(
session, promo_data.promo_code_id, user_id)
if existing_activation:
return False, _("promo_code_already_used_by_user",
code=code_input_upper)
active_sub = await self.subscription_service.get_active_subscription(
user_id)
if not active_sub:
return False, _("promo_code_no_active_subscription")
active_sub = await subscription_dal.get_active_subscription_by_user_id(
session, user_id)
if not active_sub:
return False, _("promo_code_no_active_subscription")
bonus_days = promo_data['bonus_days']
bonus_days = promo_data.bonus_days
new_end_date = await self.subscription_service.extend_active_subscription_days(
user_id=user_id,
bonus_days=bonus_days,
db_conn=db,
reason=f"promo code {code_input_upper}")
new_end_date = await self.subscription_service.extend_active_subscription_days(
session=session,
user_id=user_id,
bonus_days=bonus_days,
reason=f"promo code {code_input_upper}")
if new_end_date:
if new_end_date:
activation_success = await increment_promo_activation(
promo_data['promo_code_id'],
user_id,
db_conn=db,
payment_id=None)
if activation_success:
await db.commit()
return True, _(
"promo_code_applied_success",
code=code_input_upper,
bonus_days=bonus_days,
new_end_date=new_end_date.strftime('%Y-%m-%d'))
else:
activation_recorded = await promo_code_dal.record_promo_activation(
session, promo_data.promo_code_id, user_id, payment_id=None)
promo_incremented = await promo_code_dal.increment_promo_code_usage(
session, promo_data.promo_code_id)
await db.rollback()
return False, _("promo_code_invalid_or_expired")
else:
await db.rollback()
return False, _("error_applying_promo_bonus")
if activation_recorded and promo_incremented:
return True, _("promo_code_applied_success",
code=code_input_upper,
bonus_days=bonus_days,
new_end_date=new_end_date.strftime('%Y-%m-%d'))
else:
except Exception as e:
logging.error(
f"Error applying promo code {code_input_upper} for user {user_id}: {e}",
exc_info=True)
await db.rollback()
return False, _("error_try_again")
f"Failed to record activation or increment usage for promo {promo_data.code} by user {user_id}"
)
return False, _("error_applying_promo_bonus")
else:
return False, _("error_applying_promo_bonus")
+173 -80
View File
@@ -1,48 +1,39 @@
import logging
import aiosqlite
from typing import Optional, Dict, Callable, Any, TYPE_CHECKING, Tuple
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Optional, Dict, Any
from aiogram import Bot
from datetime import datetime
from datetime import datetime, timezone, timedelta
from config.settings import Settings
from db.database import get_user
from db.dal import user_dal
from db.models import User
from db.dal import subscription_dal
from bot.middlewares.i18n import JsonI18n
if TYPE_CHECKING:
from .subscription_service import SubscriptionService
from .subscription_service import SubscriptionService
class ReferralService:
def __init__(self, db_conn_provider: Callable[[], Any], settings: Settings,
subscription_service_instance: 'SubscriptionService',
bot: Bot, i18n: JsonI18n):
self.db_conn_provider = db_conn_provider
def __init__(self, settings: Settings,
subscription_service: SubscriptionService, bot: Bot,
i18n: JsonI18n):
self.settings = settings
self.subscription_service = subscription_service_instance
self.subscription_service = subscription_service
self.bot = bot
self.i18n = i18n
async def process_new_user_referral(self, referee_user_id: int,
inviter_user_id: Optional[int]):
if inviter_user_id and referee_user_id != inviter_user_id:
logging.info(
f"Referral link used: User {referee_user_id} was invited by {inviter_user_id}."
)
pass
async def apply_referral_bonuses_for_payment(
self, referee_user_id: int, purchased_subscription_months: int,
db_conn: aiosqlite.Connection) -> Dict[str, Any]:
"""Applies bonuses. Notifies inviter. Returns referee bonus details."""
self, session: AsyncSession, referee_user_id: int,
purchased_subscription_months: int) -> Dict[str, Any]:
referee_final_end_date: Optional[datetime] = None
referee_bonus_applied_days: Optional[int] = None
inviter_bonus_successfully_applied = False
try:
referee_user_row = await get_user(referee_user_id, db_conn=db_conn)
if not referee_user_row or referee_user_row[
'referred_by_id'] is None:
referee_user_model = await user_dal.get_user_by_id(
session, referee_user_id)
if not referee_user_model or referee_user_model.referred_by_id is None:
logging.debug(
f"User {referee_user_id} not referred or inviter ID missing. No referral bonuses."
)
@@ -51,75 +42,177 @@ class ReferralService:
"referee_new_end_date": None
}
inviter_user_id = referee_user_row['referred_by_id']
inviter_user_row = await get_user(inviter_user_id, db_conn=db_conn)
inviter_user_id = referee_user_model.referred_by_id
inviter_user_model = await user_dal.get_user_by_id(
session, inviter_user_id)
referee_name = referee_user_row[
'first_name'] or f"User {referee_user_id}"
referee_name_for_msg = referee_user_model.first_name or f"User {referee_user_id}"
default_lang_for_placeholder = getattr(self.settings,
'DEFAULT_LANGUAGE', 'en')
inviter_name = inviter_user_row[
'first_name'] if inviter_user_row else self.i18n.gettext(
default_lang_for_placeholder, "friend_placeholder")
default_lang_for_placeholder = self.settings.DEFAULT_LANGUAGE
inviter_name_for_referee_msg = (
inviter_user_model.first_name if inviter_user_model
and inviter_user_model.first_name else self.i18n.gettext(
default_lang_for_placeholder, "friend_placeholder"))
inviter_bonus = self.settings.referral_bonus_inviter.get(
inviter_bonus_days = self.settings.referral_bonus_inviter.get(
purchased_subscription_months)
referee_bonus = self.settings.referral_bonus_referee.get(
referee_bonus_days = self.settings.referral_bonus_referee.get(
purchased_subscription_months)
if inviter_bonus and inviter_bonus > 0 and inviter_user_row:
new_end_date_inviter = await self.subscription_service.extend_subscription_for_referral(
user_id=inviter_user_id,
bonus_days=inviter_bonus,
db_conn=db_conn)
if new_end_date_inviter:
logging.info(
f"Bonus applied for inviter {inviter_user_id}.")
try:
inviter_lang = inviter_user_row.get(
'language_code', default_lang_for_placeholder)
_i = lambda k, **kw: self.i18n.gettext(
inviter_lang, k, **kw)
await self.bot.send_message(
inviter_user_id,
_i("referral_bonus_inviter_notification_extended",
days=inviter_bonus,
referee_name=referee_name,
new_end_date=new_end_date_inviter.strftime(
'%Y-%m-%d')))
except Exception as e:
logging.error(
f"Failed to send bonus notification to inviter {inviter_user_id}: {e}"
)
else:
if inviter_bonus_days and inviter_bonus_days > 0:
if not inviter_user_model:
logging.warning(
f"Failed to apply bonus subscription extension for inviter {inviter_user_id}."
f"Inviter user {inviter_user_id} not found in local DB. Cannot apply inviter bonus."
)
if referee_bonus and referee_bonus > 0:
new_end_date_referee = await self.subscription_service.extend_subscription_for_referral(
user_id=referee_user_id,
bonus_days=referee_bonus,
db_conn=db_conn,
is_referee_bonus=True)
if new_end_date_referee:
logging.info(
f"Bonus applied for referee {referee_user_id}.")
referee_final_end_date = new_end_date_referee
referee_bonus_applied_days = referee_bonus
else:
inviter_panel_uuid, inviter_panel_sub_link_id, _, _ = await self.subscription_service._get_or_create_panel_user_link_details(
session, inviter_user_id, inviter_user_model)
if not inviter_panel_uuid:
logging.warning(
f"Failed to get/create panel link for inviter {inviter_user_id}. Cannot apply inviter bonus directly to panel."
)
else:
new_end_date_inviter = await self.subscription_service.extend_active_subscription_days(
session=session,
user_id=inviter_user_id,
bonus_days=inviter_bonus_days,
reason=f"referral bonus from {referee_name_for_msg}"
)
if new_end_date_inviter:
inviter_bonus_successfully_applied = True
logging.info(
f"Bonus of {inviter_bonus_days} days successfully applied/extended for inviter {inviter_user_id}."
)
try:
inviter_lang = inviter_user_model.language_code or default_lang_for_placeholder
_i = lambda k, **kw: self.i18n.gettext(
inviter_lang, k, **kw)
await self.bot.send_message(
inviter_user_id,
_i("referral_bonus_inviter_notification_extended",
days=inviter_bonus_days,
referee_name=referee_name_for_msg,
new_end_date=new_end_date_inviter.
strftime('%Y-%m-%d')))
except Exception as e_notify_inviter:
logging.error(
f"Failed to send bonus notification to inviter {inviter_user_id}: {e_notify_inviter}"
)
else:
logging.info(
f"Inviter {inviter_user_id} has no active sub to extend. Creating new bonus subscription for {inviter_bonus_days} days."
)
bonus_start_date = datetime.now(timezone.utc)
bonus_end_date = bonus_start_date + timedelta(
days=inviter_bonus_days)
if not inviter_panel_sub_link_id:
logging.error(
f"Cannot create bonus subscription for inviter {inviter_user_id}: panel_sub_link_id is missing even after link detail fetch."
)
else:
bonus_sub_payload = {
"user_id":
inviter_user_id,
"panel_user_uuid":
inviter_panel_uuid,
"panel_subscription_uuid":
inviter_panel_sub_link_id,
"start_date":
bonus_start_date,
"end_date":
bonus_end_date,
"duration_months":
0,
"is_active":
True,
"status_from_panel":
"ACTIVE_BONUS",
"traffic_limit_bytes":
self.settings.
PANEL_USER_DEFAULT_TRAFFIC_BYTES,
}
try:
await subscription_dal.deactivate_other_active_subscriptions(
session, inviter_panel_uuid,
inviter_panel_sub_link_id)
bonus_sub = await subscription_dal.upsert_subscription(
session, bonus_sub_payload)
panel_update_success = await self.subscription_service.panel_service.update_user_details_on_panel(
inviter_panel_uuid, {
"expireAt":
bonus_end_date.isoformat(
timespec='milliseconds').
replace('+00:00', 'Z'),
"status":
"ACTIVE",
})
if panel_update_success:
inviter_bonus_successfully_applied = True
logging.info(
f"New bonus subscription for {inviter_bonus_days} days created for inviter {inviter_user_id}."
)
inviter_lang = inviter_user_model.language_code or default_lang_for_placeholder
_i = lambda k, **kw: self.i18n.gettext(
inviter_lang, k, **kw)
await self.bot.send_message(
inviter_user_id,
_i("referral_bonus_inviter_notification_new_sub",
days=inviter_bonus_days,
referee_name=
referee_name_for_msg,
new_end_date=bonus_end_date.
strftime('%Y-%m-%d')))
else:
logging.warning(
f"Failed to update panel for new bonus subscription for inviter {inviter_user_id}. Local bonus sub created (ID: {bonus_sub.subscription_id}) but may not be active on panel."
)
except Exception as e_create_bonus_sub:
logging.error(
f"Failed to create new bonus subscription for inviter {inviter_user_id}: {e_create_bonus_sub}",
exc_info=True)
if referee_bonus_days and referee_bonus_days > 0:
new_end_date_referee = await self.subscription_service.extend_active_subscription_days(
session=session,
user_id=referee_user_id,
bonus_days=referee_bonus_days,
reason=
f"referee bonus (invited by {inviter_name_for_referee_msg})"
)
if new_end_date_referee:
referee_final_end_date = new_end_date_referee
referee_bonus_applied_days = referee_bonus_days
logging.info(
f"Bonus of {referee_bonus_days} days successfully applied to referee {referee_user_id}."
)
else:
logging.warning(
f"Failed to apply bonus subscription extension for referee {referee_user_id}."
f"Failed to apply referee bonus for {referee_user_id} (could not extend their new subscription)."
)
return {
"referee_bonus_applied_days": referee_bonus_applied_days,
"referee_new_end_date": referee_final_end_date
"referee_new_end_date": referee_final_end_date,
"inviter_bonus_applied_flag":
inviter_bonus_successfully_applied
}
except Exception as e:
logging.error(
f"Error in apply_referral_bonuses_for_payment (db_conn: {db_conn is not None}): {e}",
f"Error in apply_referral_bonuses_for_payment for referee {referee_user_id}: {e}",
exc_info=True)
raise
File diff suppressed because it is too large Load Diff