@@ -28,11 +28,10 @@ PATH_PREFETCH_MAP = { 'is_superuser', 'is_staff', 'is_confirmed', 'is_subscribed_to_emails', 'profile_picture_name', *_gen_only('business_account', 'uid', 'parent_company__uid', 'show_balance', 'account_privileges', 'parent_company__user__uid'), - *_gen_only('payment_plan', 'uid', 'last_payment_at', 'next_payment_at'), - *_gen_only('payment_plan__plan', 'uid', 'title', 'price', 'tokens_per_plan', 'duration', 'points'), + *_gen_only('payment_plan', 'uid', 'last_payment_at'), + *_gen_only('payment_plan__plan', 'uid', 'title', 'price', 'tokens_per_plan', 'points'), *_gen_only('business_account__parent_company__user__payment_plan', 'uid', 'last_payment_at', - 'next_payment_at', 'plan__uid', 'plan__title', 'plan__price', 'plan__tokens_per_plan', - 'plan__duration', 'plan__points') + 'plan__uid', 'plan__title', 'plan__price', 'plan__tokens_per_plan', 'plan__points') ) }, '/api/v1/payments/user-balance': { @@ -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) @@ -291,6 +291,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(0), + }, } CACHES = { @@ -347,7 +351,6 @@ MAX_UPLOAD_SIZE_PER_MODEL = { 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) @@ -491,3 +494,8 @@ 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') @@ -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,25 @@ +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() + ) + + 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: 2025-11-14 14:23+0300\n" +"POT-Creation-Date: 2025-11-24 12:19+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -134,9 +134,9 @@ msgstr "Дочерний Бизнес Аккаунт" msgid "Child Business Accounts" msgstr "Дочерние Бизнес Аккаунты" -#: authentication/models/business_group.py:8 ml_model/models.py:18 -#: ml_model/models.py:38 ml_model/models.py:62 ml_model/models.py:269 -#: payments/models/payment_plan.py:27 tools/chats/models.py:9 +#: 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 +#: payments/models/payment_plan.py:22 tools/chats/models.py:9 msgid "Title" msgstr "Название" @@ -152,8 +152,8 @@ msgstr "Бизнес Группы" #: authentication/models/email_token.py:13 authentication/models/user.py:225 #: authentication/models/user.py:226 authentication/models/user_telegram.py:22 #: authentication/models/user_vk.py:12 payments/admin.py:35 -#: payments/admin.py:89 payments/models/invoice.py:15 -#: payments/models/payment.py:26 payments/models/payment_plan.py:61 +#: payments/admin.py:88 payments/models/invoice.py:15 +#: payments/models/payment.py:26 payments/models/payment_plan.py:50 msgid "User" msgstr "Пользователь" @@ -162,7 +162,7 @@ msgid "Affiliated by" msgstr "Кем привлечена" #: authentication/models/business_host.py:36 authentication/models/user.py:138 -#: authentication/models/whitelist.py:16 ml_model/models.py:168 +#: authentication/models/whitelist.py:16 ml_model/models.py:167 #: payments/models/promocode.py:85 msgid "Is active" msgstr "Является активной" @@ -199,7 +199,7 @@ msgstr "ИНН" msgid "PSRN" msgstr "ОГРН" -#: authentication/models/business_host.py:69 ml_model/models.py:181 +#: authentication/models/business_host.py:69 ml_model/models.py:180 #: tools/public_api/models.py:30 msgid "Name" msgstr "Наименование" @@ -304,7 +304,7 @@ msgstr "Админ" msgid "Security" msgstr "Безопасность" -#: authentication/models/email_token.py:16 ml_model/models.py:271 +#: authentication/models/email_token.py:16 ml_model/models.py:270 msgid "Key" msgstr "Ключ" @@ -577,7 +577,7 @@ msgstr "Файл не может быть размером больше %(max_mb msgid "Version %(version)s already has input with the same type: %(type)s" msgstr "Версия %(version)s уже имеет входные данные с таким же типом: %(type)s" -#: ml_model/apps.py:9 ml_model/models.py:149 +#: ml_model/apps.py:9 ml_model/models.py:148 msgid "Neuron Models" msgstr "Нейронные Модели" @@ -591,6 +591,7 @@ msgid "Your request was blocked by our moderation system" msgstr "Ваш запрос был заблокирован нашей системой модерации" #: ml_model/exceptions.py:33 +#, python-format msgid "" "Image size %(cw)dx%(ch)d is not supported. Please rotate image to " "%(rw)dx%(rh)d" @@ -599,6 +600,7 @@ msgstr "" "до %(rw)dx%(rh)d" #: ml_model/exceptions.py:37 +#, python-format msgid "Image size %(cw)sx%(ch)s is not supported. Required size: %(rw)sx%(rh)s" msgstr "" "Размер изображения %(cw)sx%(ch)s не поддерживается. Требуемый размер: " @@ -633,286 +635,286 @@ msgstr "При рендеринге шаблона произошла неизв msgid "The neuron model does not exist" msgstr "Нейронная модель не существует" -#: ml_model/models.py:19 ml_model/models.py:39 ml_model/models.py:71 -#: ml_model/models.py:183 +#: ml_model/models.py:18 ml_model/models.py:38 ml_model/models.py:70 +#: ml_model/models.py:182 msgid "Slug" msgstr "Ярлык" -#: ml_model/models.py:29 ml_model/models.py:81 +#: ml_model/models.py:28 ml_model/models.py:80 msgid "Category" msgstr "Категория" -#: ml_model/models.py:30 +#: ml_model/models.py:29 msgid "Categories" msgstr "Категории" -#: ml_model/models.py:43 +#: ml_model/models.py:42 msgid "Not SVG-pictures not allowed" msgstr "Нельзя использовать не SVG-картинки" -#: ml_model/models.py:46 +#: ml_model/models.py:45 msgid "Icon" msgstr "Миниатюра" -#: ml_model/models.py:53 +#: ml_model/models.py:52 msgid "Model Tag" msgstr "Тег модели" -#: ml_model/models.py:54 +#: ml_model/models.py:53 msgid "Model Tags" msgstr "Теги модели" -#: ml_model/models.py:67 +#: ml_model/models.py:66 msgid "Alternative Titles" msgstr "Альтернативные названия" -#: ml_model/models.py:69 ml_model/models.py:182 ml_model/models.py:270 +#: ml_model/models.py:68 ml_model/models.py:181 ml_model/models.py:269 #: payments/models/payment.py:52 msgid "Description" msgstr "Описание" -#: ml_model/models.py:73 +#: ml_model/models.py:72 msgid "Fill automatically, don't touch" msgstr "Заполняется автоматически, не трогать" -#: ml_model/models.py:89 +#: ml_model/models.py:88 msgid "Avatar" msgstr "Аватар" -#: ml_model/models.py:92 +#: ml_model/models.py:91 msgid "Tags" msgstr "Теги" -#: ml_model/models.py:148 +#: ml_model/models.py:147 msgid "Neuron Model" msgstr "Нейронная Модель" -#: ml_model/models.py:157 ml_model/models.py:403 payments/admin.py:95 +#: ml_model/models.py:156 ml_model/models.py:402 payments/admin.py:94 msgid "Model" msgstr "Модель" -#: ml_model/models.py:173 ml_model/models.py:174 +#: ml_model/models.py:172 ml_model/models.py:173 msgid "Settings" msgstr "Настройки" -#: ml_model/models.py:177 +#: ml_model/models.py:176 #, python-format msgid "Settings of %(model_title)s" msgstr "Настройки %(model_title)s" -#: ml_model/models.py:196 +#: ml_model/models.py:195 #, python-format msgid "%(model_title)s | %(version_name)s" msgstr "%(model_title)s | %(version_name)s" -#: ml_model/models.py:202 +#: ml_model/models.py:201 msgid "Model Version" msgstr "Версия Модели" -#: ml_model/models.py:203 +#: ml_model/models.py:202 msgid "Model Versions" msgstr "Версии Модели" -#: ml_model/models.py:212 +#: ml_model/models.py:211 msgid "Versions" msgstr "Версии" -#: ml_model/models.py:213 +#: ml_model/models.py:212 msgid "Link to versions" msgstr "Привязка к версиям" -#: ml_model/models.py:222 reports/models/error_report.py:10 +#: ml_model/models.py:221 reports/models/error_report.py:10 msgid "Text" msgstr "Текст" -#: ml_model/models.py:223 +#: ml_model/models.py:222 msgid "Image" msgstr "Картинка" -#: ml_model/models.py:224 +#: ml_model/models.py:223 msgid "PDF" msgstr "PDF" -#: ml_model/models.py:225 +#: ml_model/models.py:224 msgid "DOCX" msgstr "DOCX" -#: ml_model/models.py:226 +#: ml_model/models.py:225 msgid "DOC" msgstr "DOC" -#: ml_model/models.py:227 +#: ml_model/models.py:226 msgid "Text File (Notebook)" msgstr "Текстовый файл (Блокнот)" -#: ml_model/models.py:228 +#: ml_model/models.py:227 msgid "ZIP Archive" msgstr "ZIP архив" -#: ml_model/models.py:229 +#: ml_model/models.py:228 msgid "Audio" msgstr "Аудио" -#: ml_model/models.py:235 ml_model/models.py:273 +#: ml_model/models.py:234 ml_model/models.py:272 #: payments/models/promocode.py:41 msgid "Type" msgstr "Тип" -#: ml_model/models.py:237 ml_model/models.py:284 +#: ml_model/models.py:236 ml_model/models.py:283 msgid "Required" msgstr "Обязательный" -#: ml_model/models.py:240 +#: ml_model/models.py:239 #, python-format msgid "%(model_title)s | %(input_type)s" msgstr "%(model_title)s | %(input_type)s" -#: ml_model/models.py:246 +#: ml_model/models.py:245 msgid "Model Input" msgstr "Модель" -#: ml_model/models.py:247 +#: ml_model/models.py:246 msgid "Model Inputs" msgstr "Входящий поток модели" -#: ml_model/models.py:252 +#: ml_model/models.py:251 msgid "Integer" msgstr "Целое число" -#: ml_model/models.py:253 +#: ml_model/models.py:252 msgid "Float" msgstr "Вещественное число" -#: ml_model/models.py:254 +#: ml_model/models.py:253 msgid "String" msgstr "Строка" -#: ml_model/models.py:257 +#: ml_model/models.py:256 msgid "List" msgstr "Список" -#: ml_model/models.py:261 +#: ml_model/models.py:260 msgid "Float range" msgstr "Вещественный диапазон" -#: ml_model/models.py:265 +#: ml_model/models.py:264 msgid "Integer range" msgstr "Целочисленный диапазон" -#: ml_model/models.py:267 +#: ml_model/models.py:266 msgid "Logical" msgstr "Логический" -#: ml_model/models.py:280 +#: ml_model/models.py:279 msgid "Values" msgstr "Значения" -#: ml_model/models.py:281 +#: ml_model/models.py:280 msgid "" "These values can contain different interfaces and default value optional" msgstr "" "Значения могут содержать различные интерфейс и, опционально, значение по " "умолчанию" -#: ml_model/models.py:283 +#: ml_model/models.py:282 msgid "Hidden" msgstr "Скрытый" -#: ml_model/models.py:289 +#: ml_model/models.py:288 #, python-format msgid "Parameter of %(model_title)s" msgstr "Параметр %(model_title)s" -#: ml_model/models.py:292 +#: ml_model/models.py:291 msgid "Parameter" msgstr "Параметр" -#: ml_model/models.py:293 +#: ml_model/models.py:292 msgid "Parameters" msgstr "Параметры" -#: ml_model/models.py:298 +#: ml_model/models.py:297 msgid "Fixed" msgstr "Фикса" -#: ml_model/models.py:299 +#: ml_model/models.py:298 msgid "Per generation second" msgstr "За секунду генерации" -#: ml_model/models.py:300 +#: ml_model/models.py:299 msgid "Per one text token" msgstr "За один текстовый токен" -#: ml_model/models.py:301 +#: ml_model/models.py:300 msgid "Per image pixel" msgstr "За один пиксель" -#: ml_model/models.py:304 +#: ml_model/models.py:303 msgid "By input data" msgstr "По входящим данным" -#: ml_model/models.py:305 +#: ml_model/models.py:304 msgid "By output data" msgstr "По исходящим данным" -#: ml_model/models.py:306 +#: ml_model/models.py:305 msgid "By all data" msgstr "По всем данным" -#: ml_model/models.py:311 +#: ml_model/models.py:310 msgid "Strategy" msgstr "Стратегия" -#: ml_model/models.py:316 +#: ml_model/models.py:315 msgid "Interaction Type" msgstr "Тип взаимодействия" -#: ml_model/models.py:321 payments/models/invoice.py:19 +#: ml_model/models.py:320 payments/models/invoice.py:19 msgid "Cost" msgstr "Цена" -#: ml_model/models.py:322 +#: ml_model/models.py:321 msgid "In RUB, per specified strategy" msgstr "В рублях, за указанную стратегию" -#: ml_model/models.py:327 +#: ml_model/models.py:326 msgid "Coefficient" msgstr "Коэффициент" -#: ml_model/models.py:328 +#: ml_model/models.py:327 msgid "Cost multiplier" msgstr "Цена" -#: ml_model/models.py:335 +#: ml_model/models.py:334 msgid "Rate" msgstr "Ставка" -#: ml_model/models.py:339 +#: ml_model/models.py:338 msgid "Payment Rule" msgstr "Платежное правило" -#: ml_model/models.py:340 +#: ml_model/models.py:339 msgid "Payment Rules" msgstr "Платежные правила" -#: ml_model/models.py:401 +#: ml_model/models.py:400 msgid "Descriptor" msgstr "Дескриптор" -#: ml_model/models.py:407 +#: ml_model/models.py:406 #, python-format msgid "Instruction of %(model_title)s" msgstr "Инструкция %(model_title)s" -#: ml_model/models.py:410 +#: ml_model/models.py:409 msgid "Model Instruction" msgstr "Инструкция Модели" -#: ml_model/models.py:411 +#: ml_model/models.py:410 msgid "Model Instructions" msgstr "Инструкции Моделей" -#: ml_model/selectors/ml_models_selector.py:106 +#: ml_model/selectors/ml_models_selector.py:122 msgid "no model by this id" msgstr "Не найдено моделей по этому ID" @@ -938,13 +940,13 @@ msgstr "Нет изображения для улучшения" msgid "Model data cannot be retrieved" msgstr "Невозможно получить данные модели" -#: payments/admin.py:33 payments/admin.py:67 payments/admin.py:87 +#: payments/admin.py:33 payments/admin.py:66 payments/admin.py:86 msgid "You can search by user email, exacted company name" msgstr "" "Вы можете осуществлять поиск по e-mail пользователя, точному названию " "компании" -#: payments/admin.py:38 payments/admin.py:92 +#: payments/admin.py:38 payments/admin.py:91 msgid "Missing" msgstr "Отсутствующий" @@ -981,7 +983,7 @@ msgstr "Списания" msgid "Amount" msgstr "Количество" -#: payments/models/payment.py:41 +#: payments/models/payment.py:41 payments/models/recurring_payment.py:19 msgid "Plan" msgstr "План" @@ -993,55 +995,43 @@ 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 "Is recurrent" msgstr "Рекуррентный" -#: payments/models/payment_plan.py:29 -msgid "Duration" -msgstr "Длительность" - -#: payments/models/payment_plan.py:34 +#: payments/models/payment_plan.py:23 msgid "Is visible" msgstr "Видимый" -#: payments/models/payment_plan.py:52 payments/models/payment_plan.py:67 +#: payments/models/payment_plan.py:41 payments/models/payment_plan.py:56 msgid "Payment Plan" msgstr "Платежный План" -#: payments/models/payment_plan.py:53 +#: payments/models/payment_plan.py:42 msgid "Payment Plans" msgstr "Платежные Планы" -#: payments/models/payment_plan.py:69 +#: payments/models/payment_plan.py:58 msgid "Last payment at" msgstr "Последнее время платежа" -#: payments/models/payment_plan.py:70 -msgid "Next payment at" -msgstr "Следующее время платежа" - -#: payments/models/payment_plan.py:72 +#: payments/models/payment_plan.py:60 msgid "Current balance" msgstr "Текущий баланс" -#: payments/models/payment_plan.py:78 -msgid "Recurrent billing task" -msgstr "Рекуррентная задача на платеж" - -#: payments/models/payment_plan.py:99 payments/models/payment_plan.py:100 +#: payments/models/payment_plan.py:77 payments/models/payment_plan.py:78 msgid "User Balance" msgstr "Баланс пользователя" @@ -1085,10 +1075,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 "Платежные методы" @@ -1097,11 +1100,11 @@ msgstr "Платежные методы" msgid "Messages for this model are not registered in a selector" msgstr "" -#: payments/selectors/payment_plan_selector.py:32 +#: payments/selectors/payment_plan_selector.py:31 msgid "Business accounts are not allowed to make purchases" msgstr "Сотрудники не могут производить покупки" -#: payments/selectors/payment_plan_selector.py:57 +#: payments/selectors/payment_plan_selector.py:53 msgid "No plan by this uid" msgstr "Не найдено подписки по этому ID" @@ -1109,14 +1112,18 @@ msgstr "Не найдено подписки по этому ID" msgid "Unknown account type" msgstr "Неизвестный тип аккаунта" -#: payments/services/payment_method_service.py:46 +#: payments/services/payment_method_service.py:43 msgid "No current active payment method is set" msgstr "Ни одного активного метода не установлено" -#: payments/services/payment_method_service.py:55 +#: payments/services/payment_method_service.py:52 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 "Адрес" @@ -1169,7 +1176,7 @@ msgstr "Публичный API" msgid "Media" msgstr "Медиа" -#: tools/chats/apis.py:178 tools/media/apis.py:161 +#: tools/chats/apis.py:181 tools/media/apis.py:160 #: tools/public_api/views/base.py:103 msgid "" "Error occured when create generation. It may cause NSFW-content not allowed, " @@ -1233,6 +1240,15 @@ msgstr "Отсутствует обязательный параметр: 'messa msgid "Model not found" msgstr "Модель не найдена" +#~ msgid "Duration" +#~ msgstr "Длительность" + +#~ msgid "Next payment at" +#~ msgstr "Следующее время платежа" + +#~ msgid "Recurrent billing task" +#~ msgstr "Рекуррентная задача на платеж" + #~ msgid "Achievement" #~ msgstr "Достижение" @@ -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', '0017_alter_paymentplan_points'), + ] + + 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', '0018_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', '0019_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', '0020_rename_payment_method_recurringpayment_method_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='paymentplan', + name='is_recurrent', + ), + ] @@ -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 __all__ = ( 'Payment', @@ -12,4 +13,5 @@ __all__ = ( 'Invoice', 'PromoCode', 'PromoCodeActivation', + 'RecurringPayment' ) @@ -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, @@ -23,14 +18,7 @@ class PaymentPlan(BaseModel): default=10, ) is_corporate = models.BooleanField(default=False, verbose_name=_('Is corporate')) - is_recurrent = models.BooleanField(default=False, verbose_name=_('Is recurrent')) title = models.CharField(verbose_name=_('Title'), max_length=120, null=True, blank=True) - 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, @@ -67,19 +55,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, @@ -89,7 +67,6 @@ 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) def __str__(self) -> str: @@ -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') @@ -1,14 +1,24 @@ +import logging +import orjson + from decimal import Decimal +from django.utils.translation import gettext_lazy as _ from ninja import Router from ninja.errors import HttpError from authentication.security import SyncAuthBearer +from payments.models import RecurringPayment from payments.schema import UserBalance 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.services.referral_account import ReferralAccountService router = Router(auth=SyncAuthBearer(), tags=['payments']) +logger = logging.getLogger(__name__) + @router.get('user-balance', tags=['payments/user-balance'], response=UserBalance) def get_user_balance(request): @@ -21,3 +31,30 @@ def get_user_balance(request): return UserBalance(current_token_balance=current_balance) except Exception as exc: raise HttpError(401, f'{exc}') + + +@router.post('payment-result', tags=['payments/payment-result'], auth=None) +def handle_yookassa_webhook(request): + try: + data = orjson.loads(request.body) + payment = PaymentService.handle_payment(data['object']['id']) + payment_instance = PaymentService.save_payment(payment) + if payment.status == 'waiting_for_capture': + PaymentService.handle_captured_payment(payment.id) + elif payment.status == 'succeeded': + PaymentService.handle_succeeded_payment(payment, payment_instance.user, payment_instance.plan) + PaymentPlanService(payment_instance.user).subscribe_user_to_plan(payment_instance.plan) + if ref_acc := payment_instance.user.referer_account: + ReferralAccountService.apply_accrual(referer_account=ref_acc, payment=payment_instance) + elif payment.status == 'canceled' and payment.metadata.get('recurring'): + PaymentService.handle_canceled_payment(payment, payment_instance.user) + return 200 + except Exception as exc: + logger.exception(exc) + raise HttpError(400, f'{exc}') + + +@router.post('revoke-recurring-payment', tags=['payments/revoke-recurring-payment']) +def revoke_recurring_payment(request): + RecurringPayment.objects.filter(method__user=request.auth).delete() + return 200, {'detail': _('The recurring payment is successfully cancelled')} @@ -24,7 +24,6 @@ class PaymentPlanSelector: def get_payment_plans( self, - plan_duration: Literal['month', 'year'] | None = None, serialize: bool = True, ): account_type = UserSelector(self.user).check_account_type() @@ -37,9 +36,6 @@ class PaymentPlanSelector: ~Q(price=Decimal('0')) & Q(is_corporate=is_corporate) & Q(is_visible=True) ).order_by('price') - if plan_duration: - plans = plans.filter(duration=plan_duration) - if serialize: return PaymentPlanSerializer(plans, many=True) @@ -87,8 +83,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) @@ -19,20 +19,17 @@ 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, + 'currently_active': True + }, ) logger.info('NEW PAYMENT METHOD: %s', payment_method) - payment_method.save() + return payment_method def delete_payment_method(self, method_id: UUID): existing = PaymentMethodSelector(self.user).get_payment_method_by_uuid(method_id) @@ -1,18 +1,12 @@ import logging from decimal import Decimal -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 PaymentLinkSerializer, SuccessPaymentResult +from payments.serializers import PaymentLinkSerializer 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,17 +20,6 @@ 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 create_payment_plan_invoice(self, payment_plan_uid: str): """""" payment_plan = PaymentPlanSelector(self.user).get_payment_plan_by_id(payment_plan_uid) @@ -45,15 +28,6 @@ class PaymentPlanService: result.is_valid(raise_exception=True) return result - 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( user=self.user, @@ -69,9 +43,6 @@ class PaymentPlanService: def cancel_payment_plan(self): plan_info: PaymentPlanUserInfo = self.user.payment_plan - task = plan_info.plan_schedule - if task is not None: - RecurrentPaymentService(task).delete() plan_info.plan = PaymentPlanSelector(self.user).get_free_plan(corporate=self.user.is_corporate()) plan_info.save() @@ -1,20 +1,24 @@ +from datetime import datetime, timedelta import logging from uuid import UUID, uuid4 +from dateutil.relativedelta import relativedelta from django.conf import settings +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 lib.unleash.client import web_client from payments.exceptions.payer_not_found import PayerNotFound +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 logger = logging.getLogger(__name__) @@ -38,6 +42,7 @@ class PaymentService: } ], } + is_recurring = web_client.get_flag_state('recurring_payments', self.user.email) payment_data = { 'amount': {'value': f'{plan.price}', 'currency': 'RUB'}, 'payment_method_data': {'type': 'bank_card'}, @@ -48,71 +53,72 @@ class PaymentService: }, 'description': str(self.user.uid), 'capture': True, + 'save_payment_method': is_recurring } - - if plan.is_recurrent: - payment_data.update(save_payment_method=True) payment = YookassaPayment.create(payment_data, uuid4()) return payment.confirmation.confirmation_url + @classmethod + def handle_payment(cls, payment_id: UUID) -> YookassaPaymentResponse: + return YookassaPayment.find_one(payment_id) + + @classmethod + def handle_captured_payment(cls, payment_id: UUID) -> None: + YookassaPayment.capture(str(payment_id)) + + @classmethod + def handle_succeeded_payment(cls, payment: YookassaPaymentResponse, user: CustomUserModel, 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', user.email) + ): + payment_method = cls.save_payment_method(user, payment.payment_method) + RecurringPayment.objects.update_or_create( + method__user=user, + defaults={ + 'method': payment_method, + 'pay_at': timezone.now() + relativedelta(months=1), + 'plan': plan + } + ) + else: + RecurringPayment.objects.filter(method__user=user).delete() + + @classmethod + def handle_canceled_payment(cls, payment: YookassaPaymentResponse, user: CustomUserModel) -> None: + logger.error(f'Recurrent payment error: {payment.cancellation_details.reason}') + if payment.cancellation_details.reason == 'permission_revoked': + RecurringPayment.objects.filter(method__user=user).delete() + else: + recurring = RecurringPayment.objects.filter(method__user=user).first() + if recurring: + recurring.pay_at = datetime.now() + timedelta(days=2) + recurring.save() + @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 + return PaymentMethodService(user).add_payment_method( + method_id=UUID(method_data.id), card_type=card.card_type, last_four=card.last4 ) @classmethod - def confirm_payment(cls, payment_id: UUID): - payment = YookassaPayment.find_one(str(payment_id)) + def save_payment(cls, payment: YookassaPaymentResponse) -> PaymentModel: try: - user = CustomUserModel.objects.get(uid=payment.description) + payer = 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, - ) - except Exception as exc: - logger.exception(exc) - return payment_instance - - @classmethod - def save_payment(cls, user: CustomUserModel, payment: YookassaPayment) -> PaymentModel: + plan = PaymentPlan.objects.get_or_none(price=payment.amount.value) payment_instance, _ = PaymentModel.objects.update_or_create( uid=payment.id, defaults=dict( - user=user, + user=payer, 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() @@ -14,7 +14,7 @@ from payments.models import ( PaymentPlanUserInfo, PromoCode, PromoCodeActivation, - UserPaymentMethod, + UserPaymentMethod, RecurringPayment, ) from payments.models.referral_account import ReferralAccount, ReferralInvite @@ -45,8 +45,6 @@ class PaymentPlanAdmin(admin.ModelAdmin): 'title', 'price', 'tokens_per_plan', - 'duration', - 'is_recurrent', 'is_corporate', 'is_visible', ] @@ -215,3 +213,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' @@ -11,6 +13,7 @@ class PaymentsConfig(AppConfig): def ready(self): from .signals import init_referral_account + web_client.client.initialize_client() setting_changed.connect(init_referral_account) return super().ready() @@ -13,7 +13,6 @@ class PaymentPlanSchema(Schema): title: str price: condecimal(max_digits=10, decimal_places=2) tokens_per_plan: condecimal(max_digits=10, decimal_places=2) - duration: str points: List accessed_models: List[str] @@ -27,7 +26,6 @@ class UserPlanDetailSchema(Schema): uid: UUID plan: PaymentPlanSchema last_payment_at: date - next_payment_at: date class PromoCodeSchema(ModelSchema): @@ -11,7 +11,6 @@ class PaymentPlanSerializer(serializers.Serializer): title = serializers.CharField() 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) points = serializers.ListField(read_only=True) accessed_models = serializers.SlugRelatedField( slug_field='slug', queryset=NeuronModel.objects.all(), many=True @@ -49,7 +48,6 @@ class UserPlanDetailSerializer(serializers.Serializer): uid = serializers.UUIDField() plan = PaymentPlanSerializer() last_payment_at = serializers.DateField() - next_payment_at = serializers.DateField() current_token_balance = serializers.IntegerField() @@ -62,10 +60,6 @@ class PaymentLinkSerializer(serializers.Serializer): payment_url = serializers.URLField() -class SuccessPaymentResult(serializers.Serializer): - id = serializers.UUIDField() - - class PaymentMethodSerializer(serializers.Serializer): uid = serializers.UUIDField() currently_active = serializers.BooleanField() @@ -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__) @@ -32,3 +37,32 @@ 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()) + 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 + receipt_data = { + 'customer': {'email': customer.email}, + 'items': [ + { + 'description': f'План {plan.tokens_per_plan} токенов за {plan.price} р.', + '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} + } + YookassaPayment.create(payment_data, uuid4()) \ No newline at end of file @@ -17,11 +17,6 @@ urlpatterns = [ 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', @@ -18,12 +18,11 @@ 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.PlanIsFree import PlanIsFree -from payments.exceptions.payer_not_found import PayerNotFound from payments.models import Invoice -from payments.models.payment import Payment, PaymentPlan +from payments.models.payment import Payment from payments.permissions import IsAllowedToPay from payments.selectors.payment_method_selector import PaymentMethodSelector from payments.selectors.payment_plan_selector import PaymentPlanSelector @@ -62,14 +61,12 @@ class PaymentPlanAPIView(APIView): permission_classes = (IsAuthenticated, IsAllowedToPay) @extend_schema( - parameters=[OpenApiParameter('duration', str, enum=[PaymentPlan.MONTH, PaymentPlan.YEAR])], responses={200: PaymentPlanSerializer}, ) def get(self, request: Request, *args, **kwargs): """List available payment plans""" try: - plan_duration = request.query_params.get('duration', None) - result = PaymentPlanSelector(self.request.user).get_payment_plans(plan_duration=plan_duration) + result = PaymentPlanSelector(self.request.user).get_payment_plans() return Response(result.data, status=status.HTTP_200_OK) except Exception as err: logger.exception(err) @@ -163,21 +160,6 @@ class UserPlanAPIView(APIView): 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]