@@ -151,7 +151,8 @@ class CustomUserModel(AbstractBaseUser, PermissionsMixin, BaseModel): @property def balance(self): - return self.payment_plan.current_token_balance + pp = self.payment_plan + return pp.current_token_balance + pp.referral_balance @property def account_type(self): @@ -230,7 +231,7 @@ def on_user_creation_signal(sender, instance, created, **kwargs): if created: free_plan = PaymentPlanSelector(instance).get_free_plan() - PaymentPlanService(instance).subscribe_user_to_plan(free_plan) + PaymentPlanService(instance).subscribe_user_to_plan(free_plan, free_plan.tokens_per_plan) if not instance.profile_picture_name: instance.profile_picture_name = instance.LAST_NAME_AVATARS.get(instance.last_name, None) instance.save() @@ -64,8 +64,9 @@ class BusinessHostService: ) -> BusinessAccountService: password = generate_token(15) user = CustomUserModel.objects.create_user(email=email, password=password) + plan = PaymentPlan.objects.get(price=0, is_corporate=False) - PaymentPlanService(user).subscribe_user_to_plan(PaymentPlan.objects.get(price=0, is_corporate=False)) + PaymentPlanService(user).subscribe_user_to_plan(plan, plan.tokens_per_plan) account_service = BusinessAccountService.create(user, host, account_privileges=account_privileges) EmailService.send_corporate_greeting_email(account_service.account, password) @@ -174,9 +175,6 @@ class BusinessHostService: serializer = NewBusinessHostSerializer(data=request.data) serializer.is_valid(raise_exception=True) - if PaymentPlanSelector(self.user).is_plan_paid(): - raise business_host_exceptions.AlreadyHasPlan() - if AccountStatusSelector(self.user).is_business_host(): raise business_host_exceptions.AlreadyHost() @@ -184,10 +182,6 @@ class BusinessHostService: raise business_host_exceptions.AlreadyAccount() host = BusinessUserHost.objects.create(user=self.user, **serializer.validated_data) - host.save() - PaymentPlanService(self.user).subscribe_user_to_plan( - PaymentPlan.objects.get(price=0, is_corporate=True) - ) EmailService(self.user).send_copr_purchase_email(host) account_type = UserSelector(self.user).check_account_type() @@ -1,4 +1,6 @@ import logging +from datetime import datetime +from decimal import Decimal from typing import Any, Sequence import dns.resolver @@ -162,3 +164,13 @@ class EmailService: cls.send_email( f'AIR: баланс корпоративного аккаунта ниже {host.token_cap}', html_message, host.token_cap_emails ) + + @classmethod + def send_revoke_recurring_email(cls, email: str) -> None: + html_message = cls._render_letter_template( + template_name='payments/revoke_recurring_email', context={} + ) + cls.send_email( + 'Отмена подписки на платформе AIR', html_message, (email,) + ) + @@ -42,17 +42,15 @@ PATH_PREFETCH_MAP = { 'account_privileges', 'parent_company__user__uid', ), - *_gen_only('payment_plan', 'uid', 'last_payment_at', 'next_payment_at'), - *_gen_only('payment_plan__plan', 'uid', 'price', 'tokens_per_plan', 'duration'), + *_gen_only('payment_plan', 'uid', 'last_payment_at'), + *_gen_only('payment_plan__plan', 'uid', 'price', 'tokens_per_plan'), *_gen_only( 'business_account__parent_company__user__payment_plan', 'uid', 'last_payment_at', - 'next_payment_at', 'plan__uid', 'plan__price', 'plan__tokens_per_plan', - 'plan__duration', ), ), }, @@ -81,7 +79,8 @@ PATH_PREFETCH_MAP = { 'host_account', 'payment_plan', 'business_account__parent_company__user__payment_plan__plan', - 'payment_plan__plan' + 'payment_plan__plan', + 'payment_plan__method', ), 'prefetch': ( Prefetch( @@ -1,8 +1,16 @@ import os from celery import Celery +from celery.signals import worker_process_init os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'backend.settings') app = Celery('backend') app.config_from_object('django.conf:settings', namespace='CELERY') app.autodiscover_tasks() + + +from lib.unleash.client import celery_client + +@worker_process_init.connect +def configure_workers(sender=None, conf=None, **kwargs): + celery_client.client.initialize_client(fetch_toggles=False) @@ -294,6 +294,10 @@ CELERY_BEAT_SCHEDULE = { 'task': 'payments.tasks.send_low_balance_message', 'schedule': crontab(0, 8), }, + 'execute_recurring_payments': { + 'task': 'payments.tasks.execute_recurring_payments', + 'schedule': crontab(*env.list('RECURRING_PAYMENT_CRONTAB_SCHEDULE', [])), + }, } CACHES = { @@ -345,7 +349,6 @@ ImageFile.LOAD_TRUNCATED_IMAGES = True YOOKASSA_ACCOUNT_ID = env.str('YOOKASSA_ACCOUNT_ID', default='defaultapikey') YOOKASSA_SECRET_KEY = env.str('YOOKASSA_SECRET_KEY', default='defaultapikey') YOOKASSA_RESULT_PAYMENT_URL = env.str('YOOKASSA_RESULT_PAYMENT_URL', default='defaultapikey') -RECURRENT_RATE = env.str('RECURRENT_RATE', 'days') # Email EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' EMAIL_USE_TLS = env.bool('EMAIL_USE_TLS', default=False) @@ -493,3 +496,12 @@ if CACHEOPS_REDIS: 'reports.*': {'ops': 'all', 'timeout': 60 * 60}, 'token_blacklist.outstandingtoken': {'ops': 'get', 'timeout': 60 * 60 * 24}, } + +# UNLEASH settings +FEATURE_FLAG_API_URL = env.str('FEATURE_FLAG_API_URL') +FEATURE_FLAG_APP_NAME = env.str('FEATURE_FLAG_APP_NAME', 'staging') +FEATURE_FLAG_INSTANCE_ID = env.str('FEATURE_FLAG_INSTANCE_ID') +FEATURE_FLAG_WEBHOOK_SECRET_KEY = env.str('FEATURE_FLAG_WEBHOOK_SECRET_KEY', 'FEATURE_FLAG_WEBHOOK_SECRET_KEY') + +# RECURRING SETTINGS +MAX_RECURRING_ATTEMPTS = env.int('MAX_RECURRING_ATTEMPTS', 1) @@ -18,16 +18,17 @@ from backend.public import urlpatterns as public_urlpatterns api = NinjaAPI(title='AIR API', version='1.0.0', docs_url=None) compatibility_api = NinjaAPI(title='AIR API DEBUG', version='0.0.1', docs_url=None) + api.add_router('copywrite/', 'tools.copywrite.routes.v1.router') api.add_router('users/', 'users.routes.v1.router') api.add_router('chats/', 'tools.chats.routes.v1.router') api.add_router('media/', 'tools.media.routes.v1.router') + compatibility_api.add_router('auth/', 'authentication.routes.v1.router') compatibility_api.add_router('payments/', 'payments.routes.v1.router') compatibility_api.add_router('reports/', 'reports.routes.v1.router') compatibility_api.add_router('ml_model/', 'ml_model.routes.v1.router') - logger = logging.getLogger(__name__) @@ -0,0 +1,15 @@ +from abc import ABC, abstractmethod +from typing import List, Mapping + +from lib.typing import Email, State + + +class FeatureFlagService(ABC): + + @abstractmethod + def get_flag_state_by_emails(self, name: str, emails: List[Email]) -> Mapping[Email, State]: + pass + + @abstractmethod + def get_flag_state(self, name: str, email: Email) -> State: + pass @@ -0,0 +1,26 @@ +from types import MappingProxyType +from typing import List, Mapping + +from UnleashClient import UnleashClient +from django.conf import settings + +from lib.services.feature_flag import FeatureFlagService +from lib.typing import Email, State +from lib.unleash.cache import UnleashRedisCache + + +class UnleashFeatureFlagService(FeatureFlagService): + def __init__(self) -> None: + self.client = UnleashClient( + url=settings.FEATURE_FLAG_API_URL, + app_name=settings.FEATURE_FLAG_APP_NAME, + instance_id=settings.FEATURE_FLAG_INSTANCE_ID, + cache=UnleashRedisCache(), + environment=settings.FEATURE_FLAG_APP_NAME + ) + + def get_flag_state_by_emails(self, name: str, emails: List[Email]) -> Mapping[Email, State]: + return MappingProxyType({email: self.get_flag_state(name, email) for email in emails}) + + def get_flag_state(self, name: str, email: Email) -> State: + return self.client.is_enabled(feature_name=name, context={'userId': email}) @@ -0,0 +1,27 @@ +from typing import Any, Optional +from UnleashClient.cache import BaseCache +from django.core.cache import caches + + +class UnleashRedisCache(BaseCache): + PREFIX = 'unleash:' + + def __init__(self, cache_alias='default'): + self.cache = caches[cache_alias] + + def set(self, key: str, value: Any): + self.cache.set(self.PREFIX + key, value) + + def mset(self, data: dict): + self.cache.set_many({self.PREFIX + k: v for k, v in data.items()}) + + def get(self, key: str, default: Optional[Any] = None): + return self.cache.get(self.PREFIX + key, default) + + def exists(self, key: str): + return self.cache.has_key(self.PREFIX + key) + + def destroy(self): + client = self.cache.client.get_client(write=True) + for key in client.scan_iter(f"{self.PREFIX}*"): + client.delete(key) @@ -0,0 +1,4 @@ +from lib.services.unleash_feature_flag import UnleashFeatureFlagService + +web_client = UnleashFeatureFlagService() +celery_client = UnleashFeatureFlagService() \ No newline at end of file @@ -0,0 +1,2 @@ +type Email = str +type State = bool \ No newline at end of file @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-03 17:52+0300\n" +"POT-Creation-Date: 2026-04-09 17:28+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -190,7 +190,7 @@ msgstr "Дочерние Бизнес Аккаунты" #: authentication/models/business_group.py:8 ml_model/models.py:17 #: ml_model/models.py:37 ml_model/models.py:61 ml_model/models.py:268 -#: tools/chats/models.py:9 tools/media/models.py:59 tools/media/models.py:93 +#: tools/chats/models.py:9 tools/media/models.py:59 tools/media/models.py:95 msgid "Title" msgstr "Название" @@ -206,9 +206,9 @@ msgstr "Бизнес Группы" #: authentication/models/email_token.py:13 authentication/models/user.py:222 #: authentication/models/user.py:223 authentication/models/user_telegram.py:22 #: authentication/models/user_vk.py:12 payments/admin.py:37 -#: payments/admin.py:97 payments/models/invoice.py:15 -#: payments/models/payment.py:26 payments/models/payment_plan.py:62 -#: tools/media/models.py:106 +#: payments/admin.py:95 payments/models/invoice.py:15 +#: payments/models/payment.py:26 payments/models/payment_plan.py:50 +#: tools/media/models.py:108 msgid "User" msgstr "Пользователь" @@ -553,7 +553,7 @@ msgstr "Бизнес-аккаунт для данного юзера не най msgid "Invited account can either accept or reject an invitation" msgstr "Приглашенный аккаунт может принять или отклонить приглашение" -#: authentication/services/email_service.py:50 +#: authentication/services/email_service.py:52 msgid "Error occured when proceed email sending" msgstr "Случилась ошибка во время отправки email" @@ -795,7 +795,7 @@ msgstr "Теги" msgid "Neuron Model" msgstr "Нейронная Модель" -#: ml_model/models.py:156 ml_model/models.py:402 payments/admin.py:103 +#: ml_model/models.py:156 ml_model/models.py:402 payments/admin.py:101 msgid "Model" msgstr "Модель" @@ -1050,20 +1050,24 @@ msgstr "Нет изображения для улучшения" msgid "Model data cannot be retrieved" msgstr "Невозможно получить данные модели" -#: payments/admin.py:35 payments/admin.py:69 payments/admin.py:95 +#: payments/admin.py:35 payments/admin.py:67 payments/admin.py:93 msgid "You can search by user email, exacted company name" msgstr "" "Вы можете осуществлять поиск по e-mail пользователя, точному названию " "компании" -#: payments/admin.py:40 payments/admin.py:100 +#: payments/admin.py:40 payments/admin.py:98 msgid "Missing" msgstr "Отсутствующий" -#: payments/apps.py:9 payments/models/payment.py:60 +#: payments/apps.py:11 payments/models/payment.py:60 msgid "Payments" msgstr "Платежи" +#: payments/exceptions/full_balance.py:5 +msgid "Your balance is already full" +msgstr "Ваш баланс уже пополнен до максимума" + #: payments/exceptions/insufficient_balance.py:18 #, python-format msgid "" @@ -1105,60 +1109,56 @@ msgstr "Статус" msgid "Payment" msgstr "Платеж" -#: payments/models/payment_plan.py:18 +#: payments/models/payment_plan.py:13 msgid "Price" msgstr "Цена" -#: payments/models/payment_plan.py:22 +#: payments/models/payment_plan.py:17 msgid "Tokens per plan" msgstr "Токенов за план" -#: payments/models/payment_plan.py:25 +#: payments/models/payment_plan.py:20 msgid "Is corporate" msgstr "Корпоративный" -#: payments/models/payment_plan.py:26 +#: payments/models/payment_plan.py:21 msgid "Individual" msgstr "Индивидуальный" -#: payments/models/payment_plan.py:27 -msgid "Is recurrent" -msgstr "Рекуррентный" - -#: payments/models/payment_plan.py:29 -msgid "Duration" -msgstr "Длительность" - -#: payments/models/payment_plan.py:34 +#: payments/models/payment_plan.py:22 msgid "Is visible" msgstr "Видимый" -#: payments/models/payment_plan.py:53 payments/models/payment_plan.py:68 +#: payments/models/payment_plan.py:41 payments/models/payment_plan.py:56 #: payments/models/payment_plan_feature.py:16 msgid "Payment Plan" msgstr "Платежный План" -#: payments/models/payment_plan.py:54 +#: payments/models/payment_plan.py:42 msgid "Payment Plans" msgstr "Платежные Планы" -#: payments/models/payment_plan.py:70 +#: payments/models/payment_plan.py:58 msgid "Last payment at" msgstr "Последнее время платежа" -#: payments/models/payment_plan.py:71 +#: payments/models/payment_plan.py:59 msgid "Next payment at" msgstr "Следующее время платежа" -#: payments/models/payment_plan.py:73 +#: payments/models/payment_plan.py:63 payments/models/user_payment_method.py:17 +msgid "Payment Method" +msgstr "Платежный метод" + +#: payments/models/payment_plan.py:69 msgid "Current balance" msgstr "Текущий баланс" -#: payments/models/payment_plan.py:79 -msgid "Recurrent billing task" -msgstr "Рекуррентная задача на платеж" +#: payments/models/payment_plan.py:75 +msgid "Referral balance" +msgstr "Реферальный баланс" -#: payments/models/payment_plan.py:100 payments/models/payment_plan.py:101 +#: payments/models/payment_plan.py:90 payments/models/payment_plan.py:91 msgid "User Balance" msgstr "Баланс пользователя" @@ -1230,19 +1230,39 @@ msgstr "Активация Промокода" msgid "Promocode Activations" msgstr "Активации Промокодов" -#: payments/models/user_payment_method.py:24 -msgid "Payment Method" -msgstr "Платежный метод" +#: payments/models/user_payment_method.py:8 +msgid "Payment method UID" +msgstr "UID платёжного метода" + +#: payments/models/user_payment_method.py:9 +msgid "Card type" +msgstr "Тип карты" + +#: payments/models/user_payment_method.py:10 +msgid "Last four card digits" +msgstr "Последние 4 цифры карты" -#: payments/models/user_payment_method.py:25 +#: payments/models/user_payment_method.py:11 +msgid "Attempts" +msgstr "Попытки" + +#: payments/models/user_payment_method.py:18 msgid "Payment Methods" msgstr "Платежные методы" +#: payments/routes/v1.py:100 +msgid "The recurring payment is successfully cancelled" +msgstr "Автоплатежи успешно отключены" + #: payments/routes/v1.py:94 +msgid "You do not have an active subscription to cancel" +msgstr "У вас нет активной подписки для отмены" + +#: payments/routes/v1.py:150 msgid "Expenses" msgstr "Затраты" -#: payments/routes/v1.py:98 +#: payments/routes/v1.py:154 msgid "Refills" msgstr "Пополнения" @@ -1254,21 +1274,13 @@ msgstr "" msgid "Unknown account type" msgstr "Неизвестный тип аккаунта" -#: payments/services/payment_method_service.py:46 -msgid "No current active payment method is set" -msgstr "Ни одного активного метода не установлено" - -#: payments/services/payment_method_service.py:55 -msgid "No payment method by this id" -msgstr "Не найдено метода платежа по этому ID" - -#: payments/tests/test_plans.py:23 payments/tests/test_plans.py:191 -#: payments/tests/test_plans.py:194 +#: payments/tests/test_plans.py:23 payments/tests/test_plans.py:190 +#: payments/tests/test_plans.py:193 msgid "Chat-bots" msgstr "Чат-боты" -#: payments/tests/test_plans.py:27 payments/tests/test_plans.py:192 -#: payments/tests/test_plans.py:197 +#: payments/tests/test_plans.py:27 payments/tests/test_plans.py:191 +#: payments/tests/test_plans.py:196 msgid "Images" msgstr "Изображения" @@ -1312,7 +1324,7 @@ msgstr "Публичный API" msgid "Media" msgstr "Медиа" -#: tools/chats/apis.py:197 tools/media/apis.py:202 +#: tools/chats/apis.py:197 tools/media/apis.py:201 #: tools/public_api/views/base.py:100 msgid "" "An unexpected generation error has occurred. Please try again later or use a " @@ -1334,12 +1346,14 @@ msgstr "Чат %(id)s" msgid "Chat" msgstr "Чат" -#: tools/media/apis.py:173 +#: tools/media/apis.py:172 msgid "" "Temporary issues with the service, we are already working on a solution." msgstr "Временные неполадки с сервисом, мы уже работаем над их решением." -#: tools/media/apis.py:255 +#: tools/media/apis.py:248 tools/media/apis.py:283 +#: tools/public_api/views/ml_service.py:63 +#: tools/public_api/views/ml_service.py:107 msgid "Voice not found." msgstr "Голос не найден." @@ -1351,7 +1365,7 @@ msgstr "Хранилище клонирования голоса" msgid "Voice clone stores" msgstr "Хранилища клонирования голоса" -#: tools/media/models.py:54 tools/media/models.py:131 +#: tools/media/models.py:54 tools/media/models.py:121 msgid "Voice" msgstr "Голос" @@ -1363,7 +1377,7 @@ msgstr "Инструментал" msgid "Kind" msgstr "Тип" -#: tools/media/models.py:72 tools/media/models.py:103 +#: tools/media/models.py:72 tools/media/models.py:105 msgid "File" msgstr "Файл" @@ -1379,23 +1393,32 @@ msgstr "Пресет" msgid "Presets" msgstr "Пресеты" -#: tools/media/models.py:100 +#: tools/media/models.py:93 +msgid "Создан" +msgstr "" + +#: tools/media/models.py:94 +msgid "Изменён" +msgstr "" + +#: tools/media/models.py:102 msgid "Only MP3, OGG, WAV and WEBA audio files are allowed." msgstr "Разрешены только аудиофайлы MP3, OGG, WAV и WEBA." -#: tools/media/models.py:108 +#: tools/media/models.py:110 msgid "Transcription" msgstr "Транскрипция" -#: tools/media/models.py:112 +#: tools/media/models.py:114 msgid "Unknown file" msgstr "Неизвестный файл" -#: tools/media/models.py:132 +#: tools/media/models.py:122 msgid "Voices" msgstr "Голоса" -#: tools/media/routes/v1.py:74 +#: tools/media/routes/v1.py:76 tools/public_api/views/voice.py:86 +#: tools/public_api/views/voice.py:98 msgid "Voice not found" msgstr "Голос не найден" @@ -1433,31 +1456,32 @@ msgstr "" msgid "The request must not be empty" msgstr "Запрос не должен быть пустым" -#: tools/public_api/views/ml_service.py:65 +#: tools/public_api/views/ml_service.py:128 msgid "You must provide a model parameter" msgstr "Необходимо указать параметр 'model'" -#: tools/public_api/views/ml_service.py:80 +#: tools/public_api/views/ml_service.py:143 msgid "Missing required parameter: 'messages'" msgstr "Отсутствует обязательный параметр: 'messages'" -#: tools/public_api/views/ml_service.py:130 +#: tools/public_api/views/ml_service.py:193 msgid "Model not found" msgstr "Модель не найдена" -#: tools/public_api/views/voice.py:40 +#: tools/public_api/views/voice.py:44 msgid "Your voice has been uploaded successfully" msgstr "Ваш голос успешно загружен" -#: tools/public_api/views/voice.py:72 +#: tools/public_api/views/voice.py:77 +msgid "Preset voices are shared and cannot be edited. Use your own voice id." +msgstr "" +"Пресеты общие для всех — их нельзя редактировать. Укажите id своего голоса." + +#: tools/public_api/views/voice.py:87 msgid "Voice title updated successfully" msgstr "Название голоса успешно обновлено" -#: tools/public_api/views/voice.py -msgid "Preset voices are shared and cannot be edited. Use your own voice id." -msgstr "Пресеты общие для всех — их нельзя редактировать. Укажите id своего голоса." - -#: tools/public_api/views/voice.py +#: tools/public_api/views/voice.py:93 msgid "Preset voices are shared and cannot be deleted. Use your own voice id." msgstr "Пресеты общие для всех — их нельзя удалить. Укажите id своего голоса." @@ -1468,6 +1492,24 @@ msgstr "Пресеты общие для всех — их нельзя удал #~ "Случилась ошибка во время генерации. Она может возникать из-за того, что " #~ "NSFW-контент запрещен. Попробуйте снова" +#~ msgid "Is recurrent" +#~ msgstr "Рекуррентный" + +#~ msgid "Payment Datetime" +#~ msgstr "Дата и время платежа" + +#~ msgid "Recurring Payment" +#~ msgstr "Автоплатеж" + +#~ msgid "Recurring Payments" +#~ msgstr "Автоплатежи" + +#~ msgid "No current active payment method is set" +#~ msgstr "Ни одного активного метода не установлено" + +#~ msgid "No payment method by this id" +#~ msgstr "Не найдено метода платежа по этому ID" + #~ msgid "Regular users cannot send introductory letters" #~ msgstr "Обычные пользователи не могут отсылать письма" @@ -0,0 +1,5 @@ +from django.utils.translation import gettext as _ + +class FullBalanceException(Exception): + def __str__(self) -> str: + return _('Your balance is already full') \ No newline at end of file @@ -0,0 +1,56 @@ +# Generated by Django 5.0.11 on 2026-02-12 08:34 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('payments', '0026_remove_paymentplan_title'), + ] + + operations = [ + migrations.CreateModel( + name='PaymentMethod', + fields=[ + ('uid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создан')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Изменён')), + ('payment_method_id', models.UUIDField(unique=True, verbose_name='Payment method UID')), + ('card_type', models.CharField(max_length=15, verbose_name='Card type')), + ('last_four', models.CharField(max_length=4, verbose_name='Last four card digits')), + ('attempts', models.PositiveSmallIntegerField(default=0, verbose_name='Attempts')), + ], + options={ + 'verbose_name': 'Payment Method', + 'verbose_name_plural': 'Payment Methods', + }, + ), + migrations.RemoveField( + model_name='paymentplan', + name='duration', + ), + migrations.RemoveField( + model_name='paymentplan', + name='is_recurrent', + ), + migrations.RemoveField( + model_name='paymentplanuserinfo', + name='plan_schedule', + ), + migrations.AlterField( + model_name='paymentplanuserinfo', + name='next_payment_at', + field=models.DateTimeField(blank=True, null=True, verbose_name='Next payment at'), + ), + migrations.AddField( + model_name='paymentplanuserinfo', + name='method', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='user_plan_info', to='payments.paymentmethod', verbose_name='Payment Method'), + ), + migrations.DeleteModel( + name='UserPaymentMethod', + ), + ] @@ -0,0 +1,18 @@ +# Generated by Django 5.0.11 on 2026-04-18 06:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('payments', '0027_paymentmethod_remove_paymentplan_duration_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='paymentplanuserinfo', + name='referral_balance', + field=models.DecimalField(decimal_places=10, default=0, max_digits=100, verbose_name='Referral balance'), + ), + ] @@ -1,6 +1,6 @@ from payments.models.payment import Payment from payments.models.payment_plan import PaymentPlan, PaymentPlanUserInfo -from payments.models.user_payment_method import UserPaymentMethod +from payments.models.user_payment_method import PaymentMethod from payments.models.invoice import Invoice from payments.models.promocode import PromoCode, PromoCodeActivation from payments.models.payment_plan_feature import PaymentPlanFeature @@ -9,9 +9,9 @@ __all__ = ( 'Payment', 'PaymentPlan', 'PaymentPlanUserInfo', - 'UserPaymentMethod', + 'PaymentMethod', 'Invoice', 'PromoCode', 'PromoCodeActivation', - 'PaymentPlanFeature' + 'PaymentPlanFeature', ) @@ -1,20 +1,15 @@ from datetime import datetime -from dateutil.relativedelta import relativedelta from django.contrib.auth import get_user_model from django.contrib.postgres.fields import ArrayField from django.db import models from django.utils.translation import gettext_lazy as _ -from django_celery_beat.models import PeriodicTask from core.models import BaseModel from ml_model.models import NeuronModel class PaymentPlan(BaseModel): - MONTH = 'month' - YEAR = 'year' - DURATION_CHOICES = ((MONTH, MONTH), (YEAR, YEAR)) price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name=_('Price')) tokens_per_plan = models.DecimalField( max_digits=50, @@ -24,13 +19,6 @@ class PaymentPlan(BaseModel): ) is_corporate = models.BooleanField(default=False, verbose_name=_('Is corporate')) individual = models.BooleanField(default=False, verbose_name=_('Individual')) - is_recurrent = models.BooleanField(default=False, verbose_name=_('Is recurrent')) - duration = models.CharField( - verbose_name=_('Duration'), - max_length=100, - choices=DURATION_CHOICES, - default=MONTH, - ) is_visible = models.BooleanField(default=True, verbose_name=_('Is visible')) points = ArrayField( default=list, @@ -68,18 +56,23 @@ class PaymentPlanUserInfo(BaseModel): verbose_name=_('Payment Plan'), ) last_payment_at = models.DateField(verbose_name=_('Last payment at')) - next_payment_at = models.DateField(verbose_name=_('Next payment at')) + next_payment_at = models.DateTimeField(blank=True, null=True, verbose_name=_('Next payment at')) + method = models.OneToOneField( + 'PaymentMethod', + on_delete=models.SET_NULL, + verbose_name=_('Payment Method'), + related_name='user_plan_info', + null=True, + blank=True + ) current_token_balance = models.DecimalField( max_digits=100, decimal_places=10, verbose_name=_('Current balance') ) - plan_schedule = models.OneToOneField( - PeriodicTask, - on_delete=models.CASCADE, - related_name='payment_plan', - verbose_name=_('Recurrent billing task'), - default=None, - null=True, - blank=True, + referral_balance = models.DecimalField( + max_digits=100, + decimal_places=10, + default=0, + verbose_name=_('Referral balance'), ) def save( @@ -90,9 +83,12 @@ class PaymentPlanUserInfo(BaseModel): update_fields=None, ): self.last_payment_at = datetime.now().date() - self.next_payment_at = self.last_payment_at + relativedelta(months=1) return super().save(force_insert, force_update, using, update_fields) + @property + def is_recurring(self) -> bool: + return self.method is not None + def __str__(self) -> str: return f'{self.user.email or "Ошибка"}' @@ -126,8 +126,9 @@ class PromoCodeActivation(BaseModel): def add_tokens_referral(self, amount: int): self.add_tokens(amount=amount) - self.promocode.owner.payment_plan.current_token_balance += amount - self.promocode.owner.payment_plan.save() + owner_pp = self.promocode.owner.payment_plan + owner_pp.referral_balance += amount + owner_pp.save(update_fields=['referral_balance']) def __str__(self) -> str: return f'{self.promocode}' @@ -1,24 +1,17 @@ -from django.contrib.auth import get_user_model from django.db import models from django.utils.translation import gettext_lazy as _ from core.models import BaseModel -class UserPaymentMethod(BaseModel): - user = models.ForeignKey( - get_user_model(), - on_delete=models.CASCADE, - related_name='payment_methods', - verbose_name='Пользователь', - ) - currently_active = models.BooleanField(default=False, verbose_name='Способ платежа активен') - payment_method_id = models.UUIDField(unique=True, verbose_name='UID платёжного метода') - card_type = models.CharField(max_length=15, verbose_name='Тип карты') - last_four = models.CharField(max_length=4, verbose_name='Последние 4 цифры карты') +class PaymentMethod(BaseModel): + payment_method_id = models.UUIDField(unique=True, verbose_name=_('Payment method UID')) + card_type = models.CharField(max_length=15, verbose_name=_('Card type')) + last_four = models.CharField(max_length=4, verbose_name=_('Last four card digits')) + attempts = models.PositiveSmallIntegerField(default=0, verbose_name=_('Attempts')) def __str__(self) -> str: - return self.user.email + return f'Card {self.card_type} ****{self.last_four}' class Meta: verbose_name = _('Payment Method') @@ -1,9 +1,14 @@ +import orjson + import calendar import logging from collections import defaultdict from datetime import date, timedelta from decimal import Decimal +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + from dateutil.relativedelta import relativedelta from django.db.models import CharField, F, Func, Prefetch, Sum, Value from django.db.models.functions import Round, TruncDay, TruncMonth, TruncYear @@ -11,8 +16,18 @@ from django.utils.translation import gettext as _ from ninja import Query, Router from ninja.errors import HttpError +from authentication.models import CustomUserModel from authentication.security import AsyncAuthBearer, SyncAuthBearer -from payments.models import Invoice, Payment, PaymentPlan, PaymentPlanFeature +from payments.exceptions.payer_not_found import PayerNotFound +from payments.models import ( + Invoice, + Payment, + PaymentPlan, + PaymentPlanFeature, + PaymentMethod, + PaymentPlanUserInfo, +) +from authentication.services.email_service import EmailService from payments.schema import UserBalance from payments.schemas import ( ExpensesParamsSchema, @@ -22,9 +37,8 @@ from payments.schemas import ( PaymentPlanSchema, ) from payments.selectors.payment_plan_selector import PaymentPlanSelector -from payments.services.payment_plan_service import PaymentPlanService -from payments.services.payment_service import PaymentService from payments.typing import IntervalStrategyEnum, SourceStrategyEnum +from payments.services.payment_service import PaymentService router = Router(auth=SyncAuthBearer(), tags=['payments']) @@ -44,6 +58,43 @@ def get_user_balance(request): raise HttpError(401, f'{exc}') +@router.post('payment-result', tags=['payments/payment-result'], auth=None) +async def handle_yookassa_webhook(request): + data = orjson.loads(request.body) + logger.info('YooKassa webhook received: payment_id=%s', data['object']['id']) + payment = await PaymentService.handle_payment(data['object']['id']) + try: + payer = await CustomUserModel.objects.prefetch_related('payment_plan', 'payment_plan__plan', 'payment_plan__method').aget( + uid=payment.description + ) + await PaymentService(payer).do_payment(payment) + logger.info( + 'YooKassa webhook processed: payment_id=%s payer_email=%s status=%s', + payment.id, + payer.email, + payment.status, + ) + except CustomUserModel.DoesNotExist: + raise HttpError(400, str(PayerNotFound)) + except Exception as exc: + logger.exception(exc) + raise HttpError(400, f'{exc}') + return 200 + + +@router.post('revoke-recurring-payment', tags=['payments/revoke-recurring-payment']) +def revoke_recurring_payment(request): + deleted_count, deleted_details = PaymentMethod.objects.filter(user_plan_info__user=request.auth).delete() + logger.info( + 'Recurring payment revoked by user: email=%s deleted_methods=%s', + request.auth.email, + deleted_count, + ) + if deleted_count == 0: + raise HttpError(400, _('You do not have an active subscription to cancel')) + return 200, {'detail': _('The recurring payment is successfully cancelled')} + + @router.get('expenses', tags=['payments/expenses'], response=list[ExpensesSchema]) def list_expenses(request, data: ExpensesParamsSchema = Query(...)): try: @@ -89,7 +140,7 @@ def list_expenses(request, data: ExpensesParamsSchema = Query(...)): ), amount=Round(Sum('cost')), ) - case SourceStrategyEnum.DAYS.BUDGET: + case SourceStrategyEnum.BUDGET: expenses = { 'source': _('Expenses'), 'amount': qs.aggregate(amount=Round(Sum('cost')))['amount'], @@ -144,7 +195,6 @@ async def list_payment_plans(request): uid=plan.uid, price=plan.price, tokens_per_plan=plan.tokens_per_plan, - duration=plan.duration, is_corporate=plan.is_corporate, points=plan.points, grouped_features=[{'name': cat, 'features': feats} for cat, feats in grouped.items()], @@ -164,16 +214,37 @@ async def create_payment_link(request, body: NewSubscriptionSchema): raise HttpError(401, 'Unauthorized') payment_plan = await PaymentPlan.objects.aget(uid=body.uid) payment_url = await PaymentService(request.auth).create_payment_link(payment_plan) + logger.info( + 'Payment link endpoint completed: email=%s plan_uid=%s', + request.auth.email, + payment_plan.uid, + ) return PaymentLinkSchema(payment_url=payment_url) except Exception as exc: raise HttpError(400, f'{exc}') -@router.delete('plans', tags=['payments/plans'], auth=AsyncAuthBearer(), response={200: None, 400: str}) -async def cancel_plan_subscription(request): +@router.post('gitlab-webhook', tags=['payments/gitlab-webhook'], auth=None) +async def handle_gitlab_webhook(request): try: - if request.auth.account_type not in {'regular', 'business_host'}: - raise HttpError(401, 'Unauthorized') - await PaymentPlanService(request.auth).cancel_payment_plan() + data = orjson.loads(request.body)['object_attributes'] + if data['name'] == 'recurring_payments': + is_active = data['active'] + if not is_active: + deleted_methods_count, deleted_details = await PaymentMethod.objects.all().adelete() + logger.info( + 'Recurring feature disabled: all payment methods removed count=%s', + deleted_methods_count, + ) + updated_count = await PaymentPlanUserInfo.objects.filter( + plan__price__gt=0, + plan__individual=False, + ).aupdate(next_payment_at=None if not is_active else (timezone.now() + relativedelta(months=1))) + logger.info( + 'Recurring feature flag synced: active=%s updated_subscriptions=%s', + is_active, + updated_count, + ) except Exception as exc: - raise HttpError(400, f'{exc}') + logger.error(exc) + return 200 @@ -1,31 +0,0 @@ -from uuid import UUID - -from authentication.models.user import CustomUserModel -from payments.models.user_payment_method import UserPaymentMethod -from payments.serializers import PaymentMethodSerializer - - -class PaymentMethodSelector: - def __init__(self, user: CustomUserModel): - self.user = user - - def list(self, serialize: bool = False): - methods = self.user.payment_methods.all() - if serialize: - return PaymentMethodSerializer(methods, many=True) - return methods - - def get_payment_method_by_uuid(self, method_id: UUID) -> UserPaymentMethod | None: - payment = UserPaymentMethod.objects.filter(user=self.user, payment_method_id=method_id) - - if not payment.exists(): - return None - - return payment.first() - - def get_current_active_method(self) -> UserPaymentMethod: - method = UserPaymentMethod.objects.filter(user=self.user, currently_active=True) - if not method.exists(): - raise Exception(f'No active payment method for user {self.user}') - - return method.first() @@ -18,15 +18,17 @@ class PaymentPlanSelector: if ( self.user.account_type in ('business_account', 'business_admin', 'business_security') ) and self.user.business_account.acceptance_status == InvitationStatus.ACCEPTED: - balance = ( + pp = self.user.business_account.parent_company.user.payment_plan + total_available = pp.current_token_balance + pp.referral_balance + limit = ( self.user.business_account.group.token_limit if self.user.business_account.group else self.user.business_account.token_limit ) - if balance is None: - balance = self.user.business_account.parent_company.user.payment_plan.current_token_balance + balance = min(total_available, limit) if limit is not None else total_available else: - balance = self.user.payment_plan.current_token_balance + pp = self.user.payment_plan + balance = pp.current_token_balance + pp.referral_balance return balance @@ -43,8 +45,8 @@ class PaymentPlanSelector: return UserPaymentPlanSerializer(plan) - def get_free_plan(self, corporate: bool = False, recurrent: bool = False) -> PaymentPlan: - return PaymentPlan.objects.get_or_create(price=0, is_corporate=corporate, is_recurrent=recurrent)[0] + def get_free_plan(self, corporate: bool = False) -> PaymentPlan: + return PaymentPlan.objects.get_or_create(price=0, is_corporate=corporate)[0] def is_plan_paid(self) -> bool: return self.user.payment_plan.plan.price != Decimal('0') @@ -1,21 +0,0 @@ -from typing import Type, TypeVar -from uuid import UUID - -from django_celery_beat.models import PeriodicTask - -Self = TypeVar('Self', bound='RecurrentPaymentSelector') - - -class RecurrentPaymentSelector: - def __init__(self, task: PeriodicTask): - self.task = task - - @classmethod - def from_user_id(cls: Type[Self], user_id: UUID) -> Self | None: - task = PeriodicTask.objects.filter(name=f'recurrent_{user_id}').first() - if task is None: - return None - return cls(task) - - def to_service(self, service): - return service(self.task) @@ -30,13 +30,20 @@ class ModelBillingService: else: raise Exception(_('Unknown account type')) - if plan.current_token_balance < amount: - raise InsufficientBalance(plan.current_token_balance, amount) + total_available = plan.current_token_balance + plan.referral_balance + if total_available < amount: + raise InsufficientBalance(total_available, amount) - if (allowance is not None) and (allowance < amount): + if allowance is not None and allowance < amount: raise InsufficientBalance(allowance, amount) - plan.current_token_balance -= amount + remainder = amount + from_main = min(plan.current_token_balance, remainder) + plan.current_token_balance -= from_main + remainder -= from_main + if remainder > 0: + plan.referral_balance -= remainder + if allowance is not None: if self.user.business_account.group is not None: self.user.business_account.group.token_limit -= amount @@ -1,69 +1,34 @@ import logging from uuid import UUID -from django.db.transaction import atomic -from django.utils.translation import gettext_lazy as _ -from rest_framework.request import Request +from authentication.models import CustomUserModel +from payments.models.user_payment_method import PaymentMethod -from authentication.models.user import CustomUserModel -from payments.models.user_payment_method import UserPaymentMethod -from payments.selectors.payment_method_selector import PaymentMethodSelector -from payments.serializers import ResetPaymentMethodSerializer logger = logging.getLogger(__name__) class PaymentMethodService: - def __init__(self, user: CustomUserModel): + def __init__(self, user: CustomUserModel) -> None: self.user = user def add_payment_method(self, method_id: UUID, card_type: str, last_four: str): - logger.info('START %s', self.add_payment_method.__name__) - existing = PaymentMethodSelector(self.user).get_payment_method_by_uuid(method_id) - logger.info('CURRENT PAYMENT METHOD: %s', existing) - if existing is not None: - return - - payment_method, created = UserPaymentMethod.objects.get_or_create( - user=self.user, - last_four=last_four, - payment_method_id=method_id, - card_type=card_type, - currently_active=True, + payment_method, created = PaymentMethod.objects.update_or_create( + user_plan_info__user=self.user, + defaults={'payment_method_id': method_id, 'last_four': last_four, 'card_type': card_type, 'attempts': 0}, ) - logger.info('NEW PAYMENT METHOD: %s', payment_method) - payment_method.save() - - def delete_payment_method(self, method_id: UUID): - existing = PaymentMethodSelector(self.user).get_payment_method_by_uuid(method_id) - if existing is not None: - existing.delete() - - def unset_payment_method(self): - current_active = PaymentMethodSelector(self.user).get_current_active_method() - - if current_active is None: - raise Exception(_('No current active payment method is set')) - - current_active.is_active = False - current_active.save() - - def set_new_payment_method(self, id: UUID): - method = PaymentMethodSelector(self.user).get_payment_method_by_uuid(id) - - if method is None: - raise Exception(_('No payment method by this id')) - - method.is_active = True - method.save() - - @atomic - def change_methods(self, id: UUID): - self.unset_payment_method() - self.set_new_payment_method(id) - - def update(self, request: Request): - serializer = ResetPaymentMethodSerializer(data=request.data) - serializer.is_valid(raise_exception=True) + logger.info( + 'Payment method saved: email=%s method_uid=%s payment_method_id=%s created=%s', + self.user.email, + payment_method.uid, + method_id, + created, + ) + return payment_method - self.change_methods(serializer.validated_data['uid']) + def delete_payment_method(self): + PaymentMethod.objects.filter(user_plan_info__user=self.user).delete() + logger.info( + 'Payment method deleted: email=%s', + self.user.email, + ) @@ -1,19 +1,10 @@ import logging from decimal import Decimal -from asgiref.sync import sync_to_async -from rest_framework.request import Request - from authentication.models import CustomUserModel from payments.models import Invoice, PaymentPlan, PaymentPlanUserInfo -from payments.selectors.payment_plan_selector import PaymentPlanSelector -from payments.selectors.recurrent_payment_selector import ( - RecurrentPaymentSelector, -) -from payments.serializers import SuccessPaymentResult from payments.services.model_billing_service import ModelBillingService from payments.services.payment_service import PaymentService -from payments.services.recurrent_payment_service import RecurrentPaymentService logger = logging.getLogger(__name__) @@ -26,46 +17,28 @@ class PaymentPlanService: def add_tokens(self, amount: float | Decimal): self.user.payment_plan.current_token_balance += amount self.user.payment_plan.save() + logger.info( + 'Tokens added to plan balance: email=%s amount=%s new_balance=%s', + self.user.email, + amount, + self.user.payment_plan.current_token_balance, + ) - @classmethod - def handle_success_payment(cls, request: Request): - """Parse Yookassa Successful Payment Request and - Subscribe user to paid Payment Plan""" - serializer = SuccessPaymentResult(data=request.data['object']) - serializer.is_valid(raise_exception=True) - payment_id = serializer.validated_data['id'] - payment = PaymentService.confirm_payment(payment_id) - if payment.status == payment.SUCCEEDED: - cls(payment.user).subscribe_user_to_plan(payment.plan) - - def handle_recurrent(self, plan: PaymentPlanUserInfo): - current = RecurrentPaymentSelector.from_user_id(self.user.uid) - if current is None: - task = RecurrentPaymentService.create(self.user, plan.plan) - plan.plan_schedule = task.task - plan.save() - else: - current.to_service(RecurrentPaymentService).switch_plan(self.user, plan.plan.uid) - - def subscribe_user_to_plan(self, plan: PaymentPlan): - plan_info, created = PaymentPlanUserInfo.objects.get_or_create( + def subscribe_user_to_plan(self, plan: PaymentPlan, amount: Decimal): + _, created = PaymentPlanUserInfo.objects.update_or_create( user=self.user, defaults={ 'plan': plan, - 'current_token_balance': plan.tokens_per_plan, + 'current_token_balance': amount, }, ) - if not created: - plan_info.plan = plan - plan_info.current_token_balance += plan.tokens_per_plan - plan_info.save() - - async def cancel_payment_plan(self): - plan_info: PaymentPlanUserInfo = self.user.payment_plan - plan_info.plan = await sync_to_async(PaymentPlanSelector(self.user).get_free_plan)( - corporate=self.user.is_corporate() + logger.info( + 'User subscribed to plan: email=%s plan_uid=%s tokens=%s created=%s', + self.user.email, + plan.uid, + amount, + created, ) - await plan_info.asave() def update_per_token_plan_details(self, payment_amount: Decimal, model=None): ModelBillingService(self.user).charge(payment_amount) @@ -1,18 +1,22 @@ import logging + +from decimal import Decimal from uuid import UUID, uuid4 +from asgiref.sync import sync_to_async +from dateutil.relativedelta import relativedelta from django.conf import settings +from django.db import transaction +from django.utils import timezone from yookassa import Configuration from yookassa import Payment as YookassaPayment -from yookassa.domain.models.payment_data.response.payment_data_bank_card import ( - PaymentDataBankCard, -) +from yookassa.domain.response import PaymentResponse as YookassaPaymentResponse from authentication.models import CustomUserModel -from payments.exceptions.payer_not_found import PayerNotFound +from lib.unleash.client import web_client +from payments.exceptions.full_balance import FullBalanceException from payments.models.payment import Payment as PaymentModel -from payments.models.payment_plan import PaymentPlan -from payments.selectors.payment_method_selector import PaymentMethodSelector +from payments.models.payment_plan import PaymentPlan, PaymentPlanUserInfo from payments.services.payment_method_service import PaymentMethodService from payments.services.referral_account import ReferralAccountService @@ -31,13 +35,18 @@ class PaymentService: 'customer': {'email': self.user.email}, 'items': [ { - 'description': f'План {plan.tokens_per_plan} токенов за {plan.price} р.', + 'description': str(plan), 'amount': {'value': f'{plan.price}', 'currency': 'RUB'}, 'vat_code': 1, 'quantity': '1', } ], } + is_recurring = ( + web_client.get_flag_state('recurring_payments', self.user.email) + and not plan.individual + and web_client.get_flag_state('auto-save-payments-enabled', self.user.email) + ) payment_data = { 'amount': {'value': f'{plan.price}', 'currency': 'RUB'}, 'payment_method_data': {'type': 'bank_card'}, @@ -48,71 +57,147 @@ class PaymentService: }, 'description': str(self.user.uid), 'capture': True, + 'save_payment_method': is_recurring, + 'metadata': {'plan_uid': str(plan.uid)}, } - - if plan.is_recurrent: - payment_data.update(save_payment_method=True) payment = YookassaPayment.create(payment_data, uuid4()) + logger.info( + 'Payment link created: email=%s plan_uid=%s price=%s recurring=%s', + self.user.email, + plan.uid, + plan.price, + is_recurring, + ) return payment.confirmation.confirmation_url - @classmethod - def save_payment_method(cls, user: CustomUserModel, method_data: PaymentDataBankCard): - method_id = method_data.id - card = method_data.card - card_type = card.card_type - - last_four = card.last4 - PaymentMethodService(user).add_payment_method( - method_id=UUID(method_id), card_type=card_type, last_four=last_four - ) + @sync_to_async + def do_payment(self, payment: YookassaPaymentResponse) -> PaymentModel: + from payments.services.payment_plan_service import PaymentPlanService - @classmethod - def confirm_payment(cls, payment_id: UUID): - payment = YookassaPayment.find_one(str(payment_id)) - try: - user = CustomUserModel.objects.get(uid=payment.description) - except CustomUserModel.DoesNotExist: - raise PayerNotFound - if payment.status == 'waiting_for_capture': - YookassaPayment.capture(str(payment_id)) - elif payment.status == 'succeeded': - method_data = payment.payment_method - if method_data.saved: - cls.save_payment_method(user, method_data) - payment_instance = cls.save_payment(user=user, payment=payment) - try: - if user.referer_account: - ReferralAccountService.apply_accrual( - referer_account=user.referer_account, - payment=payment_instance, + with transaction.atomic(): + payment_instance = self.save_payment(payment) + logger.info( + 'Processing payment webhook: payment_id=%s email=%s status=%s', + payment.id, + self.user.email, + payment.status, + ) + if payment.status == 'waiting_for_capture': + self.handle_captured_payment(payment.id) + elif payment.status == 'succeeded': + buying_tokens = ( + payment_instance.plan.tokens_per_plan + if payment.metadata.get('recurring') + else self.calculate_buying_tokens(payment_instance.plan) ) - except Exception as exc: - logger.exception(exc) + self.handle_succeeded_payment(payment, payment_instance.plan) + PaymentPlanService(self.user).subscribe_user_to_plan(payment_instance.plan, buying_tokens) + if ref_acc := self.user.referer_account: + ReferralAccountService.apply_accrual(referer_account=ref_acc, payment=payment_instance) + elif payment.status == 'canceled' and payment.metadata.get('recurring'): + self.handle_canceled_payment(payment) + return payment_instance @classmethod - def save_payment(cls, user: CustomUserModel, payment: YookassaPayment) -> PaymentModel: - payment_instance, _ = PaymentModel.objects.update_or_create( + async def handle_payment(cls, payment_id: UUID) -> YookassaPaymentResponse: + return await sync_to_async(YookassaPayment.find_one)(payment_id) + + def handle_captured_payment(self, payment_id: UUID) -> None: + YookassaPayment.capture(str(payment_id)) + logger.info('Payment captured: payment_id=%s email=%s', payment_id, self.user.email) + + def calculate_buying_tokens(self, plan: PaymentPlan): + if self.user.payment_plan.plan.price == Decimal('0'): + return plan.tokens_per_plan + return self.user.payment_plan.current_token_balance + plan.tokens_per_plan + + def handle_succeeded_payment(self, payment: YookassaPaymentResponse, plan: PaymentPlan) -> None: + if ( + payment.payment_method.saved + and not payment.authorization_details.three_d_secure.applied + and web_client.get_flag_state('recurring_payments', self.user.email) + and not plan.individual + ): + card = payment.payment_method.card + payment_method = PaymentMethodService(self.user).add_payment_method( + payment.payment_method.id, card.card_type, card.last4 + ) + PaymentPlanUserInfo.objects.filter(user=self.user).update( + method=payment_method, next_payment_at=timezone.now() + relativedelta(months=1) + ) + logger.info( + 'Recurring payment method saved: email=%s method_uid=%s next_payment_at_set=true', + self.user.email, + payment_method.uid, + ) + else: + if web_client.get_flag_state('recurring_payments', self.user.email) and not plan.individual: + PaymentPlanUserInfo.objects.filter(user=self.user).update( + next_payment_at=timezone.now() + relativedelta(months=1) + ) + logger.info( + 'Recurring schedule updated without saved method: email=%s next_payment_at_set=true', + self.user.email, + ) + else: + PaymentPlanUserInfo.objects.filter(user=self.user).update(next_payment_at=None) + logger.info( + 'Recurring schedule cleared: email=%s reason=feature_disabled_or_individual_plan', + self.user.email, + ) + PaymentMethodService(self.user).delete_payment_method() + logger.info( + 'Recurring payment method deleted after succeeded payment: email=%s', self.user.email + ) + + def handle_canceled_payment(self, payment: YookassaPaymentResponse) -> None: + logger.error(f'Recurrent payment error: {payment.cancellation_details.reason}') + temporary_cancel_reasons = ( + 'call_issuer', + 'expired_on_capture', + 'insufficient_funds', + 'internal_timeout', + 'issuer_unavailable', + 'payment_method_limit_exceeded', + ) + if payment.cancellation_details.reason in temporary_cancel_reasons: + self.user.payment_plan.method.attempts += 1 + self.user.payment_plan.method.save() + logger.info( + 'Recurring payment canceled with retry: email=%s method_uid=%s attempts=%s reason=%s', + self.user.email, + self.user.payment_plan.method.uid, + self.user.payment_plan.method.attempts, + payment.cancellation_details.reason, + ) + else: + PaymentMethodService(self.user).delete_payment_method() + logger.info( + 'Recurring payment method deleted after cancel: email=%s reason=%s', + self.user.email, + payment.cancellation_details.reason, + ) + + def save_payment(self, payment: YookassaPaymentResponse) -> PaymentModel: + plan = PaymentPlan.objects.get_or_none(uid=payment.metadata.get('plan_uid')) + payment_instance, created = PaymentModel.objects.update_or_create( uid=payment.id, defaults=dict( - user=user, + user=self.user, amount=payment.amount.value, - plan=PaymentPlan.objects.get_or_none(price=payment.amount.value), + plan=plan, status=payment.status, description=payment.description, ), ) - return payment_instance - - def conduct_recurring_payment(self, plan: PaymentPlan, description: str): - logger.info('START %s' % self.conduct_recurring_payment.__name__) - payment_method = PaymentMethodSelector(self.user).get_current_active_method() - logger.info('PAYMENT METHOD: %s' % payment_method) - YookassaPayment.create( - { - 'amount': {'value': f'{plan.price}', 'currency': 'RUB'}, - 'capture': True, - 'payment_method_id': f'{payment_method.payment_method_id}', - 'description': description, - } + logger.info( + 'Payment persisted: payment_id=%s email=%s status=%s created=%s plan_uid=%s amount=%s', + payment.id, + self.user.email, + payment.status, + created, + plan.uid if plan else None, + payment.amount.value, ) + return payment_instance @@ -1,53 +0,0 @@ -import json -import logging -from typing import Type, TypeVar - -from django.conf import settings -from django.utils import timezone -from django_celery_beat.models import IntervalSchedule, PeriodicTask - -from authentication.models.user import CustomUserModel -from payments.models import PaymentPlan - -Self = TypeVar('Self', bound='RecurrentPaymentService') -logger = logging.getLogger(__name__) - - -class RecurrentPaymentService: - def __init__(self, task: PeriodicTask): - self.task = task - - @classmethod - def create(cls: Type[Self], user: CustomUserModel, payment_plan: PaymentPlan) -> Self: - subscription_days_duration = ( - 30 - if payment_plan.duration == payment_plan.MONTH - else 365 - if payment_plan.duration == payment_plan.YEAR - else 30 - ) - schedule = IntervalSchedule.objects.filter( - every=subscription_days_duration, period=settings.RECURRENT_RATE - ) - - if not schedule.exists(): - schedule = IntervalSchedule.objects.create(every=30, period=settings.RECURRENT_RATE) - else: - schedule = schedule.first() - - task = PeriodicTask.objects.create( - name=f'recurrent_{user.uid}', - task='conduct_recurrent', - interval=schedule, - args=json.dumps([str(payment_plan.uid), str(user.uid)]), - start_time=timezone.now(), - ) - return cls(task) - - def switch_plan(self, user: CustomUserModel, new_plan: PaymentPlan) -> Self: - self.delete() - self.create(user=user, payment_plan=new_plan) - return self - - def delete(self): - self.task.delete() @@ -1,3 +1,4 @@ +import logging from decimal import Decimal from authentication.models.user import CustomUserModel @@ -8,6 +9,8 @@ from payments.models.referral_account import ( ReferralInvite, ) +logger = logging.getLogger(__name__) + class ReferralAccountService: @classmethod @@ -15,17 +18,26 @@ class ReferralAccountService: return ReferralAccount.objects.create(owner=user) @classmethod - def create_invite(cls, referer_account: CustomUserModel, invitee: CustomUserModel): + def create_invite(cls, referer_account: ReferralAccount, invitee: CustomUserModel): return ReferralInvite.objects.create(referer_account=referer_account, invitee=invitee) @classmethod def apply_accrual(cls, referer_account: ReferralAccount, payment: Payment): - accrual_amount = (payment.plan.tokens_per_plan * Decimal(0.2)).quantize(Decimal('1')) - accrual = ReferralAccrual.objects.create( - referer_account=referer_account, - payment=payment, - amount=accrual_amount, - ) - referer_account.owner.payment_plan.current_token_balance += accrual_amount - referer_account.owner.payment_plan.save() - return accrual + if not ReferralAccrual.objects.filter( + referer_account=referer_account, payment__user=payment.user + ).first(): + accrual_amount = (payment.plan.tokens_per_plan * Decimal(0.2)).quantize(Decimal('1')) + ReferralAccrual.objects.create( + referer_account=referer_account, + payment=payment, + amount=accrual_amount, + ) + pp = referer_account.owner.payment_plan + pp.referral_balance += accrual_amount + pp.save(update_fields=['referral_balance']) + logger.info( + 'Referral accrual applied: referer_email=%s invitee_email=%s amount=%s', + referer_account.owner.email, + payment.user.email if payment.user else None, + accrual_amount, + ) @@ -0,0 +1,12 @@ + + + + + Отмена подписки на платформе AIR + + +

Подписка отменена. Доступ ко всем возможностям сохранится до окончания оплаченного периода. Никаких дополнительных + списаний не будет.

+

Спасибо, что воспользовались нашим маркетплейсом нейросетей.

+ + \ No newline at end of file @@ -129,11 +129,11 @@ class PlansAPITest(BaseAuthorizedAPITest): 'uid', 'price', 'tokens_per_plan', - 'duration', + 'is_corporate', 'points', 'grouped_features', 'accessed_models', - 'individual' + 'individual', ], ) @@ -46,9 +46,21 @@ class BalanceAPITest(BaseAuthorizedAPITest): balance = self.get().json()['current_token_balance'] self.assertEqual(Decimal(balance), Decimal('8.00')) + def test_balance_includes_referral(self) -> None: + pp = self.user.payment_plan + pp.current_token_balance = Decimal('3') + pp.referral_balance = Decimal('7') + pp.save() + balance = self.get().json()['current_token_balance'] + self.assertEqual(Decimal(balance), Decimal('10')) + def test_displaying_balance(self) -> None: balance = self.get().json()['current_token_balance'] self.assertEqual(Decimal(balance), Decimal('10.00')) + hp = self.host_user.payment_plan + hp.current_token_balance = Decimal('900') + hp.referral_balance = Decimal('0') + hp.save() business_account = BusinessAccount.objects.create( user=self.user, parent_company=self.host, acceptance_status='accepted', token_limit=Decimal('50') ) @@ -16,7 +16,7 @@ from payments.models import ( PaymentPlanUserInfo, PromoCode, PromoCodeActivation, - UserPaymentMethod, + PaymentMethod, ) from payments.models.referral_account import ReferralAccount, ReferralInvite @@ -47,9 +47,6 @@ class PaymentPlanAdmin(OrderedInlineModelAdminMixin, admin.ModelAdmin): '__str__', 'uid', 'price', - 'tokens_per_plan', - 'duration', - 'is_recurrent', 'is_corporate', 'individual', 'is_visible', @@ -59,7 +56,7 @@ class PaymentPlanAdmin(OrderedInlineModelAdminMixin, admin.ModelAdmin): @admin.register(PaymentPlanUserInfo) class PaymentPlanUserInfoAdmin(admin.ModelAdmin): - list_display = ['user', 'current_token_balance', 'plan', 'updated_at'] + list_display = ['user', 'current_token_balance', 'referral_balance', 'plan', 'updated_at', 'next_payment_at'] raw_id_fields = ['user'] search_fields = [ @@ -76,9 +73,9 @@ class PaymentPlanFeatureAdmin(OrderedModelAdmin): list_filter = ('plan', 'model__category') -@admin.register(UserPaymentMethod) -class UserPaymentMethodAdmin(admin.ModelAdmin): - raw_id_fields = ['user'] +@admin.register(PaymentMethod) +class PaymentMethodAdmin(admin.ModelAdmin): + list_display = ['payment_method_id', 'card_type', 'last_four', 'attempts'] @admin.register(Invoice) @@ -224,3 +221,4 @@ class ReferralAccountAdmin(admin.ModelAdmin): @admin.display(description='Получено бонусов') def _accrued_bonuses(self, obj: ReferralAccount): return f'{obj.accrued_bonuses.aggregate(total=Coalesce(Sum("amount"), Decimal(0), output_field=models.DecimalField()))["total"]} токенов' + @@ -2,6 +2,8 @@ from django.apps import AppConfig from django.core.signals import setting_changed from django.utils.translation import gettext_lazy as _ +from lib.unleash.client import web_client + class PaymentsConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' @@ -9,8 +11,9 @@ class PaymentsConfig(AppConfig): verbose_name = _('Payments') def ready(self): - from .signals import init_referral_account + import payments.signals - setting_changed.connect(init_referral_account) + web_client.client.initialize_client() + setting_changed.connect(payments.signals.init_referral_account) return super().ready() @@ -1,4 +1,4 @@ -from datetime import date +from datetime import date, datetime from typing import List, Optional from uuid import UUID @@ -24,7 +24,6 @@ class PaymentPlanSchema(Schema): uid: UUID price: condecimal(max_digits=10, decimal_places=2) tokens_per_plan: condecimal(max_digits=10, decimal_places=2) - duration: str is_corporate: bool points: list[str] grouped_features: list[GroupedPlanFeatureSchema] = [] @@ -47,8 +46,9 @@ class PaymentLinkSchema(Schema): class UserPlanDetailSchema(Schema): uid: UUID plan: PaymentPlanSchema - last_payment_at: date - next_payment_at: date + last_payment_at: datetime + next_payment_at: datetime | None + is_recurring: bool class PromoCodeSchema(ModelSchema): @@ -66,14 +66,16 @@ class ExpensesParamsSchema(Schema): source_strategy: SourceStrategyEnum | None = None start: Optional[date] = Query( default=date.min, - example="2025-12-17", + example='2025-12-17', ) end: Optional[date] = Query( default=date.min, - example="2025-12-07", + example='2025-12-07', ) class ExpensesSchema(Schema): source: str - amount: condecimal(max_digits=10, decimal_places=2) \ No newline at end of file + amount: condecimal(max_digits=10, decimal_places=2) + + @@ -9,7 +9,6 @@ class PaymentPlanSerializer(serializers.Serializer): uid = serializers.UUIDField() price = serializers.DecimalField(max_digits=10, decimal_places=2) tokens_per_plan = serializers.DecimalField(max_digits=50, decimal_places=2) - duration = serializers.CharField(read_only=True) is_corporate = serializers.BooleanField() accessed_models = serializers.SerializerMethodField() individual = serializers.BooleanField() @@ -53,26 +52,6 @@ class UserPlanDetailSerializer(serializers.Serializer): current_token_balance = serializers.IntegerField() -class SuccessPaymentResult(serializers.Serializer): - id = serializers.UUIDField() - - -class PaymentMethodSerializer(serializers.Serializer): - uid = serializers.UUIDField() - currently_active = serializers.BooleanField() - payment_method_id = serializers.UUIDField() - card_type = serializers.CharField() - last_four = serializers.CharField() - - -class ResetPaymentMethodSerializer(serializers.Serializer): - uid = serializers.UUIDField() - - -class DeletePaymentMethodSerializer(serializers.Serializer): - uid = serializers.UUIDField() - - class UserPaymentPlanSerializer(serializers.Serializer): current_token_balance = serializers.DecimalField(max_digits=50, decimal_places=2) @@ -1,11 +1,18 @@ +import logging from typing import Type -from django.db.models.signals import post_save +from django.conf import settings +from django.db import transaction +from django.db.models.signals import post_save, pre_save, pre_delete from django.dispatch import receiver from authentication.models.user import CustomUserModel +from authentication.services.email_service import EmailService +from payments.models import PaymentPlan, PaymentMethod, PaymentPlanUserInfo from payments.services.referral_account import ReferralAccountService +logger = logging.getLogger(__name__) + @receiver(post_save, sender=CustomUserModel) def init_referral_account( @@ -16,3 +23,58 @@ def init_referral_account( ): if created: ReferralAccountService.create_account(user=instance) + + +@receiver(post_save, sender=PaymentPlan) +def delete_recurrent_for_individual_plans( + sender: Type[PaymentPlan], instance: PaymentPlan, created: bool, **kwargs +): + if instance.individual: + PaymentPlanUserInfo.objects.filter(plan=instance).update(next_payment_at=None) + PaymentMethod.objects.filter(user_plan_info__plan=instance).delete() + + +@receiver(post_save, sender=PaymentMethod) +def delete_method_with_exceeded_attempts( + sender: Type[PaymentMethod], instance: PaymentMethod, created: bool, **kwargs +): + if instance.attempts >= settings.MAX_RECURRING_ATTEMPTS: + user_email = ( + PaymentPlanUserInfo.objects.filter(method=instance).values_list('user__email', flat=True).first() + ) + logger.info( + 'Payment method deleted due to attempts limit: email=%s', + user_email, + ) + instance.delete() + + +@receiver(post_save, sender=PaymentPlanUserInfo) +def clear_recurrent_on_individual_plan_assignment( + sender: Type[PaymentPlanUserInfo], instance: PaymentPlanUserInfo, created: bool, **kwargs +): + if not instance.plan.individual: + return + + method_id = instance.method_id + PaymentPlanUserInfo.objects.filter(pk=instance.pk).update(next_payment_at=None, method=None) + + if method_id: + PaymentMethod.objects.filter(pk=method_id).delete() + + +@receiver(pre_delete, sender=PaymentMethod) +def send_revoke_email_on_method_delete(sender: Type[PaymentMethod], instance: PaymentMethod, **kwargs): + email = ( + PaymentPlanUserInfo.objects.filter(method_id=instance.pk) + .values_list('user__email', flat=True) + .first() + ) + + def _send(): + try: + EmailService.send_revoke_recurring_email(email) + except Exception: + logger.exception('Failed to send revoke recurring email') + + transaction.on_commit(_send) @@ -1,15 +1,21 @@ from decimal import Decimal -from uuid import UUID +from uuid import UUID, uuid4 from celery import shared_task from celery.utils.log import get_task_logger from django.db.models import F +from django.utils import timezone from authentication.models.business_host import BusinessUserHost from authentication.models.user import CustomUserModel from authentication.services.email_service import EmailService +from lib.unleash.client import celery_client +from payments.models import PaymentPlanUserInfo, PaymentMethod +from payments.selectors.payment_plan_selector import PaymentPlanSelector from payments.services.payment_plan_service import PaymentPlanService +from yookassa import Payment as YookassaPayment + logger = get_task_logger(__name__) @@ -17,7 +23,8 @@ logger = get_task_logger(__name__) def send_low_balance_message(): hosts = BusinessUserHost.objects.filter( token_cap_enabled=True, - token_cap__gt=F('user__payment_plan__current_token_balance'), + token_cap__gt=F('user__payment_plan__current_token_balance') + + F('user__payment_plan__referral_balance'), ) for host in hosts: EmailService.send_low_balance_email(host) @@ -27,3 +34,80 @@ def send_low_balance_message(): def withdraw(user_id: UUID, amount: Decimal): user = CustomUserModel.objects.get(uid=user_id) PaymentPlanService(user).update_per_token_plan_details(amount) + + +@shared_task +def execute_recurring_payments() -> None: + overdue_payments = PaymentPlanUserInfo.objects.select_related('user', 'plan', 'method').filter( + next_payment_at__isnull=False, + next_payment_at__lte=timezone.now(), + plan__price__gt=0, + plan__individual=False, + ) + canceled_recurring_payments = [] + logger.info('Recurring payments task started: overdue_count=%s', overdue_payments.count()) + for overdue_payment in overdue_payments: + customer = overdue_payment.user + plan = overdue_payment.plan + if not celery_client.get_flag_state('recurring_payments', overdue_payment.user.email): + overdue_payment.next_payment_at = None + canceled_recurring_payments.append(overdue_payment) + logger.info( + 'Recurring payment canceled by feature flag: email=%s plan_uid=%s', + customer.email, + plan.uid, + ) + continue + if not overdue_payment.is_recurring: + free_plan = PaymentPlanSelector(customer).get_free_plan(plan.is_corporate) + overdue_payment.next_payment_at = None + overdue_payment.plan = free_plan + overdue_payment.current_token_balance = 0 + canceled_recurring_payments.append(overdue_payment) + logger.info( + 'Recurring payment canceled due to missing method: email=%s plan_uid=%s switched_to_free_plan_uid=%s', + customer.email, + plan.uid, + free_plan.uid, + ) + continue + receipt_data = { + 'customer': {'email': customer.email}, + 'items': [ + { + 'description': str(plan), + 'amount': {'value': f'{plan.price}', 'currency': 'RUB'}, + 'vat_code': 1, + 'quantity': '1', + } + ], + } + payment_data = { + 'amount': {'value': f'{plan.price}', 'currency': 'RUB'}, + 'payment_method_id': overdue_payment.method.payment_method_id, + 'receipt': receipt_data, + 'description': str(customer.uid), + 'capture': True, + 'metadata': { + 'recurring': True, + 'plan_uid': str(plan.uid), + }, + } + YookassaPayment.create(payment_data, uuid4()) + logger.info( + 'Recurring payment initiated: email=%s plan_uid=%s amount=%s method_uid=%s', + customer.email, + plan.uid, + plan.price, + overdue_payment.method.uid, + ) + methods_for_delete = [crp.method.uid for crp in canceled_recurring_payments if crp.method] + PaymentPlanUserInfo.objects.bulk_update( + canceled_recurring_payments, fields=['next_payment_at', 'plan', 'current_token_balance'] + ) + deleted_methods_count, deleted_details = PaymentMethod.objects.filter(uid__in=methods_for_delete).delete() + logger.info( + 'Recurring payments task finished: canceled_count=%s deleted_methods=%s', + len(canceled_recurring_payments), + deleted_methods_count, + ) \ No newline at end of file @@ -10,16 +10,6 @@ urlpatterns = [ name='referral-account', ), path('invoices', views.InvoicesAPIView.as_view(), name='invoices'), - path( - 'methods', - views.PaymentMethodsAPIView.as_view(), - name='payment-methods', - ), - path( - 'payment-result', - views.PaymentConfirmationAPIView.as_view(), - name='payment-result', - ), path('promocode', views.PromoCodeAPIView.as_view(), name='promocode'), path( 'telegram-sub', @@ -15,20 +15,15 @@ from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView -from authentication.permissions import IsAnonymous, IsTelegramAirBot +from authentication.permissions import IsTelegramAirBot from authentication.selectors.user_selector import UserSelector -from payments.exceptions.payer_not_found import PayerNotFound from payments.models import Invoice from payments.permissions import IsAllowedToPay -from payments.selectors.payment_method_selector import PaymentMethodSelector -from payments.selectors.payment_plan_selector import PaymentPlanSelector from payments.selectors.payment_selector import PaymentSelector from payments.serializers import ( - DeletePaymentMethodSerializer, InvoiceSerializer, ReferralAccountSerializer, ) -from payments.services.payment_method_service import PaymentMethodService from payments.services.payment_plan_service import PaymentPlanService from payments.services.promocode_service import PromoCodeService @@ -49,54 +44,6 @@ class PaymentAPIView(APIView): return Response({'detail': f'{err}'}, status=status.HTTP_400_BAD_REQUEST) -class PaymentMethodsAPIView(APIView): - permission_classes = (IsAuthenticated, IsAllowedToPay) - - def get(self, request, *args, **kwargs): - """List user's saved payment methods (cards)""" - try: - result = PaymentMethodSelector(self.request.user).list(serialize=True) - return Response(result.data, status=status.HTTP_200_OK) - except Exception as err: - logger.exception(err) - return Response({'detail': f'{err}'}, status=status.HTTP_400_BAD_REQUEST) - - def put(self, request, *args, **kwargs): - """Update payment method (card) data.""" - try: - PaymentMethodService(self.request.user).update(request) - return Response({'ok': True}, status=status.HTTP_200_OK) - except Exception as err: - logger.exception(err) - return Response({'detail': f'{err}'}, status=status.HTTP_400_BAD_REQUEST) - - def delete(self, request, *args, **kwargs): - """Delete user's payment method (card)""" - try: - serializer = DeletePaymentMethodSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - PaymentMethodService(self.request.user).delete_payment_method(**serializer.validated_data) - return Response({'ok': True}, status=status.HTTP_200_OK) - except Exception as err: - logger.exception(err) - return Response({'detail': f'{err}'}, status=status.HTTP_400_BAD_REQUEST) - - -class PaymentConfirmationAPIView(APIView): - permission_classes = (IsAnonymous,) - - def post(self, request, *args, **kwargs): - """Endpoint for payment service (Yookassa) WebHook. Hook happens after successful payment.""" - try: - PaymentPlanService.handle_success_payment(self.request) - return Response(status=status.HTTP_200_OK) - except PayerNotFound as err: - return Response({'detail': str(err)}, status=status.HTTP_400_BAD_REQUEST) - except Exception as err: - logger.exception(err) - return Response({'detail': {str(err)}}, status=status.HTTP_400_BAD_REQUEST) - - class InvoicesAPIView(ListAPIView): permission_classes = [ IsAuthenticated, @@ -67,8 +67,6 @@ BUSINESS_EMAIL_RECIPIENT=help@root.ru YOOKASSA_ACCOUNT_ID=322563 YOOKASSA_SECRET_KEY=test_i_Au0KbXnOmdVf1icljT7v4CuDHLG8mXVkyofJQFBns YOOKASSA_RESULT_PAYMENT_URL=http://localhost -RECURRENT_RATE=days - USER_CONFIRMATION_URL='https://app.air.fail/confirm' USER_PASSWORD_RESET_URL='https://app.air.fail/changePassword' INVITATION_RESPONSE_URL='https://app.air.fail/business/confirm' @@ -94,4 +92,9 @@ LOG_LEVEL=debug DOMAIN=localhost PROVIDER=docker +# UNLEASH +FEATURE_FLAG_API_URL=https://gitlab.kisulkens.ru/api/v4/feature_flags/unleash/243 +FEATURE_FLAG_INSTANCE_ID=glffct-ic8xsVF5eR9BaUySR-_w +FEATURE_FLAG_APP_NAME=Production + COMPOSE_FILE=docker-compose.yml:docker-compose.local.yml \ No newline at end of file @@ -174,6 +174,34 @@ doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\" and python_version < \"3.14\""] trio = ["trio (>=0.26.1)"] +[[package]] +name = "apscheduler" +version = "3.11.2" +description = "In-process task scheduler with Cron-like capabilities" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "apscheduler-3.11.2-py3-none-any.whl", hash = "sha256:ce005177f741409db4e4dd40a7431b76feb856b9dd69d57e0da49d6715bfd26d"}, + {file = "apscheduler-3.11.2.tar.gz", hash = "sha256:2a9966b052ec805f020c8c4c3ae6e6a06e24b1bf19f2e11d91d8cca0473eef41"}, +] + +[package.dependencies] +tzlocal = ">=3.0" + +[package.extras] +doc = ["packaging", "sphinx", "sphinx-rtd-theme (>=1.3.0)"] +etcd = ["etcd3", "protobuf (<=3.21.0)"] +gevent = ["gevent"] +mongodb = ["pymongo (>=3.0)"] +redis = ["redis (>=3.0)"] +rethinkdb = ["rethinkdb (>=2.4.0)"] +sqlalchemy = ["sqlalchemy (>=1.4)"] +test = ["APScheduler[etcd,mongodb,redis,rethinkdb,sqlalchemy,tornado,zookeeper]", "PySide6 ; platform_python_implementation == \"CPython\" and python_version < \"3.14\"", "anyio (>=4.5.2)", "gevent ; python_version < \"3.14\"", "pytest", "pytest-timeout", "pytz", "twisted ; python_version < \"3.14\""] +tornado = ["tornado (>=4.3)"] +twisted = ["twisted"] +zookeeper = ["kazoo"] + [[package]] name = "argon2-cffi" version = "23.1.0" @@ -1512,6 +1540,21 @@ files = [ wasmer = ">=1.0.0" wasmer-compiler-cranelift = ">=1.0.0" +[[package]] +name = "fcache" +version = "0.6.0" +description = "a dictionary-like, file-based cache module for Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "fcache-0.6.0-py3-none-any.whl", hash = "sha256:dbf0753bb7400ed80d703df9ffcfb438786698872ed92c50169f95cb0eac8306"}, + {file = "fcache-0.6.0.tar.gz", hash = "sha256:79949f0aafe8cedc5c9064631b3c157941a288e59f9991dd158c23e8e60b5422"}, +] + +[package.dependencies] +platformdirs = ">=3.0,<4.0" + [[package]] name = "filetype" version = "1.2.0" @@ -2210,6 +2253,30 @@ files = [ [package.extras] all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] +[[package]] +name = "importlib-metadata" +version = "9.0.0" +description = "Read metadata from Python packages" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7"}, + {file = "importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc"}, +] + +[package.dependencies] +zipp = ">=3.20" + +[package.extras] +check = ["pytest-checkdocs (>=2.14)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=3.4)"] +perf = ["ipython"] +test = ["packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] +type = ["pytest-mypy (>=1.0.1) ; platform_python_implementation != \"PyPy\""] + [[package]] name = "incremental" version = "24.7.2" @@ -2659,6 +2726,21 @@ requests-toolbelt = ">=1.0.0,<2.0.0" [package.extras] langsmith-pyo3 = ["langsmith-pyo3 (>=0.1.0rc2,<0.2.0)"] +[[package]] +name = "launchdarkly-eventsource" +version = "1.5.1" +description = "LaunchDarkly SSE Client" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "launchdarkly_eventsource-1.5.1-py3-none-any.whl", hash = "sha256:43dfbc14a3962c9bce252320d690cdcbfda0ca00501226d517ae77e60f570d44"}, + {file = "launchdarkly_eventsource-1.5.1.tar.gz", hash = "sha256:f122f80b36db6ea1ab20af62c82b8b2668682259b415053c94400dd6c07922a7"}, +] + +[package.dependencies] +urllib3 = ">=1.26.0,<3" + [[package]] name = "lxml" version = "5.4.0" @@ -2847,6 +2929,131 @@ pycryptodome = "*" typing-extensions = "*" urllib3 = "*" +[[package]] +name = "mmh3" +version = "5.2.1" +description = "Python extension for MurmurHash (MurmurHash3), a set of fast and robust hash functions." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "mmh3-5.2.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5d87a3584093e1a89987e3d36d82c98d9621b2cb944e22a420aa1401e096758f"}, + {file = "mmh3-5.2.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:30e4d2084df019880d55f6f7bea35328d9b464ebee090baa372c096dc77556fb"}, + {file = "mmh3-5.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0bbc17250b10d3466875a40a52520a6bac3c02334ca709207648abd3c223ed5c"}, + {file = "mmh3-5.2.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:76219cd1eefb9bf4af7856e3ae563d15158efa145c0aab01e9933051a1954045"}, + {file = "mmh3-5.2.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb9d44c25244e11c8be3f12c938ca8ba8404620ef8092245d2093c6ab3df260f"}, + {file = "mmh3-5.2.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d5d542bf2abd0fd0361e8017d03f7cb5786214ceb4a40eef1539d6585d93386"}, + {file = "mmh3-5.2.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:08043f7cb1fb9467c3fbbbaea7896986e7fbc81f4d3fd9289a73d9110ab6207a"}, + {file = "mmh3-5.2.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:add7ac388d1e0bf57259afbcf9ed05621a3bf11ce5ee337e7536f1e1aaf056b0"}, + {file = "mmh3-5.2.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:41105377f6282e8297f182e393a79cfffd521dde37ace52b106373bdcd9ca5cb"}, + {file = "mmh3-5.2.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3cb61db880ec11e984348227b333259994c2c85caa775eb7875decb3768db890"}, + {file = "mmh3-5.2.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e8b5378de2b139c3a830f0209c1e91f7705919a4b3e563a10955104f5097a70a"}, + {file = "mmh3-5.2.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e904f2417f0d6f6d514f3f8b836416c360f306ddaee1f84de8eef1e722d212e5"}, + {file = "mmh3-5.2.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f1fbb0a99125b1287c6d9747f937dc66621426836d1a2d50d05aecfc81911b57"}, + {file = "mmh3-5.2.1-cp310-cp310-win32.whl", hash = "sha256:b4cce60d0223074803c9dbe0721ad3fa51dafe7d462fee4b656a1aa01ee07518"}, + {file = "mmh3-5.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:6f01f044112d43a20be2f13a11683666d87151542ad627fe41a18b9791d2802f"}, + {file = "mmh3-5.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:7501e9be34cb21e72fcfe672aafd0eee65c16ba2afa9dcb5500a587d3a0580f0"}, + {file = "mmh3-5.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:dae0f0bd7d30c0ad61b9a504e8e272cb8391eed3f1587edf933f4f6b33437450"}, + {file = "mmh3-5.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9aeaf53eaa075dd63e81512522fd180097312fb2c9f476333309184285c49ce0"}, + {file = "mmh3-5.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0634581290e6714c068f4aa24020acf7880927d1f0084fa753d9799ae9610082"}, + {file = "mmh3-5.2.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e080c0637aea036f35507e803a4778f119a9b436617694ae1c5c366805f1e997"}, + {file = "mmh3-5.2.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db0562c5f71d18596dcd45e854cf2eeba27d7543e1a3acdafb7eef728f7fe85d"}, + {file = "mmh3-5.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d9f9a3ce559a5267014b04b82956993270f63ec91765e13e9fd73daf2d2738e"}, + {file = "mmh3-5.2.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:960b1b3efa39872ac8b6cc3a556edd6fb90ed74f08c9c45e028f1005b26aa55d"}, + {file = "mmh3-5.2.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d30b650595fdbe32366b94cb14f30bb2b625e512bd4e1df00611f99dc5c27fd4"}, + {file = "mmh3-5.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:82f3802bfc4751f420d591c5c864de538b71cea117fce67e4595c2afede08a15"}, + {file = "mmh3-5.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:915e7a2418f10bd1151b1953df06d896db9783c9cfdb9a8ee1f9b3a4331ab503"}, + {file = "mmh3-5.2.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:fc78739b5ec6e4fb02301984a3d442a91406e7700efbe305071e7fd1c78278f2"}, + {file = "mmh3-5.2.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:41aac7002a749f08727cb91babff1daf8deac317c0b1f317adc69be0e6c375d1"}, + {file = "mmh3-5.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9d8089d853c7963a8ce87fff93e2a67075c0bc08684a08ea6ad13577c38ffc38"}, + {file = "mmh3-5.2.1-cp311-cp311-win32.whl", hash = "sha256:baeb47635cb33375dee4924cd93d7f5dcaa786c740b08423b0209b824a1ee728"}, + {file = "mmh3-5.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:1e4ecee40ba19e6975e1120829796770325841c2f153c0e9aecca927194c6a2a"}, + {file = "mmh3-5.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:c302245fd6c33d96bd169c7ccf2513c20f4c1e417c07ce9dce107c8bc3f8411f"}, + {file = "mmh3-5.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0cc21533878e5586b80d74c281d7f8da7932bc8ace50b8d5f6dbf7e3935f63f1"}, + {file = "mmh3-5.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4eda76074cfca2787c8cf1bec603eaebdddd8b061ad5502f85cddae998d54f00"}, + {file = "mmh3-5.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:eee884572b06bbe8a2b54f424dbd996139442cf83c76478e1ec162512e0dd2c7"}, + {file = "mmh3-5.2.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0d0b7e803191db5f714d264044e06189c8ccd3219e936cc184f07106bd17fd7b"}, + {file = "mmh3-5.2.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e6c219e375f6341d0959af814296372d265a8ca1af63825f65e2e87c618f006"}, + {file = "mmh3-5.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26fb5b9c3946bf7f1daed7b37e0c03898a6f062149127570f8ede346390a0825"}, + {file = "mmh3-5.2.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3c38d142c706201db5b2345166eeef1e7740e3e2422b470b8ba5c8727a9b4c7a"}, + {file = "mmh3-5.2.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50885073e2909251d4718634a191c49ae5f527e5e1736d738e365c3e8be8f22b"}, + {file = "mmh3-5.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b3f99e1756fc48ad507b95e5d86f2fb21b3d495012ff13e6592ebac14033f166"}, + {file = "mmh3-5.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62815d2c67f2dd1be76a253d88af4e1da19aeaa1820146dec52cf8bee2958b16"}, + {file = "mmh3-5.2.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8f767ba0911602ddef289404e33835a61168314ebd3c729833db2ed685824211"}, + {file = "mmh3-5.2.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:67e41a497bac88cc1de96eeba56eeb933c39d54bc227352f8455aa87c4ca4000"}, + {file = "mmh3-5.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3d74a03fb57757ece25aa4b3c1c60157a1cece37a020542785f942e2f827eed5"}, + {file = "mmh3-5.2.1-cp312-cp312-win32.whl", hash = "sha256:7374d6e3ef72afe49697ecd683f3da12f4fc06af2d75433d0580c6746d2fa025"}, + {file = "mmh3-5.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:3a9fed49c6ce4ed7e73f13182760c65c816da006debe67f37635580dfb0fae00"}, + {file = "mmh3-5.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:bbfcb95d9a744e6e2827dfc66ad10e1020e0cac255eb7f85652832d5a264c2fc"}, + {file = "mmh3-5.2.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:723b2681ed4cc07d3401bbea9c201ad4f2a4ca6ba8cddaff6789f715dd2b391e"}, + {file = "mmh3-5.2.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:3619473a0e0d329fd4aec8075628f8f616be2da41605300696206d6f36920c3d"}, + {file = "mmh3-5.2.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:e48d4dbe0f88e53081da605ae68644e5182752803bbc2beb228cca7f1c4454d6"}, + {file = "mmh3-5.2.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a482ac121de6973897c92c2f31defc6bafb11c83825109275cffce54bb64933f"}, + {file = "mmh3-5.2.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:17fbb47f0885ace8327ce1235d0416dc86a211dcd8cc1e703f41523be32cfec8"}, + {file = "mmh3-5.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d51fde50a77f81330523562e3c2734ffdca9c4c9e9d355478117905e1cfe16c6"}, + {file = "mmh3-5.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:19bbd3b841174ae6ed588536ab5e1b1fe83d046e668602c20266547298d939a9"}, + {file = "mmh3-5.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be77c402d5e882b6fbacfd90823f13da8e0a69658405a39a569c6b58fdb17b03"}, + {file = "mmh3-5.2.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fd96476f04db5ceba1cfa0f21228f67c1f7402296f0e73fee3513aa680ad237b"}, + {file = "mmh3-5.2.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:707151644085dd0f20fe4f4b573d28e5130c4aaa5f587e95b60989c5926653b5"}, + {file = "mmh3-5.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3737303ca9ea0f7cb83028781148fcda4f1dac7821db0c47672971dabcf63593"}, + {file = "mmh3-5.2.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2778fed822d7db23ac5008b181441af0c869455b2e7d001f4019636ac31b6fe4"}, + {file = "mmh3-5.2.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d57dea657357230cc780e13920d7fa7db059d58fe721c80020f94476da4ca0a1"}, + {file = "mmh3-5.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:169e0d178cb59314456ab30772429a802b25d13227088085b0d49b9fe1533104"}, + {file = "mmh3-5.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7e4e1f580033335c6f76d1e0d6b56baf009d1a64d6a4816347e4271ba951f46d"}, + {file = "mmh3-5.2.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:2bd9f19f7f1fcebd74e830f4af0f28adad4975d40d80620be19ffb2b2af56c9f"}, + {file = "mmh3-5.2.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:c88653877aeb514c089d1b3d473451677b8b9a6d1497dbddf1ae7934518b06d2"}, + {file = "mmh3-5.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fceef7fe67c81e1585198215e42ad3fdba3a25644beda8fbdaf85f4d7b93175a"}, + {file = "mmh3-5.2.1-cp313-cp313-win32.whl", hash = "sha256:54b64fb2433bc71488e7a449603bf8bd31fbcf9cb56fbe1eb6d459e90b86c37b"}, + {file = "mmh3-5.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:cae6383181f1e345317742d2ddd88f9e7d2682fa4c9432e3a74e47d92dce0229"}, + {file = "mmh3-5.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:022aa1a528604e6c83d0a7705fdef0b5355d897a9e0fa3a8d26709ceaa06965d"}, + {file = "mmh3-5.2.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:d771f085fcdf4035786adfb1d8db026df1eb4b41dac1c3d070d1e49512843227"}, + {file = "mmh3-5.2.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:7f196cd7910d71e9d9860da0ff7a77f64d22c1ad931f1dd18559a06e03109fc0"}, + {file = "mmh3-5.2.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b1f12bd684887a0a5d55e6363ca87056f361e45451105012d329b86ec19dbe0b"}, + {file = "mmh3-5.2.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d106493a60dcb4aef35a0fac85105e150a11cf8bc2b0d388f5a33272d756c966"}, + {file = "mmh3-5.2.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:44983e45310ee5b9f73397350251cdf6e63a466406a105f1d16cb5baa659270b"}, + {file = "mmh3-5.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:368625fb01666655985391dbad3860dc0ba7c0d6b9125819f3121ee7292b4ac8"}, + {file = "mmh3-5.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:72d1cc63bcc91e14933f77d51b3df899d6a07d184ec515ea7f56bff659e124d7"}, + {file = "mmh3-5.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e8b4b5580280b9265af3e0409974fb79c64cf7523632d03fbf11df18f8b0181e"}, + {file = "mmh3-5.2.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4cbbde66f1183db040daede83dd86c06d663c5bb2af6de1142b7c8c37923dd74"}, + {file = "mmh3-5.2.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8ff038d52ef6aa0f309feeba00c5095c9118d0abf787e8e8454d6048db2037fc"}, + {file = "mmh3-5.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4130d0b9ce5fad6af07421b1aecc7e079519f70d6c05729ab871794eded8617"}, + {file = "mmh3-5.2.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6e0bfe77d238308839699944164b96a2eeccaf55f2af400f54dc20669d8d5f2"}, + {file = "mmh3-5.2.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f963eafc0a77a6c0562397da004f5876a9bcf7265a7bcc3205e29636bc4a1312"}, + {file = "mmh3-5.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:92883836caf50d5255be03d988d75bc93e3f86ba247b7ca137347c323f731deb"}, + {file = "mmh3-5.2.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:57b52603e89355ff318025dd55158f6e71396c0f1f609d548e9ea9c94cc6ce0a"}, + {file = "mmh3-5.2.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f40a95186a72fa0b67d15fef0f157bfcda00b4f59c8a07cbe5530d41ac35d105"}, + {file = "mmh3-5.2.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:58370d05d033ee97224c81263af123dea3d931025030fd34b61227a768a8858a"}, + {file = "mmh3-5.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7be6dfb49e48fd0a7d91ff758a2b51336f1cd21f9d44b20f6801f072bd080cdd"}, + {file = "mmh3-5.2.1-cp314-cp314-win32.whl", hash = "sha256:54fe8518abe06a4c3852754bfd498b30cc58e667f376c513eac89a244ce781a4"}, + {file = "mmh3-5.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:3f796b535008708846044c43302719c6956f39ca2d93f2edda5319e79a29efbb"}, + {file = "mmh3-5.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:cd471ede0d802dd936b6fab28188302b2d497f68436025857ca72cd3810423fe"}, + {file = "mmh3-5.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:5174a697ce042fa77c407e05efe41e03aa56dae9ec67388055820fb48cf4c3ba"}, + {file = "mmh3-5.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0a3984146e414684a6be2862d84fcb1035f4984851cb81b26d933bab6119bf00"}, + {file = "mmh3-5.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bd6e7d363aa93bd3421b30b6af97064daf47bc96005bddba67c5ffbc6df426b8"}, + {file = "mmh3-5.2.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:113f78e7463a36dbbcea05bfe688efd7fa759d0f0c56e73c974d60dcfec3dfcc"}, + {file = "mmh3-5.2.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7e8ec5f606e0809426d2440e0683509fb605a8820a21ebd120dcdba61b74ef7f"}, + {file = "mmh3-5.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22b0f9971ec4e07e8223f2beebe96a6cfc779d940b6f27d26604040dd74d3a44"}, + {file = "mmh3-5.2.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:85ffc9920ffc39c5eee1e3ac9100c913a0973996fbad5111f939bbda49204bb7"}, + {file = "mmh3-5.2.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7aec798c2b01aaa65a55f1124f3405804184373abb318a3091325aece235f67c"}, + {file = "mmh3-5.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:55dbbd8ffbc40d1697d5e2d0375b08599dae8746b0b08dea05eee4ce81648fac"}, + {file = "mmh3-5.2.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6c85c38a279ca9295a69b9b088a2e48aa49737bb1b34e6a9dc6297c110e8d912"}, + {file = "mmh3-5.2.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:6290289fa5fb4c70fd7f72016e03633d60388185483ff3b162912c81205ae2cf"}, + {file = "mmh3-5.2.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:4fc6cd65dc4d2fdb2625e288939a3566e36127a84811a4913f02f3d5931da52d"}, + {file = "mmh3-5.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:623f938f6a039536cc02b7582a07a080f13fdfd48f87e63201d92d7e34d09a18"}, + {file = "mmh3-5.2.1-cp314-cp314t-win32.whl", hash = "sha256:29bc3973676ae334412efdd367fcd11d036b7be3efc1ce2407ef8676dabfeb82"}, + {file = "mmh3-5.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:28cfab66577000b9505a0d068c731aee7ca85cd26d4d63881fab17857e0fe1fb"}, + {file = "mmh3-5.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:dfd51b4c56b673dfbc43d7d27ef857dd91124801e2806c69bb45585ce0fa019b"}, + {file = "mmh3-5.2.1.tar.gz", hash = "sha256:bbea5b775f0ac84945191fb83f845a6fd9a21a03ea7f2e187defac7e401616ad"}, +] + +[package.extras] +benchmark = ["pymmh3 (==0.0.5)", "pyperf (==2.10.0)", "xxhash (==3.6.0)"] +docs = ["myst-parser (==5.0.0)", "shibuya (==2026.1.9)", "sphinx (==8.2.3)", "sphinx-copybutton (==0.5.2)"] +lint = ["actionlint-py (==1.7.11.24)", "clang-format (==22.1.0)", "codespell (==2.4.1)", "pylint (==4.0.5)", "ruff (==0.15.4)"] +plot = ["matplotlib (==3.10.8)", "pandas (==3.0.1)"] +test = ["pytest (==9.0.2)", "pytest-sugar (==1.1.1)"] +type = ["mypy (==1.19.1)"] + [[package]] name = "msgpack" version = "1.1.0" @@ -3361,6 +3568,22 @@ tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "pa typing = ["typing-extensions ; python_version < \"3.10\""] xmp = ["defusedxml"] +[[package]] +name = "platformdirs" +version = "3.11.0" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "platformdirs-3.11.0-py3-none-any.whl", hash = "sha256:e9d171d00af68be50e9202731309c4e658fd8bc76f55c11c7dd760d023bda68e"}, + {file = "platformdirs-3.11.0.tar.gz", hash = "sha256:cf8ee52a3afdb965072dcc652433e0c7e3e40cf5ea1477cd4b3b1d2eb75495b3"}, +] + +[package.extras] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.1)", "sphinx-autodoc-typehints (>=1.24)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)"] + [[package]] name = "pluggy" version = "1.5.0" @@ -4643,6 +4866,18 @@ files = [ {file = "ruff-0.9.9.tar.gz", hash = "sha256:0062ed13f22173e85f8f7056f9a24016e692efeea8704d1a5e8011b8aa850933"}, ] +[[package]] +name = "semver" +version = "3.0.4" +description = "Python helper for Semantic Versioning (https://semver.org)" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "semver-3.0.4-py3-none-any.whl", hash = "sha256:9c824d87ba7f7ab4a1890799cec8596f15c1241cb473404ea1cb0c55e4b04746"}, + {file = "semver-3.0.4.tar.gz", hash = "sha256:afc7d8c584a5ed0a11033af086e8af226a9c0b206f313e0301f8dd7b6b589602"}, +] + [[package]] name = "sentry-sdk" version = "2.39.0" @@ -5182,6 +5417,47 @@ files = [ {file = "tzdata-2024.2.tar.gz", hash = "sha256:7d85cc416e9382e69095b7bdf4afd9e3880418a2413feec7069d533d6b4e31cc"}, ] +[[package]] +name = "tzlocal" +version = "5.3.1" +description = "tzinfo object for the local timezone" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d"}, + {file = "tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd"}, +] + +[package.dependencies] +tzdata = {version = "*", markers = "platform_system == \"Windows\""} + +[package.extras] +devenv = ["check-manifest", "pytest (>=4.3)", "pytest-cov", "pytest-mock (>=3.3)", "zest.releaser"] + +[[package]] +name = "unleashclient" +version = "6.7.0" +description = "Python client for the Unleash feature toggle system!" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "unleashclient-6.7.0-py3-none-any.whl", hash = "sha256:1fbf4bc66ec9b952e0131012f954adfc1d476d54ad320905bc5f4fdea963a9fc"}, + {file = "unleashclient-6.7.0.tar.gz", hash = "sha256:a34649ef2232c3e0e18232b2067c07dd0bf474b977efc67c8806d816418ebe29"}, +] + +[package.dependencies] +apscheduler = "<4.0.0" +fcache = "*" +importlib_metadata = "*" +launchdarkly-eventsource = "*" +mmh3 = "*" +python-dateutil = "*" +requests = "*" +semver = "<4.0.0" +yggdrasil-engine = ">=1.3.0" + [[package]] name = "uritemplate" version = "4.1.1" @@ -5504,6 +5780,64 @@ idna = ">=2.0" multidict = ">=4.0" propcache = ">=0.2.0" +[[package]] +name = "yggdrasil-engine" +version = "1.3.0" +description = "Engine for evaluating Unleash feature flags" +optional = false +python-versions = "<4.0,>=3.8" +groups = ["main"] +files = [ + {file = "yggdrasil_engine-1.3.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:838880e916da4af97c10ff38e1845ef38ed91201c2868e3275e1372c8979a3ca"}, + {file = "yggdrasil_engine-1.3.0-cp310-abi3-macosx_11_0_x86_64.whl", hash = "sha256:8d23e865a82b47bac7a59376fd4f0e000ae8b3b4e25fc27a5554f00093f2ff75"}, + {file = "yggdrasil_engine-1.3.0-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:bf78418d4b66c4ec888fa9444061c65358df013105f85d7ddcd64fe78e23f38d"}, + {file = "yggdrasil_engine-1.3.0-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:8a244b6084dfd1bb15dd862cecd82f3a69d26c233e3b60d63c4b31b224d92257"}, + {file = "yggdrasil_engine-1.3.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1a332da70a8efaaed9b603676efa5dac7ed6e4f4cb4524bc3712bd4ea90c4186"}, + {file = "yggdrasil_engine-1.3.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:48a027b7c54e2837988e07a44c77ddd6e2d68aac98e8289b0d75a6a4e4f6f0da"}, + {file = "yggdrasil_engine-1.3.0-cp310-abi3-win_amd64.whl", hash = "sha256:afa6c9a454b79126048a62fced3862c0608d74a466f172625924bb28108b5ff4"}, + {file = "yggdrasil_engine-1.3.0-cp310-abi3-win_arm64.whl", hash = "sha256:bf2abdd70976ff95b8f72cc4e8a63968702cadbdac035f7924dcffb314042482"}, + {file = "yggdrasil_engine-1.3.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:80aba2cd6bf06434bbe996e384434c4fa1f5e7371a46ef5deeb1c8c34afe7fe4"}, + {file = "yggdrasil_engine-1.3.0-cp311-abi3-macosx_11_0_x86_64.whl", hash = "sha256:ac81c0a4e6120bee6e3c3049420ab7b44415d9fd876d1ae549206fb582d82a4d"}, + {file = "yggdrasil_engine-1.3.0-cp311-abi3-manylinux2014_aarch64.whl", hash = "sha256:8946d23a0883012a5e3404643f210e8cf654030af87722262ad28e92fb07aa76"}, + {file = "yggdrasil_engine-1.3.0-cp311-abi3-manylinux2014_x86_64.whl", hash = "sha256:66c1d03edb846a0fc1a622685ee80ae9552fd1ddd21d41524466dc8fe914b37c"}, + {file = "yggdrasil_engine-1.3.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:928230be698325d9fdc8a01b477c5948b86e979897a54f6fa6030f4a1d334bc5"}, + {file = "yggdrasil_engine-1.3.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f632266983af11bc572d9cf29e8baeeae4c96f736a764034ca82dea7e6640cbc"}, + {file = "yggdrasil_engine-1.3.0-cp311-abi3-win_amd64.whl", hash = "sha256:8096fdf4674987d9c1d3f72b8a86e81121cbed25f74d66d4413460556f56ea3c"}, + {file = "yggdrasil_engine-1.3.0-cp311-abi3-win_arm64.whl", hash = "sha256:3b0df9e6ab138f0dfc4cf2d88ae060b360060ef7fc239ab64a808ec56e450567"}, + {file = "yggdrasil_engine-1.3.0-cp312-abi3-macosx_11_0_arm64.whl", hash = "sha256:19d1136cab1963f7cc3d1926f6cbcfc42de1f5c8c0e4d8d6e68765768d8ed238"}, + {file = "yggdrasil_engine-1.3.0-cp312-abi3-macosx_11_0_x86_64.whl", hash = "sha256:6f0b7aa3be0aaf28cee7b7f3428922d4735f72533052aad6e4187f7814ba56f0"}, + {file = "yggdrasil_engine-1.3.0-cp312-abi3-manylinux2014_aarch64.whl", hash = "sha256:28dde2312ccd778d32c94103b2c8332a72c077b834b55d8408a334a736460f53"}, + {file = "yggdrasil_engine-1.3.0-cp312-abi3-manylinux2014_x86_64.whl", hash = "sha256:b13a49c29d676b4674ee9362eedb86491332d4d2da07afb9ea2140a1b05e8a90"}, + {file = "yggdrasil_engine-1.3.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ca0d5493e365ee2ccc9b69434c34986d5616e8277ed8609a9e307a101c4e0a23"}, + {file = "yggdrasil_engine-1.3.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b21708fb0389d2fc240b35377c7821571ecba93ef4e0a01a8d15e53f016d4971"}, + {file = "yggdrasil_engine-1.3.0-cp312-abi3-win_amd64.whl", hash = "sha256:ab3a3f024c7c2d692bbdc8e1467bd16aa98b5d30ed33938fbde61ed0f3e4ea97"}, + {file = "yggdrasil_engine-1.3.0-cp312-abi3-win_arm64.whl", hash = "sha256:8da87fcd93d6ccbab5d3a2d2aaf54b447265be61b19f65ba6044986f35798f99"}, + {file = "yggdrasil_engine-1.3.0-cp313-abi3-macosx_11_0_arm64.whl", hash = "sha256:4a700c1711e0db80d57bd5e5fe32f537d7cf999e92e0d1c5b086ce5a17e69f98"}, + {file = "yggdrasil_engine-1.3.0-cp313-abi3-macosx_11_0_x86_64.whl", hash = "sha256:a69049ca69abae3945a0d9811e4f8aa6b94e5ab12af1ff32cdf9b2bd30e41e67"}, + {file = "yggdrasil_engine-1.3.0-cp313-abi3-manylinux2014_aarch64.whl", hash = "sha256:b07d0ecd58a36d7c25c3708526900a34d5f4ccb77cd822ec27f17a6952f6278a"}, + {file = "yggdrasil_engine-1.3.0-cp313-abi3-manylinux2014_x86_64.whl", hash = "sha256:a2cacdf5ee7eb942e0b09e0a76e6d28460cd528c961126b01c58d2dcd4b14ea3"}, + {file = "yggdrasil_engine-1.3.0-cp313-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91d28e7d8838b14722f6165cd19b3ae8cea711a43485f2260f988d922c2b84ea"}, + {file = "yggdrasil_engine-1.3.0-cp313-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:18a493f539408ec262be315d40f58e1da899fea6b1be18695516469352c42ac7"}, + {file = "yggdrasil_engine-1.3.0-cp313-abi3-win_amd64.whl", hash = "sha256:3ec38c4be410618d9826749f275f451cc07dce426413e3576ef7b6a6db1474e2"}, + {file = "yggdrasil_engine-1.3.0-cp313-abi3-win_arm64.whl", hash = "sha256:1704c458dfe3dbc4a8655a5271b2bef7264d8d7b392f7e02194ff3171d3c7538"}, + {file = "yggdrasil_engine-1.3.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:e073a0ecf15fee1315db1bd02baadd0c1309b67188a16bf23a50456b2f7be951"}, + {file = "yggdrasil_engine-1.3.0-cp38-abi3-macosx_11_0_x86_64.whl", hash = "sha256:037eff18de77e9b357829d3dd5cc4ee5bd5aaf0eb949cd87e163790bd319cc27"}, + {file = "yggdrasil_engine-1.3.0-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:d005998f66048e637b17b6a169bc009d55d9c26d10dfaada2e7fe296eadfd599"}, + {file = "yggdrasil_engine-1.3.0-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:84f6168452c062df2386e7ebd858066f8a82562531c4be37b5024634a6ece9eb"}, + {file = "yggdrasil_engine-1.3.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1ab1b8f457e55b49eaa86dd1c42ab8ff45d0e6f5ec7be31cfed6c698f0c44e75"}, + {file = "yggdrasil_engine-1.3.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5f7da76799391a4ae1462223229092b91bef863626ce3f662027a0321513a27"}, + {file = "yggdrasil_engine-1.3.0-cp38-abi3-win_amd64.whl", hash = "sha256:0113ba088babf7cced92f88c64ec9ea3e28692c705fedab5b3262f3bcfc492ba"}, + {file = "yggdrasil_engine-1.3.0-cp38-abi3-win_arm64.whl", hash = "sha256:02a6cbe73892b5a574fd3962ff692e00e6ed51af5ba4f8f1ea6745392cfd5fac"}, + {file = "yggdrasil_engine-1.3.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:de45a251ceeed07818735ce894c781d7a142a60718fbff76419eae66b9859a94"}, + {file = "yggdrasil_engine-1.3.0-cp39-abi3-macosx_11_0_x86_64.whl", hash = "sha256:b1996338ff1a1dca2dfb337ee111487f4c92351cb31d94e3badb8a97524be4c3"}, + {file = "yggdrasil_engine-1.3.0-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:93e9fa8639c1a887e0175823316eb986ee58ec94999ebadbf0cc4bc84b085410"}, + {file = "yggdrasil_engine-1.3.0-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:049b407a23fabacface173abd802340922246cea6c9b9842af5926742fbf5572"}, + {file = "yggdrasil_engine-1.3.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:778c15dc202b83472407f83622e52612a58ae979c2c8240aec1277dab6f3d407"}, + {file = "yggdrasil_engine-1.3.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:fe2f98703fbb8b04aac05de5491a85d6e5263c1944c66dbb9253d1395d6e9935"}, + {file = "yggdrasil_engine-1.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:324f69da1314aaa1e81fbf7dd4ca6b2e241f2b1ce8bc2e018cf17b4193f6523f"}, + {file = "yggdrasil_engine-1.3.0-cp39-abi3-win_arm64.whl", hash = "sha256:ecbc7db34359958bcf30b9ad5ab1f68c1114d19cb47b14f2c1a341a1468cd73c"}, +] + [[package]] name = "yookassa" version = "2.5.0" @@ -5522,6 +5856,26 @@ netaddr = "*" requests = "*" urllib3 = "*" +[[package]] +name = "zipp" +version = "3.23.1" +description = "Backport of pathlib-compatible object wrapper for zip files" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "zipp-3.23.1-py3-none-any.whl", hash = "sha256:0b3596c50a5c700c9cb40ba8d86d9f2cc4807e9bedb06bcdf7fac85633e444dc"}, + {file = "zipp-3.23.1.tar.gz", hash = "sha256:32120e378d32cd9714ad503c1d024619063ec28aad2248dc6672ad13edfa5110"}, +] + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more_itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] +type = ["pytest-mypy"] + [[package]] name = "zope-interface" version = "7.2" @@ -5580,4 +5934,4 @@ testing = ["coverage[toml]", "zope.event", "zope.testing"] [metadata] lock-version = "2.1" python-versions = "^3.12" -content-hash = "be19d70adce9ad107dd4b610d061d2477ea062690bad3e553131bf6d61acbf11" +content-hash = "bd526dfc1b8b4c25eaf3e302ad83b6592868132720e4e2d04a18e472cf3a9557" @@ -63,6 +63,8 @@ wsproto = "^1.2.0" sentry-sdk = {extras = ["django"], version = "^2.39.0"} googletrans = "^4.0.2" pydub = "^0.25.1" +python-dateutil = "^2.9.0.post0" +unleashclient = "^6.4.0" [tool.poetry.group.test.dependencies]