@@ -172,9 +172,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() @@ -182,10 +179,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,18 @@ class EmailService: cls.send_email( f'AIR: баланс корпоративного аккаунта ниже {host.token_cap}', html_message, host.token_cap_emails ) + + @classmethod + def send_payment_email( + cls, product_title: str, next_payment_at: datetime | str, amount: Decimal, payer_email: str, status: str + ) -> None: + templates = { + 'succeeded': 'authentication/success_payment_notification', + 'canceled': 'authentication/canceled_payment_notification', + } + html_message = cls._render_letter_template( + template_name=templates[status], + context={'product_title': product_title, 'next_payment_at': next_payment_at, 'amount': amount}, + ) + cls.send_email('Детали платежа', html_message, (payer_email,)) + @@ -0,0 +1,22 @@ + + + + + Неуспешный платеж + + +

Здравствуйте!

+ +

К сожалению, платёж за восстановление баланса по плану на сумму {{ amount }} не был успешно + обработан.

+ +

Следующая попытка списания будет выполнена: {{ next_payment_at }}.

+ +

+ Пожалуйста, убедитесь, что на вашем счёте достаточно средств или + обновите платёжные данные, чтобы избежать прерывания доступа. +

+ +

Если у вас возникнут вопросы, вы всегда можете обратиться в нашу службу поддержки.

+ + @@ -0,0 +1,16 @@ + + + + + Детали платежа + + +

Здравствуйте!

+ +

{{ product_title }} на нашем сайте.

+ +

Следующая дата пополнения: {{ next_payment_at }}.

+ +

Если у вас возникнут вопросы, пожалуйста, свяжитесь с нашей службой поддержки — мы всегда рады помочь.

+ + @@ -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,9 @@ PATH_PREFETCH_MAP = { 'host_account', 'payment_plan', 'business_account__parent_company__user__payment_plan__plan', - 'payment_plan__plan' + 'payment_plan__plan', + 'payment_method', + 'payment_method__recurring_payment' ), '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) @@ -293,6 +293,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 = { @@ -346,7 +350,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) @@ -499,3 +502,9 @@ 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') @@ -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 @@ -1089,11 +1089,7 @@ msgstr "Индивидуальный" msgid "Is recurrent" msgstr "Рекуррентный" -#: payments/models/payment_plan.py:30 -msgid "Duration" -msgstr "Длительность" - -#: payments/models/payment_plan.py:35 +#: payments/models/payment_plan.py:23 msgid "Is visible" msgstr "Видимый" @@ -1118,11 +1114,7 @@ msgstr "Следующее время платежа" msgid "Current balance" msgstr "Текущий баланс" -#: payments/models/payment_plan.py:80 -msgid "Recurrent billing task" -msgstr "Рекуррентная задача на платеж" - -#: payments/models/payment_plan.py:101 payments/models/payment_plan.py:102 +#: payments/models/payment_plan.py:77 payments/models/payment_plan.py:78 msgid "User Balance" msgstr "Баланс пользователя" @@ -1194,10 +1186,23 @@ msgstr "Активация Промокода" msgid "Promocode Activations" msgstr "Активации Промокодов" +#: payments/models/recurring_payment.py:11 #: payments/models/user_payment_method.py:24 msgid "Payment Method" msgstr "Платежный метод" +#: payments/models/recurring_payment.py:14 +msgid "Payment Datetime" +msgstr "Дата и время платежа" + +#: payments/models/recurring_payment.py:28 +msgid "Recurring Payment" +msgstr "Автоплатеж" + +#: payments/models/recurring_payment.py:29 +msgid "Recurring Payments" +msgstr "Автоплатежи" + #: payments/models/user_payment_method.py:25 msgid "Payment Methods" msgstr "Платежные методы" @@ -1238,6 +1243,10 @@ msgstr "Ни одного активного метода не установл msgid "No payment method by this id" msgstr "Не найдено метода платежа по этому ID" +#: payments/views.py:186 +msgid "The recurring payment is successfully cancelled" +msgstr "Автоплатежи успешно отключены" + #: poller/models.py:11 msgid "Address" 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,35 @@ +# Generated by Django 5.0.11 on 2025-11-18 13:50 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('payments', '0026_remove_paymentplan_title'), + ] + + operations = [ + migrations.RemoveField( + model_name='paymentplanuserinfo', + name='next_payment_at', + ), + migrations.RemoveField( + model_name='paymentplanuserinfo', + name='plan_schedule', + ), + migrations.CreateModel( + name='RecurringPayment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('payment_datetime', models.DateTimeField(verbose_name='Payment Datetime')), + ('payment_method', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='recurring_payment', to='payments.userpaymentmethod', verbose_name='Payment Method')), + ('plan', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='recurring_payments', to='payments.paymentplan', verbose_name='Plan')), + ], + options={ + 'verbose_name': 'Recurring Payment', + 'verbose_name_plural': 'Recurring Payments', + }, + ), + ] @@ -0,0 +1,17 @@ +# Generated by Django 5.0.11 on 2025-11-19 07:45 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('payments', '0027_remove_paymentplanuserinfo_next_payment_at_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='paymentplan', + name='duration', + ), + ] @@ -0,0 +1,23 @@ +# Generated by Django 5.0.11 on 2025-11-26 14:43 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('payments', '0028_remove_paymentplan_duration'), + ] + + operations = [ + migrations.RenameField( + model_name='recurringpayment', + old_name='payment_method', + new_name='method', + ), + migrations.RenameField( + model_name='recurringpayment', + old_name='payment_datetime', + new_name='pay_at', + ), + ] @@ -0,0 +1,17 @@ +# Generated by Django 5.0.11 on 2025-11-28 09:26 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('payments', '0029_rename_payment_method_recurringpayment_method_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='paymentplan', + name='is_recurrent', + ), + ] @@ -0,0 +1,25 @@ +# Generated by Django 5.0.11 on 2026-02-03 10:11 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('payments', '0030_remove_paymentplan_is_recurrent'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.RemoveField( + model_name='userpaymentmethod', + name='currently_active', + ), + migrations.AlterField( + model_name='userpaymentmethod', + name='user', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='payment_method', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь'), + ), + ] @@ -3,6 +3,7 @@ from payments.models.payment_plan import PaymentPlan, PaymentPlanUserInfo from payments.models.user_payment_method import UserPaymentMethod from payments.models.invoice import Invoice from payments.models.promocode import PromoCode, PromoCodeActivation +from payments.models.recurring_payment import RecurringPayment from payments.models.payment_plan_feature import PaymentPlanFeature __all__ = ( @@ -13,5 +14,6 @@ __all__ = ( 'Invoice', 'PromoCode', 'PromoCodeActivation', + 'RecurringPayment', 'PaymentPlanFeature' ) @@ -1,20 +1,16 @@ 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.core.exceptions import ObjectDoesNotExist 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 +20,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,19 +57,9 @@ 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')) 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, - ) def save( self, @@ -90,9 +69,15 @@ 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): + try: + return self.user.payment_method.recurring_payment + except ObjectDoesNotExist: + return None + def __str__(self) -> str: return f'{self.user.email or "Ошибка"}' @@ -0,0 +1,29 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from payments.models import UserPaymentMethod, PaymentPlan + + +class RecurringPayment(models.Model): + method = models.OneToOneField( + UserPaymentMethod, + on_delete=models.CASCADE, + verbose_name=_('Payment Method'), + related_name='recurring_payment' + ) + pay_at = models.DateTimeField(verbose_name=_('Payment Datetime')) + plan = models.ForeignKey( + to=PaymentPlan, + on_delete=models.SET_NULL, + related_name='recurring_payments', + verbose_name=_('Plan'), + null=True, + blank=True, + ) + + def __str__(self) -> str: + return f'Recurring payment ({self.pay_at})' + + class Meta: + verbose_name = _('Recurring Payment') + verbose_name_plural = _('Recurring Payments') @@ -6,13 +6,12 @@ from core.models import BaseModel class UserPaymentMethod(BaseModel): - user = models.ForeignKey( + user = models.OneToOneField( get_user_model(), on_delete=models.CASCADE, - related_name='payment_methods', + related_name='payment_method', 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 цифры карты') @@ -1,9 +1,13 @@ +import orjson + import calendar import logging from collections import defaultdict from datetime import date, timedelta from decimal import Decimal +from django.utils.translation import gettext_lazy as _ + from dateutil.relativedelta import relativedelta from django.db.models import CharField, F, Func, Sum, Value, Prefetch from django.db.models.functions import Round, TruncDay, TruncMonth, TruncYear @@ -11,8 +15,12 @@ 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, UserPaymentMethod +from authentication.services.email_service import EmailService +from payments.models import RecurringPayment from payments.schema import UserBalance from payments.schemas import ( ExpensesParamsSchema, @@ -22,9 +30,9 @@ from payments.schemas import ( PaymentPlanSchema, ) from payments.selectors.payment_plan_selector import PaymentPlanSelector +from payments.typing import IntervalStrategyEnum, SourceStrategyEnum from payments.services.payment_plan_service import PaymentPlanService from payments.services.payment_service import PaymentService -from payments.typing import IntervalStrategyEnum, SourceStrategyEnum router = Router(auth=SyncAuthBearer(), tags=['payments']) @@ -44,6 +52,44 @@ 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) + payment = await PaymentService.handle_payment(data['object']['id']) + try: + payer = await CustomUserModel.objects.prefetch_related('payment_plan', 'payment_plan__plan').aget( + uid=payment.description + ) + payer_current_plan = payer.payment_plan.plan + payment_instance = await PaymentService(payer).do_payment(payment) + except CustomUserModel.DoesNotExist: + raise HttpError(400, str(PayerNotFound)) + except Exception as exc: + logger.exception(exc) + raise HttpError(400, f'{exc}') + try: + if payment.status in ('succeeded', 'canceled'): + recurring = await RecurringPayment.objects.filter(method__user=payment_instance.user).afirst() + next_payment_at = recurring.pay_at if recurring else 'Автоплатежи отключены' + new_plan = payment_instance.plan + EmailService.send_payment_email( + f'Вы {"приобрели план" if payer_current_plan.price != new_plan.price else "восстановили баланс по плану"} {new_plan.tokens_per_plan} токенов', + next_payment_at, + payment_instance.amount.quantize(Decimal('0.01'), rounding='ROUND_UP'), + payment_instance.user.email, + payment.status, + ) + except Exception as exc: + logger.exception(exc) + return 200 + + +@router.post('revoke-recurring-payment', tags=['payments/revoke-recurring-payment']) +def revoke_recurring_payment(request): + UserPaymentMethod.objects.filter(user=request.auth).delete() + 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 +135,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'], @@ -149,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, points=plan.points, grouped_features=[{'name': cat, 'features': feats} for cat, feats in grouped.items()], individual=plan.individual, @@ -181,3 +226,14 @@ async def cancel_plan_subscription(request): await PaymentPlanService(request.auth).cancel_payment_plan() except Exception as exc: raise HttpError(400, f'{exc}') + + +@router.post('gitlab-webhook', tags=['payments/gitlab-webhook'], auth=None) +async def handle_gitlab_webhook(request): + try: + data = orjson.loads(request.body)['object_attributes'] + if data['name'] == 'recurring_payments' and not data['active']: + await UserPaymentMethod.objects.all().adelete() + except Exception as 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() @@ -43,8 +43,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) @@ -1,14 +1,9 @@ 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.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__) @@ -19,51 +14,12 @@ class PaymentMethodService: 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( + payment_method, _ = UserPaymentMethod.objects.update_or_create( user=self.user, - last_four=last_four, - payment_method_id=method_id, - card_type=card_type, - currently_active=True, + defaults={'payment_method_id': method_id, 'last_four': last_four, 'card_type': card_type}, ) 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) + return payment_method - self.change_methods(serializer.validated_data['uid']) + def delete_payment_method(self): + UserPaymentMethod.objects.filter(user=self.user).delete() @@ -2,18 +2,12 @@ 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.models import Invoice, PaymentPlan, PaymentPlanUserInfo, RecurringPayment 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__) @@ -27,38 +21,19 @@ class PaymentPlanService: self.user.payment_plan.current_token_balance += amount self.user.payment_plan.save() - @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, mode: str = 'set', amount: Decimal = Decimal('0')): + try: + current_balance = self.user.payment_plan.current_token_balance + except PaymentPlanUserInfo.DoesNotExist: + current_balance = Decimal('0') + token_balance = amount or plan.tokens_per_plan + PaymentPlanUserInfo.objects.update_or_create( user=self.user, defaults={ 'plan': plan, - 'current_token_balance': plan.tokens_per_plan, + 'current_token_balance': token_balance if mode == 'set' else current_balance + token_balance, }, ) - 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 @@ -66,6 +41,7 @@ class PaymentPlanService: corporate=self.user.is_corporate() ) await plan_info.asave() + await RecurringPayment.objects.filter(method__user=self.user).adelete() def update_per_token_plan_details(self, payment_amount: Decimal, model=None): ModelBillingService(self.user).charge(payment_amount) @@ -1,18 +1,26 @@ +from datetime import timedelta 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 import RecurringPayment 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.services.payment_method_service import PaymentMethodService from payments.services.referral_account import ReferralAccountService @@ -27,19 +35,42 @@ class PaymentService: self.user = user async def create_payment_link(self, plan: PaymentPlan) -> str: + current_plan = self.user.payment_plan.plan + current_balance = self.user.payment_plan.current_token_balance + plan_price = plan.price + plan_tokens = plan.tokens_per_plan + tokens_mode = 'set' + if current_balance >= plan_tokens and plan == current_plan: + raise FullBalanceException + if 0 < current_plan.price <= plan.price and self.user.payment_plan.is_recurring: + plans_price_diff = plan.price - current_plan.price + token_price = current_plan.price / current_plan.tokens_per_plan + tokens_diff = current_plan.tokens_per_plan - current_balance + additional_tokens = 0 if tokens_diff < 0 else tokens_diff + plan_price = Decimal(plans_price_diff + (token_price * additional_tokens)).quantize( + Decimal('0.01'), rounding='ROUND_UP' + ) + balance_sufficient = (plan_tokens - current_balance) <= 0 + plan_tokens = plan_tokens if balance_sufficient else (plan_tokens - current_balance) + tokens_mode = 'set' if balance_sufficient else 'add' + tokens_update = {'mode': tokens_mode, 'amount': str(plan_tokens)} + product_title = f'Вы {"купили план" if current_plan.price != plan.price else "восстановили баланс по плану"} {plan.tokens_per_plan} токенов' receipt_data = { 'customer': {'email': self.user.email}, 'items': [ { - 'description': f'План {plan.tokens_per_plan} токенов за {plan.price} р.', - 'amount': {'value': f'{plan.price}', 'currency': 'RUB'}, + 'description': product_title, + '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 + ) payment_data = { - 'amount': {'value': f'{plan.price}', 'currency': 'RUB'}, + 'amount': {'value': f'{plan_price}', 'currency': 'RUB'}, 'payment_method_data': {'type': 'bank_card'}, 'receipt': receipt_data, 'confirmation': { @@ -48,71 +79,85 @@ class PaymentService: }, 'description': str(self.user.uid), 'capture': True, + 'save_payment_method': is_recurring, + 'metadata': {'plan_uid': str(plan.uid), **tokens_update}, } - - if plan.is_recurrent: - payment_data.update(save_payment_method=True) payment = YookassaPayment.create(payment_data, uuid4()) 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 + @sync_to_async + def do_payment(self, payment: YookassaPaymentResponse) -> PaymentModel: + from payments.services.payment_plan_service import PaymentPlanService - last_four = card.last4 - PaymentMethodService(user).add_payment_method( - method_id=UUID(method_id), card_type=card_type, last_four=last_four - ) - - @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) + if payment.status == 'waiting_for_capture': + self.handle_captured_payment(payment.id) + elif payment.status == 'succeeded': + self.handle_succeeded_payment(payment, payment_instance.plan) + PaymentPlanService(self.user).subscribe_user_to_plan( + payment_instance.plan, payment.metadata['mode'], Decimal(payment.metadata['amount']) ) - except Exception as exc: - logger.exception(exc) + 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: + 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)) + + 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 + ): + payment_method = self.save_payment_method(payment.payment_method) + RecurringPayment.objects.update_or_create( + method__user=self.user, + defaults={ + 'method': payment_method, + 'pay_at': timezone.now() + relativedelta(months=1), + 'plan': plan, + }, + ) + else: + PaymentMethodService(self.user).delete_payment_method() + + def handle_canceled_payment(self, payment: YookassaPaymentResponse) -> None: + logger.error(f'Recurrent payment error: {payment.cancellation_details.reason}') + if payment.cancellation_details.reason == 'permission_revoked': + RecurringPayment.objects.filter(method__user=self.user).delete() + else: + recurring = RecurringPayment.objects.filter(method__user=self.user).first() + if recurring: + recurring.pay_at = timezone.now() + timedelta(days=2) + recurring.save() + + def save_payment_method(self, method_data: PaymentDataBankCard): + card = method_data.card + + return PaymentMethodService(self.user).add_payment_method( + method_id=UUID(method_data.id), card_type=card.card_type, last_four=card.last4 + ) + + def save_payment(self, payment: YookassaPaymentResponse) -> PaymentModel: + plan = PaymentPlan.objects.get_or_none(uid=payment.metadata.get('plan_uid')) payment_instance, _ = 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, - } - ) @@ -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() @@ -15,17 +15,19 @@ 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, + ) + referer_account.owner.payment_plan.current_token_balance += accrual_amount + referer_account.owner.payment_plan.save() @@ -129,7 +129,6 @@ class PlansAPITest(BaseAuthorizedAPITest): 'uid', 'price', 'tokens_per_plan', - 'duration', 'points', 'grouped_features', 'accessed_models', @@ -16,7 +16,7 @@ from payments.models import ( PaymentPlanUserInfo, PromoCode, PromoCodeActivation, - UserPaymentMethod, + UserPaymentMethod, RecurringPayment, ) from payments.models.referral_account import ReferralAccount, ReferralInvite @@ -47,8 +47,6 @@ class PaymentPlanAdmin(OrderedInlineModelAdminMixin, admin.ModelAdmin): 'uid', 'price', 'tokens_per_plan', - 'duration', - 'is_recurrent', 'is_corporate', 'individual', 'is_visible', @@ -223,3 +221,8 @@ 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"]} токенов' + + +@admin.register(RecurringPayment) +class RecurringPaymentAdmin(admin.ModelAdmin): + list_display = ('method', 'pay_at') @@ -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() @@ -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 points: list[str] grouped_features: list[GroupedPlanFeatureSchema] = [] accessed_models: list[str] = [] @@ -47,7 +46,6 @@ class UserPlanDetailSchema(Schema): uid: UUID plan: PaymentPlanSchema last_payment_at: date - next_payment_at: date class PromoCodeSchema(ModelSchema): @@ -65,11 +63,11 @@ 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', ) @@ -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) accessed_models = serializers.SerializerMethodField() individual = serializers.BooleanField() @@ -48,30 +47,9 @@ class UserPlanDetailSerializer(serializers.Serializer): uid = serializers.UUIDField() plan = PaymentPlanSerializer() last_payment_at = serializers.DateField() - next_payment_at = serializers.DateField() 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) @@ -4,6 +4,7 @@ from django.db.models.signals import post_save from django.dispatch import receiver from authentication.models.user import CustomUserModel +from payments.models import PaymentPlan, UserPaymentMethod from payments.services.referral_account import ReferralAccountService @@ -16,3 +17,11 @@ 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: + UserPaymentMethod.objects.filter(user__payment_plan__plan=instance).delete() \ No newline at end of file @@ -1,15 +1,20 @@ 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 RecurringPayment from payments.services.payment_plan_service import PaymentPlanService +from yookassa import Payment as YookassaPayment + logger = get_task_logger(__name__) @@ -27,3 +32,40 @@ 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 = RecurringPayment.objects.filter(pay_at__lte=timezone.now(), plan__isnull=False, plan__individual=False) + for overdue_payment in overdue_payments: + if celery_client.get_flag_state('recurring_payments', overdue_payment.method.user.email): + customer = overdue_payment.method.user + plan = overdue_payment.plan + product_title = f'Вы восстановили баланс по плану {plan.tokens_per_plan} токенов' + receipt_data = { + 'customer': {'email': customer.email}, + 'items': [ + { + 'description': product_title, + '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), + 'mode': 'set', + 'amount': str(plan.tokens_per_plan), + }, + } + YookassaPayment.create(payment_data, uuid4()) + else: + overdue_payment.method.delete() \ 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, @@ -66,7 +66,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' @@ -90,4 +89,9 @@ CHANNELS_PORT_MDB=6379 LOG_LEVEL=debug DOMAIN=localhost -PROVIDER=docker \ No newline at end of file +PROVIDER=docker + +# UNLEASH +FEATURE_FLAG_API_URL=https://gitlab.com/api/v4/feature_flags/unleash/64383616 +FEATURE_FLAG_INSTANCE_ID=glffct-s7Ce_Ki4hCjMfnYVsi5y +FEATURE_FLAG_APP_NAME=production \ 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.1" +description = "In-process task scheduler with Cron-like capabilities" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "apscheduler-3.11.1-py3-none-any.whl", hash = "sha256:6162cb5683cb09923654fa9bdd3130c4be4bfda6ad8990971c9597ecd52965d2"}, + {file = "apscheduler-3.11.1.tar.gz", hash = "sha256:0db77af6400c84d1747fe98a04b8b58f0080c77d11d338c4f507a9752880f221"}, +] + +[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", "pytz", "twisted ; python_version < \"3.14\""] +tornado = ["tornado (>=4.3)"] +twisted = ["twisted"] +zookeeper = ["kazoo"] + [[package]] name = "argon2-cffi" version = "23.1.0" @@ -1590,6 +1618,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" @@ -2310,6 +2353,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 = "8.7.0" +description = "Read metadata from Python packages" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd"}, + {file = "importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000"}, +] + +[package.dependencies] +zipp = ">=3.20" + +[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)"] +perf = ["ipython"] +test = ["flufl.flake8", "importlib_resources (>=1.3) ; python_version < \"3.9\"", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] +type = ["pytest-mypy"] + [[package]] name = "incremental" version = "24.7.2" @@ -2759,6 +2826,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.0" +description = "LaunchDarkly SSE Client" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "launchdarkly_eventsource-1.5.0-py3-none-any.whl", hash = "sha256:4c3bf3e9f318792712dc2eb00905c5b15a1b8df2cf247b9dcef4dd3560fdde1e"}, + {file = "launchdarkly_eventsource-1.5.0.tar.gz", hash = "sha256:29337766b409774f81ad5dda43e2d12dcbc1f02f07c7d4fdab3330f6b46b0c5d"}, +] + +[package.dependencies] +urllib3 = ">=1.26.0,<3" + [[package]] name = "lxml" version = "5.4.0" @@ -2962,6 +3044,145 @@ pycryptodome = "*" typing-extensions = "*" urllib3 = "*" +[[package]] +name = "mmh3" +version = "5.2.0" +description = "Python extension for MurmurHash (MurmurHash3), a set of fast and robust hash functions." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "mmh3-5.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:81c504ad11c588c8629536b032940f2a359dda3b6cbfd4ad8f74cb24dcd1b0bc"}, + {file = "mmh3-5.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0b898cecff57442724a0f52bf42c2de42de63083a91008fb452887e372f9c328"}, + {file = "mmh3-5.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:be1374df449465c9f2500e62eee73a39db62152a8bdfbe12ec5b5c1cd451344d"}, + {file = "mmh3-5.2.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b0d753ad566c721faa33db7e2e0eddd74b224cdd3eaf8481d76c926603c7a00e"}, + {file = "mmh3-5.2.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:dfbead5575f6470c17e955b94f92d62a03dfc3d07f2e6f817d9b93dc211a1515"}, + {file = "mmh3-5.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7434a27754049144539d2099a6d2da5d88b8bdeedf935180bf42ad59b3607aa3"}, + {file = "mmh3-5.2.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cadc16e8ea64b5d9a47363013e2bea469e121e6e7cb416a7593aeb24f2ad122e"}, + {file = "mmh3-5.2.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d765058da196f68dc721116cab335e696e87e76720e6ef8ee5a24801af65e63d"}, + {file = "mmh3-5.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8b0c53fe0994beade1ad7c0f13bd6fec980a0664bfbe5a6a7d64500b9ab76772"}, + {file = "mmh3-5.2.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:49037d417419863b222ae47ee562b2de9c3416add0a45c8d7f4e864be8dc4f89"}, + {file = "mmh3-5.2.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:6ecb4e750d712abde046858ee6992b65c93f1f71b397fce7975c3860c07365d2"}, + {file = "mmh3-5.2.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:382a6bb3f8c6532ea084e7acc5be6ae0c6effa529240836d59352398f002e3fc"}, + {file = "mmh3-5.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7733ec52296fc1ba22e9b90a245c821adbb943e98c91d8a330a2254612726106"}, + {file = "mmh3-5.2.0-cp310-cp310-win32.whl", hash = "sha256:127c95336f2a98c51e7682341ab7cb0be3adb9df0819ab8505a726ed1801876d"}, + {file = "mmh3-5.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:419005f84ba1cab47a77465a2a843562dadadd6671b8758bf179d82a15ca63eb"}, + {file = "mmh3-5.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:d22c9dcafed659fadc605538946c041722b6d1104fe619dbf5cc73b3c8a0ded8"}, + {file = "mmh3-5.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7901c893e704ee3c65f92d39b951f8f34ccf8e8566768c58103fb10e55afb8c1"}, + {file = "mmh3-5.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4a5f5536b1cbfa72318ab3bfc8a8188b949260baed186b75f0abc75b95d8c051"}, + {file = "mmh3-5.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cedac4f4054b8f7859e5aed41aaa31ad03fce6851901a7fdc2af0275ac533c10"}, + {file = "mmh3-5.2.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eb756caf8975882630ce4e9fbbeb9d3401242a72528230422c9ab3a0d278e60c"}, + {file = "mmh3-5.2.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:097e13c8b8a66c5753c6968b7640faefe85d8e38992703c1f666eda6ef4c3762"}, + {file = "mmh3-5.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a7c0c7845566b9686480e6a7e9044db4afb60038d5fabd19227443f0104eeee4"}, + {file = "mmh3-5.2.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:61ac226af521a572700f863d6ecddc6ece97220ce7174e311948ff8c8919a363"}, + {file = "mmh3-5.2.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:582f9dbeefe15c32a5fa528b79b088b599a1dfe290a4436351c6090f90ddebb8"}, + {file = "mmh3-5.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2ebfc46b39168ab1cd44670a32ea5489bcbc74a25795c61b6d888c5c2cf654ed"}, + {file = "mmh3-5.2.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1556e31e4bd0ac0c17eaf220be17a09c171d7396919c3794274cb3415a9d3646"}, + {file = "mmh3-5.2.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:81df0dae22cd0da87f1c978602750f33d17fb3d21fb0f326c89dc89834fea79b"}, + {file = "mmh3-5.2.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:eba01ec3bd4a49b9ac5ca2bc6a73ff5f3af53374b8556fcc2966dd2af9eb7779"}, + {file = "mmh3-5.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e9a011469b47b752e7d20de296bb34591cdfcbe76c99c2e863ceaa2aa61113d2"}, + {file = "mmh3-5.2.0-cp311-cp311-win32.whl", hash = "sha256:bc44fc2b886243d7c0d8daeb37864e16f232e5b56aaec27cc781d848264cfd28"}, + {file = "mmh3-5.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:8ebf241072cf2777a492d0e09252f8cc2b3edd07dfdb9404b9757bffeb4f2cee"}, + {file = "mmh3-5.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:b5f317a727bba0e633a12e71228bc6a4acb4f471a98b1c003163b917311ea9a9"}, + {file = "mmh3-5.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:384eda9361a7bf83a85e09447e1feafe081034af9dd428893701b959230d84be"}, + {file = "mmh3-5.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c9da0d568569cc87315cb063486d761e38458b8ad513fedd3dc9263e1b81bcd"}, + {file = "mmh3-5.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86d1be5d63232e6eb93c50881aea55ff06eb86d8e08f9b5417c8c9b10db9db96"}, + {file = "mmh3-5.2.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bf7bee43e17e81671c447e9c83499f53d99bf440bc6d9dc26a841e21acfbe094"}, + {file = "mmh3-5.2.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7aa18cdb58983ee660c9c400b46272e14fa253c675ed963d3812487f8ca42037"}, + {file = "mmh3-5.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae9d032488fcec32d22be6542d1a836f00247f40f320844dbb361393b5b22773"}, + {file = "mmh3-5.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1861fb6b1d0453ed7293200139c0a9011eeb1376632e048e3766945b13313c5"}, + {file = "mmh3-5.2.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:99bb6a4d809aa4e528ddfe2c85dd5239b78b9dd14be62cca0329db78505e7b50"}, + {file = "mmh3-5.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1f8d8b627799f4e2fcc7c034fed8f5f24dc7724ff52f69838a3d6d15f1ad4765"}, + {file = "mmh3-5.2.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b5995088dd7023d2d9f310a0c67de5a2b2e06a570ecfd00f9ff4ab94a67cde43"}, + {file = "mmh3-5.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1a5f4d2e59d6bba8ef01b013c472741835ad961e7c28f50c82b27c57748744a4"}, + {file = "mmh3-5.2.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fd6e6c3d90660d085f7e73710eab6f5545d4854b81b0135a3526e797009dbda3"}, + {file = "mmh3-5.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c4a2f3d83879e3de2eb8cbf562e71563a8ed15ee9b9c2e77ca5d9f73072ac15c"}, + {file = "mmh3-5.2.0-cp312-cp312-win32.whl", hash = "sha256:2421b9d665a0b1ad724ec7332fb5a98d075f50bc51a6ff854f3a1882bd650d49"}, + {file = "mmh3-5.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d80005b7634a3a2220f81fbeb94775ebd12794623bb2e1451701ea732b4aa3"}, + {file = "mmh3-5.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:3d6bfd9662a20c054bc216f861fa330c2dac7c81e7fb8307b5e32ab5b9b4d2e0"}, + {file = "mmh3-5.2.0-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:e79c00eba78f7258e5b354eccd4d7907d60317ced924ea4a5f2e9d83f5453065"}, + {file = "mmh3-5.2.0-cp313-cp313-android_21_x86_64.whl", hash = "sha256:956127e663d05edbeec54df38885d943dfa27406594c411139690485128525de"}, + {file = "mmh3-5.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:c3dca4cb5b946ee91b3d6bb700d137b1cd85c20827f89fdf9c16258253489044"}, + {file = "mmh3-5.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:e651e17bfde5840e9e4174b01e9e080ce49277b70d424308b36a7969d0d1af73"}, + {file = "mmh3-5.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:9f64bf06f4bf623325fda3a6d02d36cd69199b9ace99b04bb2d7fd9f89688504"}, + {file = "mmh3-5.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ddc63328889bcaee77b743309e5c7d2d52cee0d7d577837c91b6e7cc9e755e0b"}, + {file = "mmh3-5.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:bb0fdc451fb6d86d81ab8f23d881b8d6e37fc373a2deae1c02d27002d2ad7a05"}, + {file = "mmh3-5.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b29044e1ffdb84fe164d0a7ea05c7316afea93c00f8ed9449cf357c36fc4f814"}, + {file = "mmh3-5.2.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:58981d6ea9646dbbf9e59a30890cbf9f610df0e4a57dbfe09215116fd90b0093"}, + {file = "mmh3-5.2.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7e5634565367b6d98dc4aa2983703526ef556b3688ba3065edb4b9b90ede1c54"}, + {file = "mmh3-5.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0271ac12415afd3171ab9a3c7cbfc71dee2c68760a7dc9d05bf8ed6ddfa3a7a"}, + {file = "mmh3-5.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:45b590e31bc552c6f8e2150ff1ad0c28dd151e9f87589e7eaf508fbdd8e8e908"}, + {file = "mmh3-5.2.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bdde97310d59604f2a9119322f61b31546748499a21b44f6715e8ced9308a6c5"}, + {file = "mmh3-5.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fc9c5f280438cf1c1a8f9abb87dc8ce9630a964120cfb5dd50d1e7ce79690c7a"}, + {file = "mmh3-5.2.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c903e71fd8debb35ad2a4184c1316b3cb22f64ce517b4e6747f25b0a34e41266"}, + {file = "mmh3-5.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:eed4bba7ff8a0d37106ba931ab03bdd3915fbb025bcf4e1f0aa02bc8114960c5"}, + {file = "mmh3-5.2.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:1fdb36b940e9261aff0b5177c5b74a36936b902f473180f6c15bde26143681a9"}, + {file = "mmh3-5.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7303aab41e97adcf010a09efd8f1403e719e59b7705d5e3cfed3dd7571589290"}, + {file = "mmh3-5.2.0-cp313-cp313-win32.whl", hash = "sha256:03e08c6ebaf666ec1e3d6ea657a2d363bb01effd1a9acfe41f9197decaef0051"}, + {file = "mmh3-5.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:7fddccd4113e7b736706e17a239a696332360cbaddf25ae75b57ba1acce65081"}, + {file = "mmh3-5.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:fa0c966ee727aad5406d516375593c5f058c766b21236ab8985693934bb5085b"}, + {file = "mmh3-5.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:e5015f0bb6eb50008bed2d4b1ce0f2a294698a926111e4bb202c0987b4f89078"}, + {file = "mmh3-5.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:e0f3ed828d709f5b82d8bfe14f8856120718ec4bd44a5b26102c3030a1e12501"}, + {file = "mmh3-5.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:f35727c5118aba95f0397e18a1a5b8405425581bfe53e821f0fb444cbdc2bc9b"}, + {file = "mmh3-5.2.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3bc244802ccab5220008cb712ca1508cb6a12f0eb64ad62997156410579a1770"}, + {file = "mmh3-5.2.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ff3d50dc3fe8a98059f99b445dfb62792b5d006c5e0b8f03c6de2813b8376110"}, + {file = "mmh3-5.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:37a358cc881fe796e099c1db6ce07ff757f088827b4e8467ac52b7a7ffdca647"}, + {file = "mmh3-5.2.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b9a87025121d1c448f24f27ff53a5fe7b6ef980574b4a4f11acaabe702420d63"}, + {file = "mmh3-5.2.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ba55d6ca32eeef8b2625e1e4bfc3b3db52bc63014bd7e5df8cc11bf2b036b12"}, + {file = "mmh3-5.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9ff37ba9f15637e424c2ab57a1a590c52897c845b768e4e0a4958084ec87f22"}, + {file = "mmh3-5.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a094319ec0db52a04af9fdc391b4d39a1bc72bc8424b47c4411afb05413a44b5"}, + {file = "mmh3-5.2.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c5584061fd3da584659b13587f26c6cad25a096246a481636d64375d0c1f6c07"}, + {file = "mmh3-5.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecbfc0437ddfdced5e7822d1ce4855c9c64f46819d0fdc4482c53f56c707b935"}, + {file = "mmh3-5.2.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:7b986d506a8e8ea345791897ba5d8ba0d9d8820cd4fc3e52dbe6de19388de2e7"}, + {file = "mmh3-5.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:38d899a156549da8ef6a9f1d6f7ef231228d29f8f69bce2ee12f5fba6d6fd7c5"}, + {file = "mmh3-5.2.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d86651fa45799530885ba4dab3d21144486ed15285e8784181a0ab37a4552384"}, + {file = "mmh3-5.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c463d7c1c4cfc9d751efeaadd936bbba07b5b0ed81a012b3a9f5a12f0872bd6e"}, + {file = "mmh3-5.2.0-cp314-cp314-win32.whl", hash = "sha256:bb4fe46bdc6104fbc28db7a6bacb115ee6368ff993366bbd8a2a7f0076e6f0c0"}, + {file = "mmh3-5.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:7c7f0b342fd06044bedd0b6e72177ddc0076f54fd89ee239447f8b271d919d9b"}, + {file = "mmh3-5.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:3193752fc05ea72366c2b63ff24b9a190f422e32d75fdeae71087c08fff26115"}, + {file = "mmh3-5.2.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:69fc339d7202bea69ef9bd7c39bfdf9fdabc8e6822a01eba62fb43233c1b3932"}, + {file = "mmh3-5.2.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:12da42c0a55c9d86ab566395324213c319c73ecb0c239fad4726324212b9441c"}, + {file = "mmh3-5.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f7f9034c7cf05ddfaac8d7a2e63a3c97a840d4615d0a0e65ba8bdf6f8576e3be"}, + {file = "mmh3-5.2.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:11730eeb16dfcf9674fdea9bb6b8e6dd9b40813b7eb839bc35113649eef38aeb"}, + {file = "mmh3-5.2.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:932a6eec1d2e2c3c9e630d10f7128d80e70e2d47fe6b8c7ea5e1afbd98733e65"}, + {file = "mmh3-5.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ca975c51c5028947bbcfc24966517aac06a01d6c921e30f7c5383c195f87991"}, + {file = "mmh3-5.2.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5b0b58215befe0f0e120b828f7645e97719bbba9f23b69e268ed0ac7adde8645"}, + {file = "mmh3-5.2.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29c2b9ce61886809d0492a274a5a53047742dea0f703f9c4d5d223c3ea6377d3"}, + {file = "mmh3-5.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a367d4741ac0103f8198c82f429bccb9359f543ca542b06a51f4f0332e8de279"}, + {file = "mmh3-5.2.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:5a5dba98e514fb26241868f6eb90a7f7ca0e039aed779342965ce24ea32ba513"}, + {file = "mmh3-5.2.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:941603bfd75a46023807511c1ac2f1b0f39cccc393c15039969806063b27e6db"}, + {file = "mmh3-5.2.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:132dd943451a7c7546978863d2f5a64977928410782e1a87d583cb60eb89e667"}, + {file = "mmh3-5.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f698733a8a494466432d611a8f0d1e026f5286dee051beea4b3c3146817e35d5"}, + {file = "mmh3-5.2.0-cp314-cp314t-win32.whl", hash = "sha256:6d541038b3fc360ec538fc116de87462627944765a6750308118f8b509a8eec7"}, + {file = "mmh3-5.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e912b19cf2378f2967d0c08e86ff4c6c360129887f678e27e4dde970d21b3f4d"}, + {file = "mmh3-5.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:e7884931fe5e788163e7b3c511614130c2c59feffdc21112290a194487efb2e9"}, + {file = "mmh3-5.2.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3c6041fd9d5fb5fcac57d5c80f521a36b74aea06b8566431c63e4ffc49aced51"}, + {file = "mmh3-5.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:58477cf9ef16664d1ce2b038f87d2dc96d70fe50733a34a7f07da6c9a5e3538c"}, + {file = "mmh3-5.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:be7d3dca9358e01dab1bad881fb2b4e8730cec58d36dd44482bc068bfcd3bc65"}, + {file = "mmh3-5.2.0-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:931d47e08c9c8a67bf75d82f0ada8399eac18b03388818b62bfa42882d571d72"}, + {file = "mmh3-5.2.0-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:dd966df3489ec13848d6c6303429bbace94a153f43d1ae2a55115fd36fd5ca5d"}, + {file = "mmh3-5.2.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c677d78887244bf3095020b73c42b505b700f801c690f8eaa90ad12d3179612f"}, + {file = "mmh3-5.2.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:63830f846797187c5d3e2dae50f0848fdc86032f5bfdc58ae352f02f857e9025"}, + {file = "mmh3-5.2.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c3f563e8901960e2eaa64c8e8821895818acabeb41c96f2efbb936f65dbe486c"}, + {file = "mmh3-5.2.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:96f1e1ac44cbb42bcc406e509f70c9af42c594e72ccc7b1257f97554204445f0"}, + {file = "mmh3-5.2.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:7bbb0df897944b5ec830f3ad883e32c5a7375370a521565f5fe24443bfb2c4f7"}, + {file = "mmh3-5.2.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:1fae471339ae1b9c641f19cf46dfe6ffd7f64b1fba7c4333b99fa3dd7f21ae0a"}, + {file = "mmh3-5.2.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:aa6e5d31fdc5ed9e3e95f9873508615a778fe9b523d52c17fc770a3eb39ab6e4"}, + {file = "mmh3-5.2.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:746a5ee71c6d1103d9b560fa147881b5e68fd35da56e54e03d5acefad0e7c055"}, + {file = "mmh3-5.2.0-cp39-cp39-win32.whl", hash = "sha256:10983c10f5c77683bd845751905ba535ec47409874acc759d5ce3ff7ef34398a"}, + {file = "mmh3-5.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:fdfd3fb739f4e22746e13ad7ba0c6eedf5f454b18d11249724a388868e308ee4"}, + {file = "mmh3-5.2.0-cp39-cp39-win_arm64.whl", hash = "sha256:33576136c06b46a7046b6d83a3d75fbca7d25f84cec743f1ae156362608dc6d2"}, + {file = "mmh3-5.2.0.tar.gz", hash = "sha256:1efc8fec8478e9243a78bb993422cf79f8ff85cb4cf6b79647480a31e0d950a8"}, +] + +[package.extras] +benchmark = ["pymmh3 (==0.0.5)", "pyperf (==2.9.0)", "xxhash (==3.5.0)"] +docs = ["myst-parser (==4.0.1)", "shibuya (==2025.7.24)", "sphinx (==8.2.3)", "sphinx-copybutton (==0.5.2)"] +lint = ["black (==25.1.0)", "clang-format (==20.1.8)", "isort (==6.0.1)", "pylint (==3.3.7)"] +plot = ["matplotlib (==3.10.3)", "pandas (==2.3.1)"] +test = ["pytest (==8.4.1)", "pytest-sugar (==1.0.0)"] +type = ["mypy (==1.17.0)"] + [[package]] name = "msgpack" version = "1.1.0" @@ -3550,6 +3771,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" @@ -4890,6 +5127,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" @@ -5478,6 +5727,47 @@ files = [ ] markers = {typing = "sys_platform == \"win32\""} +[[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.4.0" +description = "Python client for the Unleash feature toggle system!" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "unleashclient-6.4.0-py3-none-any.whl", hash = "sha256:e0b80fffbda50427115707cf042611ad130b5ed3b01611301f06a9fc8981eea7"}, + {file = "unleashclient-6.4.0.tar.gz", hash = "sha256:54952e5d64c05835e44315efd17e8ba0e7815e1cc431ba510d6c1928c363b003"}, +] + +[package.dependencies] +apscheduler = "<4.0.0" +fcache = "*" +importlib_metadata = "*" +launchdarkly-eventsource = "*" +mmh3 = "*" +python-dateutil = "*" +requests = "*" +semver = "<4.0.0" +yggdrasil-engine = ">=1.0.0" + [[package]] name = "uritemplate" version = "4.1.1" @@ -5800,6 +6090,64 @@ idna = ">=2.0" multidict = ">=4.0" propcache = ">=0.2.0" +[[package]] +name = "yggdrasil-engine" +version = "1.0.0" +description = "Engine for evaluating Unleash feature flags" +optional = false +python-versions = "<4.0,>=3.8" +groups = ["main"] +files = [ + {file = "yggdrasil_engine-1.0.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:a1ed0b4d8d1702b3e6a1f454f89be2fdac544ff1b7d979afa62224377ae0cf6e"}, + {file = "yggdrasil_engine-1.0.0-cp310-abi3-macosx_11_0_x86_64.whl", hash = "sha256:9ff016dad3f6972d79dc121625df4c5ea35e8f69519de3873a408daf0b65bd54"}, + {file = "yggdrasil_engine-1.0.0-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:de577c8abd064861423b55d245e16770927332b1d3a6ef2e94ccb4b5d759349b"}, + {file = "yggdrasil_engine-1.0.0-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:b1dece26decdcf1fc061bc9a3771618d00bf9431466cfe2b952151f92814d169"}, + {file = "yggdrasil_engine-1.0.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:938f1a88c801f4ccc6289dced4033cb05a6f7042f373581ce7d8a999da6b82d0"}, + {file = "yggdrasil_engine-1.0.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6386ec0c0fd0a9c196e54b9cd36b1edecb0233a618496210cb70f914eae593b9"}, + {file = "yggdrasil_engine-1.0.0-cp310-abi3-win_amd64.whl", hash = "sha256:345c6003f07480fcf6d7c5ba8522de2cbafa4521bbc42013ceaeef34c2395a74"}, + {file = "yggdrasil_engine-1.0.0-cp310-abi3-win_arm64.whl", hash = "sha256:52f648a83049c3ff29bc2d8f6064c745895e38d021051948fab55a041d0d1195"}, + {file = "yggdrasil_engine-1.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:cecae8456c3b45e71a84e7e5c9467fb26343c27a86c7b9b53c080959f5bf4092"}, + {file = "yggdrasil_engine-1.0.0-cp311-abi3-macosx_11_0_x86_64.whl", hash = "sha256:8f1ff08afa2a2c6b1b856439078a473abca275223636630fb7a67108e587fe79"}, + {file = "yggdrasil_engine-1.0.0-cp311-abi3-manylinux2014_aarch64.whl", hash = "sha256:447f2beb4c743db01aa8ae26eb38b5cd314aaa664388361006456ca6735b5a42"}, + {file = "yggdrasil_engine-1.0.0-cp311-abi3-manylinux2014_x86_64.whl", hash = "sha256:9c784fe3d47e88f4f059d59d47ea0e64c9e352d1e5e8b91efdaba36c3509d94d"}, + {file = "yggdrasil_engine-1.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:cf325a422b1fb1033999b6dffac3ed211e1dabe4c7e5815ce3ce3d46819eff8b"}, + {file = "yggdrasil_engine-1.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f52b362ec2c5f9e192de99877b8e801b82a46ffe2ae565d7b676c549b1b88ad1"}, + {file = "yggdrasil_engine-1.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e9adb857705bf646541124527b049c4cca17b82de9c0390ab51c8f7ab97474e1"}, + {file = "yggdrasil_engine-1.0.0-cp311-abi3-win_arm64.whl", hash = "sha256:33a11e16f9436425896faf6cdff58eab43b0cd2979011c7aff63e3e919c8888a"}, + {file = "yggdrasil_engine-1.0.0-cp312-abi3-macosx_11_0_arm64.whl", hash = "sha256:b92167283a0c4b04f38f738a04a990f01176a075f6a8af3e62d7174f34646fd1"}, + {file = "yggdrasil_engine-1.0.0-cp312-abi3-macosx_11_0_x86_64.whl", hash = "sha256:5d190184f3feace6112a220d47052398432cf3aa6eb33efe06dcfcb9b06a3d6d"}, + {file = "yggdrasil_engine-1.0.0-cp312-abi3-manylinux2014_aarch64.whl", hash = "sha256:90d627ca958eefebeb8d183b7f53cf32f1bc0e558e600ce3334ecd56bbe2b1ef"}, + {file = "yggdrasil_engine-1.0.0-cp312-abi3-manylinux2014_x86_64.whl", hash = "sha256:0a86e2cd08225bd76e7df64f1a41582161b1ac19317f41f68175b6b79bc281ae"}, + {file = "yggdrasil_engine-1.0.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a50704d10ed1054047eb87bca85c348f4c9cba35d6bff9058c2c487739c3aa3c"}, + {file = "yggdrasil_engine-1.0.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:770509f97e27228dca593663f16d07cbd08f61535fe2f3203b725ff144e191ed"}, + {file = "yggdrasil_engine-1.0.0-cp312-abi3-win_amd64.whl", hash = "sha256:8438548e05525eaf4f770918a836e04829fdaee32e14da6f2ff7e8b3f2512d53"}, + {file = "yggdrasil_engine-1.0.0-cp312-abi3-win_arm64.whl", hash = "sha256:69c0ec4ab606fad5dd8ff96b565af4c3a9c5dd1fd6844e549ea0bf3db4a2f763"}, + {file = "yggdrasil_engine-1.0.0-cp313-abi3-macosx_11_0_arm64.whl", hash = "sha256:90f9336a24a9e09fe211eb6079e5c4a9e2cc245e90842356be511bd7bc73c594"}, + {file = "yggdrasil_engine-1.0.0-cp313-abi3-macosx_11_0_x86_64.whl", hash = "sha256:d0cabfd198b1ac927d0c56d0d667ab9497acb8b60351ff45a4000214c79a54a0"}, + {file = "yggdrasil_engine-1.0.0-cp313-abi3-manylinux2014_aarch64.whl", hash = "sha256:21340f75a9283bfc1feb78ba071703f432e77ca1fcd32fe1a28a5d1314a8a39d"}, + {file = "yggdrasil_engine-1.0.0-cp313-abi3-manylinux2014_x86_64.whl", hash = "sha256:2cac50c85887a6c078ca6d3792d2b7241539d6e385648006ca8255cd20fce431"}, + {file = "yggdrasil_engine-1.0.0-cp313-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3eb8bc69111e1f3d10649504a33b6a6677cd932037c6c7e77f12c80c1d8b3c63"}, + {file = "yggdrasil_engine-1.0.0-cp313-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2fe9d2ef59ebdbfbbc689b440c45283f3c385b7b5e1ee637618d8e279fc41301"}, + {file = "yggdrasil_engine-1.0.0-cp313-abi3-win_amd64.whl", hash = "sha256:40131927c89f8f8bfc66e01f4b8e9168433b64c23b8a3ea1614bdddc0ca3ae14"}, + {file = "yggdrasil_engine-1.0.0-cp313-abi3-win_arm64.whl", hash = "sha256:d5830f797a2309a99fdf49fe469a52ad5bb0bbb22ee68b409d47a07c72e5f5b3"}, + {file = "yggdrasil_engine-1.0.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:cafb7e5bb3cb4143b2fd83f99250bd23f8c4d9f9c60f1fd8783c1615a4bdfcea"}, + {file = "yggdrasil_engine-1.0.0-cp38-abi3-macosx_11_0_x86_64.whl", hash = "sha256:7aafb572c6d7ddbdd9724ad494f53add883bdd1def24a63e175854d5c76dd26e"}, + {file = "yggdrasil_engine-1.0.0-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:4d73c51519455b7758f551982ba6f5ede70990b51da9e9c070cef2afcd2f36ce"}, + {file = "yggdrasil_engine-1.0.0-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:1581d90fb52497cbd7a0eee54d8f325139c5f76d90a3931dbd42d9d0c0209d83"}, + {file = "yggdrasil_engine-1.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b6b7a28e81e09c2719241b41ec63ff0acda00c2f2b5a54e83d703101440e41c1"}, + {file = "yggdrasil_engine-1.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ab486a8401e3e0358fa75ee183dc9096443df979602c156a70a2259884f5f856"}, + {file = "yggdrasil_engine-1.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:816fcf92995ab9725968746ad20258bb1d87602d2d2ebf14bc0b56aa405ab03e"}, + {file = "yggdrasil_engine-1.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:2a8f833cdd555c33747532792cf0cc6620dca9bb6a693ab1ff8dea375a2d9970"}, + {file = "yggdrasil_engine-1.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:4b1765db6b31cd1d7bef5821493834003df50121604907bce82e84d3dc0c80d9"}, + {file = "yggdrasil_engine-1.0.0-cp39-abi3-macosx_11_0_x86_64.whl", hash = "sha256:ee9ebcf0b206ce8959147d8b7faeab1833c0587e2573f8f3d6dd4a98605b8d97"}, + {file = "yggdrasil_engine-1.0.0-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:42a199714bab52c9e22108a74b1b4cfdefd5f451139024cc6d8828111835189d"}, + {file = "yggdrasil_engine-1.0.0-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:f3132700301f2a7f0cb3509fda9d159db2fb28437ae1e1bd214604520ad10905"}, + {file = "yggdrasil_engine-1.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25503d8711ef3d009707be73eaaa500646b90ecd88085b4c2cbe6f2a176c3986"}, + {file = "yggdrasil_engine-1.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6d4ebaa62898e2e59da960ab6e24214d2b06312f36e5199fb9d22715b46224db"}, + {file = "yggdrasil_engine-1.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:5eb80a90d002d3f32e7ecf0743699818c84b39bfd349be20ab780177dc113c33"}, + {file = "yggdrasil_engine-1.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:8be8bdd4e2ba82febe493c70fa5d4c1a6235c8f250f62db853ec3f4bcbdb2e63"}, +] + [[package]] name = "yookassa" version = "2.5.0" @@ -5818,6 +6166,26 @@ netaddr = "*" requests = "*" urllib3 = "*" +[[package]] +name = "zipp" +version = "3.23.0" +description = "Backport of pathlib-compatible object wrapper for zip files" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e"}, + {file = "zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166"}, +] + +[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" @@ -5876,4 +6244,4 @@ testing = ["coverage[toml]", "zope.event", "zope.testing"] [metadata] lock-version = "2.1" python-versions = "^3.12" -content-hash = "edd413e469f523e2a363dd9b28c0a4aeea799ac371f2995b0b327c933a456b56" +content-hash = "fd3ef389880458b7761823ce581a3e67518ed434d916f4e69ff4ea7be6eb5fe4" @@ -63,6 +63,8 @@ httptools = "^0.6.4" wsproto = "^1.2.0" sentry-sdk = {extras = ["django"], version = "^2.39.0"} googletrans = "^4.0.2" +python-dateutil = "^2.9.0.post0" +unleashclient = "^6.4.0" [tool.poetry.group.test.dependencies]