Add cooldown for Happ device reset links

This commit is contained in:
Egor
2025-12-15 10:40:59 +03:00
parent 07e1e08b1d
commit 162e7da350
14 changed files with 445 additions and 5 deletions

View File

@@ -396,6 +396,12 @@ MINIAPP_SERVICE_DESCRIPTION_RU=Безопасное и быстрое подкл
# Параметры режима happ_cryptolink
CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED=false
HAPP_CRYPTOLINK_LIMITED_LINKS_ENABLED=false
HAPP_CRYPTOLINK_PROVIDER_CODE=
HAPP_CRYPTOLINK_AUTH_KEY=
HAPP_CRYPTOLINK_INSTALL_LIMIT=0
HAPP_CRYPTOLINK_API_BASE_URL=https://api.happ-proxy.com
HAPP_CRYPTOLINK_RESET_COOLDOWN_MINUTES=0
HAPP_DOWNLOAD_LINK_IOS=
HAPP_DOWNLOAD_LINK_ANDROID=
HAPP_DOWNLOAD_LINK_MACOS=

View File

@@ -6,7 +6,7 @@ import os
import re
import html
from collections import defaultdict
from datetime import time
from datetime import time, timedelta
from typing import Dict, List, Optional, Union
from urllib.parse import urlparse
from zoneinfo import ZoneInfo
@@ -339,6 +339,12 @@ class Settings(BaseSettings):
MINIAPP_SERVICE_DESCRIPTION_RU: str = "Безопасное и быстрое подключение"
CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED: bool = False
HAPP_CRYPTOLINK_REDIRECT_TEMPLATE: Optional[str] = None
HAPP_CRYPTOLINK_LIMITED_LINKS_ENABLED: bool = False
HAPP_CRYPTOLINK_PROVIDER_CODE: Optional[str] = None
HAPP_CRYPTOLINK_AUTH_KEY: Optional[str] = None
HAPP_CRYPTOLINK_INSTALL_LIMIT: int = 0
HAPP_CRYPTOLINK_API_BASE_URL: str = "https://api.happ-proxy.com"
HAPP_CRYPTOLINK_RESET_COOLDOWN_MINUTES: int = 0
HAPP_DOWNLOAD_LINK_IOS: Optional[str] = None
HAPP_DOWNLOAD_LINK_ANDROID: Optional[str] = None
HAPP_DOWNLOAD_LINK_MACOS: Optional[str] = None
@@ -1188,6 +1194,50 @@ class Settings(BaseSettings):
def is_happ_download_button_enabled(self) -> bool:
return self.is_happ_cryptolink_mode() and self.CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED
def is_happ_cryptolink_limited_links_enabled(self) -> bool:
if not self.is_happ_cryptolink_mode():
return False
if not self.HAPP_CRYPTOLINK_LIMITED_LINKS_ENABLED:
return False
provider_code, auth_key = self.get_happ_cryptolink_credentials()
return bool(provider_code and auth_key)
def get_happ_cryptolink_credentials(self) -> tuple[Optional[str], Optional[str]]:
provider_code = (self.HAPP_CRYPTOLINK_PROVIDER_CODE or "").strip()
auth_key = (self.HAPP_CRYPTOLINK_AUTH_KEY or "").strip()
return provider_code or None, auth_key or None
def get_happ_cryptolink_install_limit(self, fallback_limit: Optional[int] = None) -> Optional[int]:
try:
limit = int(self.HAPP_CRYPTOLINK_INSTALL_LIMIT)
except (TypeError, ValueError):
limit = 0
if limit <= 0:
limit = fallback_limit or 0
if limit <= 0:
return None
return limit
def get_happ_cryptolink_add_install_url(self) -> str:
base_url = (self.HAPP_CRYPTOLINK_API_BASE_URL or "").rstrip("/")
return f"{base_url}/api/add-install"
def get_happ_cryptolink_reset_cooldown(self) -> Optional[timedelta]:
try:
minutes = int(self.HAPP_CRYPTOLINK_RESET_COOLDOWN_MINUTES)
except (TypeError, ValueError):
minutes = 0
if minutes <= 0:
return None
return timedelta(minutes=max(1, minutes))
def should_hide_subscription_link(self) -> bool:
"""Returns True when subscription links must be hidden from the interface."""

View File

@@ -682,7 +682,9 @@ class Subscription(Base):
subscription_crypto_link = Column(String, nullable=True)
device_limit = Column(Integer, default=1)
last_devices_reset_at = Column(DateTime, nullable=True)
connected_squads = Column(JSON, default=list)
autopay_enabled = Column(Boolean, default=False)

View File

@@ -3120,6 +3120,33 @@ async def add_subscription_crypto_link_column() -> bool:
return False
async def add_last_devices_reset_column() -> bool:
column_exists = await check_column_exists('subscriptions', 'last_devices_reset_at')
if column_exists:
logger.info(" Колонка last_devices_reset_at уже существует")
return True
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
await conn.execute(text("ALTER TABLE subscriptions ADD COLUMN last_devices_reset_at DATETIME"))
elif db_type == 'postgresql':
await conn.execute(text("ALTER TABLE subscriptions ADD COLUMN last_devices_reset_at TIMESTAMP"))
elif db_type == 'mysql':
await conn.execute(text("ALTER TABLE subscriptions ADD COLUMN last_devices_reset_at DATETIME"))
else:
logger.error(f"Неподдерживаемый тип БД для добавления last_devices_reset_at: {db_type}")
return False
logger.info("✅ Добавлена колонка last_devices_reset_at в таблицу subscriptions")
return True
except Exception as e:
logger.error(f"Ошибка добавления колонки last_devices_reset_at: {e}")
return False
async def fix_foreign_keys_for_user_deletion():
try:
async with engine.begin() as conn:
@@ -4541,6 +4568,13 @@ async def run_universal_migration():
else:
logger.warning("⚠️ Проблемы с добавлением колонки subscription_crypto_link")
logger.info("=== ДОБАВЛЕНИЕ КОЛОНКИ LAST_DEVICES_RESET_AT ДЛЯ ПОДПИСОК ===")
devices_reset_column_added = await add_last_devices_reset_column()
if devices_reset_column_added:
logger.info("✅ Колонка last_devices_reset_at готова")
else:
logger.warning("⚠️ Проблемы с добавлением колонки last_devices_reset_at")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ АУДИТА ПОДДЕРЖКИ ===")
try:
async with engine.begin() as conn:
@@ -4702,6 +4736,7 @@ async def check_migration_status():
"subscription_duplicates": False,
"subscription_conversions_table": False,
"subscription_events_table": False,
"subscription_last_devices_reset_column": False,
"promo_groups_table": False,
"server_promo_groups_table": False,
"server_squads_trial_column": False,
@@ -4773,6 +4808,7 @@ async def check_migration_status():
status["users_promo_offer_discount_expires_column"] = await check_column_exists('users', 'promo_offer_discount_expires_at')
status["users_referral_commission_percent_column"] = await check_column_exists('users', 'referral_commission_percent')
status["subscription_crypto_link_column"] = await check_column_exists('subscriptions', 'subscription_crypto_link')
status["subscription_last_devices_reset_column"] = await check_column_exists('subscriptions', 'last_devices_reset_at')
media_fields_exist = (
await check_column_exists('broadcast_history', 'has_media') and
@@ -4821,6 +4857,7 @@ async def check_migration_status():
"users_promo_offer_discount_expires_column": "Колонка срока действия промо-скидки у пользователей",
"users_referral_commission_percent_column": "Колонка процента реферальной комиссии у пользователей",
"subscription_crypto_link_column": "Колонка subscription_crypto_link в subscriptions",
"subscription_last_devices_reset_column": "Колонка last_devices_reset_at в subscriptions",
"discount_offers_table": "Таблица discount_offers",
"discount_offers_effect_column": "Колонка effect_type в discount_offers",
"discount_offers_extra_column": "Колонка extra_data в discount_offers",

View File

@@ -4004,8 +4004,16 @@ async def reset_user_devices(
remnawave_service = RemnaWaveService()
async with remnawave_service.get_api_client() as api:
success = await api.reset_user_devices(user.remnawave_uuid)
if success:
try:
await remnawave_service.refresh_happ_subscription_after_reset(db, user)
except Exception as refresh_error:
logger.warning(
"⚠️ Не удалось обновить Happ ссылку после сброса устройств: %s",
refresh_error,
)
await callback.message.edit_text(
"✅ Устройства пользователя успешно сброшены",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[

View File

@@ -1,6 +1,7 @@
import base64
import json
import logging
import math
from datetime import datetime, timedelta
from typing import Dict, List, Any, Tuple, Optional
from urllib.parse import quote
@@ -81,6 +82,27 @@ from app.utils.promo_offer import (
from .common import _get_addon_discount_percent_for_user, _get_period_hint_from_subscription, format_additional_section, get_apps_for_device, get_device_name, get_step_description, logger
from .countries import _get_available_countries
def _format_cooldown_duration(seconds: int, language: str) -> str:
total_minutes = max(1, math.ceil(seconds / 60))
days, remainder_minutes = divmod(total_minutes, 60 * 24)
hours, minutes = divmod(remainder_minutes, 60)
language_code = (language or "ru").split("-")[0].lower()
if language_code == "en":
day_label, hour_label, minute_label = "d", "h", "m"
else:
day_label, hour_label, minute_label = "д", "ч", "м"
parts: list[str] = []
if days:
parts.append(f"{days}{day_label}")
if hours or days:
parts.append(f"{hours}{hour_label}")
parts.append(f"{minutes}{minute_label}")
return " ".join(parts)
async def get_current_devices_detailed(db_user: User) -> dict:
try:
if not db_user.remnawave_uuid:
@@ -772,6 +794,30 @@ async def handle_all_devices_reset_from_management(
from app.services.remnawave_service import RemnaWaveService
service = RemnaWaveService()
subscription = getattr(db_user, "subscription", None)
cooldown_remaining = (
service.get_devices_reset_cooldown_remaining(subscription)
if subscription
else None
)
if cooldown_remaining:
remaining_seconds = int(cooldown_remaining.total_seconds())
cooldown = settings.get_happ_cryptolink_reset_cooldown()
cooldown_seconds = int(cooldown.total_seconds()) if cooldown else remaining_seconds
await callback.answer(
texts.t(
"DEVICE_RESET_COOLDOWN",
"Сброс устройств доступен раз в {cooldown}. Попробуйте через {remaining}.",
).format(
cooldown=_format_cooldown_duration(cooldown_seconds, db_user.language),
remaining=_format_cooldown_duration(remaining_seconds, db_user.language),
),
show_alert=True,
)
return
async with service.get_api_client() as api:
devices_response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}')
@@ -819,6 +865,15 @@ async def handle_all_devices_reset_from_management(
failed_count += 1
logger.warning(f"⚠️ У устройства нет HWID: {device}")
if success_count > 0:
try:
await service.refresh_happ_subscription_after_reset(db, db_user)
except Exception as refresh_error:
logger.warning(
"⚠️ Не удалось обновить Happ ссылку после сброса устройств: %s",
refresh_error,
)
if success_count > 0:
if failed_count == 0:
await callback.message.edit_text(

View File

@@ -982,6 +982,7 @@
"DEVICE_RESET_ALL_DONE": " All devices have been reset",
"DEVICE_RESET_ALL_FAILED_MESSAGE": "❌ <b>Couldn't reset devices</b>\n\nPlease try again later or contact support.\n\nTotal devices: {total}",
"DEVICE_RESET_ALL_SUCCESS_MESSAGE": "✅ <b>All devices have been reset!</b>\n\n🔄 Reset: {count} devices\n📱 You can now reconnect your devices\n\n💡 Use the link from the 'My subscription' section to reconnect",
"DEVICE_RESET_COOLDOWN": "⏳ Device reset is available every {cooldown}. Try again in {remaining}.",
"DEVICE_RESET_ERROR": "❌ Failed to reset the device",
"DEVICE_RESET_ID_FAILED": "❌ Unable to get device ID",
"DEVICE_RESET_INVALID_REQUEST": "❌ Error: invalid request",

View File

@@ -994,6 +994,7 @@
"DEVICE_RESET_ALL_DONE": " Все устройства сброшены",
"DEVICE_RESET_ALL_FAILED_MESSAGE": "❌ <b>Не удалось сбросить устройства</b>\n\nПопробуйте еще раз позже или обратитесь в техподдержку.\n\nВсего устройств: {total}",
"DEVICE_RESET_ALL_SUCCESS_MESSAGE": "✅ <b>Все устройства успешно сброшены!</b>\n\n🔄 Сброшено: {count} устройств\n📱 Теперь вы можете заново подключить свои устройства\n\n💡 Используйте ссылку из раздела 'Моя подписка' для повторного подключения",
"DEVICE_RESET_COOLDOWN": "⏳ Сброс устройств доступен раз в {cooldown}. Попробуйте через {remaining}.",
"DEVICE_RESET_ERROR": "❌ Ошибка сброса устройства",
"DEVICE_RESET_ID_FAILED": "❌ Не удалось получить ID устройства",
"DEVICE_RESET_INVALID_REQUEST": "❌ Ошибка: некорректный запрос",

View File

@@ -927,6 +927,7 @@
"DEVICE_RESET_ALL_DONE": " Всі пристрої скинуто",
"DEVICE_RESET_ALL_FAILED_MESSAGE": "❌ <b>Не вдалося скинути пристрої</b>\n\nСпробуйте ще раз пізніше або зверніться до техпідтримки.\n\nВсього пристроїв: {total}",
"DEVICE_RESET_ALL_SUCCESS_MESSAGE": "✅ <b>Всі пристрої успішно скинуто!</b>\n\n🔄 Скинуто: {count} пристроїв\n📱 Тепер ви можете заново підключити свої пристрої\n\n💡 Використовуйте посилання з розділу 'Моя підписка' для повторного підключення",
"DEVICE_RESET_COOLDOWN": "⏳ Скидання пристроїв доступне раз на {cooldown}. Спробуйте через {remaining}.",
"DEVICE_RESET_ERROR": "❌ Помилка скидання пристрою",
"DEVICE_RESET_ID_FAILED": "❌ Не вдалося отримати ID пристрою",
"DEVICE_RESET_INVALID_REQUEST": "❌ Помилка: некоректний запит",

View File

@@ -926,6 +926,7 @@
"DEVICE_RESET_ALL_DONE":"ℹ️所有设备已重置",
"DEVICE_RESET_ALL_FAILED_MESSAGE":"❌<b>重置设备失败</b>\n\n请稍后再试或联系技术支持。\n\n总设备数{total}",
"DEVICE_RESET_ALL_SUCCESS_MESSAGE":"✅<b>所有设备已成功重置!</b>\n\n🔄已重置{count}台设备\n📱您现在可以重新连接您的设备\n\n💡使用“我的订阅”部分中的链接重新连接",
"DEVICE_RESET_COOLDOWN":"⏳设备重置每隔{cooldown}可用一次。请在{remaining}后再试。",
"DEVICE_RESET_ERROR":"❌重置设备失败",
"DEVICE_RESET_ID_FAILED":"❌获取设备ID失败",
"DEVICE_RESET_INVALID_REQUEST":"❌错误:请求无效",

View File

@@ -17,6 +17,7 @@ from sqlalchemy import and_, cast, delete, func, select, update, String
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import selectinload
from sqlalchemy.ext.asyncio import AsyncSession
from app.utils.happ_cryptolink_utils import generate_limited_happ_link
from app.database.crud.user import (
create_user_no_commit,
get_users_list,
@@ -237,6 +238,49 @@ class RemnaWaveService:
async with self.api as api:
yield api
async def _build_happ_crypto_link(
self,
api: RemnaWaveAPI,
subscription: "Subscription",
base_subscription_url: Optional[str],
panel_crypto_link: Optional[str],
) -> Optional[str]:
if not settings.is_happ_cryptolink_mode():
return panel_crypto_link
if not settings.is_happ_cryptolink_limited_links_enabled():
return panel_crypto_link
provider_code, auth_key = settings.get_happ_cryptolink_credentials()
if not provider_code or not auth_key:
logger.debug("⚙️ Нет данных для генерации лимитированной Happ ссылки")
return panel_crypto_link
base_link = (base_subscription_url or "").strip()
if not base_link:
logger.warning("⚠️ Базовая ссылка подписки Happ отсутствует")
return panel_crypto_link
install_limit = settings.get_happ_cryptolink_install_limit(getattr(subscription, "device_limit", None))
if not install_limit:
logger.debug("⚙️ Лимит установок Happ не задан")
return panel_crypto_link
limited_link = await generate_limited_happ_link(
base_link,
settings.get_happ_cryptolink_add_install_url(),
provider_code,
auth_key,
install_limit,
)
if not limited_link:
logger.warning("⚠️ Не удалось создать лимитированную Happ ссылку")
return panel_crypto_link
encrypted_link = await api.encrypt_happ_crypto_link(limited_link)
return encrypted_link or limited_link
def _now_utc(self) -> datetime:
"""Возвращает текущее время в UTC без привязки к часовому поясу."""
return datetime.now(self._utc_timezone).replace(tzinfo=None)
@@ -2007,6 +2051,79 @@ class RemnaWaveService:
logger.error(f"Ошибка валидации данных пользователя: {e}")
return False
def get_devices_reset_cooldown_remaining(self, subscription: "Subscription") -> Optional[timedelta]:
if not settings.is_happ_cryptolink_mode() or not settings.is_happ_cryptolink_limited_links_enabled():
return None
cooldown = settings.get_happ_cryptolink_reset_cooldown()
if not cooldown:
return None
last_reset = getattr(subscription, "last_devices_reset_at", None)
if not last_reset:
return None
now = datetime.utcnow()
next_allowed = last_reset + cooldown
if now >= next_allowed:
return None
return next_allowed - now
async def refresh_happ_subscription_after_reset(
self,
db: AsyncSession,
user: "User",
) -> Optional[str]:
if not settings.is_happ_cryptolink_mode() or not settings.is_happ_cryptolink_limited_links_enabled():
return None
if not getattr(user, "remnawave_uuid", None):
logger.debug("⚙️ Нет RemnaWave UUID для обновления Happ ссылки")
return None
subscription = getattr(user, "subscription", None)
if not subscription:
logger.debug("⚙️ У пользователя нет подписки для обновления Happ ссылки")
return None
try:
async with self.get_api_client() as api:
revoked_user = await api.revoke_user_subscription(user.remnawave_uuid)
subscription.remnawave_short_uuid = revoked_user.short_uuid
subscription.subscription_url = revoked_user.subscription_url
crypto_link = await self._build_happ_crypto_link(
api,
subscription,
revoked_user.subscription_url,
revoked_user.happ_crypto_link,
)
subscription.subscription_crypto_link = crypto_link or revoked_user.happ_crypto_link
subscription.last_devices_reset_at = datetime.utcnow()
await db.commit()
logger.info(
"🔄 Обновлена Happ ссылка после сброса устройств пользователя %s",
getattr(user, "telegram_id", "?"),
)
return subscription.subscription_crypto_link
except Exception as error:
logger.error(
"❌ Ошибка обновления Happ ссылки после сброса устройств: %s",
error,
)
try:
await db.rollback()
except Exception:
pass
return None
async def force_cleanup_user_data(self, db: AsyncSession, user: User) -> bool:
try:
logger.info(f"🗑️ ПРИНУДИТЕЛЬНАЯ полная очистка данных пользователя {user.telegram_id}")

View File

@@ -20,6 +20,7 @@ from app.utils.pricing_utils import (
from app.utils.subscription_utils import (
resolve_hwid_device_limit_for_payload,
)
from app.utils.happ_cryptolink_utils import generate_limited_happ_link
logger = logging.getLogger(__name__)
@@ -160,6 +161,49 @@ class SubscriptionService:
assert self.api is not None
async with self.api as api:
yield api
async def _build_happ_crypto_link(
self,
api: RemnaWaveAPI,
subscription: Subscription,
base_subscription_url: Optional[str],
panel_crypto_link: Optional[str],
) -> Optional[str]:
if not settings.is_happ_cryptolink_mode():
return panel_crypto_link
if not settings.is_happ_cryptolink_limited_links_enabled():
return panel_crypto_link
provider_code, auth_key = settings.get_happ_cryptolink_credentials()
if not provider_code or not auth_key:
logger.debug("⚙️ Данные для лимитированных ссылок Happ не заданы")
return panel_crypto_link
base_link = (base_subscription_url or "").strip()
if not base_link:
logger.warning("⚠️ Базовая ссылка подписки для Happ отсутствует")
return panel_crypto_link
install_limit = settings.get_happ_cryptolink_install_limit(subscription.device_limit)
if not install_limit:
logger.debug("⚙️ Лимит установок для Happ не задан")
return panel_crypto_link
limited_link = await generate_limited_happ_link(
base_link,
settings.get_happ_cryptolink_add_install_url(),
provider_code,
auth_key,
install_limit,
)
if not limited_link:
logger.warning("⚠️ Не удалось сгенерировать лимитированную Happ ссылку")
return panel_crypto_link
encrypted_link = await api.encrypt_happ_crypto_link(limited_link)
return encrypted_link or limited_link
async def create_remnawave_user(
self,
@@ -266,7 +310,15 @@ class SubscriptionService:
subscription.remnawave_short_uuid = updated_user.short_uuid
subscription.subscription_url = updated_user.subscription_url
subscription.subscription_crypto_link = updated_user.happ_crypto_link
crypto_link = await self._build_happ_crypto_link(
api,
subscription,
updated_user.subscription_url,
updated_user.happ_crypto_link,
)
subscription.subscription_crypto_link = crypto_link or updated_user.happ_crypto_link
user.remnawave_uuid = updated_user.uuid
await db.commit()
@@ -348,7 +400,15 @@ class SubscriptionService:
)
subscription.subscription_url = updated_user.subscription_url
subscription.subscription_crypto_link = updated_user.happ_crypto_link
crypto_link = await self._build_happ_crypto_link(
api,
subscription,
updated_user.subscription_url,
updated_user.happ_crypto_link,
)
subscription.subscription_crypto_link = crypto_link or updated_user.happ_crypto_link
await db.commit()
status_text = "активным" if is_actually_active else "истёкшим"

View File

@@ -0,0 +1,80 @@
from typing import Optional
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
import aiohttp
def _append_query_param_to_fragment(fragment: str, param: str, value: str) -> str:
base_part, _, query_part = fragment.partition("?")
query_params = dict(parse_qsl(query_part, keep_blank_values=True))
query_params[param] = value
encoded_query = urlencode(query_params)
return f"{base_part}?{encoded_query}" if base_part else encoded_query
def append_install_code(base_link: str, install_code: str, param_name: str = "installid") -> str:
"""Добавляет параметр installid в ссылку, сохраняя существующие параметры."""
if not install_code:
return base_link
parsed = urlsplit(base_link)
if parsed.fragment:
updated_fragment = _append_query_param_to_fragment(parsed.fragment, param_name, install_code)
return urlunsplit((
parsed.scheme,
parsed.netloc,
parsed.path,
parsed.query,
updated_fragment,
))
query_params = dict(parse_qsl(parsed.query, keep_blank_values=True))
query_params[param_name] = install_code
updated_query = urlencode(query_params)
return urlunsplit((
parsed.scheme,
parsed.netloc,
parsed.path,
updated_query,
parsed.fragment,
))
async def generate_limited_happ_link(
base_link: str,
api_url: str,
provider_code: str,
auth_key: str,
install_limit: int,
*,
timeout_seconds: int = 15,
) -> Optional[str]:
if not base_link or install_limit <= 0:
return None
timeout = aiohttp.ClientTimeout(total=timeout_seconds)
params = {
"provider_code": provider_code,
"auth_key": auth_key,
"install_limit": install_limit,
}
try:
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(api_url, params=params) as response:
data = await response.json()
except Exception:
return None
if data.get("rc") != 1:
return None
install_code = data.get("install_code") or data.get("installCode")
if not install_code:
return None
return append_install_code(base_link, install_code)

View File

@@ -0,0 +1,21 @@
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "f6c5c60dba2e"
down_revision: Union[str, None] = "e3c1e0b5b4a7"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"subscriptions",
sa.Column("last_devices_reset_at", sa.DateTime(), nullable=True),
)
def downgrade() -> None:
op.drop_column("subscriptions", "last_devices_reset_at")