@@ -173,4 +173,15 @@ class EmailService: cls.send_email( 'Отмена подписки на платформе AIR', html_message, (email,) ) + logger.info('Revoke recurring email sent: email=%s', email) + + @classmethod + def send_failed_recurring_charge_email(cls, email: str) -> None: + html_message = cls._render_letter_template( + template_name='payments/failed_recurring_charge_email', context={} + ) + cls.send_email( + 'Не удалось списать оплату', html_message, (email,) + ) + logger.info('Failed recurring charge email sent: email=%s', email) @@ -39,6 +39,8 @@ from authentication.services.email_service import EmailService from authentication.services.utm_service import UTMService from authentication.utils import get_client_ip from ml_model.services.minio_service import MinIOService +from payments.models import PaymentPlanUserInfo +from payments.selectors.payment_plan_selector import PaymentPlanSelector from payments.services.payment_method_service import PaymentMethodService from payments.services.referral_account import ReferralAccountService @@ -277,7 +279,15 @@ class UserService: if self.user.account_type in ('business_admin', 'business_security', 'business_account'): self.user.business_account.delete() - PaymentMethodService(self.user).delete_payment_method() + PaymentMethodService(self.user).deactivate_payment_methods() + free_plan = PaymentPlanSelector(self.user).get_free_plan( + corporate=self.user.payment_plan.plan.is_corporate + ) + PaymentPlanUserInfo.objects.filter(user=self.user).update( + next_payment_at=None, + plan_id=free_plan.pk, + current_token_balance=0, + ) self.user.is_deleted = True self.user.is_confirmed = False @@ -1,7 +1,8 @@ # Authentication mapper -from django.db.models import Prefetch +from django.db.models import Count, Prefetch, Q from payments.models.payment_plan_feature import PaymentPlanFeature +from payments.models.user_payment_method import PaymentMethod def _gen_only(chain: str, *fields: str): @@ -19,10 +20,8 @@ PATH_PREFETCH_MAP = { 'business_account__parent_company__company_companyipwhitelist', 'business_account__parent_company__user__payment_plan', 'business_account__parent_company__user__payment_plan__plan', - 'business_account__parent_company__user__payment_plan__method', 'payment_plan', 'payment_plan__plan', - 'payment_plan__method', ), 'prefetch': ( Prefetch( @@ -43,6 +42,16 @@ PATH_PREFETCH_MAP = { 'model__uid', ), ), + Prefetch( + 'payment_plan__methods', + queryset=PaymentMethod.objects.filter(primary=True, active=True).annotate( + total_attempts=Count( + 'payment_attempts', + filter=Q(payment_attempts__in_cycle=True), + ) + ), + to_attr='primary_methods', + ), 'social_auth', ), 'only': ( @@ -89,7 +98,6 @@ PATH_PREFETCH_MAP = { 'uid', 'last_payment_at', 'next_payment_at', - 'method_id', ), *_gen_only( 'payment_plan__plan', @@ -104,7 +112,6 @@ PATH_PREFETCH_MAP = { 'uid', 'last_payment_at', 'next_payment_at', - 'method_id', ), *_gen_only( 'business_account__parent_company__user__payment_plan__plan', @@ -167,7 +174,6 @@ PATH_PREFETCH_MAP = { 'business_account__parent_company__company_companyipwhitelist', 'payment_plan', 'payment_plan__plan', - 'payment_plan__method', ), 'prefetch': (), 'only': ( @@ -186,7 +192,6 @@ PATH_PREFETCH_MAP = { ), *_gen_only('payment_plan', 'uid'), *_gen_only('payment_plan__plan', 'uid', 'price'), - *_gen_only('payment_plan__method', 'uid'), ), }, } @@ -3,6 +3,7 @@ from typing import Any import jwt from django.conf import settings +from django.db.models import Count, Prefetch, Q from django.http import HttpRequest from django.utils.translation import gettext as _ from drf_spectacular.contrib.rest_framework_simplejwt import ( @@ -18,6 +19,7 @@ from authentication.mapper import PATH_PREFETCH_MAP from authentication.models import BusinessUserHost, CustomUserModel from authentication.services.token import TokenService from authentication.utils import get_client_ip +from payments.models.user_payment_method import PaymentMethod logger = logging.getLogger(__name__) @@ -48,15 +50,30 @@ class JWTAuthentication(BaseAuthentication): except Exception as exc: raise AuthenticationFailed(_('Access token invalid'), code='token_invalid') from exc try: - user = CustomUserModel.objects.select_related( - 'payment_plan', - 'payment_plan__plan', - 'business_account', - 'business_account__group', - 'business_account__parent_company__user__payment_plan', - 'business_account__parent_company__user__payment_plan__plan', - 'host_account', - ).get(uid=payload['uid']) + user = ( + CustomUserModel.objects.select_related( + 'payment_plan', + 'payment_plan__plan', + 'business_account', + 'business_account__group', + 'business_account__parent_company__user__payment_plan', + 'business_account__parent_company__user__payment_plan__plan', + 'host_account', + ) + .prefetch_related( + Prefetch( + 'payment_plan__methods', + queryset=PaymentMethod.objects.filter(primary=True, active=True).annotate( + total_attempts=Count( + 'payment_attempts', + filter=Q(payment_attempts__in_cycle=True), + ) + ), + to_attr='primary_methods', + ) + ) + .get(uid=payload['uid']) + ) _check_ip_client(user, request) except CustomUserModel.DoesNotExist as exc: raise AuthenticationFailed(_('User not found'), code='user_not_found') from exc @@ -460,6 +460,7 @@ if (SENTRY_URL := env.str('SENTRY_URL', '')) and RELEASE and ENVIRONMENT: 'PromptLengthExceeded', 'InvalidParameterError', 'UnsupportedSize', + 'OutputSensitiveImageContentError' ], ) @@ -489,7 +490,9 @@ UNLEASH_INSTANCE_ID = env.str('UNLEASH_INSTANCE_ID', '') UNLEASH_WEBHOOK_SECRET_KEY = env.str('UNLEASH_WEBHOOK_SECRET_KEY', 'defaultsecretkey') # RECURRING SETTINGS -MAX_RECURRING_ATTEMPTS = env.int('MAX_RECURRING_ATTEMPTS', 1) +RECURRING_RETRY_OFFSETS = env.list('RECURRING_RETRY_OFFSETS', default=[1, 3, 5, 8, 12, 16, 21, 28], subcast=int) +RECURRING_FULL_ACCESS_CUTOFF_DAY = env.int('RECURRING_FULL_ACCESS_CUTOFF_DAY', 4) +RECURRING_FAILED_CHARGE_EMAIL_DAYS = env.list('RECURRING_FAILED_CHARGE_EMAIL_DAYS', default=[3], subcast=int) # SSE STREAMING FF__STREAMING_ENABLED = env.bool('FF__STREAMING_ENABLED', False) @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-06-04 10:09+0300\n" +"POT-Creation-Date: 2026-07-20 11:08+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -108,7 +108,7 @@ msgstr "При отправке письма произошла неизвест msgid "Email token not found. Please contact support" msgstr "E-mail токен не найден. Пожалуйста, свяжитесь со службой поддержки" -#: authentication/exceptions/email_token.py:6 authentication/routes/v2.py:32 +#: authentication/exceptions/email_token.py:6 authentication/routes/v2.py:31 msgid "No email token found" msgstr "Email токен не найден" @@ -116,7 +116,7 @@ msgstr "Email токен не найден" msgid "Wrong email" msgstr "Неверный email" -#: authentication/exceptions/user.py:11 backend/urls.py:49 +#: authentication/exceptions/user.py:11 backend/urls.py:97 msgid "Wrong password" msgstr "Неверный пароль" @@ -140,39 +140,39 @@ msgstr "Пользователь уже существует" msgid "Domain not found" msgstr "Домен не найден" -#: authentication/models/business_account.py:16 payments/models/promocode.py:72 +#: authentication/models/business_account.py:18 payments/models/promocode.py:72 #: tools/public_api/models.py:34 tools/public_api/services/api_key.py:24 msgid "Owner" msgstr "Владелец" -#: authentication/models/business_account.py:20 +#: authentication/models/business_account.py:22 msgid "Show balance" msgstr "Показать баланс" -#: authentication/models/business_account.py:25 +#: authentication/models/business_account.py:27 msgid "Account privileges" msgstr "Тип аккаунта" -#: authentication/models/business_account.py:31 +#: authentication/models/business_account.py:33 #: authentication/models/business_group.py:13 #: authentication/models/whitelist.py:13 msgid "Company" msgstr "Компания" -#: authentication/models/business_account.py:37 +#: authentication/models/business_account.py:39 msgid "Acceptance" msgstr "Подтверждение" -#: authentication/models/business_account.py:44 +#: authentication/models/business_account.py:46 #: authentication/models/business_group.py:21 tools/public_api/models.py:44 msgid "Token limit" msgstr "Лимит токенов" -#: authentication/models/business_account.py:52 +#: authentication/models/business_account.py:54 msgid "Group" msgstr "Группа" -#: authentication/models/business_account.py:61 +#: authentication/models/business_account.py:63 msgid "" "Impossible to add this employee to this group which does not belong to this " "company" @@ -180,16 +180,16 @@ msgstr "" "Невозможно добавить сотрудника к группе, когда он не принадлежит данной " "компании" -#: authentication/models/business_account.py:70 +#: authentication/models/business_account.py:80 msgid "Child Business Account" msgstr "Дочерний Бизнес Аккаунт" -#: authentication/models/business_account.py:71 +#: authentication/models/business_account.py:81 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:269 +#: authentication/models/business_group.py:8 ml_model/models.py:19 +#: ml_model/models.py:39 ml_model/models.py:63 ml_model/models.py:279 #: tools/chats/models.py:9 tools/media/models.py:59 tools/media/models.py:95 msgid "Title" msgstr "Название" @@ -203,10 +203,10 @@ msgid "Business Groups" msgstr "Бизнес Группы" #: authentication/models/business_host.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/email_token.py:13 authentication/models/user.py:233 +#: authentication/models/user.py:234 authentication/models/user_telegram.py:22 #: authentication/models/user_vk.py:12 payments/admin.py:37 -#: payments/admin.py:95 payments/models/invoice.py:15 +#: payments/admin.py:124 payments/models/invoice.py:15 #: payments/models/payment.py:26 payments/models/payment_plan.py:43 #: tools/media/models.py:108 msgid "User" @@ -217,7 +217,7 @@ msgid "Affiliated by" msgstr "Кем привлечена" #: authentication/models/business_host.py:36 authentication/models/user.py:135 -#: authentication/models/whitelist.py:16 ml_model/models.py:167 +#: authentication/models/whitelist.py:16 ml_model/models.py:177 #: payments/models/promocode.py:85 msgid "Is active" msgstr "Является активной" @@ -258,7 +258,7 @@ msgstr "ИНН" msgid "PSRN" msgstr "ОГРН" -#: authentication/models/business_host.py:77 ml_model/models.py:180 +#: authentication/models/business_host.py:77 ml_model/models.py:190 #: tools/public_api/models.py:31 tools/public_api/services/api_key.py:24 msgid "Name" msgstr "Наименование" @@ -363,7 +363,7 @@ msgstr "Админ" msgid "Security" msgstr "Безопасность" -#: authentication/models/email_token.py:16 ml_model/models.py:271 +#: authentication/models/email_token.py:16 ml_model/models.py:281 msgid "Key" msgstr "Ключ" @@ -507,35 +507,35 @@ msgstr "Вайтлисты для отмены политик" msgid "Invalid or expired refresh token" msgstr "Неверный или истёкший refresh токен" -#: authentication/routes/v2.py:25 +#: authentication/routes/v2.py:24 msgid "User is already confirmed" msgstr "Аккаунт уже подтвержден" -#: authentication/routes/v2.py:37 authentication/views.py:489 +#: authentication/routes/v2.py:36 authentication/views.py:489 msgid "Could not confirm email, please try again." msgstr "Невозможно подтвердить email, попробуйте позже" -#: authentication/security.py:35 +#: authentication/security.py:34 #, fuzzy #| msgid "Hidden" msgid "Forbidden" msgstr "Скрытый" -#: authentication/security.py:48 +#: authentication/security.py:47 msgid "Access token is expired" msgstr "Срок действия токена доступа истек" -#: authentication/security.py:50 +#: authentication/security.py:49 #, fuzzy #| msgid "Access token is expired" msgid "Access token invalid" msgstr "Срок действия токена доступа истек" -#: authentication/security.py:63 +#: authentication/security.py:62 msgid "User not found" msgstr "Пользователь не найден" -#: authentication/security.py:98 authentication/security.py:126 +#: authentication/security.py:97 msgid "Access token expired or does not exist" msgstr "Токен доступа просрочен или не существует" @@ -549,7 +549,7 @@ msgstr "" msgid "Host user is not registered for this account" msgstr "Пользователь бизнес-аккаунта не зарегистрирован для этого аккаунта" -#: authentication/selectors/user_selector.py:80 +#: authentication/selectors/user_selector.py:75 msgid "No user with this uid found" msgstr "Не найден пользователь с данным ID" @@ -565,23 +565,23 @@ msgstr "Приглашенный аккаунт может принять или msgid "Error occured when proceed email sending" msgstr "Случилась ошибка во время отправки email" -#: authentication/services/user_services.py:164 +#: authentication/services/user_services.py:167 msgid "No user like this in a database" msgstr "Такой пользователь отсутствует" -#: authentication/services/user_services.py:181 +#: authentication/services/user_services.py:184 msgid "token is not provided" msgstr "" -#: authentication/services/user_services.py:205 +#: authentication/services/user_services.py:208 msgid "No email token provided" msgstr "Токен не получен" -#: authentication/services/user_services.py:215 +#: authentication/services/user_services.py:218 msgid "Passwords do not match" msgstr "Пароли не совпадают" -#: authentication/services/user_services.py:253 +#: authentication/services/user_services.py:256 msgid "Current password is wrong" msgstr "Текущий пароль неверен" @@ -610,11 +610,15 @@ msgstr "Повторное приглашение сотруднику успе msgid "Business account password has been updated" msgstr "Пароль сотрудника успешно обновлен" -#: backend/urls.py:39 +#: backend/urls.py:79 msgid "Requested object does not exists" msgstr "" -#: backend/urls.py:44 +#: backend/urls.py:86 +msgid "Data size limit exceeded. Please reduce the size" +msgstr "Превышен лимит размера данных. Уменьшите размер" + +#: backend/urls.py:92 msgid "Token is invalid" msgstr "" @@ -628,7 +632,14 @@ msgstr "" msgid "A %(model)s with fields %(fields)s already exists" msgstr "Уже существует %(model)s с полями %(fields)s" +#: lib/parsers.py:18 +#, fuzzy +#| msgid "Invalid info payload" +msgid "Invalid JSON payload" +msgstr "Некорректные данные в поле info" + #: messages/serializers.py:44 ml_model/exceptions.py:86 +#: tools/chats/schemas.py:23 #, python-format msgid "The file size cannot exceed %(max_mb_size)d MB" msgstr "Файл не может быть размером больше %(max_mb_size)d мегабайт" @@ -638,7 +649,7 @@ msgstr "Файл не может быть размером больше %(max_mb msgid "Version %(version)s already has input with the same type: %(type)s" msgstr "Версия %(version)s уже имеет входные данные с таким же типом: %(type)s" -#: ml_model/apps.py:9 ml_model/models.py:148 +#: ml_model/apps.py:9 ml_model/models.py:158 msgid "Neuron Models" msgstr "Нейронные Модели" @@ -768,11 +779,15 @@ msgstr "Не найдено лицо на картинке. Попробуйте msgid "The input image may contain real person." msgstr "Загруженное изображение может содержать реального человека." -#: ml_model/exceptions.py:190 +#: ml_model/exceptions.py:186 +msgid "The generated image may contain private or prohibited content" +msgstr "Готовое изображение может содержать приватный или запрещённый контент" + +#: ml_model/exceptions.py:195 msgid "not specified" msgstr "не указана" -#: ml_model/exceptions.py:192 +#: ml_model/exceptions.py:197 #, python-format msgid "" "Version \"%(version)s\" is not available. Available versions: " @@ -780,332 +795,322 @@ msgid "" msgstr "" "Версия «%(version)s» недоступна. Доступные версии: %(available_versions)s." -#: 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 +#: ml_model/management/commands/create_indexes.py:27 +msgid "Redis is unavailable" +msgstr "Redis недоступен" + +#: ml_model/models.py:20 ml_model/models.py:40 ml_model/models.py:72 +#: ml_model/models.py:192 tools/media/models.py:67 msgid "Slug" msgstr "Ярлык" -#: ml_model/models.py:28 ml_model/models.py:80 +#: ml_model/models.py:30 ml_model/models.py:82 msgid "Category" msgstr "Категория" -#: ml_model/models.py:29 +#: ml_model/models.py:31 msgid "Categories" msgstr "Категории" -#: ml_model/models.py:42 +#: ml_model/models.py:44 msgid "Not SVG-pictures not allowed" msgstr "Нельзя использовать не SVG-картинки" -#: ml_model/models.py:45 +#: ml_model/models.py:47 msgid "Icon" msgstr "Миниатюра" -#: ml_model/models.py:52 +#: ml_model/models.py:54 msgid "Model Tag" msgstr "Тег модели" -#: ml_model/models.py:53 +#: ml_model/models.py:55 msgid "Model Tags" msgstr "Теги модели" -#: ml_model/models.py:66 +#: ml_model/models.py:68 msgid "Alternative Titles" msgstr "Альтернативные названия" -#: ml_model/models.py:68 ml_model/models.py:181 ml_model/models.py:270 +#: ml_model/models.py:70 ml_model/models.py:191 ml_model/models.py:280 #: payments/models/payment.py:52 msgid "Description" msgstr "Описание" -#: ml_model/models.py:72 +#: ml_model/models.py:74 msgid "Fill automatically, don't touch" msgstr "Заполняется автоматически, не трогать" -#: ml_model/models.py:88 +#: ml_model/models.py:90 msgid "Avatar" msgstr "Аватар" -#: ml_model/models.py:91 +#: ml_model/models.py:93 msgid "Tags" msgstr "Теги" -#: ml_model/models.py:147 payments/models/payment_plan_feature.py:18 +#: ml_model/models.py:157 payments/models/payment_plan_feature.py:18 msgid "Neuron Model" msgstr "Нейронная Модель" -#: ml_model/models.py:156 ml_model/models.py:403 payments/admin.py:101 +#: ml_model/models.py:166 ml_model/models.py:413 payments/admin.py:130 msgid "Model" msgstr "Модель" -#: ml_model/models.py:172 ml_model/models.py:173 +#: ml_model/models.py:182 ml_model/models.py:183 msgid "Settings" msgstr "Настройки" -#: ml_model/models.py:176 +#: ml_model/models.py:186 #, python-format msgid "Settings of %(model_title)s" msgstr "Настройки %(model_title)s" -#: ml_model/models.py:195 +#: ml_model/models.py:205 #, python-format msgid "%(model_title)s | %(version_name)s" msgstr "%(model_title)s | %(version_name)s" -#: ml_model/models.py:201 +#: ml_model/models.py:211 msgid "Model Version" msgstr "Версия Модели" -#: ml_model/models.py:202 +#: ml_model/models.py:212 msgid "Model Versions" msgstr "Версии Модели" -#: ml_model/models.py:211 +#: ml_model/models.py:221 msgid "Versions" msgstr "Версии" -#: ml_model/models.py:212 +#: ml_model/models.py:222 msgid "Link to versions" msgstr "Привязка к версиям" -#: ml_model/models.py:221 +#: ml_model/models.py:231 msgid "Text" msgstr "Текст" -#: ml_model/models.py:222 +#: ml_model/models.py:232 msgid "Image" msgstr "Картинка" -#: ml_model/models.py:223 +#: ml_model/models.py:233 msgid "PDF" msgstr "PDF" -#: ml_model/models.py:224 +#: ml_model/models.py:234 msgid "DOCX" msgstr "DOCX" -#: ml_model/models.py:225 +#: ml_model/models.py:235 msgid "DOC" msgstr "DOC" -#: ml_model/models.py:226 +#: ml_model/models.py:236 msgid "Text File (Notebook)" msgstr "Текстовый файл (Блокнот)" -#: ml_model/models.py:227 +#: ml_model/models.py:237 msgid "ZIP Archive" msgstr "ZIP архив" -#: ml_model/models.py:228 payments/tests/test_plans.py:35 +#: ml_model/models.py:238 payments/tests/test_plans.py:35 msgid "Audio" msgstr "Аудио" -#: ml_model/models.py:229 payments/tests/test_plans.py:31 +#: ml_model/models.py:239 payments/tests/test_plans.py:31 msgid "Video" msgstr "Видео" -#: ml_model/models.py:235 ml_model/models.py:273 +#: ml_model/models.py:245 ml_model/models.py:283 #: payments/models/promocode.py:41 msgid "Type" msgstr "Тип" -#: ml_model/models.py:237 ml_model/models.py:284 +#: ml_model/models.py:247 ml_model/models.py:294 msgid "Required" msgstr "Обязательный" -#: ml_model/models.py:240 +#: ml_model/models.py:250 #, python-format msgid "%(model_title)s | %(input_type)s" msgstr "%(model_title)s | %(input_type)s" -#: ml_model/models.py:246 +#: ml_model/models.py:256 msgid "Model Input" msgstr "Модель" -#: ml_model/models.py:247 +#: ml_model/models.py:257 msgid "Model Inputs" msgstr "Входящий поток модели" -#: ml_model/models.py:252 +#: ml_model/models.py:262 msgid "Integer" msgstr "Целое число" -#: ml_model/models.py:253 +#: ml_model/models.py:263 msgid "Float" msgstr "Вещественное число" -#: ml_model/models.py:254 +#: ml_model/models.py:264 msgid "String" msgstr "Строка" -#: ml_model/models.py:257 +#: ml_model/models.py:267 msgid "List" msgstr "Список" -#: ml_model/models.py:261 +#: ml_model/models.py:271 msgid "Float range" msgstr "Вещественный диапазон" -#: ml_model/models.py:265 +#: ml_model/models.py:275 msgid "Integer range" msgstr "Целочисленный диапазон" -#: ml_model/models.py:267 +#: ml_model/models.py:277 msgid "Logical" msgstr "Логический" -#: ml_model/models.py:280 +#: ml_model/models.py:290 msgid "Values" msgstr "Значения" -#: ml_model/models.py:281 +#: ml_model/models.py:291 msgid "" "These values can contain different interfaces and default value optional" msgstr "" "Значения могут содержать различные интерфейс и, опционально, значение по " "умолчанию" -#: ml_model/models.py:283 +#: ml_model/models.py:293 msgid "Hidden" msgstr "Скрытый" -#: ml_model/models.py:289 +#: ml_model/models.py:299 #, python-format msgid "Parameter of %(model_title)s" msgstr "Параметр %(model_title)s" -#: ml_model/models.py:292 +#: ml_model/models.py:302 msgid "Parameter" msgstr "Параметр" -#: ml_model/models.py:293 +#: ml_model/models.py:303 msgid "Parameters" msgstr "Параметры" -#: ml_model/models.py:298 +#: ml_model/models.py:308 msgid "Fixed" msgstr "Фикса" -#: ml_model/models.py:299 +#: ml_model/models.py:309 msgid "Per generation second" msgstr "За секунду генерации" -#: ml_model/models.py:300 +#: ml_model/models.py:310 msgid "Per one text token" msgstr "За один текстовый токен" -#: ml_model/models.py:301 +#: ml_model/models.py:311 msgid "Per image pixel" msgstr "За один пиксель" -#: ml_model/models.py:304 +#: ml_model/models.py:314 msgid "By input data" msgstr "По входящим данным" -#: ml_model/models.py:305 +#: ml_model/models.py:315 msgid "By output data" msgstr "По исходящим данным" -#: ml_model/models.py:306 +#: ml_model/models.py:316 msgid "By all data" msgstr "По всем данным" -#: ml_model/models.py:311 +#: ml_model/models.py:321 msgid "Strategy" msgstr "Стратегия" -#: ml_model/models.py:316 +#: ml_model/models.py:326 msgid "Interaction Type" msgstr "Тип взаимодействия" -#: ml_model/models.py:321 payments/models/invoice.py:19 +#: ml_model/models.py:331 payments/models/invoice.py:19 msgid "Cost" msgstr "Цена" -#: ml_model/models.py:322 +#: ml_model/models.py:332 msgid "In RUB, per specified strategy" msgstr "В рублях, за указанную стратегию" -#: ml_model/models.py:327 +#: ml_model/models.py:337 msgid "Coefficient" msgstr "Коэффициент" -#: ml_model/models.py:328 +#: ml_model/models.py:338 msgid "Cost multiplier" msgstr "Цена" -#: ml_model/models.py:335 +#: ml_model/models.py:345 msgid "Rate" msgstr "Ставка" -#: ml_model/models.py:339 +#: ml_model/models.py:349 msgid "Payment Rule" msgstr "Платежное правило" -#: ml_model/models.py:340 +#: ml_model/models.py:350 msgid "Payment Rules" msgstr "Платежные правила" -#: ml_model/models.py:401 +#: ml_model/models.py:411 msgid "Descriptor" msgstr "Дескриптор" -#: ml_model/models.py:407 +#: ml_model/models.py:417 #, python-format msgid "Instruction of %(model_title)s" msgstr "Инструкция %(model_title)s" -#: ml_model/models.py:410 +#: ml_model/models.py:420 msgid "Model Instruction" msgstr "Инструкция Модели" -#: ml_model/models.py:411 +#: ml_model/models.py:421 msgid "Model Instructions" msgstr "Инструкции Моделей" -#: ml_model/selectors/ml_models_selector.py:122 +#: ml_model/selectors/ml_models_selector.py:114 msgid "no model by this id" msgstr "Не найдено моделей по этому ID" -#: ml_model/services/FileService.py:110 tools/media/apis.py:258 +#: ml_model/services/FileService.py:110 tools/media/apis.py:280 #: tools/public_api/views/ml_service.py:56 -#: tools/public_api/views/providers/openai_compatible.py:209 +#: tools/public_api/views/providers/openai_compatible.py:208 msgid "Voice not found." msgstr "Голос не найден." -#: ml_model/services/chatgpt_5.py:133 ml_model/services/chatgpt_5_4.py:159 -#: ml_model/services/chatgpt_5_5.py:210 -msgid "The \"Use code\" option cannot be used together with an attached image." -msgstr "" -"Нельзя одновременно использовать параметр «Использовать код» вместе с " -"прикреплённым изображением." - -#: ml_model/services/chatgpt_5_4.py:318 ml_model/services/chatgpt_5_5.py:362 +#: ml_model/services/chatgpt.py:244 msgid "Image is ready" msgstr "Изображение готово" -#: ml_model/services/chatgpt_5_5.py:188 +#: ml_model/services/chatgpt.py:360 ml_model/services/claude.py:266 +#: ml_model/services/grok.py:190 msgid "File analysis" msgstr "Анализ файлов" +#: ml_model/services/chatgpt.py:382 ml_model/services/chatgpt_5.py:133 +msgid "The \"Use code\" option cannot be used together with an attached image." +msgstr "" +"Нельзя одновременно использовать параметр «Использовать код» вместе с " +"прикреплённым изображением." + #: ml_model/services/elevenlabs_music.py:45 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/minio_service.py:37 ml_model/services/minio_service.py:55 #: ml_model/services/minio_service.py:63 ml_model/services/minio_service.py:72 msgid "Unknown bucket destination" @@ -1115,7 +1120,7 @@ msgstr "Неизвестный бакет для загрузки" msgid "1080p output is not supported for Seedance Dreamina 2.0 Fast." msgstr "1080р разрешение не поддерживается для Seedance Dreamina 2.0 Fast." -#: ml_model/services/seedream.py:90 +#: ml_model/services/seedream.py:84 msgid "3K output is not supported for this model" msgstr "3К разрешение не поддерживается для этой модели" @@ -1123,7 +1128,7 @@ msgstr "3К разрешение не поддерживается для это msgid "No image given for improving" msgstr "Нет изображения для улучшения" -#: ml_model/tasks.py:137 +#: ml_model/tasks.py:144 msgid "Lyrics is too long" msgstr "Текст песни слишком длинный" @@ -1131,13 +1136,13 @@ msgstr "Текст песни слишком длинный" msgid "Model data cannot be retrieved" msgstr "Невозможно получить данные модели" -#: payments/admin.py:35 payments/admin.py:67 payments/admin.py:93 +#: payments/admin.py:35 payments/admin.py:76 payments/admin.py:122 msgid "You can search by user email, exacted company name" msgstr "" "Вы можете осуществлять поиск по e-mail пользователя, точному названию " "компании" -#: payments/admin.py:40 payments/admin.py:98 +#: payments/admin.py:40 payments/admin.py:127 msgid "Missing" msgstr "Отсутствующий" @@ -1178,10 +1183,6 @@ msgstr "Списания" msgid "Amount" msgstr "Количество" -#: lib/middleware.py:40 -msgid "Data size limit exceeded. Please reduce the size" -msgstr "Превышен лимит размера данных. Уменьшите размер" - #: payments/models/payment.py:41 msgid "Plan" msgstr "План" @@ -1359,19 +1360,19 @@ msgstr "Попытки" msgid "Payment Methods" msgstr "Платежные методы" -#: payments/routes/v1.py:92 +#: payments/routes/v1.py:93 msgid "You do not have an active subscription to cancel" msgstr "У вас нет активной подписки для отмены" -#: payments/routes/v1.py:93 +#: payments/routes/v1.py:94 msgid "The recurring payment is successfully cancelled" msgstr "Автоплатежи успешно отключены" -#: payments/routes/v1.py:143 +#: payments/routes/v1.py:144 msgid "Expenses" msgstr "Затраты" -#: payments/routes/v1.py:147 +#: payments/routes/v1.py:148 msgid "Refills" msgstr "Пополнения" @@ -1379,10 +1380,6 @@ msgstr "Пополнения" msgid "Messages for this model are not registered in a selector" msgstr "" -#: payments/services/model_billing_service.py:31 -msgid "Unknown account type" -msgstr "Неизвестный тип аккаунта" - #: payments/tests/test_plans.py:23 payments/tests/test_plans.py:205 #: payments/tests/test_plans.py:208 msgid "Chat-bots" @@ -1425,8 +1422,8 @@ msgstr "Публичный API" msgid "Media" msgstr "Медиа" -#: tools/chats/apis.py:205 tools/media/apis.py:211 -#: tools/public_api/views/base.py:104 +#: tools/chats/apis.py:201 tools/media/apis.py:229 +#: tools/public_api/views/base.py:102 msgid "" "An unexpected generation error has occurred. Please try again later or use a " "different model" @@ -1434,7 +1431,7 @@ msgstr "" "Произошла непредвиденная ошибка при генерации. Пожалуйста попробуйте позже " "или используйте другую модель" -#: tools/chats/apis.py:261 +#: tools/chats/apis.py:257 msgid "The message has already been deleted" msgstr "Сообщение уже было удалено" @@ -1447,7 +1444,33 @@ msgstr "Чат %(id)s" msgid "Chat" msgstr "Чат" -#: tools/media/apis.py:177 +#: tools/chats/routes/v1.py:37 tools/public_api/routes/v1.py:66 +#, fuzzy +#| msgid "User not found" +msgid "Stream not found" +msgstr "Пользователь не найден" + +#: tools/chats/routes/v1.py:51 +msgid "Chat not found" +msgstr "Чат не найден" + +#: tools/chats/routes/v1.py:54 tools/public_api/routes/v1.py:106 +msgid "Stream not supported for this model" +msgstr "Стриминг не поддерживается для этой модели" + +#: tools/chats/routes/v1.py:58 +msgid "Stream already in progress" +msgstr "" + +#: tools/chats/schemas.py:34 tools/public_api/views/ml_service.py:88 +msgid "Invalid info payload" +msgstr "Некорректные данные в поле info" + +#: tools/chats/services/sse_chat_stream.py:54 +msgid "Stream timeout" +msgstr "" + +#: tools/media/apis.py:221 msgid "" "Temporary issues with the service, we are already working on a solution." msgstr "Временные неполадки с сервисом, мы уже работаем над их решением." @@ -1508,19 +1531,11 @@ msgstr "Неизвестный файл" msgid "Voices" msgstr "Голоса" -#: tools/media/routes/v1.py:76 tools/public_api/views/voice.py:100 +#: tools/media/routes/v1.py:75 tools/public_api/views/voice.py:100 #: tools/public_api/views/voice.py:114 msgid "Voice not found" msgstr "Голос не найден" -#: tools/chats/routes/v1.py:36 -msgid "Chat not found" -msgstr "Чат не найден" - -#: tools/chats/routes/v1.py:41 -msgid "Stream not supported for this model" -msgstr "Стриминг не поддерживается для этой модели" - #: tools/public_api/exceptions.py:7 msgid "Upgrade token limit on your api-key" msgstr "Необходимо повысить лимит токенов у API-ключа" @@ -1541,60 +1556,113 @@ msgstr "API Ключ" msgid "API Keys" msgstr "API Ключи" -#: tools/public_api/views/base.py:65 -msgid "Key limit exceeded" -msgstr "Превышен лимит по ключу" +#: tools/public_api/routes/providers/openai.py:19 +#, fuzzy +#| msgid "Missing required parameter: model_id" +msgid "Missing required parameter: input" +msgstr "Отсутствует обязательный параметр: 'model_id'" -#: tools/public_api/views/base.py:70 -msgid "Model is blocked by outdating or temporary block, please retry later" -msgstr "" -"Модель заблокирована, т.к закончила обновляться или временно заблокирована, " -"попробуйте позже" +#: tools/public_api/routes/providers/openai.py:24 +#, fuzzy +#| msgid "Invalid info payload" +msgid "Invalid input payload" +msgstr "Некорректные данные в поле info" -#: tools/public_api/views/base.py:77 +#: tools/public_api/routes/providers/openai.py:58 +#: tools/public_api/routes/v1.py:109 tools/public_api/views/base.py:75 msgid "The request must not be empty" msgstr "Запрос не должен быть пустым" -#: tools/public_api/views/ml_service.py:88 -msgid "Invalid info payload" -msgstr "Некорректные данные в поле info" - -#: tools/public_api/views/providers/elevenlabs_compatible.py:133 -msgid "Missing required parameter: model_id" -msgstr "Отсутствует обязательный параметр: 'model_id'" - +#: tools/public_api/routes/providers/openai.py:85 #: tools/public_api/views/providers/elevenlabs_compatible.py:147 #: tools/public_api/views/providers/openai_compatible.py:110 -#: tools/public_api/views/providers/openai_compatible.py:246 +#: tools/public_api/views/providers/openai_compatible.py:245 msgid "Model not found" msgstr "Модель не найдена" +#: tools/public_api/routes/providers/openai.py:118 +#, fuzzy +#| msgid "File Uploading Not supported" +msgid "Only streaming is supported" +msgstr "Загрузка файлов не поддерживается" + +#: tools/public_api/routes/providers/openai.py:120 #: tools/public_api/views/providers/openai_compatible.py:61 msgid "You must provide a model parameter" msgstr "Необходимо указать параметр 'model'" +#: tools/public_api/routes/providers/openai.py:136 +msgid "Only streaming reconnect is supported" +msgstr "" + +#: tools/public_api/routes/providers/openai.py:138 +msgid "message_uuid is not provided" +msgstr "" + +#: tools/public_api/routes/v1.py:31 +msgid "No API Key in Authorization header" +msgstr "" + +#: tools/public_api/routes/v1.py:43 +#, fuzzy +#| msgid "API Key not found" +msgid "API key not found" +msgstr "API-ключ не найден" + +#: tools/public_api/routes/v1.py:46 +#, fuzzy +#| msgid "Access token is expired" +msgid "API key expired" +msgstr "Срок действия токена доступа истек" + +#: tools/public_api/routes/v1.py:48 +#, fuzzy +#| msgid "Key limit exceeded" +msgid "API key limit exceeded" +msgstr "Превышен лимит по ключу" + +#: tools/public_api/routes/v1.py:62 tools/public_api/routes/v1.py:96 +#, fuzzy +#| msgid "Host user is not registered for this account" +msgid "API key is not available for this account type" +msgstr "Пользователь бизнес-аккаунта не зарегистрирован для этого аккаунта" + +#: tools/public_api/routes/v1.py:104 tools/public_api/views/base.py:68 +msgid "Model is blocked by outdating or temporary block, please retry later" +msgstr "" +"Модель заблокирована, т.к закончила обновляться или временно заблокирована, " +"попробуйте позже" + +#: tools/public_api/views/base.py:63 +msgid "Key limit exceeded" +msgstr "Превышен лимит по ключу" + +#: tools/public_api/views/providers/elevenlabs_compatible.py:133 +msgid "Missing required parameter: model_id" +msgstr "Отсутствует обязательный параметр: 'model_id'" + #: tools/public_api/views/providers/openai_compatible.py:67 msgid "Missing required parameter: 'messages'" msgstr "Отсутствует обязательный параметр: 'messages'" -#: tools/public_api/views/providers/openai_compatible.py:177 +#: tools/public_api/views/providers/openai_compatible.py:176 msgid "Missing audio_sample." msgstr "Отсутствует параметр audio_sample." -#: tools/public_api/views/providers/openai_compatible.py:232 +#: tools/public_api/views/providers/openai_compatible.py:231 #, python-format msgid "Missing required parameter: %(param)s" msgstr "Отсутствует обязательный параметр: %(param)s" -#: tools/public_api/views/providers/openai_compatible.py:250 +#: tools/public_api/views/providers/openai_compatible.py:249 msgid "Voice must be an object with id." msgstr "Поле voice должно быть объектом с полем id." -#: tools/public_api/views/providers/openai_compatible.py:256 +#: tools/public_api/views/providers/openai_compatible.py:255 msgid "Voice id is empty." msgstr "Идентификатор голоса не указан." -#: tools/public_api/views/providers/openai_compatible.py:287 +#: tools/public_api/views/providers/openai_compatible.py:286 msgid "Failed to fetch generated audio" msgstr "Не удалось получить сгенерированное аудио" @@ -1610,6 +1678,21 @@ msgstr "Название голоса успешно обновлено" msgid "Preset voices are shared and cannot be deleted. Use your own voice id." msgstr "Пресеты общие и не удаляются. Используйте id собственного голоса." +#, python-format +#~ msgid "This video duration is not allowed for %(quality)s quality." +#~ msgstr "" +#~ "Для качества %(quality)s такая продолжительность видео не поддерживается." + +#~ msgid "" +#~ "Smooth motion mode is available only for 5-second videos at 540p and 720p " +#~ "quality" +#~ msgstr "" +#~ "Режим «Плавное движение» доступен только для 5-секундных видео в качестве " +#~ "540p и 720p" + +#~ msgid "Unknown account type" +#~ msgstr "Неизвестный тип аккаунта" + #~ msgid "No matching version found" #~ msgstr "Соответствующая версия не найдена" @@ -6,7 +6,9 @@ from rest_framework.views import APIView from messages.models import Message from messages.serializers import MessageSerializer +from ml_model.exceptions import FileNotProvided, InvalidParameterError from ml_model.models import ModelParameter +from ml_model.validators import ModelInputValidator from tools.chats.models import Chat @@ -34,7 +36,7 @@ class MessagesAPIView(APIView): """Create Message with ml_model in chat""" serializer = MessageSerializer(data=request.data) if serializer.is_valid(): - chat = Chat.objects.get(pk=chat_uid) + chat = Chat.objects.select_related('model').get(pk=chat_uid) info = {} if chat.model: service = chat.model.service @@ -54,6 +56,16 @@ class MessagesAPIView(APIView): case 'list': missing_info.update({p.key: p.default.split(',') if p.default else []}) merged_info = info | missing_info + try: + ModelInputValidator( + chat.model, + content=serializer.validated_data.get('content'), + file=serializer.validated_data.get('file'), + info=merged_info, + ).validate() + except (FileNotProvided, InvalidParameterError) as exc: + return Response({'detail': str(exc)}, status=400) + i = Message.objects.create( **serializer.validated_data, info=merged_info, @@ -11,9 +11,9 @@ from messages.services.message_service import MessageService from ml_model.exceptions import ( FileExtensionNotSupported, GenerationException, - NSFWDetectedException, RealPersonDetectedError, RequestBlocked, + OutputSensitiveImageContentError, ) from poller.models import Proxy from tools.chats.domain import RawSSEChunk @@ -364,7 +364,7 @@ class BytedanceModelArkAdapter: raise GenerationException from exc if error_code := data.get('error', {}).get('code', ''): if error_code == 'OutputImageSensitiveContentDetected': - raise NSFWDetectedException + raise OutputSensitiveImageContentError if image_data := data.get('data'): urls = [item.get('url') for item in image_data if isinstance(item, dict) and item.get('url')] @@ -1,6 +1,13 @@ +import time + import redis + from django.conf import settings +from django.core.management import CommandError from django.core.management.base import BaseCommand +from django.utils.translation import gettext as _ + + from redis.commands.search.field import TagField, TextField, VectorField from redis.commands.search.index_definition import IndexDefinition, IndexType @@ -11,6 +18,15 @@ class Command(BaseCommand): 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) + for attempt in range(10): + try: + redis_client.ping() + break + except (redis.ConnectionError, redis.TimeoutError): + if attempt == 9: + raise CommandError(_('Redis is unavailable')) + time.sleep(2) + index_configs = ( ('ml_model-index', 3072), ('ml_model-index-1536', 1536), @@ -9,6 +9,7 @@ from authentication.selectors.user_selector import UserSelector from ml_model.exceptions import NeuronModelNotExist from ml_model.models import ModelParameter, NeuronModel from ml_model.serializers import NeuronModelSerializer, NeuronModelsSerializer +from payments.services.payment_plan_service import PaymentPlanService from tools.chats.models import Chat from tools.media.models import Image, Video @@ -95,7 +96,8 @@ class NeuronModelSelector: return models def get_model_accessible_status(self, model: NeuronModel) -> bool: - in_plan = model in self.user.plan.accessed_models + plan = PaymentPlanService(self.user).get_plan_via_access() + in_plan = model in plan.accessed_models if self.user.account_type == 'business_account': return model.title in self.user.employee.parent_company.allowed_models and in_plan return in_plan @@ -58,7 +58,7 @@ class SimpleService(ABC): def translate_prompt(self, prompt: str, to: str = 'en'): if len(prompt) > 3000: - raise PromptLengthExceeded + return prompt return async_to_sync(self.translator.translate)(prompt, dest=to).text @abstractmethod @@ -35,6 +35,7 @@ from ml_model.services.base import StreamSimpleService from ml_model.services.openai_stream_mixin import OpenAIStreamMixin from payments.exceptions.insufficient_balance import InsufficientBalance from payments.selectors.payment_plan_selector import PaymentPlanSelector +from payments.services.payment_plan_service import PaymentPlanService from poller.models import Proxy from tools.chats.domain import RawSSEChunk @@ -340,7 +341,7 @@ class Chatgpt(Chatgpt_4, StreamSimpleService, OpenAIStreamMixin): predicted_input_price = Decimal(0) chunks: list[HumanMessage] = [] text_chunks: list[str] = [] - is_free_plan = self.store.user.plan.price <= 0 + is_free_plan = not PaymentPlanService(self.store.user).has_full_access() if is_free_plan: info.pop('code_interpreter', None) info.pop('verbosity', None) @@ -10,6 +10,7 @@ from langchain_core.messages import BaseMessage from messages.models import Message from ml_model.exceptions import ModelVersionNotAvailable, PaidPlanRequiredError from ml_model.services.chatgpt import Chatgpt +from payments.services.payment_plan_service import PaymentPlanService from poller.models import Proxy from tools.chats.domain import RawSSEChunk @@ -111,7 +112,7 @@ class Chatgpt_5_4(Chatgpt): model_name = input_message.info.get('version', self.BASE_VERSION) if model_name is None or model_name not in self.TOKENS_COST: raise ModelVersionNotAvailable(model_name, self.TOKENS_COST) - if self.store.user.plan.price <= 0 and model_name == 'gpt-5.4-pro': + if not PaymentPlanService(self.store.user).has_full_access() and model_name == 'gpt-5.4-pro': raise PaidPlanRequiredError('ChatGPT 5.4 PRO') if model_name == 'gpt-5.4-pro' and input_message.info.get('code_interpreter'): payload_message = copy(input_message) @@ -25,6 +25,7 @@ from ml_model.services.base import StreamSimpleService from ml_model.services.serper_mixin import SerperMixin from payments.exceptions.insufficient_balance import InsufficientBalance from payments.selectors.payment_plan_selector import PaymentPlanSelector +from payments.services.payment_plan_service import PaymentPlanService from poller.models import Proxy from tools.chats.domain import RawSSEChunk from tools.chats.models import Chat @@ -224,7 +225,7 @@ class Claude(SerperMixin, StreamSimpleService): if ( version_slug == 'claude-fable-5' and input_message.file - and self.store.user.plan.price > 0 + and PaymentPlanService(self.store.user).has_full_access() ): current_user_balance = PaymentPlanSelector(self.store.user).get_current_balance() if current_user_balance < (cost := Decimal('100')): @@ -240,7 +241,7 @@ class Claude(SerperMixin, StreamSimpleService): if version_slug == 'claude-fable-5': messages.insert(1, {'role': 'system', 'content': self.FABLE_SYSTEM_PROMPT}) - is_free_plan = self.store.user.plan.price <= 0 + is_free_plan = not PaymentPlanService(self.store.user).has_full_access() current_user_balance = PaymentPlanSelector(self.store.user).get_current_balance() is_low_balance = current_user_balance < Decimal('100') embedding_tokens = 0 @@ -19,6 +19,7 @@ from ml_model.exceptions import GenerationException, ModelVersionNotAvailable from ml_model.services.base import SimpleService from payments.exceptions.insufficient_balance import InsufficientBalance from payments.selectors.payment_plan_selector import PaymentPlanSelector +from payments.services.payment_plan_service import PaymentPlanService from ml_model.tasks import bytedance_model_ark_run, stream_bytedance_model_ark_run from tools.chats.domain import RawSSEChunk from tools.chats.models import Chat @@ -242,7 +243,7 @@ class Dola_Seed(SimpleService): predicted_input_price = ( predicted_input_tokens * price_map[prompt_type]['input'] / Decimal('1_000_000') ) - is_free_plan = self.store.user.plan.price <= 0 + is_free_plan = not PaymentPlanService(self.store.user).has_full_access() current_user_balance = PaymentPlanSelector(self.store.user).get_current_balance() max_output_tokens = min( max( @@ -12,6 +12,7 @@ from ml_model.services.base import SimpleService from ml_model.tasks import bytedance_model_ark_run, stream_bytedance_model_ark_run from payments.exceptions.insufficient_balance import InsufficientBalance from payments.selectors.payment_plan_selector import PaymentPlanSelector +from payments.services.payment_plan_service import PaymentPlanService from tools.chats.domain import RawSSEChunk from tools.chats.models import Chat from tools.copywrite.models import Copywrite @@ -65,7 +66,7 @@ class Glm_4_7(SimpleService): predicted_input_price = ( predicted_input_tokens * self.TOKENS_COST[version]['input'] / Decimal('1_000_000') ) - is_free_plan = self.store.user.plan.price <= 0 + is_free_plan = not PaymentPlanService(self.store.user).has_full_access() current_user_balance = PaymentPlanSelector(self.store.user).get_current_balance() max_output_tokens = min( max( @@ -20,6 +20,7 @@ from ml_model.services.FileService import FileProcessingService from ml_model.services.serper_mixin import SerperMixin from payments.exceptions.insufficient_balance import InsufficientBalance from payments.selectors.payment_plan_selector import PaymentPlanSelector +from payments.services.payment_plan_service import PaymentPlanService from poller.models import Proxy from tools.chats.domain import RawSSEChunk from tools.chats.models import Chat @@ -163,7 +164,7 @@ class Grok(SerperMixin, StreamSimpleService): self, input_message: Message, version: str, callback_data: dict ) -> tuple[list[dict[str, str | list]], int]: messages = self.get_chat_history() - is_free_plan = self.store.user.plan.price <= 0 + is_free_plan = not PaymentPlanService(self.store.user).has_full_access() current_user_balance = PaymentPlanSelector(self.store.user).get_current_balance() is_low_balance = current_user_balance < Decimal('100') messages.append({'role': 'user', 'content': input_message.content}) @@ -17,6 +17,7 @@ from ml_model.services.base import SimpleService from ml_model.tasks import openrouter_run from payments.exceptions.insufficient_balance import InsufficientBalance from payments.selectors.payment_plan_selector import PaymentPlanSelector +from payments.services.payment_plan_service import PaymentPlanService from poller.models import Proxy from tools.chats.models import Chat from tools.copywrite.models import Copywrite @@ -76,7 +77,7 @@ class Grok_4_1_Fast(SimpleService): + len(chunks) * 2100 * self.TOOLS_TOKEN_COSTS['text-embedding-3-small']['output'] ).quantize(Decimal('0.1'), rounding='ROUND_UP') if (balance := PaymentPlanSelector(self.store.user).get_current_balance()) < predict_price: - if self.store.user.plan.price <= 0: + if not PaymentPlanService(self.store.user).has_full_access(): return self.save_results( content='Файл не удаётся обработать — его размер больше максимально допустимого ' 'для вашего тарифа. Для продолжения выберите план с увеличенным лимитом.', @@ -47,7 +47,7 @@ class Seedream(SimpleService): def predict_price(cls, content: str, file_exists: bool, info: dict[str, Any]) -> Decimal | None: version = info.get('version', 'seedream-boosted') size = info.get('size', '2K') - price = cls.TOKEN_COST[version][size] + price = cls.TOKEN_COST[version].get(size, Decimal('0')) return price.quantize(Decimal('0.1'), rounding='ROUND_UP') @@ -1,5 +1,6 @@ from typing import Iterable +from django.utils.functional import Promise from django.utils.translation import gettext as _ # накинуть перевод через gettext_lazy @@ -140,11 +141,11 @@ class PredictionInterruptedError(Exception): class InvalidParameterError(Exception): - def __init__(self, error_text: str): + def __init__(self, error_text: str | Promise): self.error_text = error_text - def __str__(self): - return self.error_text + def __str__(self) -> str: + return str(self.error_text) class PromptLengthExceeded(Exception): def __init__(self, max_length: int = 3000) -> None: @@ -181,6 +182,11 @@ class RealPersonDetectedError(Exception): return _('The input image may contain real person.') +class OutputSensitiveImageContentError(Exception): + def __str__(self) -> str: + return _('The generated image may contain private or prohibited content') + + class ModelVersionNotAvailable(Exception): def __init__(self, version: str | None, available_versions: Iterable[str]) -> None: self.version = version @@ -208,6 +208,9 @@ def openrouter_run(version: str, messages: list, callback_data: dict, model_name input_tokens = data['usage']['prompt_tokens'] output_tokens = data['usage']['completion_tokens'] return (re.sub(r'\\+["n*]', '', answer), input_tokens, output_tokens) + if (data := resp.json()) and data.get('error', {}).get('message'): + if any(error in data['error']['message'] for error in ('PROHIBITED_CONTENT',)): + raise RequestBlocked logger.error(f'Error occured via model {model_name}. Data: {resp.content}') raise Exception(f'No answer from {model_name}, please retry later') @@ -0,0 +1,58 @@ +from typing import Any + +from django.db.models import Q +from django.utils.translation import gettext_lazy as _ + +from ml_model.exceptions import FileNotProvided, InvalidParameterError +from ml_model.models import ModelInput, NeuronModel + + +class ModelInputValidator: + def __init__( + self, + model: NeuronModel, + *, + content: str | None, + file: Any, + info: dict | None = None, + ) -> None: + self.model = model + self.content = content + self.file = file + self.info = info or {} + + def validate(self) -> None: + required_input_types = self._get_required_input_types() + + if ModelInput.TypeChoices.TEXT.value in required_input_types: + self._validate_text_input() + + required_file_input_types = required_input_types - {ModelInput.TypeChoices.TEXT.value} + if required_file_input_types: + self._validate_file_input(required_file_input_types) + + def _get_required_input_types(self) -> set[str]: + version = self.info.get('version') + required_inputs = self.model.inputs.filter(required=True) + if version: + required_inputs = required_inputs.filter( + Q(versions__isnull=True) | Q(versions__slug=version) + ).distinct() + else: + required_inputs = required_inputs.filter(versions__isnull=True) + + return set(required_inputs.values_list('type', flat=True)) + + def _validate_text_input(self) -> None: + if not self._has_text_content(): + raise InvalidParameterError(_('The request must not be empty')) + + def _validate_file_input(self, required_file_input_types: set[str]) -> None: + if not self.file: + input_type = sorted(required_file_input_types)[0] + input_label = ModelInput.TypeChoices(input_type).label + + raise FileNotProvided(input_label) + + def _has_text_content(self) -> bool: + return isinstance(self.content, str) and bool(self.content.strip()) @@ -0,0 +1,110 @@ +# Generated by Django 5.0 on 2026-07-24 11:45 + +import uuid +from datetime import timedelta + +import django.db.models.deletion +from django.db import migrations, models +from django.utils import timezone + + +def forwards_link_methods_and_attempts(apps, schema_editor): + PaymentPlanUserInfo = apps.get_model('payments', 'PaymentPlanUserInfo') + PaymentMethod = apps.get_model('payments', 'PaymentMethod') + PaymentAttempt = apps.get_model('payments', 'PaymentAttempt') + Payment = apps.get_model('payments', 'Payment') + + linked = set() + rows = ( + PaymentPlanUserInfo.objects.exclude(method_id=None) + .values_list('pk', 'user_id', 'method_id', 'next_payment_at', 'method__attempts') + .iterator() + ) + for ppi_id, user_id, method_id, next_at, attempts in rows: + linked.add(method_id) + PaymentMethod.objects.filter(pk=method_id).update( + user_plan_info_id=ppi_id, + primary=True, + active=True, + ) + if attempts <= 0: + continue + + failed_at = ( + Payment.objects.filter(user_id=user_id, status='canceled') + .order_by('-created_at') + .values_list('created_at', flat=True) + .first() + ) or timezone.now() + attempt = PaymentAttempt.objects.create(method_id=method_id, in_cycle=True) + PaymentAttempt.objects.filter(pk=attempt.pk).update( + created_at=failed_at, + updated_at=failed_at, + ) + if next_at is not None: + PaymentPlanUserInfo.objects.filter(pk=ppi_id).update( + next_payment_at=timezone.now() + timedelta(days=2), + ) + + PaymentMethod.objects.exclude(pk__in=linked).delete() + + +class Migration(migrations.Migration): + atomic = False + + dependencies = [ + ('payments', '0030_remove_paymentplan_points'), + ] + + operations = [ + migrations.CreateModel( + name='PaymentAttempt', + 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='Изменён')), + ('cancel_reason', models.CharField(blank=True, max_length=50, null=True, verbose_name='Cancel Reason')), + ('in_cycle', models.BooleanField(default=True, verbose_name='In Cycle')), + ('method', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payment_attempts', to='payments.paymentmethod', verbose_name='Payment Method')), + ], + options={ + 'verbose_name': 'Payment Attempt', + 'verbose_name_plural': 'Payment Attempts', + 'ordering': ('-created_at',), + }, + ), + migrations.AddField( + model_name='paymentmethod', + name='user_plan_info', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='methods', to='payments.paymentplanuserinfo', verbose_name='User Plan Info'), + ), + migrations.AddField( + model_name='paymentmethod', + name='active', + field=models.BooleanField(default=False, verbose_name='Active'), + ), + migrations.AddField( + model_name='paymentmethod', + name='primary', + field=models.BooleanField(default=True, verbose_name='Primary'), + ), + migrations.RunPython(forwards_link_methods_and_attempts, migrations.RunPython.noop), + migrations.RemoveField( + model_name='paymentmethod', + name='attempts', + ), + migrations.RemoveField( + model_name='paymentplanuserinfo', + name='method', + ), + migrations.AlterField( + model_name='paymentmethod', + name='user_plan_info', + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='methods', + to='payments.paymentplanuserinfo', + verbose_name='User Plan Info', + ), + ), + ] @@ -0,0 +1,24 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from core.models import BaseModel +from payments.models import PaymentMethod + + +class PaymentAttempt(BaseModel): + method = models.ForeignKey( + PaymentMethod, + on_delete=models.CASCADE, + verbose_name=_('Payment Method'), + related_name='payment_attempts', + ) + cancel_reason = models.CharField(max_length=50, blank=True, null=True, verbose_name=_('Cancel Reason')) + in_cycle = models.BooleanField(default=True, verbose_name=_('In Cycle')) + + def __str__(self) -> str: + return f'Attempt of ({self.method})\nReason: {self.cancel_reason}' + + class Meta: + verbose_name = _('Payment Attempt') + verbose_name_plural = _('Payment Attempts') + ordering = ('-created_at',) @@ -50,14 +50,6 @@ class PaymentPlanUserInfo(BaseModel): ) last_payment_at = models.DateField(verbose_name=_('Last 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') ) @@ -78,9 +70,15 @@ class PaymentPlanUserInfo(BaseModel): self.last_payment_at = datetime.now().date() return super().save(force_insert, force_update, using, update_fields) + @property + def primary_method(self): + return self.methods.filter(active=True, primary=True).first() + @property def is_recurring(self) -> bool: - return self.method is not None + if hasattr(self, 'primary_methods'): + return bool(self.primary_methods) + return self.methods.filter(active=True, primary=True).exists() def __str__(self) -> str: return f'{self.user.email or "Ошибка"}' @@ -1,7 +1,8 @@ -from django.db import models +from django.db import models, transaction from django.utils.translation import gettext_lazy as _ from core.models import BaseModel +from payments.models import PaymentPlanUserInfo class PaymentMethod(BaseModel): @@ -13,10 +14,30 @@ class PaymentMethod(BaseModel): T_BANK = ('tinkoff_bank', _('T-bank')) SBP = ('sbp', _('SBP')) + user_plan_info = models.ForeignKey( + PaymentPlanUserInfo, + on_delete=models.CASCADE, + verbose_name=_('User Plan Info'), + related_name='methods', + ) gateway = models.CharField(max_length=20, choices=GatewayChoices.choices, verbose_name=_('Gateway')) payment_method_id = models.UUIDField(unique=True, verbose_name=_('Payment method UID')) metadata = models.JSONField(verbose_name=_('Meta')) - attempts = models.PositiveSmallIntegerField(default=0, verbose_name=_('Attempts')) + active = models.BooleanField(default=False, verbose_name=_('Active')) + primary = models.BooleanField(default=True, verbose_name=_('Primary')) + + @property + def attempts(self): + return self.payment_attempts.filter(in_cycle=True).count() + + def save(self, *args, **kwargs): + with transaction.atomic(): + if self.primary: + self.active = True + self.__class__.objects.filter(user_plan_info=self.user_plan_info).exclude(pk=self.pk).update( + primary=False, active=False + ) + super().save(*args, **kwargs) def __str__(self) -> str: return f'{self.gateway} ({self.payment_method_id})' @@ -6,19 +6,18 @@ from collections import defaultdict from datetime import date, timedelta from decimal import Decimal -from django.utils import timezone +from django.db.models.aggregates import Count 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 import CharField, F, Func, Prefetch, Q, Sum, Value from django.db.models.functions import Round, TruncDay, TruncMonth, TruncYear 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 SyncAuthBearer, SyncAuthBearer -from payments.exceptions.payer_not_found import PayerNotFound +from authentication.security import SyncAuthBearer from authentication.exceptions.business_host_exceptions.access_denied import AccessDenied from payments.models import ( Invoice, @@ -36,6 +35,7 @@ from payments.schemas import ( PaymentPlanSchema, ) from payments.selectors.payment_plan_selector import PaymentPlanSelector +from payments.services.payment_method_service import PaymentMethodService from payments.typing import IntervalStrategyEnum, SourceStrategyEnum from payments.services.payment_service import PaymentService @@ -63,9 +63,25 @@ def handle_yookassa_webhook(request): logger.info('YooKassa webhook received: payment_id=%s', data['object']['id']) try: payment = PaymentService.handle_payment(data['object']['id']) - payer = CustomUserModel.objects.prefetch_related( - 'payment_plan', 'payment_plan__plan', 'payment_plan__method' - ).get(uid=payment.description) + payer = ( + CustomUserModel.objects.select_related( + 'payment_plan', + 'payment_plan__plan', + ) + .prefetch_related( + Prefetch( + 'payment_plan__methods', + queryset=PaymentMethod.objects.filter(primary=True, active=True).annotate( + total_attempts=Count( + 'payment_attempts', + filter=Q(payment_attempts__in_cycle=True), + ) + ), + to_attr='primary_methods', + ) + ) + .get(uid=payment.description) + ) PaymentService(payer).do_payment(payment) logger.info( 'YooKassa webhook processed: payment_id=%s payer_email=%s status=%s', @@ -74,7 +90,11 @@ def handle_yookassa_webhook(request): payment.status, ) except CustomUserModel.DoesNotExist: - raise HttpError(400, str(PayerNotFound)) + logger.info( + 'YooKassa webhook payer not found, ack without retry: payment_id=%s description=%s', + payment.id, + payment.description, + ) except Exception as exc: logger.exception(exc) raise HttpError(400, f'{exc}') @@ -83,13 +103,13 @@ def handle_yookassa_webhook(request): @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() + deactivate_count = PaymentMethodService(request.auth).deactivate_payment_methods() logger.info( - 'Recurring payment revoked by user: email=%s deleted_methods=%s', + 'Recurring payment revoked by user: email=%s deactivated_methods=%s', request.auth.email, - deleted_count, + deactivate_count, ) - if deleted_count == 0: + if deactivate_count == 0: raise HttpError(400, _('You do not have an active subscription to cancel')) return 200, {'detail': _('The recurring payment is successfully cancelled')} @@ -227,32 +247,3 @@ def create_payment_link(request, body: NewSubscriptionSchema): raise HttpError(403, str(exc)) except Exception as exc: raise HttpError(400, f'{exc}') - - -@router.post('gitlab-webhook', tags=['payments/gitlab-webhook'], auth=None) -def handle_gitlab_webhook(request): - """ - Currently disabled, pending future feature flags - """ - # try: - # 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 = PaymentMethod.objects.all().delete() - # logger.info( - # 'Recurring feature disabled: all payment methods removed count=%s', - # deleted_methods_count, - # ) - # updated_count = PaymentPlanUserInfo.objects.filter( - # plan__price__gt=0, - # plan__individual=False, - # ).update(next_payment_at=None if not is_active else (timezone.now() + timedelta(days=30))) - # logger.info( - # 'Recurring feature flag synced: active=%s updated_subscriptions=%s', - # is_active, - # updated_count, - # ) - # except Exception as exc: - # logger.error(exc) - return 200 @@ -19,7 +19,7 @@ class PaymentPlanSelector: return balance def get_free_plan(self, corporate: bool = False) -> PaymentPlan: - return PaymentPlan.objects.get_or_create(price=0, is_corporate=corporate)[0] + return PaymentPlan.objects.get(price=0, is_corporate=corporate) def is_plan_paid(self) -> bool: return self.user.payment_plan.plan.price != Decimal('0') @@ -1,9 +1,12 @@ import logging from django.conf import settings -from django.db.models import F +from django.db import transaction +from django.db.models import Q from authentication.models import CustomUserModel +from authentication.services.email_service import EmailService +from payments.models.attempt import PaymentAttempt from payments.models.user_payment_method import PaymentMethod @@ -16,28 +19,31 @@ class PaymentMethodService: def add_payment_method(self, yookassa_payment_method): gateway = yookassa_payment_method.type - if yookassa_payment_method.type in ('bank_card', 'sberbank', 'tinkoff_bank'): + if gateway in ('bank_card', 'sberbank', 'tinkoff_bank'): metadata = { 'card_type': yookassa_payment_method.card.card_type, 'last4': yookassa_payment_method.card.last4, } if source := getattr(yookassa_payment_method.card, 'source', None): gateway = source - elif yookassa_payment_method.type == 'yoo_money': + elif gateway == 'yoo_money': metadata = {'account_number': yookassa_payment_method.account_number} - elif yookassa_payment_method.type == 'sbp': + elif gateway == 'sbp': metadata = {'sbp_operation_id': yookassa_payment_method.sbp_operation_id} else: metadata = {} payment_method, created = PaymentMethod.objects.update_or_create( - user_plan_info__user=self.user, + payment_method_id=yookassa_payment_method.id, defaults={ + 'user_plan_info': self.user.payment_plan, 'gateway': gateway, - 'payment_method_id': yookassa_payment_method.id, 'metadata': metadata, - 'attempts': 0, + 'primary': True, + 'active': True, }, ) + if not created: + payment_method.payment_attempts.filter(in_cycle=True).update(in_cycle=False) logger.info( 'Payment method saved: email=%s method_uid=%s payment_method_id=%s created=%s', self.user.email, @@ -47,27 +53,49 @@ class PaymentMethodService: ) return payment_method - def inc_attempts(self) -> int: - return PaymentMethod.objects.filter(user_plan_info__user=self.user).update( - attempts=F('attempts') + 1 + def inc_attempts(self, cancel_reason: str) -> PaymentAttempt | None: + pp = self.user.payment_plan + method = pp.primary_methods[0] if pp.primary_methods else None + if not method: + return None + payment_attempt = PaymentAttempt.objects.create(method=method, cancel_reason=cancel_reason) + logger.info( + 'PaymentAttempt created: email=%s method_uid=%s reason=%s attempt_number=%s', + self.user.email, + method.uid, + cancel_reason, + method.attempts, + ) + return payment_attempt + + @classmethod + def get_next_postpone_payment_at(cls, attempts: int) -> int: + return settings.RECURRING_RETRY_OFFSETS[attempts] - settings.RECURRING_RETRY_OFFSETS[attempts - 1] + + def compare_attempts_with_max(self, attempts: int) -> bool: + if attempts >= len(settings.RECURRING_RETRY_OFFSETS): + self.deactivate_payment_methods() + logger.info( + 'Recurring payment methods deactivated due to attempts limit: email=%s attempts=%s', + self.user.email, + attempts, + ) + return True + return False + + def deactivate_payment_methods(self) -> int: + deactivated = ( + PaymentMethod.objects.filter(user_plan_info=self.user.payment_plan) + .filter(Q(active=True) | Q(primary=True)) + .update(active=False, primary=False) ) + if deactivated: - def compare_attempts_with_max(self, attempts: int) -> None: - if attempts >= settings.MAX_RECURRING_ATTEMPTS: - deleted = self.delete_payment_method() - if deleted: - logger.info( - 'Recurring payment method deleted due to attempts limit: email=%s attempts=%s', - self.user.email, - attempts, - ) - else: - logger.info( - 'Recurring payment method delete skipped after attempts limit, ' - 'payment method not found: email=%s attempts=%s', - self.user.email, - attempts, - ) + def _send(): + try: + EmailService.send_revoke_recurring_email(self.user.email) + except Exception: + logger.exception('Failed to send revoke recurring email') - def delete_payment_method(self) -> int: - return PaymentMethod.objects.filter(user_plan_info__user=self.user).delete()[0] + transaction.on_commit(_send) + return deactivated @@ -1,8 +1,14 @@ import logging +from datetime import timedelta from decimal import Decimal +from django.conf import settings +from django.db.models import F +from django.utils import timezone + from authentication.models import CustomUserModel from payments.models import Invoice, PaymentPlan, PaymentPlanUserInfo +from payments.selectors.payment_plan_selector import PaymentPlanSelector from payments.services.model_billing_service import ModelBillingService from payments.services.payment_service import PaymentService @@ -44,3 +50,44 @@ class PaymentPlanService: ModelBillingService(self.user).charge(payment_amount) if model: return Invoice.objects.create(model=model, user=self.user, cost=payment_amount) + + def inc_next_payment_at(self, time_diff: timedelta) -> None: + PaymentPlanUserInfo.objects.filter(user=self.user).update( + next_payment_at=F('next_payment_at') + time_diff + ) + + def has_full_access(self) -> bool: + pp = self.user.payment_plan_details + plan = pp.plan + if plan.price <= 0: + return False + if plan.individual or plan.is_corporate or pp.next_payment_at is None: + return True + + if hasattr(pp, 'primary_methods'): + method = pp.primary_methods[0] if pp.primary_methods else None + attempts = method.total_attempts if method else None + else: + method = pp.primary_method + attempts = method.attempts if method else None + if method is None: + return True + + offsets = settings.RECURRING_RETRY_OFFSETS + if not 1 <= attempts < len(offsets): + return True + + cutoff = settings.RECURRING_FULL_ACCESS_CUTOFF_DAY + cur, nxt = offsets[attempts - 1], offsets[attempts] + if cutoff > nxt: + return True + if cutoff <= cur: + return False + return pp.next_payment_at > timezone.now() + timedelta(days=nxt - cutoff) + + def get_plan_via_access(self): + return ( + self.user.plan + if self.has_full_access() + else PaymentPlanSelector(self.user).get_free_plan(corporate=self.user.plan.is_corporate) + ) \ No newline at end of file @@ -13,6 +13,7 @@ from yookassa import Payment as YookassaPayment from yookassa.domain.response import PaymentResponse as YookassaPaymentResponse from authentication.models import CustomUserModel +from authentication.services.email_service import EmailService from payments.models.payment import Payment as PaymentModel from payments.models.payment_plan import PaymentPlan, PaymentPlanUserInfo from payments.services.payment_method_service import PaymentMethodService @@ -62,22 +63,20 @@ class PaymentService: from payments.services.payment_plan_service import PaymentPlanService with transaction.atomic(): - payment_instance = self.save_payment(payment) + payment_instance, should_process = self.save_payment(payment) + if not should_process: + return payment_instance 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) + if payment.status == 'succeeded': + buying_tokens = self._calculate_buying_tokens( + payment_instance.plan, payment.metadata.get('recurring', False) ) - self.handle_succeeded_payment(payment, payment_instance.plan) + 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) @@ -86,7 +85,7 @@ class PaymentService: and payment.metadata.get('recurring') and self.user.payment_plan.is_recurring ): - self.handle_canceled_payment(payment) + self._handle_canceled_payment(payment) return payment_instance @@ -94,21 +93,16 @@ class PaymentService: def handle_payment(cls, payment_id: UUID) -> YookassaPaymentResponse: return YookassaPayment.find_one(payment_id) - def handle_captured_payment(self, payment_id: UUID) -> None: - idempotency_key = hashlib.sha256(f'capture:{payment_id}'.encode('utf-8')).hexdigest() - YookassaPayment.capture(str(payment_id), idempotency_key=idempotency_key) - 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'): + def _calculate_buying_tokens(self, plan: PaymentPlan, recurring: bool = False): + if self.user.payment_plan.plan.price == Decimal('0') or recurring: 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: + def _handle_succeeded_payment(self, payment: YookassaPaymentResponse, plan: PaymentPlan) -> None: if payment.payment_method.saved and not plan.individual: payment_method = PaymentMethodService(self.user).add_payment_method(payment.payment_method) PaymentPlanUserInfo.objects.filter(user=self.user).update( - method=payment_method, next_payment_at=timezone.now() + timedelta(days=30) + next_payment_at=timezone.now() + timedelta(days=30) ) logger.info( 'Recurring payment method saved: email=%s method_uid=%s next_payment_at_set=true', @@ -130,15 +124,17 @@ class PaymentService: 'Recurring schedule cleared: email=%s reason=individual_plan', self.user.email, ) - PaymentMethodService(self.user).delete_payment_method() + PaymentMethodService(self.user).deactivate_payment_methods() logger.info( - 'Recurring payment method deleted after succeeded payment: email=%s', self.user.email + 'Recurring payment methods deactivated after succeeded payment: email=%s', self.user.email ) - def handle_canceled_payment(self, payment: YookassaPaymentResponse) -> None: + def _handle_canceled_payment(self, payment: YookassaPaymentResponse) -> None: + from payments.services.payment_plan_service import PaymentPlanService + temporary_cancel_reasons = ( 'call_issuer', - 'expired_on_capture', + 'general_decline', 'insufficient_funds', 'internal_timeout', 'issuer_unavailable', @@ -146,46 +142,76 @@ class PaymentService: ) payment_method_service = PaymentMethodService(self.user) if payment.cancellation_details.reason in temporary_cancel_reasons: - updated = payment_method_service.inc_attempts() - if updated: - self.user.payment_plan.method.refresh_from_db(fields=['attempts']) + payment_attempt = payment_method_service.inc_attempts(payment.cancellation_details.reason) + if payment_attempt: + attempts = payment_attempt.method.total_attempts + 1 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_attempt.method.uid, + attempts, payment.cancellation_details.reason, ) - payment_method_service.compare_attempts_with_max( - attempts=self.user.payment_plan.method.attempts - ) + if ( + settings.RECURRING_RETRY_OFFSETS[attempts - 1] + in settings.RECURRING_FAILED_CHARGE_EMAIL_DAYS + ): + email = self.user.email + + def _send_failed_charge_email(): + try: + EmailService.send_failed_recurring_charge_email(email) + except Exception: + logger.exception('Failed to send failed recurring charge email') + + transaction.on_commit(_send_failed_charge_email) + deactivated = payment_method_service.compare_attempts_with_max(attempts=attempts) + if not deactivated: + postpone_days = payment_method_service.get_next_postpone_payment_at(attempts) + PaymentPlanService(self.user).inc_next_payment_at(timedelta(days=postpone_days)) else: logger.info( - 'Recurring payment retry skipped, payment method not found: email=%s', + 'Recurring payment retry skipped, no active primary payment method: email=%s', self.user.email, ) else: - deleted = payment_method_service.delete_payment_method() - if deleted: + deactivated = payment_method_service.deactivate_payment_methods() + if deactivated: logger.info( - 'Recurring payment method deleted after cancel: email=%s reason=%s', + 'Recurring payment methods deactivated after cancel: email=%s reason=%s', self.user.email, payment.cancellation_details.reason, ) else: logger.info( - 'Recurring payment method delete skipped, payment method not found: email=%s', + 'Recurring payment methods deactivate skipped, no active/primary methods to deactivate: email=%s', self.user.email, ) - def save_payment(self, payment: YookassaPaymentResponse) -> PaymentModel: - plan = PaymentPlan.objects.get_or_none(uid=payment.metadata.get('plan_uid')) + def save_payment(self, payment: YookassaPaymentResponse) -> tuple[PaymentModel, bool]: + existing = PaymentModel.objects.filter(uid=payment.id).first() + if ( + existing + and existing.status == payment.status + and existing.status + in ( + PaymentModel.SUCCEEDED, + PaymentModel.CANCELLED, + ) + ): + logger.info( + 'Duplicate webhook skipped: payment_id=%s email=%s status=%s', + payment.id, + self.user.email, + payment.status, + ) + return existing, False payment_instance, created = PaymentModel.objects.update_or_create( uid=payment.id, defaults=dict( user=self.user, amount=payment.amount.value, - plan=plan, + plan_id=payment.metadata.get('plan_uid'), status=payment.status, description=payment.description, ), @@ -196,7 +222,7 @@ class PaymentService: self.user.email, payment.status, created, - plan.uid if plan else None, + payment.metadata.get('plan_uid'), payment.amount.value, ) - return payment_instance + return payment_instance, True @@ -0,0 +1,10 @@ + + + + + Не удалось списать оплату + + +

Не удалось списать оплату за подписку. Мы повторим попытку позже - вам ничего делать не нужно.

+ + @@ -18,6 +18,7 @@ from payments.models import ( PromoCodeActivation, PaymentMethod, ) +from payments.models.attempt import PaymentAttempt from payments.models.referral_account import ReferralAccount, ReferralInvite @@ -54,6 +55,12 @@ class PaymentPlanAdmin(OrderedInlineModelAdminMixin, admin.ModelAdmin): search_fields = ['price', 'tokens_per_plan'] +class PaymentMethodInline(admin.TabularInline): + model = PaymentMethod + extra = 0 + show_change_link = True + + @admin.register(PaymentPlanUserInfo) class PaymentPlanUserInfoAdmin(admin.ModelAdmin): list_display = [ @@ -65,6 +72,8 @@ class PaymentPlanUserInfoAdmin(admin.ModelAdmin): 'updated_at', 'next_payment_at', ] + + inlines = [PaymentMethodInline] raw_id_fields = ['user'] search_fields = [ @@ -102,9 +111,43 @@ class PaymentPlanFeatureAdmin(OrderedModelAdmin): list_filter = ('plan', 'model__category') +class PaymentAttemptInline(admin.TabularInline): + model = PaymentAttempt + extra = 0 + show_change_link = True + fields = ('cancel_reason', 'in_cycle') + readonly_fields = ('created_at',) + + @admin.register(PaymentMethod) class PaymentMethodAdmin(admin.ModelAdmin): - list_display = ['gateway', 'payment_method_id', 'attempts'] + list_display = ['user_email', 'gateway', 'primary', 'active'] + list_filter = ['primary', 'active', 'gateway'] + inlines = [PaymentAttemptInline] + search_fields = ['user_plan_info__user__email'] + list_select_related = ['user_plan_info', 'user_plan_info__user'] + raw_id_fields = ['user_plan_info'] + + @admin.display(description=_('Email'), ordering='user_plan_info__user__email') + def user_email(self, obj: PaymentMethod): + return obj.user_plan_info.user.email + + +@admin.register(PaymentAttempt) +class PaymentAttemptAdmin(admin.ModelAdmin): + list_display = ('method_gateway', 'method_payment_method_id', 'cancel_reason', 'in_cycle') + list_filter = ('in_cycle',) + search_fields = ('method__user_plan_info__user__email',) + raw_id_fields = ('method',) + list_select_related = ('method', 'method__user_plan_info__user') + + @admin.display(description=_('Gateway'), ordering='method__gateway') + def method_gateway(self, obj: PaymentAttempt): + return obj.method.gateway + + @admin.display(description=_('Payment method UID'), ordering='method__payment_method_id') + def method_payment_method_id(self, obj: PaymentAttempt): + return obj.method.payment_method_id @admin.register(Invoice) @@ -30,7 +30,14 @@ class PaymentPlanSchema(Schema): individual: bool @staticmethod - def resolve_accessed_models(obj): + def resolve_accessed_models(obj, context): + request = context.get('request') if context else None + user = getattr(request, 'auth', None) if request else None + if user is not None: + from payments.services.payment_plan_service import PaymentPlanService + + plan = PaymentPlanService(user).get_plan_via_access() + return list(plan.accessed_models.values_list('slug', flat=True)) return list(obj.accessed_models.values_list('slug', flat=True)) @@ -1,14 +1,12 @@ import logging from typing import Type -from django.conf import settings -from django.db import transaction -from django.db.models.signals import post_save, pre_save, pre_delete +from django.db.models.signals import post_save 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.models import PaymentPlanUserInfo +from payments.services.payment_method_service import PaymentMethodService from payments.services.referral_account import ReferralAccountService logger = logging.getLogger(__name__) @@ -25,41 +23,11 @@ def init_referral_account( 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=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) + PaymentPlanUserInfo.objects.filter(pk=instance.pk).update(next_payment_at=None) + PaymentMethodService(instance.user).deactivate_payment_methods() \ No newline at end of file @@ -8,13 +8,13 @@ from celery import shared_task from celery.utils.log import get_task_logger from django.core.cache import cache from django.db import transaction -from django.db.models import F +from django.db.models import F, Prefetch 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 payments.models import PaymentPlan, PaymentPlanUserInfo +from payments.models import PaymentMethod, PaymentPlan, PaymentPlanUserInfo from payments.services.payment_plan_service import PaymentPlanService from yookassa import Payment as YookassaPayment @@ -44,33 +44,41 @@ def withdraw(user_id: UUID, amount: Decimal): @shared_task def execute_recurring_payments() -> None: overdue_payments = ( - PaymentPlanUserInfo.objects.select_related('user', 'plan', 'method') + PaymentPlanUserInfo.objects.select_related('user', 'plan') .filter( next_payment_at__isnull=False, next_payment_at__lte=timezone.now(), plan__price__gt=0, plan__individual=False, plan__is_corporate=False, - method__isnull=False, user__is_deleted=False, + methods__primary=True, + methods__active=True, + ) + .distinct() + .order_by('uid') + .prefetch_related( + Prefetch( + 'methods', + queryset=PaymentMethod.objects.filter(primary=True, active=True), + to_attr='primary_methods', + ) ) .only( 'uid', 'next_payment_at', 'user_id', 'plan_id', - 'method_id', 'user__uid', 'user__email', 'plan__uid', 'plan__price', 'plan__tokens_per_plan', - 'method__uid', - 'method__payment_method_id', - 'method__attempts', ) ) for overdue_payment in overdue_payments.iterator(chunk_size=CHUNK_SIZE): + if not overdue_payment.primary_methods: + continue customer = overdue_payment.user plan = overdue_payment.plan receipt_data = { @@ -86,7 +94,7 @@ def execute_recurring_payments() -> None: } payment_data = { 'amount': {'value': f'{plan.price}', 'currency': 'RUB'}, - 'payment_method_id': overdue_payment.method.payment_method_id, + 'payment_method_id': overdue_payment.primary_methods[0].payment_method_id, 'receipt': receipt_data, 'description': str(customer.uid), 'capture': True, @@ -100,7 +108,7 @@ def execute_recurring_payments() -> None: dt = timezone.make_aware(dt) period = dt.astimezone(dt_timezone.utc).replace(microsecond=0).isoformat() idempotency_key = hashlib.sha256( - f'recurring:{customer.uid}:{plan.uid}:{period}:{overdue_payment.method.attempts}'.encode('utf-8') + f'recurring:{customer.uid}:{plan.uid}:{period}'.encode('utf-8') ).hexdigest() YookassaPayment.create(payment_data, idempotency_key=idempotency_key) logger.info( @@ -108,7 +116,7 @@ def execute_recurring_payments() -> None: customer.email, plan.uid, plan.price, - overdue_payment.method.uid, + overdue_payment.primary_methods[0].uid, ) @@ -119,25 +127,37 @@ def revoke_recurring_payments() -> None: logger.info('Revoke recurring already running, skipping') return try: - qs = PaymentPlanUserInfo.objects.filter( - next_payment_at__isnull=False, - next_payment_at__lte=timezone.now(), - plan__price__gt=0, - plan__individual=False, - plan__is_corporate=False, - method__isnull=True, + qs = ( + PaymentPlanUserInfo.objects.filter( + next_payment_at__isnull=False, + next_payment_at__lte=timezone.now(), + plan__price__gt=0, + plan__individual=False, + plan__is_corporate=False, + ) + .exclude(methods__primary=True, methods__active=True) + .distinct() + .order_by('uid') ) canceled_count = 0 - free_regular_plan = PaymentPlan.objects.get_or_create(price=0, is_corporate=False)[0] + free_regular_plan = PaymentPlan.objects.get(price=0, is_corporate=False) qs_iter = qs.values_list('uid', flat=True).iterator(chunk_size=CHUNK_SIZE) while uids := list(islice(qs_iter, CHUNK_SIZE)): with transaction.atomic(): - canceled_count += qs.filter(uid__in=uids).update( - next_payment_at=None, - plan_id=free_regular_plan.pk, - current_token_balance=0, + canceled_count += ( + qs.filter(uid__in=uids) + .exclude( + methods__primary=True, + methods__active=True, + ) + .distinct() + .update( + next_payment_at=None, + plan_id=free_regular_plan.pk, + current_token_balance=0, + ) ) logger.info('Revoke recurring finished: free_regular=%s', canceled_count) finally: @@ -8,8 +8,10 @@ from ninja.errors import HttpError from authentication.security import SyncAuthBearer from messages.models import Message +from ml_model.exceptions import FileNotProvided, InvalidParameterError from ml_model.models import NeuronModel from ml_model.schemas import NeuronModelLink +from ml_model.validators import ModelInputValidator from tools.chats.models import Chat from tools.chats.schemas import MessageInSchema from tools.chats.services.sse_chat_stream import SSEChatStreamService @@ -58,6 +60,16 @@ def stream_message(request, chat_uid: UUID, body: MessageInSchema): raise HttpError(409, _('Stream already in progress')) data = body.model_dump(include={'content', 'file', 'info'}, exclude_unset=True) + try: + ModelInputValidator( + chat.model, + content=data.get('content'), + file=data.get('file'), + info=data.get('info'), + ).validate() + except (FileNotProvided, InvalidParameterError) as exc: + raise HttpError(400, str(exc)) + input_message = Message.objects.create(content_object=chat, from_model=False, **data) store.start() @@ -23,6 +23,7 @@ from ml_model.exceptions import ( DeploymentDisabled, ExceededContextLengthError, FileExtensionNotSupported, + FileNotProvided, FileTooLargeError, FileUploadUnsupported, ImageAnalysisError, @@ -32,10 +33,12 @@ from ml_model.exceptions import ( PaidPlanRequiredError, PromptLengthExceeded, RequestBlocked, + OutputSensitiveImageContentError, TemplateNotFound, TemplateUnknownException, UnrecognizedFileError, ) +from ml_model.validators import ModelInputValidator from payments.exceptions.insufficient_balance import InsufficientBalance from tools.chats.models import Chat from tools.chats.permissions import IsChatAvailable @@ -153,9 +156,19 @@ class MessagesAPIView(APIView): """ serializer = MessageSerializer(data=request.data) if serializer.is_valid(): - chat = Chat.objects.get(pk=chat_uid) + chat = Chat.objects.select_related('model').get(pk=chat_uid) info = serializer.validated_data.pop('info', {}) service = chat.model.service + try: + ModelInputValidator( + chat.model, + content=serializer.validated_data.get('content'), + file=serializer.validated_data.get('file'), + info=info, + ).validate() + except (FileNotProvided, InvalidParameterError) as exc: + return Response({'detail': str(exc)}, status=HTTP_400_BAD_REQUEST) + input_message = Message.objects.create( **serializer.validated_data, info=info, @@ -166,11 +179,11 @@ class MessagesAPIView(APIView): output_messages = service(chat).make(input_message) except DeploymentDisabled as exc: return Response( - {'detail': f'{exc}'}, + {'detail': str(exc)}, status=HTTP_503_SERVICE_UNAVAILABLE, ) except PaidPlanRequiredError as exc: - return Response({'detail': f'{exc}'}, status=HTTP_402_PAYMENT_REQUIRED) + return Response({'detail': str(exc)}, status=HTTP_402_PAYMENT_REQUIRED) except ( FileExtensionNotSupported, ExceededContextLengthError, @@ -183,19 +196,20 @@ class MessagesAPIView(APIView): UnrecognizedFileError, InvalidParameterError, ModelVersionNotAvailable, + OutputSensitiveImageContentError, ImageTooLargeError, ) as exc: - return Response({'detail': f'{exc}'}, status=HTTP_400_BAD_REQUEST) + return Response({'detail': str(exc)}, status=HTTP_400_BAD_REQUEST) except TemplateNotFound as exc: - return Response({'detail': f'{exc}'}, status=HTTP_500_INTERNAL_SERVER_ERROR) + return Response({'detail': str(exc)}, status=HTTP_500_INTERNAL_SERVER_ERROR) except TemplateUnknownException as exc: logger.exception(exc) - return Response({'detail': f'{exc}'}, status=HTTP_500_INTERNAL_SERVER_ERROR) + return Response({'detail': str(exc)}, status=HTTP_500_INTERNAL_SERVER_ERROR) except Exception as exc: input_message.is_sent = False input_message.save() if isinstance(exc, InsufficientBalance): - return Response({'detail': f'{exc}'}, status=HTTP_402_PAYMENT_REQUIRED) + return Response({'detail': str(exc)}, status=HTTP_402_PAYMENT_REQUIRED) logger.exception(exc) return Response( { @@ -30,11 +30,13 @@ from ml_model.exceptions import ( PromptLengthExceeded, RealPersonDetectedError, RequestBlocked, + OutputSensitiveImageContentError, ServiceHighDemandError, UnrecognizedFileError, UnsupportedSize, ) from ml_model.models import NeuronModel +from ml_model.validators import ModelInputValidator from payments.exceptions.insufficient_balance import InsufficientBalance from .models import Audio, Image, Video, VoiceClone, Voice, Preset @@ -126,7 +128,7 @@ class MediaAPIView(APIView): """ List Messages """ - gallery, _ = self.manager.objects.get_or_create( + gallery, _ = self.manager.objects.select_related('model').get_or_create( user=request.user, model__slug=model, defaults={ @@ -170,7 +172,7 @@ class MediaAPIView(APIView): serializer = MessageSerializer(data=data) if serializer.is_valid(): - gallery, created = self.manager.objects.get_or_create( + gallery, created = self.manager.objects.select_related('model').get_or_create( user=request.user, model__slug=model, defaults={ @@ -180,6 +182,16 @@ class MediaAPIView(APIView): ) info = serializer.validated_data.pop('info', {}) service = gallery.model.service + try: + ModelInputValidator( + gallery.model, + content=serializer.validated_data.get('content'), + file=serializer.validated_data.get('file'), + info=info, + ).validate() + except (FileNotProvided, InvalidParameterError) as exc: + return Response({'detail': str(exc)}, status=HTTP_400_BAD_REQUEST) + input_message = Message.objects.create( **serializer.validated_data, info=info, @@ -207,14 +219,15 @@ class MediaAPIView(APIView): UnrecognizedFileError, FaceNotFoundError, RealPersonDetectedError, + OutputSensitiveImageContentError, ImageTooLargeError, ) as exc: - return Response({'detail': f'{exc}'}, status=HTTP_400_BAD_REQUEST) + return Response({'detail': str(exc)}, status=HTTP_400_BAD_REQUEST) except Exception as exc: input_message.is_sent = False input_message.save() if isinstance(exc, InsufficientBalance): - return Response({'detail': f'{exc}'}, status=HTTP_402_PAYMENT_REQUIRED) + return Response({'detail': str(exc)}, status=HTTP_402_PAYMENT_REQUIRED) logger.exception(exc) if any(phrase in str(exc) for phrase in ('Insufficient credit', 'Request was throttled')): return Response( @@ -7,7 +7,9 @@ from ninja.errors import HttpError from django.utils.translation import gettext as _ from messages.models import Message +from ml_model.exceptions import FileNotProvided, InvalidParameterError from ml_model.selectors.ml_models_selector import NeuronModelSelector +from ml_model.validators import ModelInputValidator from tools.chats.schemas import MessageInSchema from tools.chats.services.sse_chat_stream import SSEChatStreamService @@ -105,10 +107,17 @@ def public_stream_message(request, model_slug: str, body: MessageInSchema): if not model.streaming: raise HttpError(501, _('Stream not supported for this model')) - if not body.content: - raise HttpError(400, _('The request must not be empty')) - data = body.model_dump(include={'content', 'file', 'info'}, exclude_unset=True) + try: + ModelInputValidator( + model, + content=data.get('content'), + file=data.get('file'), + info=data.get('info'), + ).validate() + except (FileNotProvided, InvalidParameterError) as exc: + raise HttpError(400, str(exc)) + input_message = Message.objects.create( content_object=api_store, from_model=False, from_public_api=True, **data ) @@ -14,10 +14,11 @@ from rest_framework.views import APIView from messages.models import Message from messages.serializers import MessageSerializer from ml_model.choices import ContentTypes -from ml_model.exceptions import InvalidParameterError +from ml_model.exceptions import FileNotProvided, InvalidParameterError from ml_model.models import NeuronModel from ml_model.selectors.ml_models_selector import NeuronModelSelector from ml_model.serializers import PublicNeuronModelSerializer +from ml_model.validators import ModelInputValidator from payments.exceptions.insufficient_balance import InsufficientBalance from tools.public_api.models import APIKey, APIStore from tools.public_api.permissions import HasAPIKey @@ -70,13 +71,18 @@ class BaseGenerationView(APIView): ) serializer = MessageSerializer(data=request.data) serializer.is_valid(raise_exception=True) - if not serializer.validated_data.get('content'): - return Response( - {'detail': _('The request must not be empty')}, - status=HTTP_400_BAD_REQUEST, - ) service = model.service info = serializer.validated_data.pop('info', {}) + try: + ModelInputValidator( + model, + content=serializer.validated_data.get('content'), + file=serializer.validated_data.get('file'), + info=info, + ).validate() + except (FileNotProvided, InvalidParameterError) as exc: + return Response({'detail': str(exc)}, status=HTTP_400_BAD_REQUEST) + input_message = Message.objects.create( **serializer.validated_data, info=info, @@ -92,9 +98,9 @@ class BaseGenerationView(APIView): input_message.is_sent = False input_message.save() if isinstance(exc, InsufficientBalance): - return Response({'detail': f'{exc}'}, status=HTTP_402_PAYMENT_REQUIRED) + return Response({'detail': str(exc)}, status=HTTP_402_PAYMENT_REQUIRED) elif isinstance(exc, (InvalidParameterError, ValidationError)): - return Response({'detail': f'{exc}'}, status=HTTP_400_BAD_REQUEST) + return Response({'detail': str(exc)}, status=HTTP_400_BAD_REQUEST) logger.exception(exc) return Response( { @@ -70,6 +70,9 @@ BUSINESS_EMAIL_RECIPIENT=help@root.ru YOOKASSA_ACCOUNT_ID=322563 YOOKASSA_SECRET_KEY=test_i_Au0KbXnOmdVf1icljT7v4CuDHLG8mXVkyofJQFBns YOOKASSA_RESULT_PAYMENT_URL=http://localhost +RECURRING_RETRY_OFFSETS=1,3,5,8,12,16,21,28 +RECURRING_FULL_ACCESS_CUTOFF_DAY=4 +RECURRING_FAILED_CHARGE_EMAIL_DAYS=3 USER_CONFIRMATION_URL='https://app.air.fail/confirm' USER_PASSWORD_RESET_URL='https://app.air.fail/changePassword' INVITATION_RESPONSE_URL='https://app.air.fail/business/confirm' @@ -27,3 +27,7 @@ celerybeat-schedule .vscode/ scripts/ + +# Codex local instructions +AGENTS.md +agents.md @@ -39,7 +39,7 @@ services: - /bin/sh - -c - | - python manage.py compilemessages --locale ru_RU + python manage.py compilemessages gunicorn backend.wsgi:application networks: - default