@@ -151,7 +151,8 @@ class CustomUserModel(AbstractBaseUser, PermissionsMixin, BaseModel): @property def balance(self): - return self.payment_plan.current_token_balance + pp = self.payment_plan + return pp.current_token_balance + pp.referral_balance @property def account_type(self): @@ -230,7 +231,7 @@ def on_user_creation_signal(sender, instance, created, **kwargs): if created: free_plan = PaymentPlanSelector(instance).get_free_plan() - PaymentPlanService(instance).subscribe_user_to_plan(free_plan) + PaymentPlanService(instance).subscribe_user_to_plan(free_plan, free_plan.tokens_per_plan) if not instance.profile_picture_name: instance.profile_picture_name = instance.LAST_NAME_AVATARS.get(instance.last_name, None) instance.save() @@ -64,8 +64,9 @@ class BusinessHostService: ) -> BusinessAccountService: password = generate_token(15) user = CustomUserModel.objects.create_user(email=email, password=password) + plan = PaymentPlan.objects.get(price=0, is_corporate=False) - PaymentPlanService(user).subscribe_user_to_plan(PaymentPlan.objects.get(price=0, is_corporate=False)) + PaymentPlanService(user).subscribe_user_to_plan(plan, plan.tokens_per_plan) account_service = BusinessAccountService.create(user, host, account_privileges=account_privileges) EmailService.send_corporate_greeting_email(account_service.account, password) @@ -174,9 +175,6 @@ class BusinessHostService: serializer = NewBusinessHostSerializer(data=request.data) serializer.is_valid(raise_exception=True) - if PaymentPlanSelector(self.user).is_plan_paid(): - raise business_host_exceptions.AlreadyHasPlan() - if AccountStatusSelector(self.user).is_business_host(): raise business_host_exceptions.AlreadyHost() @@ -184,10 +182,6 @@ class BusinessHostService: raise business_host_exceptions.AlreadyAccount() host = BusinessUserHost.objects.create(user=self.user, **serializer.validated_data) - host.save() - PaymentPlanService(self.user).subscribe_user_to_plan( - PaymentPlan.objects.get(price=0, is_corporate=True) - ) EmailService(self.user).send_copr_purchase_email(host) account_type = UserSelector(self.user).check_account_type() @@ -1,4 +1,6 @@ import logging +from datetime import datetime +from decimal import Decimal from typing import Any, Sequence import dns.resolver @@ -162,3 +164,13 @@ class EmailService: cls.send_email( f'AIR: баланс корпоративного аккаунта ниже {host.token_cap}', html_message, host.token_cap_emails ) + + @classmethod + def send_revoke_recurring_email(cls, email: str) -> None: + html_message = cls._render_letter_template( + template_name='payments/revoke_recurring_email', context={} + ) + cls.send_email( + 'Отмена подписки на платформе AIR', html_message, (email,) + ) + @@ -42,17 +42,15 @@ PATH_PREFETCH_MAP = { 'account_privileges', 'parent_company__user__uid', ), - *_gen_only('payment_plan', 'uid', 'last_payment_at', 'next_payment_at'), - *_gen_only('payment_plan__plan', 'uid', 'price', 'tokens_per_plan', 'duration'), + *_gen_only('payment_plan', 'uid', 'last_payment_at'), + *_gen_only('payment_plan__plan', 'uid', 'price', 'tokens_per_plan'), *_gen_only( 'business_account__parent_company__user__payment_plan', 'uid', 'last_payment_at', - 'next_payment_at', 'plan__uid', 'plan__price', 'plan__tokens_per_plan', - 'plan__duration', ), ), }, @@ -81,7 +79,8 @@ PATH_PREFETCH_MAP = { 'host_account', 'payment_plan', 'business_account__parent_company__user__payment_plan__plan', - 'payment_plan__plan' + 'payment_plan__plan', + 'payment_plan__method', ), 'prefetch': ( Prefetch( @@ -1,8 +1,16 @@ import os from celery import Celery +from celery.signals import worker_process_init os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'backend.settings') app = Celery('backend') app.config_from_object('django.conf:settings', namespace='CELERY') app.autodiscover_tasks() + + +from lib.unleash.client import celery_client + +@worker_process_init.connect +def configure_workers(sender=None, conf=None, **kwargs): + celery_client.client.initialize_client(fetch_toggles=False) @@ -294,6 +294,10 @@ CELERY_BEAT_SCHEDULE = { 'task': 'payments.tasks.send_low_balance_message', 'schedule': crontab(0, 8), }, + 'execute_recurring_payments': { + 'task': 'payments.tasks.execute_recurring_payments', + 'schedule': crontab(*env.list('RECURRING_PAYMENT_CRONTAB_SCHEDULE', [])), + }, } CACHES = { @@ -345,7 +349,6 @@ ImageFile.LOAD_TRUNCATED_IMAGES = True YOOKASSA_ACCOUNT_ID = env.str('YOOKASSA_ACCOUNT_ID', default='defaultapikey') YOOKASSA_SECRET_KEY = env.str('YOOKASSA_SECRET_KEY', default='defaultapikey') YOOKASSA_RESULT_PAYMENT_URL = env.str('YOOKASSA_RESULT_PAYMENT_URL', default='defaultapikey') -RECURRENT_RATE = env.str('RECURRENT_RATE', 'days') # Email EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' EMAIL_USE_TLS = env.bool('EMAIL_USE_TLS', default=False) @@ -398,52 +401,50 @@ ENVIRONMENT = env.str('ENVIRONMENT') LOGGING = { 'version': 1, - 'disable_existing_loggers': True, - 'filters': { - 'sensitive': { - '()': 'lib.logging.filters.SensitiveDataFilter', - }, - 'safe': { - '()': 'lib.logging.filters.SafeAttributeFilter', + 'disable_existing_loggers': False, + 'handlers': { + 'console': { + 'class': 'logging.StreamHandler', + 'level': 'INFO', + 'stream': 'ext://sys.stdout', }, }, 'formatters': { - 'json': { - '()': 'lib.logging.formatters.JsonFormatter', - 'datefmt': '%Y-%m-%dT%H:%M:%S%z', - }, 'console_pretty': { '()': 'lib.logging.formatters.ConsoleFormatter', 'datefmt': '%Y-%m-%dT%H:%M:%S%z', }, }, - 'handlers': { - 'console': { - 'class': 'logging.StreamHandler', - 'formatter': 'console_pretty', - 'filters': ['sensitive'], - 'level': 'INFO', - 'stream': 'ext://sys.stdout', - }, - }, 'loggers': { + 'UnleashClient': { + 'level': 'CRITICAL', + 'propagate': False, + }, + 'apscheduler': { + 'level': 'CRITICAL', + 'propagate': False, + }, '': { 'handlers': ['console'], + 'formatter': 'console_pretty', 'level': 'INFO', 'propagate': False, }, 'django': { 'handlers': ['console'], + 'formatter': 'console_pretty', 'level': 'INFO', 'propagate': False, }, - 'gunicorn': { + 'uvicorn': { 'handlers': ['console'], + 'formatter': 'console_pretty', 'level': 'INFO', 'propagate': False, }, '__main__': { 'handlers': ['console'], + 'formatter': 'console_pretty', 'level': 'INFO', 'propagate': False, }, @@ -493,3 +494,14 @@ if CACHEOPS_REDIS: 'reports.*': {'ops': 'all', 'timeout': 60 * 60}, 'token_blacklist.outstandingtoken': {'ops': 'get', 'timeout': 60 * 60 * 24}, } + +# UNLEASH settings +FEATURE_FLAG_API_URL = env.str('FEATURE_FLAG_API_URL') +FEATURE_FLAG_APP_NAME = env.str('FEATURE_FLAG_APP_NAME', 'staging') +FEATURE_FLAG_INSTANCE_ID = env.str('FEATURE_FLAG_INSTANCE_ID') +FEATURE_FLAG_WEBHOOK_SECRET_KEY = env.str( + 'FEATURE_FLAG_WEBHOOK_SECRET_KEY', 'FEATURE_FLAG_WEBHOOK_SECRET_KEY' +) + +# RECURRING SETTINGS +MAX_RECURRING_ATTEMPTS = env.int('MAX_RECURRING_ATTEMPTS', 1) @@ -18,16 +18,17 @@ from backend.public import urlpatterns as public_urlpatterns api = NinjaAPI(title='AIR API', version='1.0.0', docs_url=None) compatibility_api = NinjaAPI(title='AIR API DEBUG', version='0.0.1', docs_url=None) + api.add_router('copywrite/', 'tools.copywrite.routes.v1.router') api.add_router('users/', 'users.routes.v1.router') api.add_router('chats/', 'tools.chats.routes.v1.router') api.add_router('media/', 'tools.media.routes.v1.router') + compatibility_api.add_router('auth/', 'authentication.routes.v1.router') compatibility_api.add_router('payments/', 'payments.routes.v1.router') compatibility_api.add_router('reports/', 'reports.routes.v1.router') compatibility_api.add_router('ml_model/', 'ml_model.routes.v1.router') - logger = logging.getLogger(__name__) @@ -0,0 +1,15 @@ +from abc import ABC, abstractmethod +from typing import List, Mapping + +from lib.typing import Email, State + + +class FeatureFlagService(ABC): + + @abstractmethod + def get_flag_state_by_emails(self, name: str, emails: List[Email]) -> Mapping[Email, State]: + pass + + @abstractmethod + def get_flag_state(self, name: str, email: Email) -> State: + pass @@ -0,0 +1,26 @@ +from types import MappingProxyType +from typing import List, Mapping + +from UnleashClient import UnleashClient +from django.conf import settings + +from lib.services.feature_flag import FeatureFlagService +from lib.typing import Email, State +from lib.unleash.cache import UnleashRedisCache + + +class UnleashFeatureFlagService(FeatureFlagService): + def __init__(self) -> None: + self.client = UnleashClient( + url=settings.FEATURE_FLAG_API_URL, + app_name=settings.FEATURE_FLAG_APP_NAME, + instance_id=settings.FEATURE_FLAG_INSTANCE_ID, + cache=UnleashRedisCache(), + environment=settings.FEATURE_FLAG_APP_NAME + ) + + def get_flag_state_by_emails(self, name: str, emails: List[Email]) -> Mapping[Email, State]: + return MappingProxyType({email: self.get_flag_state(name, email) for email in emails}) + + def get_flag_state(self, name: str, email: Email) -> State: + return self.client.is_enabled(feature_name=name, context={'userId': email}) @@ -0,0 +1,27 @@ +from typing import Any, Optional +from UnleashClient.cache import BaseCache +from django.core.cache import caches + + +class UnleashRedisCache(BaseCache): + PREFIX = 'unleash:' + + def __init__(self, cache_alias='default'): + self.cache = caches[cache_alias] + + def set(self, key: str, value: Any): + self.cache.set(self.PREFIX + key, value) + + def mset(self, data: dict): + self.cache.set_many({self.PREFIX + k: v for k, v in data.items()}) + + def get(self, key: str, default: Optional[Any] = None): + return self.cache.get(self.PREFIX + key, default) + + def exists(self, key: str): + return self.cache.has_key(self.PREFIX + key) + + def destroy(self): + client = self.cache.client.get_client(write=True) + for key in client.scan_iter(f"{self.PREFIX}*"): + client.delete(key) @@ -0,0 +1,4 @@ +from lib.services.unleash_feature_flag import UnleashFeatureFlagService + +web_client = UnleashFeatureFlagService() +celery_client = UnleashFeatureFlagService() \ No newline at end of file @@ -0,0 +1,2 @@ +type Email = str +type State = bool \ No newline at end of file @@ -2,13 +2,17 @@ # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. -# +# #, fuzzy msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-03 17:52+0300\n" +<<<<<<< HEAD +"POT-Creation-Date: 2026-04-30 14:11+0300\n" +======= +"POT-Creation-Date: 2026-04-30 13:54+0300\n" +>>>>>>> 1ebd6790 (django.po апдейт) "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -116,7 +120,7 @@ msgstr "Email токен не найден" msgid "Wrong email" msgstr "Неверный email" -#: authentication/exceptions/user.py:11 backend/urls.py:46 +#: authentication/exceptions/user.py:11 backend/urls.py:47 msgid "Wrong password" msgstr "Неверный пароль" @@ -189,8 +193,8 @@ msgid "Child Business Accounts" msgstr "Дочерние Бизнес Аккаунты" #: authentication/models/business_group.py:8 ml_model/models.py:17 -#: ml_model/models.py:37 ml_model/models.py:61 ml_model/models.py:268 -#: tools/chats/models.py:9 tools/media/models.py:59 tools/media/models.py:93 +#: ml_model/models.py:37 ml_model/models.py:61 ml_model/models.py:269 +#: tools/chats/models.py:9 tools/media/models.py:59 tools/media/models.py:95 msgid "Title" msgstr "Название" @@ -203,12 +207,12 @@ msgid "Business Groups" msgstr "Бизнес Группы" #: authentication/models/business_host.py:22 -#: authentication/models/email_token.py:13 authentication/models/user.py:222 -#: authentication/models/user.py:223 authentication/models/user_telegram.py:22 +#: authentication/models/email_token.py:13 authentication/models/user.py:223 +#: authentication/models/user.py:224 authentication/models/user_telegram.py:22 #: authentication/models/user_vk.py:12 payments/admin.py:37 -#: payments/admin.py:97 payments/models/invoice.py:15 -#: payments/models/payment.py:26 payments/models/payment_plan.py:62 -#: tools/media/models.py:106 +#: payments/admin.py:95 payments/models/invoice.py:15 +#: payments/models/payment.py:26 payments/models/payment_plan.py:50 +#: tools/media/models.py:108 msgid "User" msgstr "Пользователь" @@ -363,7 +367,7 @@ msgstr "Админ" msgid "Security" msgstr "Безопасность" -#: authentication/models/email_token.py:16 ml_model/models.py:270 +#: authentication/models/email_token.py:16 ml_model/models.py:271 msgid "Key" msgstr "Ключ" @@ -553,7 +557,7 @@ msgstr "Бизнес-аккаунт для данного юзера не най msgid "Invited account can either accept or reject an invitation" msgstr "Приглашенный аккаунт может принять или отклонить приглашение" -#: authentication/services/email_service.py:50 +#: authentication/services/email_service.py:52 msgid "Error occured when proceed email sending" msgstr "Случилась ошибка во время отправки email" @@ -606,11 +610,11 @@ msgstr "Пароль сотрудника успешно обновлен" msgid "Could not confirm email, please try again." msgstr "Невозможно подтвердить email, попробуйте позже" -#: backend/urls.py:36 +#: backend/urls.py:37 msgid "Requested object does not exists" msgstr "" -#: backend/urls.py:41 +#: backend/urls.py:42 msgid "Token is invalid" msgstr "" @@ -624,7 +628,7 @@ msgstr "" msgid "A %(model)s with fields %(fields)s already exists" msgstr "Уже существует %(model)s с полями %(fields)s" -#: messages/serializers.py:44 ml_model/exceptions.py:68 +#: messages/serializers.py:44 ml_model/exceptions.py:78 #, python-format msgid "The file size cannot exceed %(max_mb_size)d MB" msgstr "Файл не может быть размером больше %(max_mb_size)d мегабайт" @@ -684,63 +688,76 @@ msgstr "Возможно, файл повреждён. Попробуйте за msgid "File Uploading Not supported" msgstr "Загрузка файлов не поддерживается" -#: ml_model/exceptions.py:65 +#: ml_model/exceptions.py:70 msgid "Unable to recognize the file" msgstr "Не удаётся распознать файл" -#: ml_model/exceptions.py:73 +#: ml_model/exceptions.py:83 msgid "The length of the context has been exceeded." msgstr "Длина контекста превышена." -#: ml_model/exceptions.py:78 +#: ml_model/exceptions.py:88 msgid "Jinja template not found" msgstr "Jinja-шаблон не найден" -#: ml_model/exceptions.py:83 +#: ml_model/exceptions.py:93 msgid "There was an unknown error while rendering a template" msgstr "При рендеринге шаблона произошла неизвестная ошибка" -#: ml_model/exceptions.py:88 +#: ml_model/exceptions.py:98 msgid "The neuron model does not exist" msgstr "Нейронная модель не существует" -#: ml_model/exceptions.py:96 +#: ml_model/exceptions.py:106 #, python-format msgid "The %(file_type)s is not attached" msgstr "Файл (%(file_type)s) не прикреплен" -#: ml_model/exceptions.py:101 +#: ml_model/exceptions.py:111 msgid "No image content found in response. Try a different request" msgstr "В промпте отсутствует описание изображения. Попробуйте другой запрос" -#: ml_model/exceptions.py:106 +#: ml_model/exceptions.py:116 +msgid "" +"The model could not analyze your request. Please rephrase it and try again" +msgstr "" +"Модель не смогла проанализировать ваш запрос. Перефразируйте его и " +"попробуйте снова" + +#: ml_model/exceptions.py:121 msgid "Image analysis error. Please try another image." msgstr "Ошибка анализа изображения. Попробуйте другую картинку." -#: ml_model/exceptions.py:111 +#: ml_model/exceptions.py:126 msgid "Use style type AUTO or GENERAL when a style preset is selected" msgstr "При выбранном стиле используйте тип стиля AUTO или GENERAL" -#: ml_model/exceptions.py:116 +#: ml_model/exceptions.py:131 msgid "Prediction interrupted. Please retry again" msgstr "Генерация прервана. Пожалуйста, повторите попытку еще раз" -#: ml_model/exceptions.py:131 +#: ml_model/exceptions.py:146 #, python-format msgid "Prompt is too long. Maximum length is %(max_length)s characters." msgstr "Промпт слишком длинный. Максимальная длина — %(max_length)s символов." -#: ml_model/exceptions.py:138 +#: ml_model/exceptions.py:153 msgid "" "Service is currently unavailable due to high demand. Please try again later" msgstr "" "Сервис временно недоступен из-за высокой нагрузки. Пожалуйста, попробуйте " "позже" -#: ml_model/exceptions.py:143 +#: ml_model/exceptions.py:158 msgid "Available only in paid plan" msgstr "Доступно только в платном тарифе" +#: ml_model/exceptions.py:163 +#, fuzzy +#| msgid "Image analysis error. Please try another image." +msgid "Face not found in the image. Please try another image with a face." +msgstr "Не найдено лицо на картинке. Попробуйте другую картинку с лицом." + #: ml_model/models.py:18 ml_model/models.py:38 ml_model/models.py:70 #: ml_model/models.py:182 tools/media/models.py:67 msgid "Slug" @@ -774,7 +791,7 @@ msgstr "Теги модели" msgid "Alternative Titles" msgstr "Альтернативные названия" -#: ml_model/models.py:68 ml_model/models.py:181 ml_model/models.py:269 +#: ml_model/models.py:68 ml_model/models.py:181 ml_model/models.py:270 #: payments/models/payment.py:52 msgid "Description" msgstr "Описание" @@ -795,7 +812,7 @@ msgstr "Теги" msgid "Neuron Model" msgstr "Нейронная Модель" -#: ml_model/models.py:156 ml_model/models.py:402 payments/admin.py:103 +#: ml_model/models.py:156 ml_model/models.py:403 payments/admin.py:101 msgid "Model" msgstr "Модель" @@ -861,162 +878,166 @@ msgstr "ZIP архив" msgid "Audio" msgstr "Аудио" -#: ml_model/models.py:234 ml_model/models.py:272 +#: ml_model/models.py:229 payments/tests/test_plans.py:31 +msgid "Video" +msgstr "Видео" + +#: ml_model/models.py:235 ml_model/models.py:273 #: payments/models/promocode.py:41 msgid "Type" msgstr "Тип" -#: ml_model/models.py:236 ml_model/models.py:283 +#: ml_model/models.py:237 ml_model/models.py:284 msgid "Required" msgstr "Обязательный" -#: ml_model/models.py:239 +#: ml_model/models.py:240 #, python-format msgid "%(model_title)s | %(input_type)s" msgstr "%(model_title)s | %(input_type)s" -#: ml_model/models.py:245 +#: ml_model/models.py:246 msgid "Model Input" msgstr "Модель" -#: ml_model/models.py:246 +#: ml_model/models.py:247 msgid "Model Inputs" msgstr "Входящий поток модели" -#: ml_model/models.py:251 +#: ml_model/models.py:252 msgid "Integer" msgstr "Целое число" -#: ml_model/models.py:252 +#: ml_model/models.py:253 msgid "Float" msgstr "Вещественное число" -#: ml_model/models.py:253 +#: ml_model/models.py:254 msgid "String" msgstr "Строка" -#: ml_model/models.py:256 +#: ml_model/models.py:257 msgid "List" msgstr "Список" -#: ml_model/models.py:260 +#: ml_model/models.py:261 msgid "Float range" msgstr "Вещественный диапазон" -#: ml_model/models.py:264 +#: ml_model/models.py:265 msgid "Integer range" msgstr "Целочисленный диапазон" -#: ml_model/models.py:266 +#: ml_model/models.py:267 msgid "Logical" msgstr "Логический" -#: ml_model/models.py:279 +#: ml_model/models.py:280 msgid "Values" msgstr "Значения" -#: ml_model/models.py:280 +#: ml_model/models.py:281 msgid "" "These values can contain different interfaces and default value optional" msgstr "" "Значения могут содержать различные интерфейс и, опционально, значение по " "умолчанию" -#: ml_model/models.py:282 +#: ml_model/models.py:283 msgid "Hidden" msgstr "Скрытый" -#: ml_model/models.py:288 +#: ml_model/models.py:289 #, python-format msgid "Parameter of %(model_title)s" msgstr "Параметр %(model_title)s" -#: ml_model/models.py:291 +#: ml_model/models.py:292 msgid "Parameter" msgstr "Параметр" -#: ml_model/models.py:292 +#: ml_model/models.py:293 msgid "Parameters" msgstr "Параметры" -#: ml_model/models.py:297 +#: ml_model/models.py:298 msgid "Fixed" msgstr "Фикса" -#: ml_model/models.py:298 +#: ml_model/models.py:299 msgid "Per generation second" msgstr "За секунду генерации" -#: ml_model/models.py:299 +#: ml_model/models.py:300 msgid "Per one text token" msgstr "За один текстовый токен" -#: ml_model/models.py:300 +#: ml_model/models.py:301 msgid "Per image pixel" msgstr "За один пиксель" -#: ml_model/models.py:303 +#: ml_model/models.py:304 msgid "By input data" msgstr "По входящим данным" -#: ml_model/models.py:304 +#: ml_model/models.py:305 msgid "By output data" msgstr "По исходящим данным" -#: ml_model/models.py:305 +#: ml_model/models.py:306 msgid "By all data" msgstr "По всем данным" -#: ml_model/models.py:310 +#: ml_model/models.py:311 msgid "Strategy" msgstr "Стратегия" -#: ml_model/models.py:315 +#: ml_model/models.py:316 msgid "Interaction Type" msgstr "Тип взаимодействия" -#: ml_model/models.py:320 payments/models/invoice.py:19 +#: ml_model/models.py:321 payments/models/invoice.py:19 msgid "Cost" msgstr "Цена" -#: ml_model/models.py:321 +#: ml_model/models.py:322 msgid "In RUB, per specified strategy" msgstr "В рублях, за указанную стратегию" -#: ml_model/models.py:326 +#: ml_model/models.py:327 msgid "Coefficient" msgstr "Коэффициент" -#: ml_model/models.py:327 +#: ml_model/models.py:328 msgid "Cost multiplier" msgstr "Цена" -#: ml_model/models.py:334 +#: ml_model/models.py:335 msgid "Rate" msgstr "Ставка" -#: ml_model/models.py:338 +#: ml_model/models.py:339 msgid "Payment Rule" msgstr "Платежное правило" -#: ml_model/models.py:339 +#: ml_model/models.py:340 msgid "Payment Rules" msgstr "Платежные правила" -#: ml_model/models.py:400 +#: ml_model/models.py:401 msgid "Descriptor" msgstr "Дескриптор" -#: ml_model/models.py:406 +#: ml_model/models.py:407 #, python-format msgid "Instruction of %(model_title)s" msgstr "Инструкция %(model_title)s" -#: ml_model/models.py:409 +#: ml_model/models.py:410 msgid "Model Instruction" msgstr "Инструкция Модели" -#: ml_model/models.py:410 +#: ml_model/models.py:411 msgid "Model Instructions" msgstr "Инструкции Моделей" @@ -1024,7 +1045,7 @@ msgstr "Инструкции Моделей" msgid "no model by this id" msgstr "Не найдено моделей по этому ID" -#: ml_model/services/chatgpt.py:148 +#: ml_model/services/chatgpt.py:154 msgid "No matching version found" msgstr "Соответствующая версия не найдена" @@ -1032,6 +1053,24 @@ msgstr "Соответствующая версия не найдена" msgid "Image is ready" msgstr "Изображение готово" +#: ml_model/services/elevenlabs_music.py:46 +msgid "Duration cannot be less than 5 seconds" +msgstr "Длительность не может быть меньше 5 секунд" + +#: ml_model/services/hunyuan.py:103 +#, python-format +msgid "This video duration is not allowed for %(quality)s quality." +msgstr "" +"Для качества %(quality)s такая продолжительность видео не поддерживается." + +#: ml_model/services/hunyuan.py:108 +msgid "" +"Smooth motion mode is available only for 5-second videos at 540p and 720p " +"quality" +msgstr "" +"Режим «Плавное движение» доступен только для 5-секундных видео в качестве " +"540p и 720p" + #: ml_model/services/minimaxmusic.py:58 #: ml_model/services/minimaxmusic_lite.py:62 msgid "Lyrics is too long" @@ -1050,20 +1089,24 @@ msgstr "Нет изображения для улучшения" msgid "Model data cannot be retrieved" msgstr "Невозможно получить данные модели" -#: payments/admin.py:35 payments/admin.py:69 payments/admin.py:95 +#: payments/admin.py:35 payments/admin.py:67 payments/admin.py:93 msgid "You can search by user email, exacted company name" msgstr "" "Вы можете осуществлять поиск по e-mail пользователя, точному названию " "компании" -#: payments/admin.py:40 payments/admin.py:100 +#: payments/admin.py:40 payments/admin.py:98 msgid "Missing" msgstr "Отсутствующий" -#: payments/apps.py:9 payments/models/payment.py:60 +#: payments/apps.py:11 payments/models/payment.py:60 msgid "Payments" msgstr "Платежи" +#: payments/exceptions/full_balance.py:5 +msgid "Your balance is already full" +msgstr "Ваш баланс уже пополнен до максимума" + #: payments/exceptions/insufficient_balance.py:18 #, python-format msgid "" @@ -1105,60 +1148,56 @@ msgstr "Статус" msgid "Payment" msgstr "Платеж" -#: payments/models/payment_plan.py:18 +#: payments/models/payment_plan.py:13 msgid "Price" msgstr "Цена" -#: payments/models/payment_plan.py:22 +#: payments/models/payment_plan.py:17 msgid "Tokens per plan" msgstr "Токенов за план" -#: payments/models/payment_plan.py:25 +#: payments/models/payment_plan.py:20 msgid "Is corporate" msgstr "Корпоративный" -#: payments/models/payment_plan.py:26 +#: payments/models/payment_plan.py:21 msgid "Individual" msgstr "Индивидуальный" -#: payments/models/payment_plan.py:27 -msgid "Is recurrent" -msgstr "Рекуррентный" - -#: payments/models/payment_plan.py:29 -msgid "Duration" -msgstr "Длительность" - -#: payments/models/payment_plan.py:34 +#: payments/models/payment_plan.py:22 msgid "Is visible" msgstr "Видимый" -#: payments/models/payment_plan.py:53 payments/models/payment_plan.py:68 +#: payments/models/payment_plan.py:41 payments/models/payment_plan.py:56 #: payments/models/payment_plan_feature.py:16 msgid "Payment Plan" msgstr "Платежный План" -#: payments/models/payment_plan.py:54 +#: payments/models/payment_plan.py:42 msgid "Payment Plans" msgstr "Платежные Планы" -#: payments/models/payment_plan.py:70 +#: payments/models/payment_plan.py:58 msgid "Last payment at" msgstr "Последнее время платежа" -#: payments/models/payment_plan.py:71 +#: payments/models/payment_plan.py:59 msgid "Next payment at" msgstr "Следующее время платежа" -#: payments/models/payment_plan.py:73 +#: payments/models/payment_plan.py:63 payments/models/user_payment_method.py:17 +msgid "Payment Method" +msgstr "Платежный метод" + +#: payments/models/payment_plan.py:69 msgid "Current balance" msgstr "Текущий баланс" -#: payments/models/payment_plan.py:79 -msgid "Recurrent billing task" -msgstr "Рекуррентная задача на платеж" +#: payments/models/payment_plan.py:75 +msgid "Referral balance" +msgstr "Реферальный баланс" -#: payments/models/payment_plan.py:100 payments/models/payment_plan.py:101 +#: payments/models/payment_plan.py:96 payments/models/payment_plan.py:97 msgid "User Balance" msgstr "Баланс пользователя" @@ -1222,27 +1261,47 @@ msgstr "Промокоды" msgid "Activated by" msgstr "Кем активирован" -#: payments/models/promocode.py:137 +#: payments/models/promocode.py:138 msgid "Promocode Activation" msgstr "Активация Промокода" -#: payments/models/promocode.py:138 +#: payments/models/promocode.py:139 msgid "Promocode Activations" msgstr "Активации Промокодов" -#: payments/models/user_payment_method.py:24 -msgid "Payment Method" -msgstr "Платежный метод" +#: payments/models/user_payment_method.py:8 +msgid "Payment method UID" +msgstr "UID платёжного метода" + +#: payments/models/user_payment_method.py:9 +msgid "Card type" +msgstr "Тип карты" -#: payments/models/user_payment_method.py:25 +#: payments/models/user_payment_method.py:10 +msgid "Last four card digits" +msgstr "Последние 4 цифры карты" + +#: payments/models/user_payment_method.py:11 +msgid "Attempts" +msgstr "Попытки" + +#: payments/models/user_payment_method.py:18 msgid "Payment Methods" msgstr "Платежные методы" #: payments/routes/v1.py:94 +msgid "You do not have an active subscription to cancel" +msgstr "У вас нет активной подписки для отмены" + +#: payments/routes/v1.py:95 +msgid "The recurring payment is successfully cancelled" +msgstr "Автоплатежи успешно отключены" + +#: payments/routes/v1.py:145 msgid "Expenses" msgstr "Затраты" -#: payments/routes/v1.py:98 +#: payments/routes/v1.py:149 msgid "Refills" msgstr "Пополнения" @@ -1254,14 +1313,6 @@ msgstr "" msgid "Unknown account type" msgstr "Неизвестный тип аккаунта" -#: payments/services/payment_method_service.py:46 -msgid "No current active payment method is set" -msgstr "Ни одного активного метода не установлено" - -#: payments/services/payment_method_service.py:55 -msgid "No payment method by this id" -msgstr "Не найдено метода платежа по этому ID" - #: payments/tests/test_plans.py:23 payments/tests/test_plans.py:191 #: payments/tests/test_plans.py:194 msgid "Chat-bots" @@ -1272,10 +1323,6 @@ msgstr "Чат-боты" msgid "Images" msgstr "Изображения" -#: payments/tests/test_plans.py:31 -msgid "Video" -msgstr "Видео" - #: poller/models.py:11 msgid "Address" msgstr "Адрес" @@ -1312,7 +1359,7 @@ msgstr "Публичный API" msgid "Media" msgstr "Медиа" -#: tools/chats/apis.py:197 tools/media/apis.py:202 +#: tools/chats/apis.py:201 tools/media/apis.py:207 #: tools/public_api/views/base.py:100 msgid "" "An unexpected generation error has occurred. Please try again later or use a " @@ -1321,7 +1368,7 @@ msgstr "" "Произошла непредвиденная ошибка при генерации. Пожалуйста попробуйте позже " "или используйте другую модель" -#: tools/chats/apis.py:253 +#: tools/chats/apis.py:257 msgid "The message has already been deleted" msgstr "Сообщение уже было удалено" @@ -1334,12 +1381,21 @@ msgstr "Чат %(id)s" msgid "Chat" msgstr "Чат" -#: tools/media/apis.py:173 +<<<<<<< HEAD +#: tools/media/apis.py:174 +======= +#: tools/media/apis.py:175 +>>>>>>> 1ebd6790 (django.po апдейт) msgid "" "Temporary issues with the service, we are already working on a solution." msgstr "Временные неполадки с сервисом, мы уже работаем над их решением." -#: tools/media/apis.py:255 +<<<<<<< HEAD +#: tools/media/apis.py:252 tools/public_api/views/ml_service.py:63 +======= +#: tools/media/apis.py:254 tools/public_api/views/ml_service.py:63 +>>>>>>> 1ebd6790 (django.po апдейт) +#: tools/public_api/views/ml_service.py:107 msgid "Voice not found." msgstr "Голос не найден." @@ -1351,7 +1407,7 @@ msgstr "Хранилище клонирования голоса" msgid "Voice clone stores" msgstr "Хранилища клонирования голоса" -#: tools/media/models.py:54 tools/media/models.py:131 +#: tools/media/models.py:54 tools/media/models.py:121 msgid "Voice" msgstr "Голос" @@ -1363,7 +1419,7 @@ msgstr "Инструментал" msgid "Kind" msgstr "Тип" -#: tools/media/models.py:72 tools/media/models.py:103 +#: tools/media/models.py:72 tools/media/models.py:105 msgid "File" msgstr "Файл" @@ -1379,23 +1435,32 @@ msgstr "Пресет" msgid "Presets" msgstr "Пресеты" -#: tools/media/models.py:100 +#: tools/media/models.py:93 +msgid "Создан" +msgstr "" + +#: tools/media/models.py:94 +msgid "Изменён" +msgstr "" + +#: tools/media/models.py:102 msgid "Only MP3, OGG, WAV and WEBA audio files are allowed." msgstr "Разрешены только аудиофайлы MP3, OGG, WAV и WEBA." -#: tools/media/models.py:108 +#: tools/media/models.py:110 msgid "Transcription" msgstr "Транскрипция" -#: tools/media/models.py:112 +#: tools/media/models.py:114 msgid "Unknown file" msgstr "Неизвестный файл" -#: tools/media/models.py:132 +#: tools/media/models.py:122 msgid "Voices" msgstr "Голоса" -#: tools/media/routes/v1.py:74 +#: tools/media/routes/v1.py:76 tools/public_api/views/voice.py:86 +#: tools/public_api/views/voice.py:98 msgid "Voice not found" msgstr "Голос не найден" @@ -1433,31 +1498,32 @@ msgstr "" msgid "The request must not be empty" msgstr "Запрос не должен быть пустым" -#: tools/public_api/views/ml_service.py:65 +#: tools/public_api/views/ml_service.py:128 msgid "You must provide a model parameter" msgstr "Необходимо указать параметр 'model'" -#: tools/public_api/views/ml_service.py:80 +#: tools/public_api/views/ml_service.py:143 msgid "Missing required parameter: 'messages'" msgstr "Отсутствует обязательный параметр: 'messages'" -#: tools/public_api/views/ml_service.py:130 +#: tools/public_api/views/ml_service.py:193 msgid "Model not found" msgstr "Модель не найдена" -#: tools/public_api/views/voice.py:40 +#: tools/public_api/views/voice.py:44 msgid "Your voice has been uploaded successfully" msgstr "Ваш голос успешно загружен" -#: tools/public_api/views/voice.py:72 +#: tools/public_api/views/voice.py:77 +msgid "Preset voices are shared and cannot be edited. Use your own voice id." +msgstr "" +"Пресеты общие для всех — их нельзя редактировать. Укажите id своего голоса." + +#: tools/public_api/views/voice.py:87 msgid "Voice title updated successfully" msgstr "Название голоса успешно обновлено" -#: tools/public_api/views/voice.py -msgid "Preset voices are shared and cannot be edited. Use your own voice id." -msgstr "Пресеты общие для всех — их нельзя редактировать. Укажите id своего голоса." - -#: tools/public_api/views/voice.py +#: tools/public_api/views/voice.py:93 msgid "Preset voices are shared and cannot be deleted. Use your own voice id." msgstr "Пресеты общие для всех — их нельзя удалить. Укажите id своего голоса." @@ -1468,6 +1534,24 @@ msgstr "Пресеты общие для всех — их нельзя удал #~ "Случилась ошибка во время генерации. Она может возникать из-за того, что " #~ "NSFW-контент запрещен. Попробуйте снова" +#~ msgid "Is recurrent" +#~ msgstr "Рекуррентный" + +#~ msgid "Payment Datetime" +#~ msgstr "Дата и время платежа" + +#~ msgid "Recurring Payment" +#~ msgstr "Автоплатеж" + +#~ msgid "Recurring Payments" +#~ msgstr "Автоплатежи" + +#~ msgid "No current active payment method is set" +#~ msgstr "Ни одного активного метода не установлено" + +#~ msgid "No payment method by this id" +#~ msgstr "Не найдено метода платежа по этому ID" + #~ msgid "Regular users cannot send introductory letters" #~ msgstr "Обычные пользователи не могут отсылать письма" @@ -1520,16 +1604,6 @@ msgstr "Пресеты общие для всех — их нельзя удал #~ msgid "Issued achievement" #~ msgstr "Выданное достижение" -msgid "Smooth motion mode is available only for 5-second videos at 540p and 720p quality" -msgstr "Режим «Плавное движение» доступен только для 5-секундных видео в качестве 540p и 720p" - -#, python-format -msgid "This video duration is not allowed for %(quality)s quality." -msgstr "Для качества %(quality)s такая продолжительность видео не поддерживается." - -msgid "The model could not analyze your request. Please rephrase it and try again" -msgstr "Модель не смогла проанализировать ваш запрос. Перефразируйте его и попробуйте снова" - #~ msgid "Account is already confirmed" #~ msgstr "Аккаунт уже подтвержден" @@ -0,0 +1,44 @@ +import redis + +from django.core.management.base import BaseCommand + +from django.conf import settings +from redis.commands.search.field import TagField, TextField, VectorField +from redis.commands.search.indexDefinition import IndexDefinition, IndexType + + +class Command(BaseCommand): + def handle(self, *args, **options) -> None: + """ + A command for creating indexes for storing a chunk's data (content, vectors, etc.) + """ + redis_client = redis.Redis(host=settings.REDIS_HOST, port=settings.REDIS_PORT, db=0) + index_configs = ( + ('ml_model-index', 3072), + ('ml_model-index-1536', 1536), + ) + + for index_name, dim in index_configs: + try: + redis_client.ft(index_name).info() + self.stdout.write(f'Index {index_name} already exists') + except Exception: + message_uid = TagField('message_uid') + chunk_id = TextField('chunk_id') + section_text = TextField('section_text') + section_embeddings = VectorField( + 'section_embeddings', + 'FLAT', + { + 'TYPE': 'FLOAT32', + 'DIM': dim, + 'DISTANCE_METRIC': 'COSINE', + 'INITIAL_CAP': 10_000, + }, + ) + fields = [message_uid, chunk_id, section_text, section_embeddings] + redis_client.ft(index_name).create_index( + fields=fields, + definition=IndexDefinition(prefix=['ml_model:messages:'], index_type=IndexType.HASH), + ) + self.stdout.write(f'Index {index_name} has been successfully created') @@ -35,3 +35,5 @@ def calculate_predict_price(request, body: PredictPriceInputSchema): return PredictPriceSchema(price=predicted_price) + + @@ -7,23 +7,24 @@ from ml_model.services.dalle import Dalle from ml_model.services.deepl import Deepl from ml_model.services.deepseek import Deepseek from ml_model.services.djourney import Djourney -from ml_model.services.epicphotogasm import Epicphotogasm -from ml_model.services.elevenlabs_music import Elevenlabs_Music from ml_model.services.elevenlabs import Elevenlabs +from ml_model.services.elevenlabs_music import Elevenlabs_Music +from ml_model.services.epicphotogasm import Epicphotogasm from ml_model.services.flux import Flux from ml_model.services.flux_2 import Flux_2 from ml_model.services.fluxkrea import Fluxkrea from ml_model.services.fluxlorafast import Fluxlorafast from ml_model.services.fluxproultra import Fluxproultra +from ml_model.services.fluxpulid import Fluxpulid from ml_model.services.gemini import Gemini from ml_model.services.gemini_3_1 import Gemini_3_1 -from ml_model.services.gemma import Gemma from ml_model.services.geminiimage import Geminiimage +from ml_model.services.gemma import Gemma from ml_model.services.gptimage import Gptimage from ml_model.services.granite import Granite from ml_model.services.grok import Grok -from ml_model.services.grok_image import Grok_Image from ml_model.services.grok_4_1_fast import Grok_4_1_Fast +from ml_model.services.grok_image import Grok_Image from ml_model.services.grok_imagine_video import Grok_Imagine_Video from ml_model.services.hailuo import Hailuo from ml_model.services.hunyuan import Hunyuan @@ -36,22 +37,22 @@ from ml_model.services.leonardo import Leonardo from ml_model.services.lightning import Lightning from ml_model.services.llama import Llama from ml_model.services.logoai import Logoai -from ml_model.services.lyria import Lyria from ml_model.services.ltx import Ltx +from ml_model.services.lyria import Lyria from ml_model.services.midjourney import Midjourney -from ml_model.services.minimaxvideo import Minimaxvideo from ml_model.services.minimaxmusic import Minimaxmusic from ml_model.services.minimaxmusic_lite import Minimaxmusic_Lite +from ml_model.services.minimaxvideo import Minimaxvideo from ml_model.services.mistral import Mistral from ml_model.services.musicgen import Musicgen from ml_model.services.nanobanana import Nanobanana from ml_model.services.nanobanana_2 import Nanobanana_2 from ml_model.services.perplexity import Perplexity -from ml_model.services.pulid import Pulid from ml_model.services.photon import Photon from ml_model.services.pixverse import Pixverse -from ml_model.services.prunaai import Prunaai from ml_model.services.pruna_v import Pruna_V +from ml_model.services.prunaai import Prunaai +from ml_model.services.pulid import Pulid from ml_model.services.qwen import Qwen from ml_model.services.qwen_235B import Qwen_235B from ml_model.services.qwen_3_5 import Qwen_3_5 @@ -62,9 +63,9 @@ from ml_model.services.recraft import Recraft from ml_model.services.reve import Reve from ml_model.services.runway import Runway from ml_model.services.sdxlemoji import Sdxlemoji +from ml_model.services.seedance import Seedance from ml_model.services.seedream import Seedream from ml_model.services.sora import Sora -from ml_model.services.seedance import Seedance from ml_model.services.stablediffusion import Stablediffusion from ml_model.services.stablemusic import Stablemusic from ml_model.services.suno import Suno @@ -12,24 +12,32 @@ from tools.public_api.models import APIStore class Deepseek(SimpleService): TOKENS_COST = { - 'deepseek/deepseek-chat': { - 'input': Decimal('390') / 1_000_000, - 'output': Decimal('390') / 1_000_000, + # 'deepseek/deepseek-chat': { + # 'input': Decimal('390') / 1_000_000, + # 'output': Decimal('390') / 1_000_000, + # }, + # 'deepseek/deepseek-r1': { + # 'input': Decimal('900') / 1_000_000, + # 'output': Decimal('900') / 1_000_000, + # }, + # 'deepseek/deepseek-r1:free': { + # 'input': Decimal('0'), + # 'output': Decimal('0'), + # }, + 'deepseek/deepseek-v4-pro': { + 'input': Decimal('218') / 1_000_000, + 'output': Decimal('435') / 1_000_000, }, - 'deepseek/deepseek-r1': { - 'input': Decimal('900') / 1_000_000, - 'output': Decimal('900') / 1_000_000, - }, - 'deepseek/deepseek-r1:free': { - 'input': Decimal('0'), - 'output': Decimal('0') + 'deepseek/deepseek-v4-flash': { + 'input': Decimal('70') / 1_000_000, + 'output': Decimal('140') / 1_000_000, }, } - PRICE_BIAS = Decimal('0.05') def calculate_price(self, version: str, input_tokens: int, output_tokens: int) -> Decimal: price_map = self.TOKENS_COST[version] - price = input_tokens * price_map['input'] + output_tokens * price_map['output'] + self.PRICE_BIAS + price = input_tokens * price_map['input'] + output_tokens * price_map['output'] + return price.quantize(Decimal('0.1'), rounding='ROUND_UP') def save_results(self, content: str, t: timedelta, save: bool = True) -> list[Message]: @@ -47,15 +55,17 @@ class Deepseek(SimpleService): def make(self, input_message: Message, save: bool = True) -> list[Message]: info = input_message.info.copy() version = info.pop('version') - system_prompt = input_message.info.pop('system_prompt', '') + system_prompt = info.pop('system_prompt', '') + callback_data = { - **input_message.info, + **info, } messages = [ {'role': 'system', 'content': system_prompt}, *self.get_chat_history(), {'role': 'user', 'content': input_message.content} ] + start_time = time.time() result = openrouter_run(version, messages, callback_data, 'Deepseek') process_time = timedelta(seconds=(time.time() - start_time)) @@ -66,8 +76,11 @@ class Deepseek(SimpleService): output_tokens=result[2], ) msgs = self.save_results(result[0], process_time) + return msgs + + def get_chat_history(self, message_limit: int = 10, max_character_limit: int = 1500) -> list[dict[str, str | list]]: if isinstance(self.store, Chat): air_messages = list( @@ -100,3 +113,4 @@ class Deepseek(SimpleService): while character_length > max_character_limit: character_length -= len(memory.pop(0)['content']) return memory + @@ -6,10 +6,11 @@ from typing import Any import requests from django.core.files import File +from django.utils.translation import gettext as _ from replicate.exceptions import ModelError from messages.models import Message -from ml_model.exceptions import RequestBlocked, GenerationException +from ml_model.exceptions import RequestBlocked, GenerationException, InvalidParameterError from ml_model.services.base import SimpleService from ml_model.tasks import replicate_run @@ -41,6 +42,8 @@ class Elevenlabs_Music(SimpleService): def make(self, input_message: Message, save: bool = True) -> list[Message]: duration = input_message.info.pop('duration') + if duration < 5: + raise InvalidParameterError(_('Duration cannot be less than 5 seconds')) if (balance := PaymentPlanSelector(self.store.user).get_current_balance()) < ( cost := self.calculate_price(duration) ): @@ -0,0 +1,89 @@ +from datetime import timedelta +from decimal import Decimal +from io import BytesIO +import time +from typing import Any + +from django.core.files import File +from replicate.exceptions import ModelError +import requests + +from messages.models.message import Message +from ml_model.exceptions import FaceNotFoundError, GenerationException, RequestBlocked +from ml_model.models import ModelParameter +from ml_model.services.base import SimpleService +from ml_model.tasks import replicate_run +from payments.exceptions.insufficient_balance import InsufficientBalance +from payments.selectors.payment_plan_selector import PaymentPlanSelector + + +class Fluxpulid(SimpleService): + TOKEN_COST = Decimal('15.0') + ENDPOINT = 'bytedance/flux-pulid:8baa7ef2255075b46f4d91cd238c21d31181b3e6a864463f967960bb0112525b' + + @classmethod + def predict_price(cls, content: str, file_exists: bool, info: dict[str, Any]) -> Decimal | None: + price = info.get('num_outputs', 1) * cls.TOKEN_COST + + return price.quantize(Decimal('0.1'), rounding='ROUND_UP') + + def calculate_price(self, num_outputs: int) -> Decimal: + price = num_outputs * self.TOKEN_COST + + return price.quantize(Decimal('0.1'), rounding='ROUND_UP') + + def save_results(self, content: str, images: list, t: timedelta, save: bool = True) -> list[Message]: + messages: list[Message] = [] + for image in images: + messages.append( + Message( + content_object=self.store, + elapsed_time=t, + content=content, + file=File(BytesIO(requests.get(image.url).content), '.png'), + ) + ) + + if save: + return Message.objects.bulk_create(messages) + + return messages + + def make(self, input_message: Message, save: bool = True) -> list[Message]: + num_outputs = input_message.info.get('num_outputs', 1) + if (balance := PaymentPlanSelector(self.store.user).get_current_balance()) < ( + cost := self.TOKEN_COST * num_outputs + ): + raise InsufficientBalance(balance, cost) + + size = input_message.info.pop('size', '1024x1024') + width, height = size.split('x') + + translated_prompt = self.translate_prompt(input_message.content) + callback_data = dict( + { + 'prompt': translated_prompt, + 'main_face_image': BytesIO(input_message.file.read()), + 'output_quality': 100, + 'output_format': 'png', + 'max_sequence_length': 256, + 'width': int(width), + 'height': int(height), + **input_message.info, + } + ) + start_time = time.time() + try: + images = replicate_run(self.ENDPOINT, callback_data) + except ModelError as exc: + if any(error in str(exc) for error in ('E005', 'E006', 'sexual')): + raise RequestBlocked + elif exc.prediction.error == 'facexlib align face fail': + raise FaceNotFoundError + raise GenerationException from exc + + process_time = timedelta(seconds=(time.time() - start_time)) + self.handle_invoice(input_message.content_object.model, len(images)) + msgs = self.save_results(input_message.content, images, process_time, save) + + return msgs @@ -1,19 +1,21 @@ +import base64 import time from datetime import timedelta from decimal import Decimal from io import BytesIO -from typing import Optional, Any +from typing import Any, Optional +import filetype import requests from django.core.files import File from replicate.exceptions import ModelError from messages.models import Message from ml_model.exceptions import ( - ImageContentNotFound, GenerationException, + ImageContentNotFound, + ModelCouldNotInterpretPrompt, RequestBlocked, - ServiceHighDemandError, ) from ml_model.services.base import SimpleService from ml_model.tasks import replicate_run @@ -21,17 +23,25 @@ from ml_model.tasks import replicate_run class Nanobanana_2(SimpleService): TOKENS_COST = { - '1K': Decimal('20.1'), - '2K': Decimal('30.3'), - '4K': Decimal('45.3'), + 'nano-banana-pro': { + '1K': Decimal('45'), + '2K': Decimal('45'), + '4K': Decimal('90'), + }, } - def calculate_price(self, resolution: Optional[str]) -> Decimal: - return self.TOKENS_COST[resolution] + def calculate_price(self, version: str, resolution: Optional[str]) -> Decimal: + if resolution: + return self.TOKENS_COST[version][resolution] + return self.TOKENS_COST[version] @classmethod def predict_price(cls, content: str, file_exists: bool, info: dict[str, Any]) -> Decimal | None: - return cls.TOKENS_COST[info['resolution']] + version = 'nano-banana-pro' + resolution = info.get('resolution', '2K') + if resolution: + return cls.TOKENS_COST[version][resolution] + return cls.TOKENS_COST[version] def save_results( self, @@ -52,28 +62,32 @@ class Nanobanana_2(SimpleService): def make(self, input_message: Message, save: bool = True) -> list[Message]: start_time = time.time() + version = 'nano-banana-pro' resolution = input_message.info.get('resolution', '2K') callback_data = dict( { - 'prompt': self.translate_prompt(input_message.content), + 'prompt': input_message.content, **input_message.info, } ) if input_message.file: - callback_data.update( - {'image_input': [input_message.file.url], 'aspect_ratio': 'match_input_image'} - ) + kind = filetype.guess(input_message.file.read(20)) + mime = kind.mime if kind else 'application/octet-stream' + input_message.file.seek(0) + image = f'data:{mime};base64,{base64.b64encode(input_message.file.read()).decode("utf-8")}' + input_message.file.close() + callback_data.update({'image_input': [image]}) try: - image = replicate_run('google/nano-banana-2', callback_data) + image = replicate_run(f'google/{version}', callback_data) except ModelError as exc: if exc.prediction.error == 'No image content found in response': raise ImageContentNotFound + elif exc.prediction.error in ('400', 'Failed to generate image.'): + raise ModelCouldNotInterpretPrompt from exc elif any(error in str(exc) for error in ('E005', 'E006', 'sexual')): raise RequestBlocked - elif 'E003' in str(exc): - raise ServiceHighDemandError from exc raise GenerationException from exc process_time = timedelta(seconds=(time.time() - start_time)) - self.handle_invoice(input_message.content_object.model, resolution=resolution) + self.handle_invoice(input_message.content_object.model, version=version, resolution=resolution) msgs = self.save_results(input_message.content, image, process_time, save) return msgs @@ -128,7 +128,7 @@ class NeuronModelAdmin( if formset.model == ModelInput: existed = set() for input in formset.cleaned_data: - if input['DELETE']: + if input.get('DELETE', False): continue for version in input['versions']: if (pair := (version.pk, input['type'])) in existed: @@ -10,9 +10,6 @@ class MLModelConfig(AppConfig): def ready(self): from .signals import create_settings - from .utils import create_redis_search_index setting_changed.connect(create_settings) - create_redis_search_index() - return super().ready() @@ -156,3 +156,8 @@ class ServiceHighDemandError(Exception): class PaidPlanRequiredError(Exception): def __str__(self) -> str: return _('Available only in paid plan') + + +class FaceNotFoundError(Exception): + def __str__(self) -> str: + return _('Face not found in the image. Please try another image with a face.') @@ -1,5 +1,4 @@ from typing import List, Optional, Any - from ninja import ModelSchema, Schema from pydantic import condecimal @@ -61,36 +61,3 @@ def count_openrouter_tokens(model_name: str, messages: List[Dict[str, Any]], out output_tokens = len(encoding.encode(output)) return (input_tokens, output_tokens) - -def create_redis_search_index() -> None: - """ - A method for creating an index for storing a chunk's data (content, vectors, etc.) - """ - redis_client = redis.Redis(host=settings.REDIS_HOST, port=settings.REDIS_PORT, db=0) - index_configs = ( - ('ml_model-index', 3072), - ('ml_model-index-1536', 1536), - ) - - for index_name, dim in index_configs: - try: - redis_client.ft(index_name).info() - except Exception: - message_uid = TagField('message_uid') - chunk_id = TextField('chunk_id') - section_text = TextField('section_text') - section_embeddings = VectorField( - 'section_embeddings', - 'FLAT', - { - 'TYPE': 'FLOAT32', - 'DIM': dim, - 'DISTANCE_METRIC': 'COSINE', - 'INITIAL_CAP': 10_000, - }, - ) - fields = [message_uid, chunk_id, section_text, section_embeddings] - redis_client.ft(index_name).create_index( - fields=fields, - definition=IndexDefinition(prefix=['ml_model:messages:'], index_type=IndexType.HASH), - ) @@ -0,0 +1,5 @@ +from django.utils.translation import gettext as _ + +class FullBalanceException(Exception): + def __str__(self) -> str: + return _('Your balance is already full') \ No newline at end of file @@ -0,0 +1,56 @@ +# Generated by Django 5.0.11 on 2026-02-12 08:34 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('payments', '0026_remove_paymentplan_title'), + ] + + operations = [ + migrations.CreateModel( + name='PaymentMethod', + fields=[ + ('uid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создан')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Изменён')), + ('payment_method_id', models.UUIDField(unique=True, verbose_name='Payment method UID')), + ('card_type', models.CharField(max_length=15, verbose_name='Card type')), + ('last_four', models.CharField(max_length=4, verbose_name='Last four card digits')), + ('attempts', models.PositiveSmallIntegerField(default=0, verbose_name='Attempts')), + ], + options={ + 'verbose_name': 'Payment Method', + 'verbose_name_plural': 'Payment Methods', + }, + ), + migrations.RemoveField( + model_name='paymentplan', + name='duration', + ), + migrations.RemoveField( + model_name='paymentplan', + name='is_recurrent', + ), + migrations.RemoveField( + model_name='paymentplanuserinfo', + name='plan_schedule', + ), + migrations.AlterField( + model_name='paymentplanuserinfo', + name='next_payment_at', + field=models.DateTimeField(blank=True, null=True, verbose_name='Next payment at'), + ), + migrations.AddField( + model_name='paymentplanuserinfo', + name='method', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='user_plan_info', to='payments.paymentmethod', verbose_name='Payment Method'), + ), + migrations.DeleteModel( + name='UserPaymentMethod', + ), + ] @@ -0,0 +1,18 @@ +# Generated by Django 5.0.11 on 2026-04-18 06:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('payments', '0027_paymentmethod_remove_paymentplan_duration_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='paymentplanuserinfo', + name='referral_balance', + field=models.DecimalField(decimal_places=10, default=0, max_digits=100, verbose_name='Referral balance'), + ), + ] @@ -1,6 +1,6 @@ from payments.models.payment import Payment from payments.models.payment_plan import PaymentPlan, PaymentPlanUserInfo -from payments.models.user_payment_method import UserPaymentMethod +from payments.models.user_payment_method import PaymentMethod from payments.models.invoice import Invoice from payments.models.promocode import PromoCode, PromoCodeActivation from payments.models.payment_plan_feature import PaymentPlanFeature @@ -9,9 +9,9 @@ __all__ = ( 'Payment', 'PaymentPlan', 'PaymentPlanUserInfo', - 'UserPaymentMethod', + 'PaymentMethod', 'Invoice', 'PromoCode', 'PromoCodeActivation', - 'PaymentPlanFeature' + 'PaymentPlanFeature', ) @@ -1,20 +1,15 @@ from datetime import datetime -from dateutil.relativedelta import relativedelta from django.contrib.auth import get_user_model from django.contrib.postgres.fields import ArrayField from django.db import models from django.utils.translation import gettext_lazy as _ -from django_celery_beat.models import PeriodicTask from core.models import BaseModel from ml_model.models import NeuronModel class PaymentPlan(BaseModel): - MONTH = 'month' - YEAR = 'year' - DURATION_CHOICES = ((MONTH, MONTH), (YEAR, YEAR)) price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name=_('Price')) tokens_per_plan = models.DecimalField( max_digits=50, @@ -24,13 +19,6 @@ class PaymentPlan(BaseModel): ) is_corporate = models.BooleanField(default=False, verbose_name=_('Is corporate')) individual = models.BooleanField(default=False, verbose_name=_('Individual')) - is_recurrent = models.BooleanField(default=False, verbose_name=_('Is recurrent')) - duration = models.CharField( - verbose_name=_('Duration'), - max_length=100, - choices=DURATION_CHOICES, - default=MONTH, - ) is_visible = models.BooleanField(default=True, verbose_name=_('Is visible')) points = ArrayField( default=list, @@ -68,18 +56,23 @@ class PaymentPlanUserInfo(BaseModel): verbose_name=_('Payment Plan'), ) last_payment_at = models.DateField(verbose_name=_('Last payment at')) - next_payment_at = models.DateField(verbose_name=_('Next payment at')) + next_payment_at = models.DateTimeField(blank=True, null=True, verbose_name=_('Next payment at')) + method = models.OneToOneField( + 'PaymentMethod', + on_delete=models.SET_NULL, + verbose_name=_('Payment Method'), + related_name='user_plan_info', + null=True, + blank=True + ) current_token_balance = models.DecimalField( max_digits=100, decimal_places=10, verbose_name=_('Current balance') ) - plan_schedule = models.OneToOneField( - PeriodicTask, - on_delete=models.CASCADE, - related_name='payment_plan', - verbose_name=_('Recurrent billing task'), - default=None, - null=True, - blank=True, + referral_balance = models.DecimalField( + max_digits=100, + decimal_places=10, + default=0, + verbose_name=_('Referral balance'), ) def save( @@ -90,9 +83,12 @@ class PaymentPlanUserInfo(BaseModel): update_fields=None, ): self.last_payment_at = datetime.now().date() - self.next_payment_at = self.last_payment_at + relativedelta(months=1) return super().save(force_insert, force_update, using, update_fields) + @property + def is_recurring(self) -> bool: + return self.method is not None + def __str__(self) -> str: return f'{self.user.email or "Ошибка"}' @@ -126,8 +126,9 @@ class PromoCodeActivation(BaseModel): def add_tokens_referral(self, amount: int): self.add_tokens(amount=amount) - self.promocode.owner.payment_plan.current_token_balance += amount - self.promocode.owner.payment_plan.save() + owner_pp = self.promocode.owner.payment_plan + owner_pp.referral_balance += amount + owner_pp.save(update_fields=['referral_balance']) def __str__(self) -> str: return f'{self.promocode}' @@ -1,24 +1,17 @@ -from django.contrib.auth import get_user_model from django.db import models from django.utils.translation import gettext_lazy as _ from core.models import BaseModel -class UserPaymentMethod(BaseModel): - user = models.ForeignKey( - get_user_model(), - on_delete=models.CASCADE, - related_name='payment_methods', - verbose_name='Пользователь', - ) - currently_active = models.BooleanField(default=False, verbose_name='Способ платежа активен') - payment_method_id = models.UUIDField(unique=True, verbose_name='UID платёжного метода') - card_type = models.CharField(max_length=15, verbose_name='Тип карты') - last_four = models.CharField(max_length=4, verbose_name='Последние 4 цифры карты') +class PaymentMethod(BaseModel): + payment_method_id = models.UUIDField(unique=True, verbose_name=_('Payment method UID')) + card_type = models.CharField(max_length=15, verbose_name=_('Card type')) + last_four = models.CharField(max_length=4, verbose_name=_('Last four card digits')) + attempts = models.PositiveSmallIntegerField(default=0, verbose_name=_('Attempts')) def __str__(self) -> str: - return self.user.email + return f'Card {self.card_type} ****{self.last_four}' class Meta: verbose_name = _('Payment Method') @@ -1,9 +1,14 @@ +import orjson + import calendar import logging from collections import defaultdict from datetime import date, timedelta from decimal import Decimal +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + from dateutil.relativedelta import relativedelta from django.db.models import CharField, F, Func, Prefetch, Sum, Value from django.db.models.functions import Round, TruncDay, TruncMonth, TruncYear @@ -11,8 +16,18 @@ from django.utils.translation import gettext as _ from ninja import Query, Router from ninja.errors import HttpError +from authentication.models import CustomUserModel from authentication.security import AsyncAuthBearer, SyncAuthBearer -from payments.models import Invoice, Payment, PaymentPlan, PaymentPlanFeature +from payments.exceptions.payer_not_found import PayerNotFound +from payments.models import ( + Invoice, + Payment, + PaymentPlan, + PaymentPlanFeature, + PaymentMethod, + PaymentPlanUserInfo, +) +from authentication.services.email_service import EmailService from payments.schema import UserBalance from payments.schemas import ( ExpensesParamsSchema, @@ -22,9 +37,8 @@ from payments.schemas import ( PaymentPlanSchema, ) from payments.selectors.payment_plan_selector import PaymentPlanSelector -from payments.services.payment_plan_service import PaymentPlanService -from payments.services.payment_service import PaymentService from payments.typing import IntervalStrategyEnum, SourceStrategyEnum +from payments.services.payment_service import PaymentService router = Router(auth=SyncAuthBearer(), tags=['payments']) @@ -44,6 +58,43 @@ def get_user_balance(request): raise HttpError(401, f'{exc}') +@router.post('payment-result', tags=['payments/payment-result'], auth=None) +async def handle_yookassa_webhook(request): + data = orjson.loads(request.body) + logger.info('YooKassa webhook received: payment_id=%s', data['object']['id']) + payment = await PaymentService.handle_payment(data['object']['id']) + try: + payer = await CustomUserModel.objects.prefetch_related('payment_plan', 'payment_plan__plan', 'payment_plan__method').aget( + uid=payment.description + ) + await PaymentService(payer).do_payment(payment) + logger.info( + 'YooKassa webhook processed: payment_id=%s payer_email=%s status=%s', + payment.id, + payer.email, + payment.status, + ) + except CustomUserModel.DoesNotExist: + raise HttpError(400, str(PayerNotFound)) + except Exception as exc: + logger.exception(exc) + raise HttpError(400, f'{exc}') + return 200 + + +@router.post('revoke-recurring-payment', tags=['payments/revoke-recurring-payment']) +def revoke_recurring_payment(request): + deleted_count, deleted_details = PaymentMethod.objects.filter(user_plan_info__user=request.auth).delete() + logger.info( + 'Recurring payment revoked by user: email=%s deleted_methods=%s', + request.auth.email, + deleted_count, + ) + if deleted_count == 0: + raise HttpError(400, _('You do not have an active subscription to cancel')) + return 200, {'detail': _('The recurring payment is successfully cancelled')} + + @router.get('expenses', tags=['payments/expenses'], response=list[ExpensesSchema]) def list_expenses(request, data: ExpensesParamsSchema = Query(...)): try: @@ -89,7 +140,7 @@ def list_expenses(request, data: ExpensesParamsSchema = Query(...)): ), amount=Round(Sum('cost')), ) - case SourceStrategyEnum.DAYS.BUDGET: + case SourceStrategyEnum.BUDGET: expenses = { 'source': _('Expenses'), 'amount': qs.aggregate(amount=Round(Sum('cost')))['amount'], @@ -144,7 +195,6 @@ async def list_payment_plans(request): uid=plan.uid, price=plan.price, tokens_per_plan=plan.tokens_per_plan, - duration=plan.duration, is_corporate=plan.is_corporate, points=plan.points, grouped_features=[{'name': cat, 'features': feats} for cat, feats in grouped.items()], @@ -164,16 +214,37 @@ async def create_payment_link(request, body: NewSubscriptionSchema): raise HttpError(401, 'Unauthorized') payment_plan = await PaymentPlan.objects.aget(uid=body.uid) payment_url = await PaymentService(request.auth).create_payment_link(payment_plan) + logger.info( + 'Payment link endpoint completed: email=%s plan_uid=%s', + request.auth.email, + payment_plan.uid, + ) return PaymentLinkSchema(payment_url=payment_url) except Exception as exc: raise HttpError(400, f'{exc}') -@router.delete('plans', tags=['payments/plans'], auth=AsyncAuthBearer(), response={200: None, 400: str}) -async def cancel_plan_subscription(request): +@router.post('gitlab-webhook', tags=['payments/gitlab-webhook'], auth=None) +async def handle_gitlab_webhook(request): try: - if request.auth.account_type not in {'regular', 'business_host'}: - raise HttpError(401, 'Unauthorized') - await PaymentPlanService(request.auth).cancel_payment_plan() + data = orjson.loads(request.body)['object_attributes'] + if data['name'] == 'recurring_payments': + is_active = data['active'] + if not is_active: + deleted_methods_count, deleted_details = await PaymentMethod.objects.all().adelete() + logger.info( + 'Recurring feature disabled: all payment methods removed count=%s', + deleted_methods_count, + ) + updated_count = await PaymentPlanUserInfo.objects.filter( + plan__price__gt=0, + plan__individual=False, + ).aupdate(next_payment_at=None if not is_active else (timezone.now() + relativedelta(months=1))) + logger.info( + 'Recurring feature flag synced: active=%s updated_subscriptions=%s', + is_active, + updated_count, + ) except Exception as exc: - raise HttpError(400, f'{exc}') + logger.error(exc) + return 200 @@ -1,31 +0,0 @@ -from uuid import UUID - -from authentication.models.user import CustomUserModel -from payments.models.user_payment_method import UserPaymentMethod -from payments.serializers import PaymentMethodSerializer - - -class PaymentMethodSelector: - def __init__(self, user: CustomUserModel): - self.user = user - - def list(self, serialize: bool = False): - methods = self.user.payment_methods.all() - if serialize: - return PaymentMethodSerializer(methods, many=True) - return methods - - def get_payment_method_by_uuid(self, method_id: UUID) -> UserPaymentMethod | None: - payment = UserPaymentMethod.objects.filter(user=self.user, payment_method_id=method_id) - - if not payment.exists(): - return None - - return payment.first() - - def get_current_active_method(self) -> UserPaymentMethod: - method = UserPaymentMethod.objects.filter(user=self.user, currently_active=True) - if not method.exists(): - raise Exception(f'No active payment method for user {self.user}') - - return method.first() @@ -18,15 +18,17 @@ class PaymentPlanSelector: if ( self.user.account_type in ('business_account', 'business_admin', 'business_security') ) and self.user.business_account.acceptance_status == InvitationStatus.ACCEPTED: - balance = ( + pp = self.user.business_account.parent_company.user.payment_plan + total_available = pp.current_token_balance + pp.referral_balance + limit = ( self.user.business_account.group.token_limit if self.user.business_account.group else self.user.business_account.token_limit ) - if balance is None: - balance = self.user.business_account.parent_company.user.payment_plan.current_token_balance + balance = min(total_available, limit) if limit is not None else total_available else: - balance = self.user.payment_plan.current_token_balance + pp = self.user.payment_plan + balance = pp.current_token_balance + pp.referral_balance return balance @@ -43,8 +45,8 @@ class PaymentPlanSelector: return UserPaymentPlanSerializer(plan) - def get_free_plan(self, corporate: bool = False, recurrent: bool = False) -> PaymentPlan: - return PaymentPlan.objects.get_or_create(price=0, is_corporate=corporate, is_recurrent=recurrent)[0] + def get_free_plan(self, corporate: bool = False) -> PaymentPlan: + return PaymentPlan.objects.get_or_create(price=0, is_corporate=corporate)[0] def is_plan_paid(self) -> bool: return self.user.payment_plan.plan.price != Decimal('0') @@ -1,21 +0,0 @@ -from typing import Type, TypeVar -from uuid import UUID - -from django_celery_beat.models import PeriodicTask - -Self = TypeVar('Self', bound='RecurrentPaymentSelector') - - -class RecurrentPaymentSelector: - def __init__(self, task: PeriodicTask): - self.task = task - - @classmethod - def from_user_id(cls: Type[Self], user_id: UUID) -> Self | None: - task = PeriodicTask.objects.filter(name=f'recurrent_{user_id}').first() - if task is None: - return None - return cls(task) - - def to_service(self, service): - return service(self.task) @@ -30,13 +30,20 @@ class ModelBillingService: else: raise Exception(_('Unknown account type')) - if plan.current_token_balance < amount: - raise InsufficientBalance(plan.current_token_balance, amount) + total_available = plan.current_token_balance + plan.referral_balance + if total_available < amount: + raise InsufficientBalance(total_available, amount) - if (allowance is not None) and (allowance < amount): + if allowance is not None and allowance < amount: raise InsufficientBalance(allowance, amount) - plan.current_token_balance -= amount + remainder = amount + from_main = min(plan.current_token_balance, remainder) + plan.current_token_balance -= from_main + remainder -= from_main + if remainder > 0: + plan.referral_balance -= remainder + if allowance is not None: if self.user.business_account.group is not None: self.user.business_account.group.token_limit -= amount @@ -1,69 +1,34 @@ import logging from uuid import UUID -from django.db.transaction import atomic -from django.utils.translation import gettext_lazy as _ -from rest_framework.request import Request +from authentication.models import CustomUserModel +from payments.models.user_payment_method import PaymentMethod -from authentication.models.user import CustomUserModel -from payments.models.user_payment_method import UserPaymentMethod -from payments.selectors.payment_method_selector import PaymentMethodSelector -from payments.serializers import ResetPaymentMethodSerializer logger = logging.getLogger(__name__) class PaymentMethodService: - def __init__(self, user: CustomUserModel): + def __init__(self, user: CustomUserModel) -> None: self.user = user def add_payment_method(self, method_id: UUID, card_type: str, last_four: str): - logger.info('START %s', self.add_payment_method.__name__) - existing = PaymentMethodSelector(self.user).get_payment_method_by_uuid(method_id) - logger.info('CURRENT PAYMENT METHOD: %s', existing) - if existing is not None: - return - - payment_method, created = UserPaymentMethod.objects.get_or_create( - user=self.user, - last_four=last_four, - payment_method_id=method_id, - card_type=card_type, - currently_active=True, + payment_method, created = PaymentMethod.objects.update_or_create( + user_plan_info__user=self.user, + defaults={'payment_method_id': method_id, 'last_four': last_four, 'card_type': card_type, 'attempts': 0}, ) - logger.info('NEW PAYMENT METHOD: %s', payment_method) - payment_method.save() - - def delete_payment_method(self, method_id: UUID): - existing = PaymentMethodSelector(self.user).get_payment_method_by_uuid(method_id) - if existing is not None: - existing.delete() - - def unset_payment_method(self): - current_active = PaymentMethodSelector(self.user).get_current_active_method() - - if current_active is None: - raise Exception(_('No current active payment method is set')) - - current_active.is_active = False - current_active.save() - - def set_new_payment_method(self, id: UUID): - method = PaymentMethodSelector(self.user).get_payment_method_by_uuid(id) - - if method is None: - raise Exception(_('No payment method by this id')) - - method.is_active = True - method.save() - - @atomic - def change_methods(self, id: UUID): - self.unset_payment_method() - self.set_new_payment_method(id) - - def update(self, request: Request): - serializer = ResetPaymentMethodSerializer(data=request.data) - serializer.is_valid(raise_exception=True) + logger.info( + 'Payment method saved: email=%s method_uid=%s payment_method_id=%s created=%s', + self.user.email, + payment_method.uid, + method_id, + created, + ) + return payment_method - self.change_methods(serializer.validated_data['uid']) + def delete_payment_method(self): + PaymentMethod.objects.filter(user_plan_info__user=self.user).delete() + logger.info( + 'Payment method deleted: email=%s', + self.user.email, + ) @@ -1,19 +1,10 @@ import logging from decimal import Decimal -from asgiref.sync import sync_to_async -from rest_framework.request import Request - from authentication.models import CustomUserModel from payments.models import Invoice, PaymentPlan, PaymentPlanUserInfo -from payments.selectors.payment_plan_selector import PaymentPlanSelector -from payments.selectors.recurrent_payment_selector import ( - RecurrentPaymentSelector, -) -from payments.serializers import SuccessPaymentResult from payments.services.model_billing_service import ModelBillingService from payments.services.payment_service import PaymentService -from payments.services.recurrent_payment_service import RecurrentPaymentService logger = logging.getLogger(__name__) @@ -26,46 +17,28 @@ class PaymentPlanService: def add_tokens(self, amount: float | Decimal): self.user.payment_plan.current_token_balance += amount self.user.payment_plan.save() + logger.info( + 'Tokens added to plan balance: email=%s amount=%s new_balance=%s', + self.user.email, + amount, + self.user.payment_plan.current_token_balance, + ) - @classmethod - def handle_success_payment(cls, request: Request): - """Parse Yookassa Successful Payment Request and - Subscribe user to paid Payment Plan""" - serializer = SuccessPaymentResult(data=request.data['object']) - serializer.is_valid(raise_exception=True) - payment_id = serializer.validated_data['id'] - payment = PaymentService.confirm_payment(payment_id) - if payment.status == payment.SUCCEEDED: - cls(payment.user).subscribe_user_to_plan(payment.plan) - - def handle_recurrent(self, plan: PaymentPlanUserInfo): - current = RecurrentPaymentSelector.from_user_id(self.user.uid) - if current is None: - task = RecurrentPaymentService.create(self.user, plan.plan) - plan.plan_schedule = task.task - plan.save() - else: - current.to_service(RecurrentPaymentService).switch_plan(self.user, plan.plan.uid) - - def subscribe_user_to_plan(self, plan: PaymentPlan): - plan_info, created = PaymentPlanUserInfo.objects.get_or_create( + def subscribe_user_to_plan(self, plan: PaymentPlan, amount: Decimal): + _, created = PaymentPlanUserInfo.objects.update_or_create( user=self.user, defaults={ 'plan': plan, - 'current_token_balance': plan.tokens_per_plan, + 'current_token_balance': amount, }, ) - if not created: - plan_info.plan = plan - plan_info.current_token_balance += plan.tokens_per_plan - plan_info.save() - - async def cancel_payment_plan(self): - plan_info: PaymentPlanUserInfo = self.user.payment_plan - plan_info.plan = await sync_to_async(PaymentPlanSelector(self.user).get_free_plan)( - corporate=self.user.is_corporate() + logger.info( + 'User subscribed to plan: email=%s plan_uid=%s tokens=%s created=%s', + self.user.email, + plan.uid, + amount, + created, ) - await plan_info.asave() def update_per_token_plan_details(self, payment_amount: Decimal, model=None): ModelBillingService(self.user).charge(payment_amount) @@ -1,18 +1,22 @@ import logging + +from decimal import Decimal from uuid import UUID, uuid4 +from asgiref.sync import sync_to_async +from dateutil.relativedelta import relativedelta from django.conf import settings +from django.db import transaction +from django.utils import timezone from yookassa import Configuration from yookassa import Payment as YookassaPayment -from yookassa.domain.models.payment_data.response.payment_data_bank_card import ( - PaymentDataBankCard, -) +from yookassa.domain.response import PaymentResponse as YookassaPaymentResponse from authentication.models import CustomUserModel -from payments.exceptions.payer_not_found import PayerNotFound +from lib.unleash.client import web_client +from payments.exceptions.full_balance import FullBalanceException from payments.models.payment import Payment as PaymentModel -from payments.models.payment_plan import PaymentPlan -from payments.selectors.payment_method_selector import PaymentMethodSelector +from payments.models.payment_plan import PaymentPlan, PaymentPlanUserInfo from payments.services.payment_method_service import PaymentMethodService from payments.services.referral_account import ReferralAccountService @@ -31,13 +35,18 @@ class PaymentService: 'customer': {'email': self.user.email}, 'items': [ { - 'description': f'План {plan.tokens_per_plan} токенов за {plan.price} р.', + 'description': str(plan), 'amount': {'value': f'{plan.price}', 'currency': 'RUB'}, 'vat_code': 1, 'quantity': '1', } ], } + is_recurring = ( + web_client.get_flag_state('recurring_payments', self.user.email) + and not plan.individual + and web_client.get_flag_state('auto-save-payments-enabled', self.user.email) + ) payment_data = { 'amount': {'value': f'{plan.price}', 'currency': 'RUB'}, 'payment_method_data': {'type': 'bank_card'}, @@ -48,71 +57,146 @@ class PaymentService: }, 'description': str(self.user.uid), 'capture': True, + 'save_payment_method': is_recurring, + 'metadata': {'plan_uid': str(plan.uid)}, } - - if plan.is_recurrent: - payment_data.update(save_payment_method=True) payment = YookassaPayment.create(payment_data, uuid4()) + logger.info( + 'Payment link created: email=%s plan_uid=%s price=%s recurring=%s', + self.user.email, + plan.uid, + plan.price, + is_recurring, + ) return payment.confirmation.confirmation_url - @classmethod - def save_payment_method(cls, user: CustomUserModel, method_data: PaymentDataBankCard): - method_id = method_data.id - card = method_data.card - card_type = card.card_type - - last_four = card.last4 - PaymentMethodService(user).add_payment_method( - method_id=UUID(method_id), card_type=card_type, last_four=last_four - ) + @sync_to_async + def do_payment(self, payment: YookassaPaymentResponse) -> PaymentModel: + from payments.services.payment_plan_service import PaymentPlanService - @classmethod - def confirm_payment(cls, payment_id: UUID): - payment = YookassaPayment.find_one(str(payment_id)) - try: - user = CustomUserModel.objects.get(uid=payment.description) - except CustomUserModel.DoesNotExist: - raise PayerNotFound - if payment.status == 'waiting_for_capture': - YookassaPayment.capture(str(payment_id)) - elif payment.status == 'succeeded': - method_data = payment.payment_method - if method_data.saved: - cls.save_payment_method(user, method_data) - payment_instance = cls.save_payment(user=user, payment=payment) - try: - if user.referer_account: - ReferralAccountService.apply_accrual( - referer_account=user.referer_account, - payment=payment_instance, + with transaction.atomic(): + payment_instance = self.save_payment(payment) + logger.info( + 'Processing payment webhook: payment_id=%s email=%s status=%s', + payment.id, + self.user.email, + payment.status, + ) + if payment.status == 'waiting_for_capture': + self.handle_captured_payment(payment.id) + elif payment.status == 'succeeded': + buying_tokens = ( + payment_instance.plan.tokens_per_plan + if payment.metadata.get('recurring') + else self.calculate_buying_tokens(payment_instance.plan) ) - except Exception as exc: - logger.exception(exc) + self.handle_succeeded_payment(payment, payment_instance.plan) + PaymentPlanService(self.user).subscribe_user_to_plan(payment_instance.plan, buying_tokens) + if ref_acc := self.user.referer_account: + ReferralAccountService.apply_accrual(referer_account=ref_acc, payment=payment_instance) + elif payment.status == 'canceled' and payment.metadata.get('recurring'): + self.handle_canceled_payment(payment) + return payment_instance @classmethod - def save_payment(cls, user: CustomUserModel, payment: YookassaPayment) -> PaymentModel: - payment_instance, _ = PaymentModel.objects.update_or_create( + async def handle_payment(cls, payment_id: UUID) -> YookassaPaymentResponse: + return await sync_to_async(YookassaPayment.find_one)(payment_id) + + def handle_captured_payment(self, payment_id: UUID) -> None: + YookassaPayment.capture(str(payment_id)) + logger.info('Payment captured: payment_id=%s email=%s', payment_id, self.user.email) + + def calculate_buying_tokens(self, plan: PaymentPlan): + if self.user.payment_plan.plan.price == Decimal('0'): + return plan.tokens_per_plan + return self.user.payment_plan.current_token_balance + plan.tokens_per_plan + + def handle_succeeded_payment(self, payment: YookassaPaymentResponse, plan: PaymentPlan) -> None: + if ( + payment.payment_method.saved + and web_client.get_flag_state('recurring_payments', self.user.email) + and not plan.individual + ): + card = payment.payment_method.card + payment_method = PaymentMethodService(self.user).add_payment_method( + payment.payment_method.id, card.card_type, card.last4 + ) + PaymentPlanUserInfo.objects.filter(user=self.user).update( + method=payment_method, next_payment_at=timezone.now() + relativedelta(months=1) + ) + logger.info( + 'Recurring payment method saved: email=%s method_uid=%s next_payment_at_set=true', + self.user.email, + payment_method.uid, + ) + else: + if web_client.get_flag_state('recurring_payments', self.user.email) and not plan.individual: + PaymentPlanUserInfo.objects.filter(user=self.user).update( + next_payment_at=timezone.now() + relativedelta(months=1) + ) + logger.info( + 'Recurring schedule updated without saved method: email=%s next_payment_at_set=true', + self.user.email, + ) + else: + PaymentPlanUserInfo.objects.filter(user=self.user).update(next_payment_at=None) + logger.info( + 'Recurring schedule cleared: email=%s reason=feature_disabled_or_individual_plan', + self.user.email, + ) + PaymentMethodService(self.user).delete_payment_method() + logger.info( + 'Recurring payment method deleted after succeeded payment: email=%s', self.user.email + ) + + def handle_canceled_payment(self, payment: YookassaPaymentResponse) -> None: + logger.error(f'Recurrent payment error: {payment.cancellation_details.reason}') + temporary_cancel_reasons = ( + 'call_issuer', + 'expired_on_capture', + 'insufficient_funds', + 'internal_timeout', + 'issuer_unavailable', + 'payment_method_limit_exceeded', + ) + if payment.cancellation_details.reason in temporary_cancel_reasons: + self.user.payment_plan.method.attempts += 1 + self.user.payment_plan.method.save() + logger.info( + 'Recurring payment canceled with retry: email=%s method_uid=%s attempts=%s reason=%s', + self.user.email, + self.user.payment_plan.method.uid, + self.user.payment_plan.method.attempts, + payment.cancellation_details.reason, + ) + else: + PaymentMethodService(self.user).delete_payment_method() + logger.info( + 'Recurring payment method deleted after cancel: email=%s reason=%s', + self.user.email, + payment.cancellation_details.reason, + ) + + def save_payment(self, payment: YookassaPaymentResponse) -> PaymentModel: + plan = PaymentPlan.objects.get_or_none(uid=payment.metadata.get('plan_uid')) + payment_instance, created = PaymentModel.objects.update_or_create( uid=payment.id, defaults=dict( - user=user, + user=self.user, amount=payment.amount.value, - plan=PaymentPlan.objects.get_or_none(price=payment.amount.value), + plan=plan, status=payment.status, description=payment.description, ), ) - return payment_instance - - def conduct_recurring_payment(self, plan: PaymentPlan, description: str): - logger.info('START %s' % self.conduct_recurring_payment.__name__) - payment_method = PaymentMethodSelector(self.user).get_current_active_method() - logger.info('PAYMENT METHOD: %s' % payment_method) - YookassaPayment.create( - { - 'amount': {'value': f'{plan.price}', 'currency': 'RUB'}, - 'capture': True, - 'payment_method_id': f'{payment_method.payment_method_id}', - 'description': description, - } + logger.info( + 'Payment persisted: payment_id=%s email=%s status=%s created=%s plan_uid=%s amount=%s', + payment.id, + self.user.email, + payment.status, + created, + plan.uid if plan else None, + payment.amount.value, ) + return payment_instance @@ -1,53 +0,0 @@ -import json -import logging -from typing import Type, TypeVar - -from django.conf import settings -from django.utils import timezone -from django_celery_beat.models import IntervalSchedule, PeriodicTask - -from authentication.models.user import CustomUserModel -from payments.models import PaymentPlan - -Self = TypeVar('Self', bound='RecurrentPaymentService') -logger = logging.getLogger(__name__) - - -class RecurrentPaymentService: - def __init__(self, task: PeriodicTask): - self.task = task - - @classmethod - def create(cls: Type[Self], user: CustomUserModel, payment_plan: PaymentPlan) -> Self: - subscription_days_duration = ( - 30 - if payment_plan.duration == payment_plan.MONTH - else 365 - if payment_plan.duration == payment_plan.YEAR - else 30 - ) - schedule = IntervalSchedule.objects.filter( - every=subscription_days_duration, period=settings.RECURRENT_RATE - ) - - if not schedule.exists(): - schedule = IntervalSchedule.objects.create(every=30, period=settings.RECURRENT_RATE) - else: - schedule = schedule.first() - - task = PeriodicTask.objects.create( - name=f'recurrent_{user.uid}', - task='conduct_recurrent', - interval=schedule, - args=json.dumps([str(payment_plan.uid), str(user.uid)]), - start_time=timezone.now(), - ) - return cls(task) - - def switch_plan(self, user: CustomUserModel, new_plan: PaymentPlan) -> Self: - self.delete() - self.create(user=user, payment_plan=new_plan) - return self - - def delete(self): - self.task.delete() @@ -1,3 +1,4 @@ +import logging from decimal import Decimal from authentication.models.user import CustomUserModel @@ -8,6 +9,8 @@ from payments.models.referral_account import ( ReferralInvite, ) +logger = logging.getLogger(__name__) + class ReferralAccountService: @classmethod @@ -15,17 +18,26 @@ class ReferralAccountService: return ReferralAccount.objects.create(owner=user) @classmethod - def create_invite(cls, referer_account: CustomUserModel, invitee: CustomUserModel): + def create_invite(cls, referer_account: ReferralAccount, invitee: CustomUserModel): return ReferralInvite.objects.create(referer_account=referer_account, invitee=invitee) @classmethod def apply_accrual(cls, referer_account: ReferralAccount, payment: Payment): - accrual_amount = (payment.plan.tokens_per_plan * Decimal(0.2)).quantize(Decimal('1')) - accrual = ReferralAccrual.objects.create( - referer_account=referer_account, - payment=payment, - amount=accrual_amount, - ) - referer_account.owner.payment_plan.current_token_balance += accrual_amount - referer_account.owner.payment_plan.save() - return accrual + if not ReferralAccrual.objects.filter( + referer_account=referer_account, payment__user=payment.user + ).first(): + accrual_amount = (payment.plan.tokens_per_plan * Decimal(0.2)).quantize(Decimal('1')) + ReferralAccrual.objects.create( + referer_account=referer_account, + payment=payment, + amount=accrual_amount, + ) + pp = referer_account.owner.payment_plan + pp.referral_balance += accrual_amount + pp.save(update_fields=['referral_balance']) + logger.info( + 'Referral accrual applied: referer_email=%s invitee_email=%s amount=%s', + referer_account.owner.email, + payment.user.email if payment.user else None, + accrual_amount, + ) @@ -0,0 +1,12 @@ + + + + + Отмена подписки на платформе AIR + + +

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

+

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

+ + \ No newline at end of file @@ -129,11 +129,11 @@ class PlansAPITest(BaseAuthorizedAPITest): 'uid', 'price', 'tokens_per_plan', - 'duration', + 'is_corporate', 'points', 'grouped_features', 'accessed_models', - 'individual' + 'individual', ], ) @@ -46,9 +46,21 @@ class BalanceAPITest(BaseAuthorizedAPITest): balance = self.get().json()['current_token_balance'] self.assertEqual(Decimal(balance), Decimal('8.00')) + def test_balance_includes_referral(self) -> None: + pp = self.user.payment_plan + pp.current_token_balance = Decimal('3') + pp.referral_balance = Decimal('7') + pp.save() + balance = self.get().json()['current_token_balance'] + self.assertEqual(Decimal(balance), Decimal('10')) + def test_displaying_balance(self) -> None: balance = self.get().json()['current_token_balance'] self.assertEqual(Decimal(balance), Decimal('10.00')) + hp = self.host_user.payment_plan + hp.current_token_balance = Decimal('900') + hp.referral_balance = Decimal('0') + hp.save() business_account = BusinessAccount.objects.create( user=self.user, parent_company=self.host, acceptance_status='accepted', token_limit=Decimal('50') ) @@ -16,7 +16,7 @@ from payments.models import ( PaymentPlanUserInfo, PromoCode, PromoCodeActivation, - UserPaymentMethod, + PaymentMethod, ) from payments.models.referral_account import ReferralAccount, ReferralInvite @@ -47,9 +47,6 @@ class PaymentPlanAdmin(OrderedInlineModelAdminMixin, admin.ModelAdmin): '__str__', 'uid', 'price', - 'tokens_per_plan', - 'duration', - 'is_recurrent', 'is_corporate', 'individual', 'is_visible', @@ -59,7 +56,7 @@ class PaymentPlanAdmin(OrderedInlineModelAdminMixin, admin.ModelAdmin): @admin.register(PaymentPlanUserInfo) class PaymentPlanUserInfoAdmin(admin.ModelAdmin): - list_display = ['user', 'current_token_balance', 'plan', 'updated_at'] + list_display = ['user', 'current_token_balance', 'referral_balance', 'plan', 'updated_at', 'next_payment_at'] raw_id_fields = ['user'] search_fields = [ @@ -76,9 +73,9 @@ class PaymentPlanFeatureAdmin(OrderedModelAdmin): list_filter = ('plan', 'model__category') -@admin.register(UserPaymentMethod) -class UserPaymentMethodAdmin(admin.ModelAdmin): - raw_id_fields = ['user'] +@admin.register(PaymentMethod) +class PaymentMethodAdmin(admin.ModelAdmin): + list_display = ['payment_method_id', 'card_type', 'last_four', 'attempts'] @admin.register(Invoice) @@ -224,3 +221,4 @@ class ReferralAccountAdmin(admin.ModelAdmin): @admin.display(description='Получено бонусов') def _accrued_bonuses(self, obj: ReferralAccount): return f'{obj.accrued_bonuses.aggregate(total=Coalesce(Sum("amount"), Decimal(0), output_field=models.DecimalField()))["total"]} токенов' + @@ -2,6 +2,8 @@ from django.apps import AppConfig from django.core.signals import setting_changed from django.utils.translation import gettext_lazy as _ +from lib.unleash.client import web_client + class PaymentsConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' @@ -9,8 +11,9 @@ class PaymentsConfig(AppConfig): verbose_name = _('Payments') def ready(self): - from .signals import init_referral_account + import payments.signals - setting_changed.connect(init_referral_account) + web_client.client.initialize_client() + setting_changed.connect(payments.signals.init_referral_account) return super().ready() @@ -1,4 +1,4 @@ -from datetime import date +from datetime import date, datetime from typing import List, Optional from uuid import UUID @@ -24,7 +24,6 @@ class PaymentPlanSchema(Schema): uid: UUID price: condecimal(max_digits=10, decimal_places=2) tokens_per_plan: condecimal(max_digits=10, decimal_places=2) - duration: str is_corporate: bool points: list[str] grouped_features: list[GroupedPlanFeatureSchema] = [] @@ -47,8 +46,9 @@ class PaymentLinkSchema(Schema): class UserPlanDetailSchema(Schema): uid: UUID plan: PaymentPlanSchema - last_payment_at: date - next_payment_at: date + last_payment_at: datetime + next_payment_at: datetime | None + is_recurring: bool class PromoCodeSchema(ModelSchema): @@ -66,14 +66,16 @@ class ExpensesParamsSchema(Schema): source_strategy: SourceStrategyEnum | None = None start: Optional[date] = Query( default=date.min, - example="2025-12-17", + example='2025-12-17', ) end: Optional[date] = Query( default=date.min, - example="2025-12-07", + example='2025-12-07', ) class ExpensesSchema(Schema): source: str - amount: condecimal(max_digits=10, decimal_places=2) \ No newline at end of file + amount: condecimal(max_digits=10, decimal_places=2) + + @@ -9,7 +9,6 @@ class PaymentPlanSerializer(serializers.Serializer): uid = serializers.UUIDField() price = serializers.DecimalField(max_digits=10, decimal_places=2) tokens_per_plan = serializers.DecimalField(max_digits=50, decimal_places=2) - duration = serializers.CharField(read_only=True) is_corporate = serializers.BooleanField() accessed_models = serializers.SerializerMethodField() individual = serializers.BooleanField() @@ -49,30 +48,10 @@ class UserPlanDetailSerializer(serializers.Serializer): uid = serializers.UUIDField() plan = PaymentPlanSerializer() last_payment_at = serializers.DateField() - next_payment_at = serializers.DateField() + next_payment_at = serializers.DateTimeField(allow_null=True) current_token_balance = serializers.IntegerField() -class SuccessPaymentResult(serializers.Serializer): - id = serializers.UUIDField() - - -class PaymentMethodSerializer(serializers.Serializer): - uid = serializers.UUIDField() - currently_active = serializers.BooleanField() - payment_method_id = serializers.UUIDField() - card_type = serializers.CharField() - last_four = serializers.CharField() - - -class ResetPaymentMethodSerializer(serializers.Serializer): - uid = serializers.UUIDField() - - -class DeletePaymentMethodSerializer(serializers.Serializer): - uid = serializers.UUIDField() - - class UserPaymentPlanSerializer(serializers.Serializer): current_token_balance = serializers.DecimalField(max_digits=50, decimal_places=2) @@ -1,11 +1,18 @@ +import logging from typing import Type -from django.db.models.signals import post_save +from django.conf import settings +from django.db import transaction +from django.db.models.signals import post_save, pre_save, pre_delete from django.dispatch import receiver from authentication.models.user import CustomUserModel +from authentication.services.email_service import EmailService +from payments.models import PaymentPlan, PaymentMethod, PaymentPlanUserInfo from payments.services.referral_account import ReferralAccountService +logger = logging.getLogger(__name__) + @receiver(post_save, sender=CustomUserModel) def init_referral_account( @@ -16,3 +23,58 @@ def init_referral_account( ): if created: ReferralAccountService.create_account(user=instance) + + +@receiver(post_save, sender=PaymentPlan) +def delete_recurrent_for_individual_plans( + sender: Type[PaymentPlan], instance: PaymentPlan, created: bool, **kwargs +): + if instance.individual: + PaymentPlanUserInfo.objects.filter(plan=instance).update(next_payment_at=None) + PaymentMethod.objects.filter(user_plan_info__plan=instance).delete() + + +@receiver(post_save, sender=PaymentMethod) +def delete_method_with_exceeded_attempts( + sender: Type[PaymentMethod], instance: PaymentMethod, created: bool, **kwargs +): + if instance.attempts >= settings.MAX_RECURRING_ATTEMPTS: + user_email = ( + PaymentPlanUserInfo.objects.filter(method=instance).values_list('user__email', flat=True).first() + ) + logger.info( + 'Payment method deleted due to attempts limit: email=%s', + user_email, + ) + instance.delete() + + +@receiver(post_save, sender=PaymentPlanUserInfo) +def clear_recurrent_on_individual_plan_assignment( + sender: Type[PaymentPlanUserInfo], instance: PaymentPlanUserInfo, created: bool, **kwargs +): + if not instance.plan.individual: + return + + method_id = instance.method_id + PaymentPlanUserInfo.objects.filter(pk=instance.pk).update(next_payment_at=None, method=None) + + if method_id: + PaymentMethod.objects.filter(pk=method_id).delete() + + +@receiver(pre_delete, sender=PaymentMethod) +def send_revoke_email_on_method_delete(sender: Type[PaymentMethod], instance: PaymentMethod, **kwargs): + email = ( + PaymentPlanUserInfo.objects.filter(method_id=instance.pk) + .values_list('user__email', flat=True) + .first() + ) + + def _send(): + try: + EmailService.send_revoke_recurring_email(email) + except Exception: + logger.exception('Failed to send revoke recurring email') + + transaction.on_commit(_send) @@ -1,15 +1,21 @@ from decimal import Decimal -from uuid import UUID +from uuid import UUID, uuid4 from celery import shared_task from celery.utils.log import get_task_logger from django.db.models import F +from django.utils import timezone from authentication.models.business_host import BusinessUserHost from authentication.models.user import CustomUserModel from authentication.services.email_service import EmailService +from lib.unleash.client import celery_client +from payments.models import PaymentPlanUserInfo, PaymentMethod +from payments.selectors.payment_plan_selector import PaymentPlanSelector from payments.services.payment_plan_service import PaymentPlanService +from yookassa import Payment as YookassaPayment + logger = get_task_logger(__name__) @@ -17,7 +23,8 @@ logger = get_task_logger(__name__) def send_low_balance_message(): hosts = BusinessUserHost.objects.filter( token_cap_enabled=True, - token_cap__gt=F('user__payment_plan__current_token_balance'), + token_cap__gt=F('user__payment_plan__current_token_balance') + + F('user__payment_plan__referral_balance'), ) for host in hosts: EmailService.send_low_balance_email(host) @@ -27,3 +34,80 @@ def send_low_balance_message(): def withdraw(user_id: UUID, amount: Decimal): user = CustomUserModel.objects.get(uid=user_id) PaymentPlanService(user).update_per_token_plan_details(amount) + + +@shared_task +def execute_recurring_payments() -> None: + overdue_payments = PaymentPlanUserInfo.objects.select_related('user', 'plan', 'method').filter( + next_payment_at__isnull=False, + next_payment_at__lte=timezone.now(), + plan__price__gt=0, + plan__individual=False, + ) + canceled_recurring_payments = [] + logger.info('Recurring payments task started: overdue_count=%s', overdue_payments.count()) + for overdue_payment in overdue_payments: + customer = overdue_payment.user + plan = overdue_payment.plan + if not celery_client.get_flag_state('recurring_payments', overdue_payment.user.email): + overdue_payment.next_payment_at = None + canceled_recurring_payments.append(overdue_payment) + logger.info( + 'Recurring payment canceled by feature flag: email=%s plan_uid=%s', + customer.email, + plan.uid, + ) + continue + if not overdue_payment.is_recurring: + free_plan = PaymentPlanSelector(customer).get_free_plan(plan.is_corporate) + overdue_payment.next_payment_at = None + overdue_payment.plan = free_plan + overdue_payment.current_token_balance = 0 + canceled_recurring_payments.append(overdue_payment) + logger.info( + 'Recurring payment canceled due to missing method: email=%s plan_uid=%s switched_to_free_plan_uid=%s', + customer.email, + plan.uid, + free_plan.uid, + ) + continue + receipt_data = { + 'customer': {'email': customer.email}, + 'items': [ + { + 'description': str(plan), + 'amount': {'value': f'{plan.price}', 'currency': 'RUB'}, + 'vat_code': 1, + 'quantity': '1', + } + ], + } + payment_data = { + 'amount': {'value': f'{plan.price}', 'currency': 'RUB'}, + 'payment_method_id': overdue_payment.method.payment_method_id, + 'receipt': receipt_data, + 'description': str(customer.uid), + 'capture': True, + 'metadata': { + 'recurring': True, + 'plan_uid': str(plan.uid), + }, + } + YookassaPayment.create(payment_data, uuid4()) + logger.info( + 'Recurring payment initiated: email=%s plan_uid=%s amount=%s method_uid=%s', + customer.email, + plan.uid, + plan.price, + overdue_payment.method.uid, + ) + methods_for_delete = [crp.method.uid for crp in canceled_recurring_payments if crp.method] + PaymentPlanUserInfo.objects.bulk_update( + canceled_recurring_payments, fields=['next_payment_at', 'plan', 'current_token_balance'] + ) + deleted_methods_count, deleted_details = PaymentMethod.objects.filter(uid__in=methods_for_delete).delete() + logger.info( + 'Recurring payments task finished: canceled_count=%s deleted_methods=%s', + len(canceled_recurring_payments), + deleted_methods_count, + ) \ No newline at end of file @@ -10,16 +10,6 @@ urlpatterns = [ name='referral-account', ), path('invoices', views.InvoicesAPIView.as_view(), name='invoices'), - path( - 'methods', - views.PaymentMethodsAPIView.as_view(), - name='payment-methods', - ), - path( - 'payment-result', - views.PaymentConfirmationAPIView.as_view(), - name='payment-result', - ), path('promocode', views.PromoCodeAPIView.as_view(), name='promocode'), path( 'telegram-sub', @@ -15,20 +15,15 @@ from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView -from authentication.permissions import IsAnonymous, IsTelegramAirBot +from authentication.permissions import IsTelegramAirBot from authentication.selectors.user_selector import UserSelector -from payments.exceptions.payer_not_found import PayerNotFound from payments.models import Invoice from payments.permissions import IsAllowedToPay -from payments.selectors.payment_method_selector import PaymentMethodSelector -from payments.selectors.payment_plan_selector import PaymentPlanSelector from payments.selectors.payment_selector import PaymentSelector from payments.serializers import ( - DeletePaymentMethodSerializer, InvoiceSerializer, ReferralAccountSerializer, ) -from payments.services.payment_method_service import PaymentMethodService from payments.services.payment_plan_service import PaymentPlanService from payments.services.promocode_service import PromoCodeService @@ -49,54 +44,6 @@ class PaymentAPIView(APIView): return Response({'detail': f'{err}'}, status=status.HTTP_400_BAD_REQUEST) -class PaymentMethodsAPIView(APIView): - permission_classes = (IsAuthenticated, IsAllowedToPay) - - def get(self, request, *args, **kwargs): - """List user's saved payment methods (cards)""" - try: - result = PaymentMethodSelector(self.request.user).list(serialize=True) - return Response(result.data, status=status.HTTP_200_OK) - except Exception as err: - logger.exception(err) - return Response({'detail': f'{err}'}, status=status.HTTP_400_BAD_REQUEST) - - def put(self, request, *args, **kwargs): - """Update payment method (card) data.""" - try: - PaymentMethodService(self.request.user).update(request) - return Response({'ok': True}, status=status.HTTP_200_OK) - except Exception as err: - logger.exception(err) - return Response({'detail': f'{err}'}, status=status.HTTP_400_BAD_REQUEST) - - def delete(self, request, *args, **kwargs): - """Delete user's payment method (card)""" - try: - serializer = DeletePaymentMethodSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - PaymentMethodService(self.request.user).delete_payment_method(**serializer.validated_data) - return Response({'ok': True}, status=status.HTTP_200_OK) - except Exception as err: - logger.exception(err) - return Response({'detail': f'{err}'}, status=status.HTTP_400_BAD_REQUEST) - - -class PaymentConfirmationAPIView(APIView): - permission_classes = (IsAnonymous,) - - def post(self, request, *args, **kwargs): - """Endpoint for payment service (Yookassa) WebHook. Hook happens after successful payment.""" - try: - PaymentPlanService.handle_success_payment(self.request) - return Response(status=status.HTTP_200_OK) - except PayerNotFound as err: - return Response({'detail': str(err)}, status=status.HTTP_400_BAD_REQUEST) - except Exception as err: - logger.exception(err) - return Response({'detail': {str(err)}}, status=status.HTTP_400_BAD_REQUEST) - - class InvoicesAPIView(ListAPIView): permission_classes = [ IsAuthenticated, @@ -13,6 +13,7 @@ from messages.serializers import MessageSerializer from ml_model.exceptions import ( CorruptedFileError, ExceededContextLengthError, + FaceNotFoundError, FileExtensionNotSupported, FileNotProvided, FileTooLargeError, @@ -196,6 +197,7 @@ class MediaAPIView(APIView): FileTooLargeError, ImageAnalysisError, UnrecognizedFileError, + FaceNotFoundError, ), ): return Response({'detail': f'{exc}'}, status=HTTP_400_BAD_REQUEST) @@ -1,7 +1,10 @@ # CORE SETTINGS SECRET_KEY=testtest DEBUG=true -STATIC_PATH_PREFIX=static/ +STATIC_PATH_PREFIX=static + +RELEASE=1.0.0 +ENVIRONMENT=dev # NEURON MODELS OPENAI_API_KEY=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 @@ -25,7 +28,7 @@ UPSCALE_MULTIPLIER_HOST=packet:8080 JWT_SECRET_KEY=testtest JWT_ACCESS_TOKEN_LIFETIME=604800 JWT_REFRESH_TOKEN_LIFETIME=604800 -ALLOWED_HOSTS=localhost +ALLOWED_HOSTS=localhost, 127.0.0.1 CSRF_TRUSTED_ORIGINS=http://localhost CORS_ALLOWED_ORIGINS=http://localhost:3000 TELEGRAM_BOT_TOKEN=None @@ -67,7 +70,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' @@ -94,4 +96,9 @@ LOG_LEVEL=debug DOMAIN=localhost 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 + COMPOSE_FILE=docker-compose.yml:docker-compose.local.yml \ No newline at end of file @@ -32,6 +32,7 @@ services: - /bin/sh - -c - | + python manage.py create_indexes python manage.py compilemessages python -m uvicorn backend.asgi:application --host 0.0.0.0 --ws wsproto --http httptools --lifespan off --log-level info networks: @@ -174,6 +174,34 @@ doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\" and python_version < \"3.14\""] trio = ["trio (>=0.26.1)"] +[[package]] +name = "apscheduler" +version = "3.11.2" +description = "In-process task scheduler with Cron-like capabilities" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "apscheduler-3.11.2-py3-none-any.whl", hash = "sha256:ce005177f741409db4e4dd40a7431b76feb856b9dd69d57e0da49d6715bfd26d"}, + {file = "apscheduler-3.11.2.tar.gz", hash = "sha256:2a9966b052ec805f020c8c4c3ae6e6a06e24b1bf19f2e11d91d8cca0473eef41"}, +] + +[package.dependencies] +tzlocal = ">=3.0" + +[package.extras] +doc = ["packaging", "sphinx", "sphinx-rtd-theme (>=1.3.0)"] +etcd = ["etcd3", "protobuf (<=3.21.0)"] +gevent = ["gevent"] +mongodb = ["pymongo (>=3.0)"] +redis = ["redis (>=3.0)"] +rethinkdb = ["rethinkdb (>=2.4.0)"] +sqlalchemy = ["sqlalchemy (>=1.4)"] +test = ["APScheduler[etcd,mongodb,redis,rethinkdb,sqlalchemy,tornado,zookeeper]", "PySide6 ; platform_python_implementation == \"CPython\" and python_version < \"3.14\"", "anyio (>=4.5.2)", "gevent ; python_version < \"3.14\"", "pytest", "pytest-timeout", "pytz", "twisted ; python_version < \"3.14\""] +tornado = ["tornado (>=4.3)"] +twisted = ["twisted"] +zookeeper = ["kazoo"] + [[package]] name = "argon2-cffi" version = "23.1.0" @@ -1512,6 +1540,21 @@ files = [ wasmer = ">=1.0.0" wasmer-compiler-cranelift = ">=1.0.0" +[[package]] +name = "fcache" +version = "0.6.0" +description = "a dictionary-like, file-based cache module for Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "fcache-0.6.0-py3-none-any.whl", hash = "sha256:dbf0753bb7400ed80d703df9ffcfb438786698872ed92c50169f95cb0eac8306"}, + {file = "fcache-0.6.0.tar.gz", hash = "sha256:79949f0aafe8cedc5c9064631b3c157941a288e59f9991dd158c23e8e60b5422"}, +] + +[package.dependencies] +platformdirs = ">=3.0,<4.0" + [[package]] name = "filetype" version = "1.2.0" @@ -2210,6 +2253,30 @@ files = [ [package.extras] all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] +[[package]] +name = "importlib-metadata" +version = "9.0.0" +description = "Read metadata from Python packages" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7"}, + {file = "importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc"}, +] + +[package.dependencies] +zipp = ">=3.20" + +[package.extras] +check = ["pytest-checkdocs (>=2.14)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=3.4)"] +perf = ["ipython"] +test = ["packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] +type = ["pytest-mypy (>=1.0.1) ; platform_python_implementation != \"PyPy\""] + [[package]] name = "incremental" version = "24.7.2" @@ -2659,6 +2726,21 @@ requests-toolbelt = ">=1.0.0,<2.0.0" [package.extras] langsmith-pyo3 = ["langsmith-pyo3 (>=0.1.0rc2,<0.2.0)"] +[[package]] +name = "launchdarkly-eventsource" +version = "1.5.1" +description = "LaunchDarkly SSE Client" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "launchdarkly_eventsource-1.5.1-py3-none-any.whl", hash = "sha256:43dfbc14a3962c9bce252320d690cdcbfda0ca00501226d517ae77e60f570d44"}, + {file = "launchdarkly_eventsource-1.5.1.tar.gz", hash = "sha256:f122f80b36db6ea1ab20af62c82b8b2668682259b415053c94400dd6c07922a7"}, +] + +[package.dependencies] +urllib3 = ">=1.26.0,<3" + [[package]] name = "lxml" version = "5.4.0" @@ -2847,6 +2929,131 @@ pycryptodome = "*" typing-extensions = "*" urllib3 = "*" +[[package]] +name = "mmh3" +version = "5.2.1" +description = "Python extension for MurmurHash (MurmurHash3), a set of fast and robust hash functions." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "mmh3-5.2.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5d87a3584093e1a89987e3d36d82c98d9621b2cb944e22a420aa1401e096758f"}, + {file = "mmh3-5.2.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:30e4d2084df019880d55f6f7bea35328d9b464ebee090baa372c096dc77556fb"}, + {file = "mmh3-5.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0bbc17250b10d3466875a40a52520a6bac3c02334ca709207648abd3c223ed5c"}, + {file = "mmh3-5.2.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:76219cd1eefb9bf4af7856e3ae563d15158efa145c0aab01e9933051a1954045"}, + {file = "mmh3-5.2.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb9d44c25244e11c8be3f12c938ca8ba8404620ef8092245d2093c6ab3df260f"}, + {file = "mmh3-5.2.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d5d542bf2abd0fd0361e8017d03f7cb5786214ceb4a40eef1539d6585d93386"}, + {file = "mmh3-5.2.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:08043f7cb1fb9467c3fbbbaea7896986e7fbc81f4d3fd9289a73d9110ab6207a"}, + {file = "mmh3-5.2.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:add7ac388d1e0bf57259afbcf9ed05621a3bf11ce5ee337e7536f1e1aaf056b0"}, + {file = "mmh3-5.2.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:41105377f6282e8297f182e393a79cfffd521dde37ace52b106373bdcd9ca5cb"}, + {file = "mmh3-5.2.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3cb61db880ec11e984348227b333259994c2c85caa775eb7875decb3768db890"}, + {file = "mmh3-5.2.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e8b5378de2b139c3a830f0209c1e91f7705919a4b3e563a10955104f5097a70a"}, + {file = "mmh3-5.2.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e904f2417f0d6f6d514f3f8b836416c360f306ddaee1f84de8eef1e722d212e5"}, + {file = "mmh3-5.2.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f1fbb0a99125b1287c6d9747f937dc66621426836d1a2d50d05aecfc81911b57"}, + {file = "mmh3-5.2.1-cp310-cp310-win32.whl", hash = "sha256:b4cce60d0223074803c9dbe0721ad3fa51dafe7d462fee4b656a1aa01ee07518"}, + {file = "mmh3-5.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:6f01f044112d43a20be2f13a11683666d87151542ad627fe41a18b9791d2802f"}, + {file = "mmh3-5.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:7501e9be34cb21e72fcfe672aafd0eee65c16ba2afa9dcb5500a587d3a0580f0"}, + {file = "mmh3-5.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:dae0f0bd7d30c0ad61b9a504e8e272cb8391eed3f1587edf933f4f6b33437450"}, + {file = "mmh3-5.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9aeaf53eaa075dd63e81512522fd180097312fb2c9f476333309184285c49ce0"}, + {file = "mmh3-5.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0634581290e6714c068f4aa24020acf7880927d1f0084fa753d9799ae9610082"}, + {file = "mmh3-5.2.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e080c0637aea036f35507e803a4778f119a9b436617694ae1c5c366805f1e997"}, + {file = "mmh3-5.2.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db0562c5f71d18596dcd45e854cf2eeba27d7543e1a3acdafb7eef728f7fe85d"}, + {file = "mmh3-5.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d9f9a3ce559a5267014b04b82956993270f63ec91765e13e9fd73daf2d2738e"}, + {file = "mmh3-5.2.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:960b1b3efa39872ac8b6cc3a556edd6fb90ed74f08c9c45e028f1005b26aa55d"}, + {file = "mmh3-5.2.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d30b650595fdbe32366b94cb14f30bb2b625e512bd4e1df00611f99dc5c27fd4"}, + {file = "mmh3-5.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:82f3802bfc4751f420d591c5c864de538b71cea117fce67e4595c2afede08a15"}, + {file = "mmh3-5.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:915e7a2418f10bd1151b1953df06d896db9783c9cfdb9a8ee1f9b3a4331ab503"}, + {file = "mmh3-5.2.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:fc78739b5ec6e4fb02301984a3d442a91406e7700efbe305071e7fd1c78278f2"}, + {file = "mmh3-5.2.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:41aac7002a749f08727cb91babff1daf8deac317c0b1f317adc69be0e6c375d1"}, + {file = "mmh3-5.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9d8089d853c7963a8ce87fff93e2a67075c0bc08684a08ea6ad13577c38ffc38"}, + {file = "mmh3-5.2.1-cp311-cp311-win32.whl", hash = "sha256:baeb47635cb33375dee4924cd93d7f5dcaa786c740b08423b0209b824a1ee728"}, + {file = "mmh3-5.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:1e4ecee40ba19e6975e1120829796770325841c2f153c0e9aecca927194c6a2a"}, + {file = "mmh3-5.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:c302245fd6c33d96bd169c7ccf2513c20f4c1e417c07ce9dce107c8bc3f8411f"}, + {file = "mmh3-5.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0cc21533878e5586b80d74c281d7f8da7932bc8ace50b8d5f6dbf7e3935f63f1"}, + {file = "mmh3-5.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4eda76074cfca2787c8cf1bec603eaebdddd8b061ad5502f85cddae998d54f00"}, + {file = "mmh3-5.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:eee884572b06bbe8a2b54f424dbd996139442cf83c76478e1ec162512e0dd2c7"}, + {file = "mmh3-5.2.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0d0b7e803191db5f714d264044e06189c8ccd3219e936cc184f07106bd17fd7b"}, + {file = "mmh3-5.2.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e6c219e375f6341d0959af814296372d265a8ca1af63825f65e2e87c618f006"}, + {file = "mmh3-5.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26fb5b9c3946bf7f1daed7b37e0c03898a6f062149127570f8ede346390a0825"}, + {file = "mmh3-5.2.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3c38d142c706201db5b2345166eeef1e7740e3e2422b470b8ba5c8727a9b4c7a"}, + {file = "mmh3-5.2.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50885073e2909251d4718634a191c49ae5f527e5e1736d738e365c3e8be8f22b"}, + {file = "mmh3-5.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b3f99e1756fc48ad507b95e5d86f2fb21b3d495012ff13e6592ebac14033f166"}, + {file = "mmh3-5.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62815d2c67f2dd1be76a253d88af4e1da19aeaa1820146dec52cf8bee2958b16"}, + {file = "mmh3-5.2.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8f767ba0911602ddef289404e33835a61168314ebd3c729833db2ed685824211"}, + {file = "mmh3-5.2.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:67e41a497bac88cc1de96eeba56eeb933c39d54bc227352f8455aa87c4ca4000"}, + {file = "mmh3-5.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3d74a03fb57757ece25aa4b3c1c60157a1cece37a020542785f942e2f827eed5"}, + {file = "mmh3-5.2.1-cp312-cp312-win32.whl", hash = "sha256:7374d6e3ef72afe49697ecd683f3da12f4fc06af2d75433d0580c6746d2fa025"}, + {file = "mmh3-5.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:3a9fed49c6ce4ed7e73f13182760c65c816da006debe67f37635580dfb0fae00"}, + {file = "mmh3-5.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:bbfcb95d9a744e6e2827dfc66ad10e1020e0cac255eb7f85652832d5a264c2fc"}, + {file = "mmh3-5.2.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:723b2681ed4cc07d3401bbea9c201ad4f2a4ca6ba8cddaff6789f715dd2b391e"}, + {file = "mmh3-5.2.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:3619473a0e0d329fd4aec8075628f8f616be2da41605300696206d6f36920c3d"}, + {file = "mmh3-5.2.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:e48d4dbe0f88e53081da605ae68644e5182752803bbc2beb228cca7f1c4454d6"}, + {file = "mmh3-5.2.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a482ac121de6973897c92c2f31defc6bafb11c83825109275cffce54bb64933f"}, + {file = "mmh3-5.2.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:17fbb47f0885ace8327ce1235d0416dc86a211dcd8cc1e703f41523be32cfec8"}, + {file = "mmh3-5.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d51fde50a77f81330523562e3c2734ffdca9c4c9e9d355478117905e1cfe16c6"}, + {file = "mmh3-5.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:19bbd3b841174ae6ed588536ab5e1b1fe83d046e668602c20266547298d939a9"}, + {file = "mmh3-5.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be77c402d5e882b6fbacfd90823f13da8e0a69658405a39a569c6b58fdb17b03"}, + {file = "mmh3-5.2.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fd96476f04db5ceba1cfa0f21228f67c1f7402296f0e73fee3513aa680ad237b"}, + {file = "mmh3-5.2.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:707151644085dd0f20fe4f4b573d28e5130c4aaa5f587e95b60989c5926653b5"}, + {file = "mmh3-5.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3737303ca9ea0f7cb83028781148fcda4f1dac7821db0c47672971dabcf63593"}, + {file = "mmh3-5.2.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2778fed822d7db23ac5008b181441af0c869455b2e7d001f4019636ac31b6fe4"}, + {file = "mmh3-5.2.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d57dea657357230cc780e13920d7fa7db059d58fe721c80020f94476da4ca0a1"}, + {file = "mmh3-5.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:169e0d178cb59314456ab30772429a802b25d13227088085b0d49b9fe1533104"}, + {file = "mmh3-5.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7e4e1f580033335c6f76d1e0d6b56baf009d1a64d6a4816347e4271ba951f46d"}, + {file = "mmh3-5.2.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:2bd9f19f7f1fcebd74e830f4af0f28adad4975d40d80620be19ffb2b2af56c9f"}, + {file = "mmh3-5.2.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:c88653877aeb514c089d1b3d473451677b8b9a6d1497dbddf1ae7934518b06d2"}, + {file = "mmh3-5.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fceef7fe67c81e1585198215e42ad3fdba3a25644beda8fbdaf85f4d7b93175a"}, + {file = "mmh3-5.2.1-cp313-cp313-win32.whl", hash = "sha256:54b64fb2433bc71488e7a449603bf8bd31fbcf9cb56fbe1eb6d459e90b86c37b"}, + {file = "mmh3-5.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:cae6383181f1e345317742d2ddd88f9e7d2682fa4c9432e3a74e47d92dce0229"}, + {file = "mmh3-5.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:022aa1a528604e6c83d0a7705fdef0b5355d897a9e0fa3a8d26709ceaa06965d"}, + {file = "mmh3-5.2.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:d771f085fcdf4035786adfb1d8db026df1eb4b41dac1c3d070d1e49512843227"}, + {file = "mmh3-5.2.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:7f196cd7910d71e9d9860da0ff7a77f64d22c1ad931f1dd18559a06e03109fc0"}, + {file = "mmh3-5.2.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b1f12bd684887a0a5d55e6363ca87056f361e45451105012d329b86ec19dbe0b"}, + {file = "mmh3-5.2.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d106493a60dcb4aef35a0fac85105e150a11cf8bc2b0d388f5a33272d756c966"}, + {file = "mmh3-5.2.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:44983e45310ee5b9f73397350251cdf6e63a466406a105f1d16cb5baa659270b"}, + {file = "mmh3-5.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:368625fb01666655985391dbad3860dc0ba7c0d6b9125819f3121ee7292b4ac8"}, + {file = "mmh3-5.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:72d1cc63bcc91e14933f77d51b3df899d6a07d184ec515ea7f56bff659e124d7"}, + {file = "mmh3-5.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e8b4b5580280b9265af3e0409974fb79c64cf7523632d03fbf11df18f8b0181e"}, + {file = "mmh3-5.2.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4cbbde66f1183db040daede83dd86c06d663c5bb2af6de1142b7c8c37923dd74"}, + {file = "mmh3-5.2.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8ff038d52ef6aa0f309feeba00c5095c9118d0abf787e8e8454d6048db2037fc"}, + {file = "mmh3-5.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4130d0b9ce5fad6af07421b1aecc7e079519f70d6c05729ab871794eded8617"}, + {file = "mmh3-5.2.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6e0bfe77d238308839699944164b96a2eeccaf55f2af400f54dc20669d8d5f2"}, + {file = "mmh3-5.2.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f963eafc0a77a6c0562397da004f5876a9bcf7265a7bcc3205e29636bc4a1312"}, + {file = "mmh3-5.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:92883836caf50d5255be03d988d75bc93e3f86ba247b7ca137347c323f731deb"}, + {file = "mmh3-5.2.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:57b52603e89355ff318025dd55158f6e71396c0f1f609d548e9ea9c94cc6ce0a"}, + {file = "mmh3-5.2.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f40a95186a72fa0b67d15fef0f157bfcda00b4f59c8a07cbe5530d41ac35d105"}, + {file = "mmh3-5.2.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:58370d05d033ee97224c81263af123dea3d931025030fd34b61227a768a8858a"}, + {file = "mmh3-5.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7be6dfb49e48fd0a7d91ff758a2b51336f1cd21f9d44b20f6801f072bd080cdd"}, + {file = "mmh3-5.2.1-cp314-cp314-win32.whl", hash = "sha256:54fe8518abe06a4c3852754bfd498b30cc58e667f376c513eac89a244ce781a4"}, + {file = "mmh3-5.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:3f796b535008708846044c43302719c6956f39ca2d93f2edda5319e79a29efbb"}, + {file = "mmh3-5.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:cd471ede0d802dd936b6fab28188302b2d497f68436025857ca72cd3810423fe"}, + {file = "mmh3-5.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:5174a697ce042fa77c407e05efe41e03aa56dae9ec67388055820fb48cf4c3ba"}, + {file = "mmh3-5.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0a3984146e414684a6be2862d84fcb1035f4984851cb81b26d933bab6119bf00"}, + {file = "mmh3-5.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bd6e7d363aa93bd3421b30b6af97064daf47bc96005bddba67c5ffbc6df426b8"}, + {file = "mmh3-5.2.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:113f78e7463a36dbbcea05bfe688efd7fa759d0f0c56e73c974d60dcfec3dfcc"}, + {file = "mmh3-5.2.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7e8ec5f606e0809426d2440e0683509fb605a8820a21ebd120dcdba61b74ef7f"}, + {file = "mmh3-5.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22b0f9971ec4e07e8223f2beebe96a6cfc779d940b6f27d26604040dd74d3a44"}, + {file = "mmh3-5.2.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:85ffc9920ffc39c5eee1e3ac9100c913a0973996fbad5111f939bbda49204bb7"}, + {file = "mmh3-5.2.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7aec798c2b01aaa65a55f1124f3405804184373abb318a3091325aece235f67c"}, + {file = "mmh3-5.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:55dbbd8ffbc40d1697d5e2d0375b08599dae8746b0b08dea05eee4ce81648fac"}, + {file = "mmh3-5.2.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6c85c38a279ca9295a69b9b088a2e48aa49737bb1b34e6a9dc6297c110e8d912"}, + {file = "mmh3-5.2.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:6290289fa5fb4c70fd7f72016e03633d60388185483ff3b162912c81205ae2cf"}, + {file = "mmh3-5.2.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:4fc6cd65dc4d2fdb2625e288939a3566e36127a84811a4913f02f3d5931da52d"}, + {file = "mmh3-5.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:623f938f6a039536cc02b7582a07a080f13fdfd48f87e63201d92d7e34d09a18"}, + {file = "mmh3-5.2.1-cp314-cp314t-win32.whl", hash = "sha256:29bc3973676ae334412efdd367fcd11d036b7be3efc1ce2407ef8676dabfeb82"}, + {file = "mmh3-5.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:28cfab66577000b9505a0d068c731aee7ca85cd26d4d63881fab17857e0fe1fb"}, + {file = "mmh3-5.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:dfd51b4c56b673dfbc43d7d27ef857dd91124801e2806c69bb45585ce0fa019b"}, + {file = "mmh3-5.2.1.tar.gz", hash = "sha256:bbea5b775f0ac84945191fb83f845a6fd9a21a03ea7f2e187defac7e401616ad"}, +] + +[package.extras] +benchmark = ["pymmh3 (==0.0.5)", "pyperf (==2.10.0)", "xxhash (==3.6.0)"] +docs = ["myst-parser (==5.0.0)", "shibuya (==2026.1.9)", "sphinx (==8.2.3)", "sphinx-copybutton (==0.5.2)"] +lint = ["actionlint-py (==1.7.11.24)", "clang-format (==22.1.0)", "codespell (==2.4.1)", "pylint (==4.0.5)", "ruff (==0.15.4)"] +plot = ["matplotlib (==3.10.8)", "pandas (==3.0.1)"] +test = ["pytest (==9.0.2)", "pytest-sugar (==1.1.1)"] +type = ["mypy (==1.19.1)"] + [[package]] name = "msgpack" version = "1.1.0" @@ -3265,102 +3472,129 @@ files = [ [[package]] name = "pillow" -version = "10.4.0" -description = "Python Imaging Library (Fork)" +version = "12.2.0" +description = "Python Imaging Library (fork)" optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "pillow-10.4.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:4d9667937cfa347525b319ae34375c37b9ee6b525440f3ef48542fcf66f2731e"}, - {file = "pillow-10.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:543f3dc61c18dafb755773efc89aae60d06b6596a63914107f75459cf984164d"}, - {file = "pillow-10.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7928ecbf1ece13956b95d9cbcfc77137652b02763ba384d9ab508099a2eca856"}, - {file = "pillow-10.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4d49b85c4348ea0b31ea63bc75a9f3857869174e2bf17e7aba02945cd218e6f"}, - {file = "pillow-10.4.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:6c762a5b0997f5659a5ef2266abc1d8851ad7749ad9a6a5506eb23d314e4f46b"}, - {file = "pillow-10.4.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:a985e028fc183bf12a77a8bbf36318db4238a3ded7fa9df1b9a133f1cb79f8fc"}, - {file = "pillow-10.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:812f7342b0eee081eaec84d91423d1b4650bb9828eb53d8511bcef8ce5aecf1e"}, - {file = "pillow-10.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ac1452d2fbe4978c2eec89fb5a23b8387aba707ac72810d9490118817d9c0b46"}, - {file = "pillow-10.4.0-cp310-cp310-win32.whl", hash = "sha256:bcd5e41a859bf2e84fdc42f4edb7d9aba0a13d29a2abadccafad99de3feff984"}, - {file = "pillow-10.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:ecd85a8d3e79cd7158dec1c9e5808e821feea088e2f69a974db5edf84dc53141"}, - {file = "pillow-10.4.0-cp310-cp310-win_arm64.whl", hash = "sha256:ff337c552345e95702c5fde3158acb0625111017d0e5f24bf3acdb9cc16b90d1"}, - {file = "pillow-10.4.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0a9ec697746f268507404647e531e92889890a087e03681a3606d9b920fbee3c"}, - {file = "pillow-10.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfe91cb65544a1321e631e696759491ae04a2ea11d36715eca01ce07284738be"}, - {file = "pillow-10.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5dc6761a6efc781e6a1544206f22c80c3af4c8cf461206d46a1e6006e4429ff3"}, - {file = "pillow-10.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e84b6cc6a4a3d76c153a6b19270b3526a5a8ed6b09501d3af891daa2a9de7d6"}, - {file = "pillow-10.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:bbc527b519bd3aa9d7f429d152fea69f9ad37c95f0b02aebddff592688998abe"}, - {file = "pillow-10.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:76a911dfe51a36041f2e756b00f96ed84677cdeb75d25c767f296c1c1eda1319"}, - {file = "pillow-10.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:59291fb29317122398786c2d44427bbd1a6d7ff54017075b22be9d21aa59bd8d"}, - {file = "pillow-10.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:416d3a5d0e8cfe4f27f574362435bc9bae57f679a7158e0096ad2beb427b8696"}, - {file = "pillow-10.4.0-cp311-cp311-win32.whl", hash = "sha256:7086cc1d5eebb91ad24ded9f58bec6c688e9f0ed7eb3dbbf1e4800280a896496"}, - {file = "pillow-10.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cbed61494057c0f83b83eb3a310f0bf774b09513307c434d4366ed64f4128a91"}, - {file = "pillow-10.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:f5f0c3e969c8f12dd2bb7e0b15d5c468b51e5017e01e2e867335c81903046a22"}, - {file = "pillow-10.4.0-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:673655af3eadf4df6b5457033f086e90299fdd7a47983a13827acf7459c15d94"}, - {file = "pillow-10.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:866b6942a92f56300012f5fbac71f2d610312ee65e22f1aa2609e491284e5597"}, - {file = "pillow-10.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29dbdc4207642ea6aad70fbde1a9338753d33fb23ed6956e706936706f52dd80"}, - {file = "pillow-10.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf2342ac639c4cf38799a44950bbc2dfcb685f052b9e262f446482afaf4bffca"}, - {file = "pillow-10.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:f5b92f4d70791b4a67157321c4e8225d60b119c5cc9aee8ecf153aace4aad4ef"}, - {file = "pillow-10.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:86dcb5a1eb778d8b25659d5e4341269e8590ad6b4e8b44d9f4b07f8d136c414a"}, - {file = "pillow-10.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:780c072c2e11c9b2c7ca37f9a2ee8ba66f44367ac3e5c7832afcfe5104fd6d1b"}, - {file = "pillow-10.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:37fb69d905be665f68f28a8bba3c6d3223c8efe1edf14cc4cfa06c241f8c81d9"}, - {file = "pillow-10.4.0-cp312-cp312-win32.whl", hash = "sha256:7dfecdbad5c301d7b5bde160150b4db4c659cee2b69589705b6f8a0c509d9f42"}, - {file = "pillow-10.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:1d846aea995ad352d4bdcc847535bd56e0fd88d36829d2c90be880ef1ee4668a"}, - {file = "pillow-10.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:e553cad5179a66ba15bb18b353a19020e73a7921296a7979c4a2b7f6a5cd57f9"}, - {file = "pillow-10.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8bc1a764ed8c957a2e9cacf97c8b2b053b70307cf2996aafd70e91a082e70df3"}, - {file = "pillow-10.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6209bb41dc692ddfee4942517c19ee81b86c864b626dbfca272ec0f7cff5d9fb"}, - {file = "pillow-10.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bee197b30783295d2eb680b311af15a20a8b24024a19c3a26431ff83eb8d1f70"}, - {file = "pillow-10.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ef61f5dd14c300786318482456481463b9d6b91ebe5ef12f405afbba77ed0be"}, - {file = "pillow-10.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:297e388da6e248c98bc4a02e018966af0c5f92dfacf5a5ca22fa01cb3179bca0"}, - {file = "pillow-10.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:e4db64794ccdf6cb83a59d73405f63adbe2a1887012e308828596100a0b2f6cc"}, - {file = "pillow-10.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd2880a07482090a3bcb01f4265f1936a903d70bc740bfcb1fd4e8a2ffe5cf5a"}, - {file = "pillow-10.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b35b21b819ac1dbd1233317adeecd63495f6babf21b7b2512d244ff6c6ce309"}, - {file = "pillow-10.4.0-cp313-cp313-win32.whl", hash = "sha256:551d3fd6e9dc15e4c1eb6fc4ba2b39c0c7933fa113b220057a34f4bb3268a060"}, - {file = "pillow-10.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:030abdbe43ee02e0de642aee345efa443740aa4d828bfe8e2eb11922ea6a21ea"}, - {file = "pillow-10.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:5b001114dd152cfd6b23befeb28d7aee43553e2402c9f159807bf55f33af8a8d"}, - {file = "pillow-10.4.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:8d4d5063501b6dd4024b8ac2f04962d661222d120381272deea52e3fc52d3736"}, - {file = "pillow-10.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7c1ee6f42250df403c5f103cbd2768a28fe1a0ea1f0f03fe151c8741e1469c8b"}, - {file = "pillow-10.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b15e02e9bb4c21e39876698abf233c8c579127986f8207200bc8a8f6bb27acf2"}, - {file = "pillow-10.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a8d4bade9952ea9a77d0c3e49cbd8b2890a399422258a77f357b9cc9be8d680"}, - {file = "pillow-10.4.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:43efea75eb06b95d1631cb784aa40156177bf9dd5b4b03ff38979e048258bc6b"}, - {file = "pillow-10.4.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:950be4d8ba92aca4b2bb0741285a46bfae3ca699ef913ec8416c1b78eadd64cd"}, - {file = "pillow-10.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d7480af14364494365e89d6fddc510a13e5a2c3584cb19ef65415ca57252fb84"}, - {file = "pillow-10.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:73664fe514b34c8f02452ffb73b7a92c6774e39a647087f83d67f010eb9a0cf0"}, - {file = "pillow-10.4.0-cp38-cp38-win32.whl", hash = "sha256:e88d5e6ad0d026fba7bdab8c3f225a69f063f116462c49892b0149e21b6c0a0e"}, - {file = "pillow-10.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:5161eef006d335e46895297f642341111945e2c1c899eb406882a6c61a4357ab"}, - {file = "pillow-10.4.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:0ae24a547e8b711ccaaf99c9ae3cd975470e1a30caa80a6aaee9a2f19c05701d"}, - {file = "pillow-10.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:298478fe4f77a4408895605f3482b6cc6222c018b2ce565c2b6b9c354ac3229b"}, - {file = "pillow-10.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:134ace6dc392116566980ee7436477d844520a26a4b1bd4053f6f47d096997fd"}, - {file = "pillow-10.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:930044bb7679ab003b14023138b50181899da3f25de50e9dbee23b61b4de2126"}, - {file = "pillow-10.4.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:c76e5786951e72ed3686e122d14c5d7012f16c8303a674d18cdcd6d89557fc5b"}, - {file = "pillow-10.4.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:b2724fdb354a868ddf9a880cb84d102da914e99119211ef7ecbdc613b8c96b3c"}, - {file = "pillow-10.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dbc6ae66518ab3c5847659e9988c3b60dc94ffb48ef9168656e0019a93dbf8a1"}, - {file = "pillow-10.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:06b2f7898047ae93fad74467ec3d28fe84f7831370e3c258afa533f81ef7f3df"}, - {file = "pillow-10.4.0-cp39-cp39-win32.whl", hash = "sha256:7970285ab628a3779aecc35823296a7869f889b8329c16ad5a71e4901a3dc4ef"}, - {file = "pillow-10.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:961a7293b2457b405967af9c77dcaa43cc1a8cd50d23c532e62d48ab6cdd56f5"}, - {file = "pillow-10.4.0-cp39-cp39-win_arm64.whl", hash = "sha256:32cda9e3d601a52baccb2856b8ea1fc213c90b340c542dcef77140dfa3278a9e"}, - {file = "pillow-10.4.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5b4815f2e65b30f5fbae9dfffa8636d992d49705723fe86a3661806e069352d4"}, - {file = "pillow-10.4.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:8f0aef4ef59694b12cadee839e2ba6afeab89c0f39a3adc02ed51d109117b8da"}, - {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9f4727572e2918acaa9077c919cbbeb73bd2b3ebcfe033b72f858fc9fbef0026"}, - {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff25afb18123cea58a591ea0244b92eb1e61a1fd497bf6d6384f09bc3262ec3e"}, - {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:dc3e2db6ba09ffd7d02ae9141cfa0ae23393ee7687248d46a7507b75d610f4f5"}, - {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:02a2be69f9c9b8c1e97cf2713e789d4e398c751ecfd9967c18d0ce304efbf885"}, - {file = "pillow-10.4.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:0755ffd4a0c6f267cccbae2e9903d95477ca2f77c4fcf3a3a09570001856c8a5"}, - {file = "pillow-10.4.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:a02364621fe369e06200d4a16558e056fe2805d3468350df3aef21e00d26214b"}, - {file = "pillow-10.4.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:1b5dea9831a90e9d0721ec417a80d4cbd7022093ac38a568db2dd78363b00908"}, - {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b885f89040bb8c4a1573566bbb2f44f5c505ef6e74cec7ab9068c900047f04b"}, - {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87dd88ded2e6d74d31e1e0a99a726a6765cda32d00ba72dc37f0651f306daaa8"}, - {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:2db98790afc70118bd0255c2eeb465e9767ecf1f3c25f9a1abb8ffc8cfd1fe0a"}, - {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:f7baece4ce06bade126fb84b8af1c33439a76d8a6fd818970215e0560ca28c27"}, - {file = "pillow-10.4.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:cfdd747216947628af7b259d274771d84db2268ca062dd5faf373639d00113a3"}, - {file = "pillow-10.4.0.tar.gz", hash = "sha256:166c1cd4d24309b30d61f79f4a9114b7b2313d7450912277855ff5dfd7cd4a06"}, + {file = "pillow-12.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:a4e8f36e677d3336f35089648c8955c51c6d386a13cf6ee9c189c5f5bd713a9f"}, + {file = "pillow-12.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e589959f10d9824d39b350472b92f0ce3b443c0a3442ebf41c40cb8361c5b97"}, + {file = "pillow-12.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a52edc8bfff4429aaabdf4d9ee0daadbbf8562364f940937b941f87a4290f5ff"}, + {file = "pillow-12.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:975385f4776fafde056abb318f612ef6285b10a1f12b8570f3647ad0d74b48ec"}, + {file = "pillow-12.2.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd9c0c7a0c681a347b3194c500cb1e6ca9cab053ea4d82a5cf45b6b754560136"}, + {file = "pillow-12.2.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88d387ff40b3ff7c274947ed3125dedf5262ec6919d83946753b5f3d7c67ea4c"}, + {file = "pillow-12.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:51c4167c34b0d8ba05b547a3bb23578d0ba17b80a5593f93bd8ecb123dd336a3"}, + {file = "pillow-12.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:34c0d99ecccea270c04882cb3b86e7b57296079c9a4aff88cb3b33563d95afaa"}, + {file = "pillow-12.2.0-cp310-cp310-win32.whl", hash = "sha256:b85f66ae9eb53e860a873b858b789217ba505e5e405a24b85c0464822fe88032"}, + {file = "pillow-12.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:673aa32138f3e7531ccdbca7b3901dba9b70940a19ccecc6a37c77d5fdeb05b5"}, + {file = "pillow-12.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:3e080565d8d7c671db5802eedfb438e5565ffa40115216eabb8cd52d0ecce024"}, + {file = "pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab"}, + {file = "pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65"}, + {file = "pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7"}, + {file = "pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e"}, + {file = "pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705"}, + {file = "pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176"}, + {file = "pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b"}, + {file = "pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909"}, + {file = "pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808"}, + {file = "pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60"}, + {file = "pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe"}, + {file = "pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5"}, + {file = "pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421"}, + {file = "pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987"}, + {file = "pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76"}, + {file = "pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005"}, + {file = "pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780"}, + {file = "pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5"}, + {file = "pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5"}, + {file = "pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940"}, + {file = "pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5"}, + {file = "pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414"}, + {file = "pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c"}, + {file = "pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2"}, + {file = "pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c"}, + {file = "pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795"}, + {file = "pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f"}, + {file = "pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed"}, + {file = "pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9"}, + {file = "pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed"}, + {file = "pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3"}, + {file = "pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9"}, + {file = "pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795"}, + {file = "pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e"}, + {file = "pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b"}, + {file = "pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06"}, + {file = "pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b"}, + {file = "pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f"}, + {file = "pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612"}, + {file = "pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c"}, + {file = "pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea"}, + {file = "pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4"}, + {file = "pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4"}, + {file = "pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea"}, + {file = "pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24"}, + {file = "pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98"}, + {file = "pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453"}, + {file = "pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8"}, + {file = "pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b"}, + {file = "pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295"}, + {file = "pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed"}, + {file = "pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae"}, + {file = "pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601"}, + {file = "pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be"}, + {file = "pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f"}, + {file = "pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286"}, + {file = "pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50"}, + {file = "pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104"}, + {file = "pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7"}, + {file = "pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150"}, + {file = "pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1"}, + {file = "pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463"}, + {file = "pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3"}, + {file = "pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166"}, + {file = "pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe"}, + {file = "pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd"}, + {file = "pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e"}, + {file = "pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06"}, + {file = "pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43"}, + {file = "pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354"}, + {file = "pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1"}, + {file = "pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb"}, + {file = "pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f"}, + {file = "pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d"}, + {file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f"}, + {file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e"}, + {file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0"}, + {file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1"}, + {file = "pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e"}, + {file = "pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5"}, ] [package.extras] -docs = ["furo", "olefile", "sphinx (>=7.3)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] +docs = ["furo", "olefile", "sphinx (>=8.2)", "sphinx-autobuild", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] fpx = ["olefile"] mic = ["olefile"] -tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"] -typing = ["typing-extensions ; python_version < \"3.10\""] +test-arrow = ["arro3-compute", "arro3-core", "nanoarrow", "pyarrow"] +tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma (>=5)", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "trove-classifiers (>=2024.10.12)"] xmp = ["defusedxml"] +[[package]] +name = "platformdirs" +version = "3.11.0" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "platformdirs-3.11.0-py3-none-any.whl", hash = "sha256:e9d171d00af68be50e9202731309c4e658fd8bc76f55c11c7dd760d023bda68e"}, + {file = "platformdirs-3.11.0.tar.gz", hash = "sha256:cf8ee52a3afdb965072dcc652433e0c7e3e40cf5ea1477cd4b3b1d2eb75495b3"}, +] + +[package.extras] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.1)", "sphinx-autodoc-typehints (>=1.24)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)"] + [[package]] name = "pluggy" version = "1.5.0" @@ -4643,6 +4877,18 @@ files = [ {file = "ruff-0.9.9.tar.gz", hash = "sha256:0062ed13f22173e85f8f7056f9a24016e692efeea8704d1a5e8011b8aa850933"}, ] +[[package]] +name = "semver" +version = "3.0.4" +description = "Python helper for Semantic Versioning (https://semver.org)" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "semver-3.0.4-py3-none-any.whl", hash = "sha256:9c824d87ba7f7ab4a1890799cec8596f15c1241cb473404ea1cb0c55e4b04746"}, + {file = "semver-3.0.4.tar.gz", hash = "sha256:afc7d8c584a5ed0a11033af086e8af226a9c0b206f313e0301f8dd7b6b589602"}, +] + [[package]] name = "sentry-sdk" version = "2.39.0" @@ -5182,6 +5428,47 @@ files = [ {file = "tzdata-2024.2.tar.gz", hash = "sha256:7d85cc416e9382e69095b7bdf4afd9e3880418a2413feec7069d533d6b4e31cc"}, ] +[[package]] +name = "tzlocal" +version = "5.3.1" +description = "tzinfo object for the local timezone" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d"}, + {file = "tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd"}, +] + +[package.dependencies] +tzdata = {version = "*", markers = "platform_system == \"Windows\""} + +[package.extras] +devenv = ["check-manifest", "pytest (>=4.3)", "pytest-cov", "pytest-mock (>=3.3)", "zest.releaser"] + +[[package]] +name = "unleashclient" +version = "6.7.0" +description = "Python client for the Unleash feature toggle system!" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "unleashclient-6.7.0-py3-none-any.whl", hash = "sha256:1fbf4bc66ec9b952e0131012f954adfc1d476d54ad320905bc5f4fdea963a9fc"}, + {file = "unleashclient-6.7.0.tar.gz", hash = "sha256:a34649ef2232c3e0e18232b2067c07dd0bf474b977efc67c8806d816418ebe29"}, +] + +[package.dependencies] +apscheduler = "<4.0.0" +fcache = "*" +importlib_metadata = "*" +launchdarkly-eventsource = "*" +mmh3 = "*" +python-dateutil = "*" +requests = "*" +semver = "<4.0.0" +yggdrasil-engine = ">=1.3.0" + [[package]] name = "uritemplate" version = "4.1.1" @@ -5504,6 +5791,64 @@ idna = ">=2.0" multidict = ">=4.0" propcache = ">=0.2.0" +[[package]] +name = "yggdrasil-engine" +version = "1.3.0" +description = "Engine for evaluating Unleash feature flags" +optional = false +python-versions = "<4.0,>=3.8" +groups = ["main"] +files = [ + {file = "yggdrasil_engine-1.3.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:838880e916da4af97c10ff38e1845ef38ed91201c2868e3275e1372c8979a3ca"}, + {file = "yggdrasil_engine-1.3.0-cp310-abi3-macosx_11_0_x86_64.whl", hash = "sha256:8d23e865a82b47bac7a59376fd4f0e000ae8b3b4e25fc27a5554f00093f2ff75"}, + {file = "yggdrasil_engine-1.3.0-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:bf78418d4b66c4ec888fa9444061c65358df013105f85d7ddcd64fe78e23f38d"}, + {file = "yggdrasil_engine-1.3.0-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:8a244b6084dfd1bb15dd862cecd82f3a69d26c233e3b60d63c4b31b224d92257"}, + {file = "yggdrasil_engine-1.3.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1a332da70a8efaaed9b603676efa5dac7ed6e4f4cb4524bc3712bd4ea90c4186"}, + {file = "yggdrasil_engine-1.3.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:48a027b7c54e2837988e07a44c77ddd6e2d68aac98e8289b0d75a6a4e4f6f0da"}, + {file = "yggdrasil_engine-1.3.0-cp310-abi3-win_amd64.whl", hash = "sha256:afa6c9a454b79126048a62fced3862c0608d74a466f172625924bb28108b5ff4"}, + {file = "yggdrasil_engine-1.3.0-cp310-abi3-win_arm64.whl", hash = "sha256:bf2abdd70976ff95b8f72cc4e8a63968702cadbdac035f7924dcffb314042482"}, + {file = "yggdrasil_engine-1.3.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:80aba2cd6bf06434bbe996e384434c4fa1f5e7371a46ef5deeb1c8c34afe7fe4"}, + {file = "yggdrasil_engine-1.3.0-cp311-abi3-macosx_11_0_x86_64.whl", hash = "sha256:ac81c0a4e6120bee6e3c3049420ab7b44415d9fd876d1ae549206fb582d82a4d"}, + {file = "yggdrasil_engine-1.3.0-cp311-abi3-manylinux2014_aarch64.whl", hash = "sha256:8946d23a0883012a5e3404643f210e8cf654030af87722262ad28e92fb07aa76"}, + {file = "yggdrasil_engine-1.3.0-cp311-abi3-manylinux2014_x86_64.whl", hash = "sha256:66c1d03edb846a0fc1a622685ee80ae9552fd1ddd21d41524466dc8fe914b37c"}, + {file = "yggdrasil_engine-1.3.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:928230be698325d9fdc8a01b477c5948b86e979897a54f6fa6030f4a1d334bc5"}, + {file = "yggdrasil_engine-1.3.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f632266983af11bc572d9cf29e8baeeae4c96f736a764034ca82dea7e6640cbc"}, + {file = "yggdrasil_engine-1.3.0-cp311-abi3-win_amd64.whl", hash = "sha256:8096fdf4674987d9c1d3f72b8a86e81121cbed25f74d66d4413460556f56ea3c"}, + {file = "yggdrasil_engine-1.3.0-cp311-abi3-win_arm64.whl", hash = "sha256:3b0df9e6ab138f0dfc4cf2d88ae060b360060ef7fc239ab64a808ec56e450567"}, + {file = "yggdrasil_engine-1.3.0-cp312-abi3-macosx_11_0_arm64.whl", hash = "sha256:19d1136cab1963f7cc3d1926f6cbcfc42de1f5c8c0e4d8d6e68765768d8ed238"}, + {file = "yggdrasil_engine-1.3.0-cp312-abi3-macosx_11_0_x86_64.whl", hash = "sha256:6f0b7aa3be0aaf28cee7b7f3428922d4735f72533052aad6e4187f7814ba56f0"}, + {file = "yggdrasil_engine-1.3.0-cp312-abi3-manylinux2014_aarch64.whl", hash = "sha256:28dde2312ccd778d32c94103b2c8332a72c077b834b55d8408a334a736460f53"}, + {file = "yggdrasil_engine-1.3.0-cp312-abi3-manylinux2014_x86_64.whl", hash = "sha256:b13a49c29d676b4674ee9362eedb86491332d4d2da07afb9ea2140a1b05e8a90"}, + {file = "yggdrasil_engine-1.3.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ca0d5493e365ee2ccc9b69434c34986d5616e8277ed8609a9e307a101c4e0a23"}, + {file = "yggdrasil_engine-1.3.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b21708fb0389d2fc240b35377c7821571ecba93ef4e0a01a8d15e53f016d4971"}, + {file = "yggdrasil_engine-1.3.0-cp312-abi3-win_amd64.whl", hash = "sha256:ab3a3f024c7c2d692bbdc8e1467bd16aa98b5d30ed33938fbde61ed0f3e4ea97"}, + {file = "yggdrasil_engine-1.3.0-cp312-abi3-win_arm64.whl", hash = "sha256:8da87fcd93d6ccbab5d3a2d2aaf54b447265be61b19f65ba6044986f35798f99"}, + {file = "yggdrasil_engine-1.3.0-cp313-abi3-macosx_11_0_arm64.whl", hash = "sha256:4a700c1711e0db80d57bd5e5fe32f537d7cf999e92e0d1c5b086ce5a17e69f98"}, + {file = "yggdrasil_engine-1.3.0-cp313-abi3-macosx_11_0_x86_64.whl", hash = "sha256:a69049ca69abae3945a0d9811e4f8aa6b94e5ab12af1ff32cdf9b2bd30e41e67"}, + {file = "yggdrasil_engine-1.3.0-cp313-abi3-manylinux2014_aarch64.whl", hash = "sha256:b07d0ecd58a36d7c25c3708526900a34d5f4ccb77cd822ec27f17a6952f6278a"}, + {file = "yggdrasil_engine-1.3.0-cp313-abi3-manylinux2014_x86_64.whl", hash = "sha256:a2cacdf5ee7eb942e0b09e0a76e6d28460cd528c961126b01c58d2dcd4b14ea3"}, + {file = "yggdrasil_engine-1.3.0-cp313-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91d28e7d8838b14722f6165cd19b3ae8cea711a43485f2260f988d922c2b84ea"}, + {file = "yggdrasil_engine-1.3.0-cp313-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:18a493f539408ec262be315d40f58e1da899fea6b1be18695516469352c42ac7"}, + {file = "yggdrasil_engine-1.3.0-cp313-abi3-win_amd64.whl", hash = "sha256:3ec38c4be410618d9826749f275f451cc07dce426413e3576ef7b6a6db1474e2"}, + {file = "yggdrasil_engine-1.3.0-cp313-abi3-win_arm64.whl", hash = "sha256:1704c458dfe3dbc4a8655a5271b2bef7264d8d7b392f7e02194ff3171d3c7538"}, + {file = "yggdrasil_engine-1.3.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:e073a0ecf15fee1315db1bd02baadd0c1309b67188a16bf23a50456b2f7be951"}, + {file = "yggdrasil_engine-1.3.0-cp38-abi3-macosx_11_0_x86_64.whl", hash = "sha256:037eff18de77e9b357829d3dd5cc4ee5bd5aaf0eb949cd87e163790bd319cc27"}, + {file = "yggdrasil_engine-1.3.0-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:d005998f66048e637b17b6a169bc009d55d9c26d10dfaada2e7fe296eadfd599"}, + {file = "yggdrasil_engine-1.3.0-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:84f6168452c062df2386e7ebd858066f8a82562531c4be37b5024634a6ece9eb"}, + {file = "yggdrasil_engine-1.3.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1ab1b8f457e55b49eaa86dd1c42ab8ff45d0e6f5ec7be31cfed6c698f0c44e75"}, + {file = "yggdrasil_engine-1.3.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5f7da76799391a4ae1462223229092b91bef863626ce3f662027a0321513a27"}, + {file = "yggdrasil_engine-1.3.0-cp38-abi3-win_amd64.whl", hash = "sha256:0113ba088babf7cced92f88c64ec9ea3e28692c705fedab5b3262f3bcfc492ba"}, + {file = "yggdrasil_engine-1.3.0-cp38-abi3-win_arm64.whl", hash = "sha256:02a6cbe73892b5a574fd3962ff692e00e6ed51af5ba4f8f1ea6745392cfd5fac"}, + {file = "yggdrasil_engine-1.3.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:de45a251ceeed07818735ce894c781d7a142a60718fbff76419eae66b9859a94"}, + {file = "yggdrasil_engine-1.3.0-cp39-abi3-macosx_11_0_x86_64.whl", hash = "sha256:b1996338ff1a1dca2dfb337ee111487f4c92351cb31d94e3badb8a97524be4c3"}, + {file = "yggdrasil_engine-1.3.0-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:93e9fa8639c1a887e0175823316eb986ee58ec94999ebadbf0cc4bc84b085410"}, + {file = "yggdrasil_engine-1.3.0-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:049b407a23fabacface173abd802340922246cea6c9b9842af5926742fbf5572"}, + {file = "yggdrasil_engine-1.3.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:778c15dc202b83472407f83622e52612a58ae979c2c8240aec1277dab6f3d407"}, + {file = "yggdrasil_engine-1.3.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:fe2f98703fbb8b04aac05de5491a85d6e5263c1944c66dbb9253d1395d6e9935"}, + {file = "yggdrasil_engine-1.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:324f69da1314aaa1e81fbf7dd4ca6b2e241f2b1ce8bc2e018cf17b4193f6523f"}, + {file = "yggdrasil_engine-1.3.0-cp39-abi3-win_arm64.whl", hash = "sha256:ecbc7db34359958bcf30b9ad5ab1f68c1114d19cb47b14f2c1a341a1468cd73c"}, +] + [[package]] name = "yookassa" version = "2.5.0" @@ -5522,6 +5867,26 @@ netaddr = "*" requests = "*" urllib3 = "*" +[[package]] +name = "zipp" +version = "3.23.1" +description = "Backport of pathlib-compatible object wrapper for zip files" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "zipp-3.23.1-py3-none-any.whl", hash = "sha256:0b3596c50a5c700c9cb40ba8d86d9f2cc4807e9bedb06bcdf7fac85633e444dc"}, + {file = "zipp-3.23.1.tar.gz", hash = "sha256:32120e378d32cd9714ad503c1d024619063ec28aad2248dc6672ad13edfa5110"}, +] + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more_itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] +type = ["pytest-mypy"] + [[package]] name = "zope-interface" version = "7.2" @@ -5580,4 +5945,4 @@ testing = ["coverage[toml]", "zope.event", "zope.testing"] [metadata] lock-version = "2.1" python-versions = "^3.12" -content-hash = "be19d70adce9ad107dd4b610d061d2477ea062690bad3e553131bf6d61acbf11" +content-hash = "062dda7a1043076481446c18a95fa53ce5e21da80cdbe045621204ecf8a72811" @@ -27,7 +27,6 @@ django-oauth-toolkit = "^2.3.0" openpyxl = "^3.1.2" pypdf2 = "^3.0.1" mutagen = "^1.47.0" -pillow = "^10.2.0" langserve = {extras = ["client"], version = "^0.0.46"} django-ordered-model = "^3.7.4" langchainhub = "^0.1.15" @@ -62,7 +61,10 @@ 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" pydub = "^0.25.1" +pillow = "^12.2.0" [tool.poetry.group.test.dependencies]