Migrating to Postgres from sqlite3
This commit is contained in:
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user