Compare commits

...

22 Commits

Author SHA1 Message Date
Egor be00256618 Merge pull request #2631 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.16.3
2026-02-18 15:01:49 +03:00
github-actions[bot] 7f693f2b58 chore(main): release 3.16.3 2026-02-18 12:00:19 +00:00
Egor c3bf0dc0fd Merge pull request #2630 from BEDOLAGA-DEV/dev
Dev
2026-02-18 14:59:51 +03:00
Fringg d651a6c02f fix: eliminate deadlock by matching lock order with webhook
Deadlock: DELETE locks server_squads first, then subscriptions.
Webhook locks subscriptions first, then server_squads. Classic deadlock.

Fix: remove duplicate decrement block (was decrementing server_squads
twice), restructure subscription block to delete subscription FIRST
then decrement server_squads — matching webhook's lock acquisition order.
2026-02-18 12:24:08 +03:00
Fringg d7039d75a4 fix: connected_squads stores UUIDs, not int IDs — use get_server_ids_by_uuids
connected_squads JSON contains squad UUIDs like 'b4d782fa-...', not
integer IDs. int() cast fails on these. Now resolves UUIDs to integer
IDs via get_server_ids_by_uuids() before passing to remove_user_from_servers.
2026-02-18 12:17:27 +03:00
Fringg 6409b0c023 fix: auth middleware catches all commit errors, not just connection errors
When a handler swallows a DB error (e.g. ProgrammingError for missing
column), the transaction is aborted but the handler returns normally.
The auth middleware then tries db.commit() which fails with DBAPIError.

Now catches any exception on commit and does rollback, preventing the
cascade of "current transaction is aborted" errors through all
subsequent middleware layers.
2026-02-18 12:01:40 +03:00
Fringg af31c551d2 fix: 3 user deletion bugs — type cast, inner savepoint, lazy load
1. connected_squads JSON stores IDs as strings but server_squads.id is
   integer — cast to int before passing to remove_user_from_servers
2. Wrap remove_user_from_servers in its own db.begin_nested() so its
   failure doesn't abort the parent savepoint (subscription deletion)
3. Pre-fetch admin.id before delete_user_account to avoid MissingGreenlet
   when transaction rollback expires the ORM object
2026-02-18 11:59:25 +03:00
Fringg a38dfcb75a fix: wrap user deletion steps in savepoints to prevent transaction cascade abort
When one deletion step fails (e.g. missing campaign_id column in referral_earnings),
PostgreSQL aborts the entire transaction. All subsequent operations then fail with
"current transaction is aborted, commands ignored until end of transaction block".

Each of the 24 try/except blocks now uses `async with db.begin_nested():`
(PostgreSQL SAVEPOINT) so individual failures are isolated and rolled back
without poisoning the outer transaction.
2026-02-18 11:48:37 +03:00
Fringg b7b83abb72 fix: deadlock on user deletion + robust migration 0002
Decrement server_squads.current_users BEFORE deleting subscription
to match lock ordering with webhook handler, preventing deadlocks.

Also made migration 0002 robust with table existence checks to
prevent failures on DBs missing referral_earnings or
advertising_campaign_registrations tables.
2026-02-18 11:34:07 +03:00
Fringg f076269c32 fix: make migration 0002 robust with table existence checks
Migration was failing on DBs where referral_earnings or
advertising_campaign_registrations tables didn't exist yet,
causing campaign_id column to never be added. Added _has_table
and _has_column guards, wrapped backfill in existence check.
2026-02-18 11:30:38 +03:00
Egor 8d16935c1c Merge pull request #2629 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.16.2
2026-02-18 11:18:08 +03:00
github-actions[bot] 49d8de76a2 chore(main): release 3.16.2 2026-02-18 08:17:02 +00:00
Egor b4d8cabbd8 Merge pull request #2628 from BEDOLAGA-DEV/dev
Dev
2026-02-18 11:16:34 +03:00
Fringg a7f3d652c5 fix: use AwareDateTime TypeDecorator for all datetime columns
TypeDecorator with process_result_value guarantees naive datetimes
from pre-TIMESTAMPTZ databases are converted to UTC-aware on every
load. Replaces unreliable event listener approach. All 175 DateTime
columns now use AwareDateTime.
2026-02-18 11:11:58 +03:00
Fringg 38f3a9a16a fix: handle naive datetime in raw SQL row comparison (payment/common) 2026-02-18 11:02:09 +03:00
Fringg f7d33a7d2b fix: auto-convert naive datetimes to UTC-aware on model load
SQLAlchemy event listener on Base ensures all DateTime columns are
timezone-aware after loading from DB. Fixes TypeError crashes in
50+ comparison sites across handlers, services, and middlewares
for pre-TIMESTAMPTZ databases.
2026-02-18 11:01:04 +03:00
Fringg bd11801467 fix: extend naive datetime guard to all model properties
Move _aware() to module level and apply to 4 more models:
- PromoCode.is_valid (valid_from, valid_until)
- TrafficPurchase.is_expired (expires_at)
- CabinetRefreshToken.is_expired (expires_at)
- Ticket.is_user_reply_blocked (user_reply_block_until)
2026-02-18 10:44:13 +03:00
Fringg e512e5fe6e fix: handle naive datetimes in Subscription properties
Databases that haven't run the TIMESTAMPTZ migration return naive
datetimes from end_date. Comparing with datetime.now(UTC) raises
TypeError. Added _aware() helper to normalize naive→aware in
is_active, is_expired, should_be_expired, actual_status, days_left,
time_left_display, and extend_subscription.
2026-02-18 10:36:46 +03:00
Egor 799c83dd84 Merge pull request #2627 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.16.1
2026-02-18 10:29:59 +03:00
github-actions[bot] 4cc18cbc9a chore(main): release 3.16.1 2026-02-18 07:29:30 +00:00
Egor 4645be53cb Merge pull request #2626 from BEDOLAGA-DEV/dev
fix: add migration for partner system tables and columns
2026-02-18 10:29:04 +03:00
Fringg 79ea398d1d fix: add migration for partner system tables and columns
Existing databases stamped at 0001 (create_all checkfirst=True) are
missing new columns/tables from the partner system:
- users.partner_status
- broadcast_history.blocked_count
- advertising_campaigns.partner_user_id
- withdrawal_requests table
- partner_applications table

All checks are idempotent — safe for fresh and existing databases.
2026-02-18 10:26:07 +03:00
11 changed files with 708 additions and 474 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.16.0"
".": "3.16.3"
}
+32
View File
@@ -1,5 +1,37 @@
# Changelog
## [3.16.3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.16.2...v3.16.3) (2026-02-18)
### Bug Fixes
* 3 user deletion bugs — type cast, inner savepoint, lazy load ([af31c55](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/af31c551d2f23ef01425bdb2db8f255dbc3047e2))
* auth middleware catches all commit errors, not just connection errors ([6409b0c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6409b0c023cd7957c43d5c1c3d83e671ccaf959c))
* connected_squads stores UUIDs, not int IDs — use get_server_ids_by_uuids ([d7039d7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d7039d75a47fbf67436a9d39f2cd9f65f2646544))
* deadlock on user deletion + robust migration 0002 ([b7b83ab](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b7b83abb723913b3167e7462ff592a374c3f421b))
* eliminate deadlock by matching lock order with webhook ([d651a6c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d651a6c02f501b7a0ded570f2db6addcc16173a9))
* make migration 0002 robust with table existence checks ([f076269](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f076269c323726c683a38db092d907591a26e647))
* wrap user deletion steps in savepoints to prevent transaction cascade abort ([a38dfcb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a38dfcb75a47a185d979a8202f637d8b79812e67))
## [3.16.2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.16.1...v3.16.2) (2026-02-18)
### Bug Fixes
* auto-convert naive datetimes to UTC-aware on model load ([f7d33a7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f7d33a7d2b31145a839ee54676816aa657ac90da))
* extend naive datetime guard to all model properties ([bd11801](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bd11801467e917d76005d1a782c71f5ae4ffee6e))
* handle naive datetime in raw SQL row comparison (payment/common) ([38f3a9a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/38f3a9a16a24e85adf473f2150aad31574a87060))
* handle naive datetimes in Subscription properties ([e512e5f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e512e5fe6e9009992b5bc8b9be7f53e0612f234a))
* use AwareDateTime TypeDecorator for all datetime columns ([a7f3d65](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a7f3d652c51ecd653900a530b7d38feaf603ecf1))
## [3.16.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.16.0...v3.16.1) (2026-02-18)
### Bug Fixes
* add migration for partner system tables and columns ([4645be5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4645be53cbb3799aa6b2b6a623af30460357a554))
* add migration for partner system tables and columns ([79ea398](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/79ea398d1db436a7812a799bf01b2c1c3b1b73be))
## [3.16.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.15.1...v3.16.0) (2026-02-18)
+1 -1
View File
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
FROM python:3.13-slim
ARG VERSION="v3.16.0" # x-release-please-version
ARG VERSION="v3.16.3" # x-release-please-version
ARG BUILD_DATE
ARG VCS_REF
+5 -2
View File
@@ -1771,15 +1771,18 @@ async def full_delete_user(
panel_error: str | None = None
deleted_from_panel = False
# Pre-fetch admin.id to avoid MissingGreenlet after transaction rollback
admin_id_val = admin.id
# UserService.delete_user_account handles both bot DB and Remnawave panel
user_service = UserService()
success = await user_service.delete_user_account(db, user_id, admin.id)
success = await user_service.delete_user_account(db, user_id, admin_id_val)
if success:
deleted_from_panel = request.delete_from_panel and user.remnawave_uuid is not None
reason_text = f' (reason: {request.reason})' if request.reason else ''
logger.info('Admin fully deleted user', admin_id=admin.id, user_id=user_id, reason_text=reason_text)
logger.info('Admin fully deleted user', admin_id=admin_id_val, user_id=user_id, reason_text=reason_text)
return FullDeleteUserResponse(
success=success,
+227 -203
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -222,6 +222,13 @@ class AuthMiddleware(BaseMiddleware):
except (InterfaceError, OperationalError) as conn_err:
# Соединение закрылось (таймаут после долгой операции) - просто логируем
logger.warning('⚠️ Соединение с БД закрыто после обработки, пропускаем commit', conn_err=conn_err)
except Exception as commit_err:
# Transaction aborted (e.g. handler swallowed a ProgrammingError) — rollback
logger.warning('⚠️ Не удалось commit после обработки, rollback', commit_err=commit_err)
try:
await db.rollback()
except Exception:
pass
return result
except (InterfaceError, OperationalError) as conn_err:
+4 -1
View File
@@ -61,7 +61,10 @@ class PaymentCommonMixin:
)
row = result.one_or_none()
if row:
is_active = row.status == 'active' and row.end_date > datetime.now(UTC)
end_date = row.end_date
if end_date is not None and end_date.tzinfo is None:
end_date = end_date.replace(tzinfo=UTC)
is_active = row.status == 'active' and end_date is not None and end_date > datetime.now(UTC)
has_active_subscription = bool(is_active and not row.is_trial)
except Exception as db_error:
logger.warning(
+273 -235
View File
@@ -9,10 +9,7 @@ from sqlalchemy.orm import selectinload
from app.config import settings
from app.database.crud.promo_group import get_promo_group_by_id
from app.database.crud.subscription import (
decrement_subscription_server_counts,
get_subscription_by_user_id,
)
from app.database.crud.subscription import get_subscription_by_user_id
from app.database.crud.transaction import get_user_transactions_count
from app.database.crud.user import (
add_user_balance,
@@ -796,144 +793,141 @@ class UserService:
logger.error('❌ Ошибка деактивации RemnaWave как fallback', fallback_e=fallback_e)
try:
sent_notifications_result = await db.execute(
select(SentNotification).where(SentNotification.user_id == user_id)
)
sent_notifications = sent_notifications_result.scalars().all()
async with db.begin_nested():
sent_notifications_result = await db.execute(
select(SentNotification).where(SentNotification.user_id == user_id)
)
sent_notifications = sent_notifications_result.scalars().all()
if sent_notifications:
logger.info('🔄 Удаляем уведомлений', sent_notifications_count=len(sent_notifications))
await db.execute(delete(SentNotification).where(SentNotification.user_id == user_id))
await db.flush()
if sent_notifications:
logger.info('🔄 Удаляем уведомлений', sent_notifications_count=len(sent_notifications))
await db.execute(delete(SentNotification).where(SentNotification.user_id == user_id))
await db.flush()
except Exception as e:
logger.error('❌ Ошибка удаления уведомлений', error=e)
try:
if user.subscription:
subscription_servers_result = await db.execute(
select(SubscriptionServer).where(SubscriptionServer.subscription_id == user.subscription.id)
async with db.begin_nested():
user_messages_result = await db.execute(
update(UserMessage).where(UserMessage.created_by == user_id).values(created_by=None)
)
subscription_servers = subscription_servers_result.scalars().all()
await decrement_subscription_server_counts(
db,
user.subscription,
subscription_servers=subscription_servers,
)
if subscription_servers:
logger.info(
'🔄 Удаляем связей подписка-сервер', subscription_servers_count=len(subscription_servers)
)
await db.execute(
delete(SubscriptionServer).where(SubscriptionServer.subscription_id == user.subscription.id)
)
await db.flush()
except Exception as e:
logger.error('❌ Ошибка удаления связей подписка-сервер', error=e)
try:
user_messages_result = await db.execute(
update(UserMessage).where(UserMessage.created_by == user_id).values(created_by=None)
)
if user_messages_result.rowcount > 0:
logger.info('🔄 Обновлено пользовательских сообщений', rowcount=user_messages_result.rowcount)
await db.flush()
if user_messages_result.rowcount > 0:
logger.info('🔄 Обновлено пользовательских сообщений', rowcount=user_messages_result.rowcount)
await db.flush()
except Exception as e:
logger.error('❌ Ошибка обновления пользовательских сообщений', error=e)
try:
promocodes_result = await db.execute(
update(PromoCode).where(PromoCode.created_by == user_id).values(created_by=None)
)
if promocodes_result.rowcount > 0:
logger.info('🔄 Обновлено промокодов', rowcount=promocodes_result.rowcount)
await db.flush()
async with db.begin_nested():
promocodes_result = await db.execute(
update(PromoCode).where(PromoCode.created_by == user_id).values(created_by=None)
)
if promocodes_result.rowcount > 0:
logger.info('🔄 Обновлено промокодов', rowcount=promocodes_result.rowcount)
await db.flush()
except Exception as e:
logger.error('❌ Ошибка обновления промокодов', error=e)
try:
welcome_texts_result = await db.execute(
update(WelcomeText).where(WelcomeText.created_by == user_id).values(created_by=None)
)
if welcome_texts_result.rowcount > 0:
logger.info('🔄 Обновлено приветственных текстов', rowcount=welcome_texts_result.rowcount)
await db.flush()
async with db.begin_nested():
welcome_texts_result = await db.execute(
update(WelcomeText).where(WelcomeText.created_by == user_id).values(created_by=None)
)
if welcome_texts_result.rowcount > 0:
logger.info('🔄 Обновлено приветственных текстов', rowcount=welcome_texts_result.rowcount)
await db.flush()
except Exception as e:
logger.error('❌ Ошибка обновления приветственных текстов', error=e)
try:
referrals_result = await db.execute(
update(User).where(User.referred_by_id == user_id).values(referred_by_id=None)
)
if referrals_result.rowcount > 0:
logger.info('🔗 Очищены реферальные ссылки у рефералов', rowcount=referrals_result.rowcount)
await db.flush()
async with db.begin_nested():
referrals_result = await db.execute(
update(User).where(User.referred_by_id == user_id).values(referred_by_id=None)
)
if referrals_result.rowcount > 0:
logger.info('🔗 Очищены реферальные ссылки у рефералов', rowcount=referrals_result.rowcount)
await db.flush()
except Exception as e:
logger.error('❌ Ошибка очистки реферальных ссылок', error=e)
try:
yookassa_result = await db.execute(select(YooKassaPayment).where(YooKassaPayment.user_id == user_id))
yookassa_payments = yookassa_result.scalars().all()
if yookassa_payments:
logger.info('🔄 Удаляем YooKassa платежей', yookassa_payments_count=len(yookassa_payments))
await db.execute(
update(YooKassaPayment).where(YooKassaPayment.user_id == user_id).values(transaction_id=None)
async with db.begin_nested():
yookassa_result = await db.execute(
select(YooKassaPayment).where(YooKassaPayment.user_id == user_id)
)
await db.flush()
await db.execute(delete(YooKassaPayment).where(YooKassaPayment.user_id == user_id))
await db.flush()
yookassa_payments = yookassa_result.scalars().all()
if yookassa_payments:
logger.info('🔄 Удаляем YooKassa платежей', yookassa_payments_count=len(yookassa_payments))
await db.execute(
update(YooKassaPayment)
.where(YooKassaPayment.user_id == user_id)
.values(transaction_id=None)
)
await db.flush()
await db.execute(delete(YooKassaPayment).where(YooKassaPayment.user_id == user_id))
await db.flush()
except Exception as e:
logger.error('❌ Ошибка удаления YooKassa платежей', error=e)
try:
cryptobot_result = await db.execute(select(CryptoBotPayment).where(CryptoBotPayment.user_id == user_id))
cryptobot_payments = cryptobot_result.scalars().all()
if cryptobot_payments:
logger.info('🔄 Удаляем CryptoBot платежей', cryptobot_payments_count=len(cryptobot_payments))
await db.execute(
update(CryptoBotPayment).where(CryptoBotPayment.user_id == user_id).values(transaction_id=None)
async with db.begin_nested():
cryptobot_result = await db.execute(
select(CryptoBotPayment).where(CryptoBotPayment.user_id == user_id)
)
await db.flush()
await db.execute(delete(CryptoBotPayment).where(CryptoBotPayment.user_id == user_id))
await db.flush()
cryptobot_payments = cryptobot_result.scalars().all()
if cryptobot_payments:
logger.info('🔄 Удаляем CryptoBot платежей', cryptobot_payments_count=len(cryptobot_payments))
await db.execute(
update(CryptoBotPayment)
.where(CryptoBotPayment.user_id == user_id)
.values(transaction_id=None)
)
await db.flush()
await db.execute(delete(CryptoBotPayment).where(CryptoBotPayment.user_id == user_id))
await db.flush()
except Exception as e:
logger.error('❌ Ошибка удаления CryptoBot платежей', error=e)
try:
platega_result = await db.execute(select(PlategaPayment).where(PlategaPayment.user_id == user_id))
platega_payments = platega_result.scalars().all()
async with db.begin_nested():
platega_result = await db.execute(select(PlategaPayment).where(PlategaPayment.user_id == user_id))
platega_payments = platega_result.scalars().all()
if platega_payments:
logger.info('🔄 Удаляем Platega платежей', platega_payments_count=len(platega_payments))
await db.execute(
update(PlategaPayment).where(PlategaPayment.user_id == user_id).values(transaction_id=None)
)
await db.flush()
await db.execute(delete(PlategaPayment).where(PlategaPayment.user_id == user_id))
await db.flush()
if platega_payments:
logger.info('🔄 Удаляем Platega платежей', platega_payments_count=len(platega_payments))
await db.execute(
update(PlategaPayment).where(PlategaPayment.user_id == user_id).values(transaction_id=None)
)
await db.flush()
await db.execute(delete(PlategaPayment).where(PlategaPayment.user_id == user_id))
await db.flush()
except Exception as e:
logger.error('❌ Ошибка удаления Platega платежей', error=e)
try:
mulenpay_result = await db.execute(select(MulenPayPayment).where(MulenPayPayment.user_id == user_id))
mulenpay_payments = mulenpay_result.scalars().all()
async with db.begin_nested():
mulenpay_result = await db.execute(
select(MulenPayPayment).where(MulenPayPayment.user_id == user_id)
)
mulenpay_payments = mulenpay_result.scalars().all()
if mulenpay_payments:
mulenpay_name = settings.get_mulenpay_display_name()
logger.info(
'🔄 Удаляем платежей',
mulenpay_payments_count=len(mulenpay_payments),
mulenpay_name=mulenpay_name,
)
await db.execute(
update(MulenPayPayment).where(MulenPayPayment.user_id == user_id).values(transaction_id=None)
)
await db.flush()
await db.execute(delete(MulenPayPayment).where(MulenPayPayment.user_id == user_id))
await db.flush()
if mulenpay_payments:
mulenpay_name = settings.get_mulenpay_display_name()
logger.info(
'🔄 Удаляем платежей',
mulenpay_payments_count=len(mulenpay_payments),
mulenpay_name=mulenpay_name,
)
await db.execute(
update(MulenPayPayment)
.where(MulenPayPayment.user_id == user_id)
.values(transaction_id=None)
)
await db.flush()
await db.execute(delete(MulenPayPayment).where(MulenPayPayment.user_id == user_id))
await db.flush()
except Exception as e:
logger.error(
'❌ Ошибка удаления платежей',
@@ -942,206 +936,250 @@ class UserService:
)
try:
pal24_result = await db.execute(select(Pal24Payment).where(Pal24Payment.user_id == user_id))
pal24_payments = pal24_result.scalars().all()
async with db.begin_nested():
pal24_result = await db.execute(select(Pal24Payment).where(Pal24Payment.user_id == user_id))
pal24_payments = pal24_result.scalars().all()
if pal24_payments:
logger.info('🔄 Удаляем Pal24 платежей', pal24_payments_count=len(pal24_payments))
await db.execute(
update(Pal24Payment).where(Pal24Payment.user_id == user_id).values(transaction_id=None)
)
await db.flush()
await db.execute(delete(Pal24Payment).where(Pal24Payment.user_id == user_id))
await db.flush()
if pal24_payments:
logger.info('🔄 Удаляем Pal24 платежей', pal24_payments_count=len(pal24_payments))
await db.execute(
update(Pal24Payment).where(Pal24Payment.user_id == user_id).values(transaction_id=None)
)
await db.flush()
await db.execute(delete(Pal24Payment).where(Pal24Payment.user_id == user_id))
await db.flush()
except Exception as e:
logger.error('❌ Ошибка удаления Pal24 платежей', error=e)
try:
heleket_result = await db.execute(select(HeleketPayment).where(HeleketPayment.user_id == user_id))
heleket_payments = heleket_result.scalars().all()
async with db.begin_nested():
heleket_result = await db.execute(select(HeleketPayment).where(HeleketPayment.user_id == user_id))
heleket_payments = heleket_result.scalars().all()
if heleket_payments:
logger.info('🔄 Удаляем Heleket платежей', heleket_payments_count=len(heleket_payments))
await db.execute(
update(HeleketPayment).where(HeleketPayment.user_id == user_id).values(transaction_id=None)
)
await db.flush()
await db.execute(delete(HeleketPayment).where(HeleketPayment.user_id == user_id))
await db.flush()
if heleket_payments:
logger.info('🔄 Удаляем Heleket платежей', heleket_payments_count=len(heleket_payments))
await db.execute(
update(HeleketPayment).where(HeleketPayment.user_id == user_id).values(transaction_id=None)
)
await db.flush()
await db.execute(delete(HeleketPayment).where(HeleketPayment.user_id == user_id))
await db.flush()
except Exception as e:
logger.error('❌ Ошибка удаления Heleket платежей', error=e)
# Удаляем Freekassa платежи
try:
freekassa_payments_result = await db.execute(
select(FreekassaPayment).where(FreekassaPayment.user_id == user_id)
)
freekassa_payments = freekassa_payments_result.scalars().all()
if freekassa_payments:
logger.info('🔄 Удаляем Freekassa платежей', freekassa_payments_count=len(freekassa_payments))
await db.execute(
update(FreekassaPayment).where(FreekassaPayment.user_id == user_id).values(transaction_id=None)
async with db.begin_nested():
freekassa_payments_result = await db.execute(
select(FreekassaPayment).where(FreekassaPayment.user_id == user_id)
)
await db.flush()
await db.execute(delete(FreekassaPayment).where(FreekassaPayment.user_id == user_id))
await db.flush()
freekassa_payments = freekassa_payments_result.scalars().all()
if freekassa_payments:
logger.info('🔄 Удаляем Freekassa платежей', freekassa_payments_count=len(freekassa_payments))
await db.execute(
update(FreekassaPayment)
.where(FreekassaPayment.user_id == user_id)
.values(transaction_id=None)
)
await db.flush()
await db.execute(delete(FreekassaPayment).where(FreekassaPayment.user_id == user_id))
await db.flush()
except Exception as e:
logger.error('❌ Ошибка удаления Freekassa платежей', error=e)
# Удаляем Wata платежи (до транзакций, т.к. wata_payments.transaction_id -> transactions.id)
try:
wata_payments_result = await db.execute(select(WataPayment).where(WataPayment.user_id == user_id))
wata_payments = wata_payments_result.scalars().all()
async with db.begin_nested():
wata_payments_result = await db.execute(select(WataPayment).where(WataPayment.user_id == user_id))
wata_payments = wata_payments_result.scalars().all()
if wata_payments:
logger.info('🔄 Удаляем Wata платежей', wata_payments_count=len(wata_payments))
await db.execute(
update(WataPayment).where(WataPayment.user_id == user_id).values(transaction_id=None)
)
await db.flush()
await db.execute(delete(WataPayment).where(WataPayment.user_id == user_id))
await db.flush()
if wata_payments:
logger.info('🔄 Удаляем Wata платежей', wata_payments_count=len(wata_payments))
await db.execute(
update(WataPayment).where(WataPayment.user_id == user_id).values(transaction_id=None)
)
await db.flush()
await db.execute(delete(WataPayment).where(WataPayment.user_id == user_id))
await db.flush()
except Exception as e:
logger.error('❌ Ошибка удаления Wata платежей', error=e)
# Удаляем CloudPayments платежи
try:
cloudpayments_result = await db.execute(
select(CloudPaymentsPayment).where(CloudPaymentsPayment.user_id == user_id)
)
cloudpayments_payments = cloudpayments_result.scalars().all()
async with db.begin_nested():
cloudpayments_result = await db.execute(
select(CloudPaymentsPayment).where(CloudPaymentsPayment.user_id == user_id)
)
cloudpayments_payments = cloudpayments_result.scalars().all()
if cloudpayments_payments:
logger.info(
'🔄 Удаляем CloudPayments платежей', cloudpayments_payments_count=len(cloudpayments_payments)
)
await db.execute(
update(CloudPaymentsPayment)
.where(CloudPaymentsPayment.user_id == user_id)
.values(transaction_id=None)
)
await db.flush()
await db.execute(delete(CloudPaymentsPayment).where(CloudPaymentsPayment.user_id == user_id))
await db.flush()
if cloudpayments_payments:
logger.info(
'🔄 Удаляем CloudPayments платежей',
cloudpayments_payments_count=len(cloudpayments_payments),
)
await db.execute(
update(CloudPaymentsPayment)
.where(CloudPaymentsPayment.user_id == user_id)
.values(transaction_id=None)
)
await db.flush()
await db.execute(delete(CloudPaymentsPayment).where(CloudPaymentsPayment.user_id == user_id))
await db.flush()
except Exception as e:
logger.error('❌ Ошибка удаления CloudPayments платежей', error=e)
# Удаляем KassaAi платежи
try:
kassa_ai_result = await db.execute(select(KassaAiPayment).where(KassaAiPayment.user_id == user_id))
kassa_ai_payments = kassa_ai_result.scalars().all()
async with db.begin_nested():
kassa_ai_result = await db.execute(select(KassaAiPayment).where(KassaAiPayment.user_id == user_id))
kassa_ai_payments = kassa_ai_result.scalars().all()
if kassa_ai_payments:
logger.info('🔄 Удаляем KassaAi платежей', kassa_ai_payments_count=len(kassa_ai_payments))
await db.execute(
update(KassaAiPayment).where(KassaAiPayment.user_id == user_id).values(transaction_id=None)
)
await db.flush()
await db.execute(delete(KassaAiPayment).where(KassaAiPayment.user_id == user_id))
await db.flush()
if kassa_ai_payments:
logger.info('🔄 Удаляем KassaAi платежей', kassa_ai_payments_count=len(kassa_ai_payments))
await db.execute(
update(KassaAiPayment).where(KassaAiPayment.user_id == user_id).values(transaction_id=None)
)
await db.flush()
await db.execute(delete(KassaAiPayment).where(KassaAiPayment.user_id == user_id))
await db.flush()
except Exception as e:
logger.error('❌ Ошибка удаления KassaAi платежей', error=e)
try:
transactions_result = await db.execute(select(Transaction).where(Transaction.user_id == user_id))
transactions = transactions_result.scalars().all()
async with db.begin_nested():
transactions_result = await db.execute(select(Transaction).where(Transaction.user_id == user_id))
transactions = transactions_result.scalars().all()
if transactions:
logger.info('🔄 Удаляем транзакций', transactions_count=len(transactions))
await db.execute(delete(Transaction).where(Transaction.user_id == user_id))
await db.flush()
if transactions:
logger.info('🔄 Удаляем транзакций', transactions_count=len(transactions))
await db.execute(delete(Transaction).where(Transaction.user_id == user_id))
await db.flush()
except Exception as e:
logger.error('❌ Ошибка удаления транзакций', error=e)
try:
promocode_uses_result = await db.execute(select(PromoCodeUse).where(PromoCodeUse.user_id == user_id))
promocode_uses = promocode_uses_result.scalars().all()
async with db.begin_nested():
promocode_uses_result = await db.execute(
select(PromoCodeUse).where(PromoCodeUse.user_id == user_id)
)
promocode_uses = promocode_uses_result.scalars().all()
if promocode_uses:
logger.info('🔄 Удаляем использований промокодов', promocode_uses_count=len(promocode_uses))
await db.execute(delete(PromoCodeUse).where(PromoCodeUse.user_id == user_id))
await db.flush()
if promocode_uses:
logger.info('🔄 Удаляем использований промокодов', promocode_uses_count=len(promocode_uses))
await db.execute(delete(PromoCodeUse).where(PromoCodeUse.user_id == user_id))
await db.flush()
except Exception as e:
logger.error('❌ Ошибка удаления использований промокодов', error=e)
try:
referral_earnings_result = await db.execute(
select(ReferralEarning).where(ReferralEarning.user_id == user_id)
)
referral_earnings = referral_earnings_result.scalars().all()
async with db.begin_nested():
referral_earnings_result = await db.execute(
select(ReferralEarning).where(ReferralEarning.user_id == user_id)
)
referral_earnings = referral_earnings_result.scalars().all()
if referral_earnings:
logger.info('🔄 Удаляем реферальных доходов', referral_earnings_count=len(referral_earnings))
await db.execute(delete(ReferralEarning).where(ReferralEarning.user_id == user_id))
await db.flush()
if referral_earnings:
logger.info('🔄 Удаляем реферальных доходов', referral_earnings_count=len(referral_earnings))
await db.execute(delete(ReferralEarning).where(ReferralEarning.user_id == user_id))
await db.flush()
except Exception as e:
logger.error('❌ Ошибка удаления реферальных доходов', error=e)
try:
referral_records_result = await db.execute(
select(ReferralEarning).where(ReferralEarning.referral_id == user_id)
)
referral_records = referral_records_result.scalars().all()
async with db.begin_nested():
referral_records_result = await db.execute(
select(ReferralEarning).where(ReferralEarning.referral_id == user_id)
)
referral_records = referral_records_result.scalars().all()
if referral_records:
logger.info('🔄 Удаляем записей о рефералах', referral_records_count=len(referral_records))
await db.execute(delete(ReferralEarning).where(ReferralEarning.referral_id == user_id))
await db.flush()
if referral_records:
logger.info('🔄 Удаляем записей о рефералах', referral_records_count=len(referral_records))
await db.execute(delete(ReferralEarning).where(ReferralEarning.referral_id == user_id))
await db.flush()
except Exception as e:
logger.error('❌ Ошибка удаления записей о рефералах', error=e)
try:
conversions_result = await db.execute(
select(SubscriptionConversion).where(SubscriptionConversion.user_id == user_id)
)
conversions = conversions_result.scalars().all()
async with db.begin_nested():
conversions_result = await db.execute(
select(SubscriptionConversion).where(SubscriptionConversion.user_id == user_id)
)
conversions = conversions_result.scalars().all()
if conversions:
logger.info('🔄 Удаляем записей конверсий', conversions_count=len(conversions))
await db.execute(delete(SubscriptionConversion).where(SubscriptionConversion.user_id == user_id))
await db.flush()
if conversions:
logger.info('🔄 Удаляем записей конверсий', conversions_count=len(conversions))
await db.execute(
delete(SubscriptionConversion).where(SubscriptionConversion.user_id == user_id)
)
await db.flush()
except Exception as e:
logger.error('❌ Ошибка удаления записей конверсий', error=e)
try:
broadcast_history_result = await db.execute(
select(BroadcastHistory).where(BroadcastHistory.admin_id == user_id)
)
broadcast_history = broadcast_history_result.scalars().all()
async with db.begin_nested():
broadcast_history_result = await db.execute(
select(BroadcastHistory).where(BroadcastHistory.admin_id == user_id)
)
broadcast_history = broadcast_history_result.scalars().all()
if broadcast_history:
logger.info('🔄 Удаляем записей истории рассылок', broadcast_history_count=len(broadcast_history))
await db.execute(delete(BroadcastHistory).where(BroadcastHistory.admin_id == user_id))
await db.flush()
if broadcast_history:
logger.info(
'🔄 Удаляем записей истории рассылок', broadcast_history_count=len(broadcast_history)
)
await db.execute(delete(BroadcastHistory).where(BroadcastHistory.admin_id == user_id))
await db.flush()
except Exception as e:
logger.error('❌ Ошибка удаления истории рассылок', error=e)
try:
campaigns_result = await db.execute(
select(AdvertisingCampaign).where(AdvertisingCampaign.created_by == user_id)
)
campaigns = campaigns_result.scalars().all()
if campaigns:
logger.info('🔄 Очищаем создателя у рекламных кампаний', campaigns_count=len(campaigns))
await db.execute(
update(AdvertisingCampaign)
.where(AdvertisingCampaign.created_by == user_id)
.values(created_by=None)
async with db.begin_nested():
campaigns_result = await db.execute(
select(AdvertisingCampaign).where(AdvertisingCampaign.created_by == user_id)
)
await db.flush()
campaigns = campaigns_result.scalars().all()
if campaigns:
logger.info('🔄 Очищаем создателя у рекламных кампаний', campaigns_count=len(campaigns))
await db.execute(
update(AdvertisingCampaign)
.where(AdvertisingCampaign.created_by == user_id)
.values(created_by=None)
)
await db.flush()
except Exception as e:
logger.error('❌ Ошибка обновления рекламных кампаний', error=e)
try:
if user.subscription:
logger.info('🔄 Удаляем подписку', subscription_id=user.subscription.id)
await db.execute(
delete(SubscriptionServer).where(SubscriptionServer.subscription_id == user.subscription.id)
)
await db.execute(delete(Subscription).where(Subscription.user_id == user_id))
await db.flush()
async with db.begin_nested():
if user.subscription:
logger.info('🔄 Удаляем подписку', subscription_id=user.subscription.id)
# Save squad info before deleting subscription
squad_ids = user.subscription.connected_squads
# Delete subscription_servers and subscription FIRST
# Lock order: subscriptions → server_squads (matches webhook order)
await db.execute(
delete(SubscriptionServer).where(SubscriptionServer.subscription_id == user.subscription.id)
)
await db.execute(delete(Subscription).where(Subscription.user_id == user_id))
await db.flush()
# Decrement server_squads.current_users AFTER subscription delete
# to match lock ordering with webhook and avoid deadlocks
if squad_ids:
try:
from app.database.crud.server_squad import (
get_server_ids_by_uuids,
remove_user_from_servers,
)
int_squad_ids = await get_server_ids_by_uuids(db, list(squad_ids))
if int_squad_ids:
await remove_user_from_servers(db, int_squad_ids)
except Exception as sq_err:
logger.warning('⚠️ Не удалось уменьшить счётчик серверов', error=sq_err)
except Exception as e:
logger.error('❌ Ошибка удаления подписки', error=e)
@@ -20,42 +20,59 @@ branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add column (idempotent check)
def _has_table(name: str) -> bool:
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = [c['name'] for c in inspector.get_columns('referral_earnings')]
return name in inspector.get_table_names()
if 'campaign_id' not in columns:
def _has_column(table: str, column: str) -> bool:
conn = op.get_bind()
inspector = sa.inspect(conn)
return column in [c['name'] for c in inspector.get_columns(table)]
def upgrade() -> None:
# Skip if referral_earnings table doesn't exist yet
# (fresh DBs create it via create_all in 0001 with campaign_id already present)
if not _has_table('referral_earnings'):
return
if not _has_column('referral_earnings', 'campaign_id'):
op.add_column('referral_earnings', sa.Column('campaign_id', sa.Integer(), nullable=True))
op.create_foreign_key(
'fk_referral_earnings_campaign_id',
'referral_earnings',
'advertising_campaigns',
['campaign_id'],
['id'],
ondelete='SET NULL',
)
# Only create FK if target table exists
if _has_table('advertising_campaigns'):
op.create_foreign_key(
'fk_referral_earnings_campaign_id',
'referral_earnings',
'advertising_campaigns',
['campaign_id'],
['id'],
ondelete='SET NULL',
)
op.create_index('ix_referral_earnings_campaign_id', 'referral_earnings', ['campaign_id'])
# Backfill existing data — pick earliest campaign registration per user
# (matches runtime logic in get_user_campaign_id: ORDER BY created_at ASC LIMIT 1)
op.execute(
"""
UPDATE referral_earnings re
SET campaign_id = sub.campaign_id
FROM (
SELECT DISTINCT ON (user_id) user_id, campaign_id
FROM advertising_campaign_registrations
ORDER BY user_id, created_at ASC
) sub
WHERE sub.user_id = re.referral_id
AND re.campaign_id IS NULL
"""
)
# Backfill existing data — only if source table exists
if _has_table('advertising_campaign_registrations') and _has_table('referral_earnings'):
op.execute(
sa.text("""
UPDATE referral_earnings re
SET campaign_id = sub.campaign_id
FROM (
SELECT DISTINCT ON (user_id) user_id, campaign_id
FROM advertising_campaign_registrations
ORDER BY user_id, created_at ASC
) sub
WHERE sub.user_id = re.referral_id
AND re.campaign_id IS NULL
""")
)
def downgrade() -> None:
op.drop_index('ix_referral_earnings_campaign_id', table_name='referral_earnings')
op.drop_constraint('fk_referral_earnings_campaign_id', 'referral_earnings', type_='foreignkey')
op.drop_column('referral_earnings', 'campaign_id')
if _has_table('referral_earnings') and _has_column('referral_earnings', 'campaign_id'):
op.drop_index('ix_referral_earnings_campaign_id', table_name='referral_earnings')
op.drop_constraint('fk_referral_earnings_campaign_id', 'referral_earnings', type_='foreignkey')
op.drop_column('referral_earnings', 'campaign_id')
@@ -0,0 +1,110 @@
"""add partner system tables and columns
Revision ID: 0003
Revises: 0002
Create Date: 2026-02-18
Adds partner_status to users, creates withdrawal_requests and
partner_applications tables, adds partner_user_id to advertising_campaigns,
adds blocked_count to broadcast_history.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '0003'
down_revision: Union[str, None] = '0002'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _has_column(table: str, column: str) -> bool:
conn = op.get_bind()
inspector = sa.inspect(conn)
return column in [c['name'] for c in inspector.get_columns(table)]
def _has_table(table: str) -> bool:
conn = op.get_bind()
inspector = sa.inspect(conn)
return table in inspector.get_table_names()
def upgrade() -> None:
# 1. users.partner_status
if not _has_column('users', 'partner_status'):
op.add_column('users', sa.Column('partner_status', sa.String(20), nullable=False, server_default='none'))
op.create_index('ix_users_partner_status', 'users', ['partner_status'])
# 2. broadcast_history.blocked_count
if _has_table('broadcast_history') and not _has_column('broadcast_history', 'blocked_count'):
op.add_column('broadcast_history', sa.Column('blocked_count', sa.Integer(), nullable=True, server_default='0'))
# 3. advertising_campaigns.partner_user_id
if _has_table('advertising_campaigns') and not _has_column('advertising_campaigns', 'partner_user_id'):
op.add_column('advertising_campaigns', sa.Column('partner_user_id', sa.Integer(), nullable=True))
op.create_foreign_key(
'fk_advertising_campaigns_partner_user_id',
'advertising_campaigns',
'users',
['partner_user_id'],
['id'],
ondelete='SET NULL',
)
op.create_index('ix_advertising_campaigns_partner_user_id', 'advertising_campaigns', ['partner_user_id'])
# 4. withdrawal_requests table
if not _has_table('withdrawal_requests'):
op.create_table(
'withdrawal_requests',
sa.Column('id', sa.Integer(), primary_key=True, index=True),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False, index=True),
sa.Column('amount_kopeks', sa.Integer(), nullable=False),
sa.Column('status', sa.String(50), nullable=False, server_default='pending', index=True),
sa.Column('payment_details', sa.Text(), nullable=True),
sa.Column('risk_score', sa.Integer(), server_default='0'),
sa.Column('risk_analysis', sa.Text(), nullable=True),
sa.Column('processed_by', sa.Integer(), sa.ForeignKey('users.id'), nullable=True),
sa.Column('processed_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('admin_comment', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
)
# 5. partner_applications table
if not _has_table('partner_applications'):
op.create_table(
'partner_applications',
sa.Column('id', sa.Integer(), primary_key=True, index=True),
sa.Column(
'user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False
),
sa.Column('company_name', sa.String(255), nullable=True),
sa.Column('website_url', sa.String(500), nullable=True),
sa.Column('telegram_channel', sa.String(255), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('expected_monthly_referrals', sa.Integer(), nullable=True),
sa.Column('status', sa.String(20), nullable=False, server_default='pending'),
sa.Column('admin_comment', sa.Text(), nullable=True),
sa.Column('approved_commission_percent', sa.Integer(), nullable=True),
sa.Column(
'processed_by', sa.Integer(), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True
),
sa.Column('processed_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
)
def downgrade() -> None:
op.drop_table('partner_applications')
op.drop_table('withdrawal_requests')
op.drop_index('ix_advertising_campaigns_partner_user_id', table_name='advertising_campaigns')
op.drop_constraint('fk_advertising_campaigns_partner_user_id', 'advertising_campaigns', type_='foreignkey')
op.drop_column('advertising_campaigns', 'partner_user_id')
op.drop_column('broadcast_history', 'blocked_count')
op.drop_index('ix_users_partner_status', table_name='users')
op.drop_column('users', 'partner_status')
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = 'remnawave-bedolaga-telegram-bot'
version = "3.16.0"
version = "3.16.3"
description = 'Telegram bot for RemnaWave VPN service'
readme = 'README.md'
license = { text = 'MIT' }