@@ -34,3 +34,9 @@ class UserAlreadyExists(Exception): class DomainNotFound(Exception): def __str__(self): return _('Domain not found') + + +class EmailSendFailed(Exception): + def __str__(self): + return _('Failed to send the email. Verify that the email exists and is available') + @@ -1,6 +1,6 @@ import logging +import smtplib from datetime import datetime -from decimal import Decimal from typing import Any, Sequence import dns.resolver @@ -8,12 +8,13 @@ from django.conf import settings from django.core.mail import EmailMessage, send_mail from django.template import TemplateDoesNotExist from django.template.loader import get_template +from django.utils import timezone from django.utils.html import strip_tags from django.utils.safestring import SafeString from django.utils.translation import gettext_lazy as _ from authentication.exceptions.email_exceptions import LetterNotFound, LetterUnknownException -from authentication.exceptions.user import DomainNotFound +from authentication.exceptions.user import DomainNotFound, EmailSendFailed from authentication.models import BusinessAccount, BusinessUserHost from authentication.models.user import CustomUserModel from authentication.services.email_token_service import EmailTokenService @@ -47,6 +48,10 @@ class EmailService: ) except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN, dns.exception.Timeout): raise DomainNotFound + except smtplib.SMTPRecipientsRefused: + raise DomainNotFound + except smtplib.SMTPDataError: + raise EmailSendFailed except Exception as exc: logger.exception(exc) raise Exception(_('Error occured when proceed email sending')) @@ -166,22 +171,65 @@ class EmailService: ) @classmethod - def send_revoke_recurring_email(cls, email: str) -> None: + def _subscription_greeting(cls, user: CustomUserModel) -> str: + if user.account_type == 'business_host': + return user.email + return f'{user.first_name} {user.last_name}'.strip() + + @classmethod + def send_revoke_recurring_email(cls, user: CustomUserModel) -> None: html_message = cls._render_letter_template( - template_name='payments/revoke_recurring_email', context={} + template_name='payments/revoke_recurring_email', + context={'greeting': cls._subscription_greeting(user)}, ) - cls.send_email( - 'Отмена подписки на платформе AIR', html_message, (email,) + cls.send_email('Отмена подписки на платформе AIR', html_message, (user.email,)) + logger.info('Revoke recurring email sent: email=%s', user.email) + + @classmethod + def send_failed_subscription_renewal_email(cls, user: CustomUserModel) -> None: + html_message = cls._render_letter_template( + template_name='payments/failed_subscription_renewal_email', + context={'greeting': cls._subscription_greeting(user)}, + ) + cls.send_email('Не удалось продлить подписку на AIR', html_message, (user.email,)) + logger.info('Failed subscription renewal email sent: email=%s', user.email) + + @classmethod + def send_account_deleted_email(cls, user: CustomUserModel, *, subscription_cancelled: bool) -> None: + html_message = cls._render_letter_template( + template_name='payments/account_deleted_email', + context={ + 'greeting': cls._subscription_greeting(user), + 'subscription_cancelled': subscription_cancelled, + }, + ) + cls.send_email('Аккаунт успешно удален', html_message, (user.email,)) + logger.info( + 'Account deleted email sent: email=%s subscription_cancelled=%s', + user.email, + subscription_cancelled, ) - 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,) - ) + cls.send_email('Не удалось списать оплату', html_message, (email,)) logger.info('Failed recurring charge email sent: email=%s', email) + @classmethod + def send_successful_recurring_email(cls, user: CustomUserModel, next_payment_at: datetime) -> None: + html_message = cls._render_letter_template( + template_name='payments/successful_recurring_email', + context={ + 'greeting': cls._subscription_greeting(user), + 'next_payment_at': timezone.localtime(next_payment_at).strftime('%d.%m.%Y'), + }, + ) + cls.send_email( + 'Ваша подписка на AIR продлена автоматически', + html_message, + (user.email,), + ) + logger.info('Successful recurring email sent: email=%s', user.email) @@ -4,7 +4,7 @@ from uuid import UUID from django.contrib.auth import authenticate, login, logout from django.db.models import Q, QuerySet -from django.db.transaction import atomic +from django.db.transaction import atomic, on_commit from django.utils.translation import gettext_lazy as _ from rest_framework.request import Request @@ -279,7 +279,7 @@ class UserService: if self.user.account_type in ('business_admin', 'business_security', 'business_account'): self.user.business_account.delete() - PaymentMethodService(self.user).deactivate_payment_methods() + deactivated_count = PaymentMethodService(self.user).deactivate_payment_methods(notify=None) free_plan = PaymentPlanSelector(self.user).get_free_plan( corporate=self.user.payment_plan.plan.is_corporate ) @@ -293,6 +293,17 @@ class UserService: self.user.is_confirmed = False self.user.save() + user = self.user + subscription_cancelled = deactivated_count > 0 + + def _send_account_deleted_email(): + try: + EmailService.send_account_deleted_email(user, subscription_cancelled=subscription_cancelled) + except Exception: + logger.exception('Failed to send account deleted email') + + on_commit(_send_account_deleted_email) + @classmethod def exists_in_whitelist(cls, request: Request) -> bool: return PolicyWhitelist.objects.filter(emails__contains=[request.query_params['email']]).exists() @@ -194,4 +194,76 @@ PATH_PREFETCH_MAP = { *_gen_only('payment_plan__plan', 'uid', 'price'), ), }, + '/api/v1/payments/restore-subscription': { + 'select': ( + 'host_account', + 'host_account__company_companyipwhitelist', + 'business_account', + 'business_account__parent_company', + 'business_account__parent_company__company_companyipwhitelist', + 'payment_plan', + 'payment_plan__plan', + ), + 'prefetch': ( + Prefetch( + 'payment_plan__methods', + queryset=PaymentMethod.objects.filter(active=True) + .order_by('-primary', '-created_at') + .only('uid', 'created_at', 'payment_method_id', 'primary', 'active', 'user_plan_info_id'), + to_attr='active_methods', + ), + ), + 'only': ( + 'uid', + 'email', + 'is_staff', + 'is_superuser', + *_gen_only('host_account', 'uid'), + *_gen_only('host_account__company_companyipwhitelist', 'uid', 'is_enabled'), + *_gen_only('business_account', 'account_privileges'), + *_gen_only('business_account__parent_company', 'uid'), + *_gen_only( + 'business_account__parent_company__company_companyipwhitelist', + 'uid', + 'is_enabled', + ), + *_gen_only('payment_plan', 'uid'), + *_gen_only( + 'payment_plan__plan', + 'uid', + 'price', + 'tokens_per_plan', + ), + ), + }, + '/api/v1/payments/restore-subscription/blocked': { + 'select': ( + 'host_account', + 'host_account__company_companyipwhitelist', + 'business_account', + 'business_account__parent_company', + 'business_account__parent_company__company_companyipwhitelist', + 'payment_plan', + ), + 'only': ( + 'uid', + 'email', + 'is_staff', + 'is_superuser', + *_gen_only('host_account', 'uid'), + *_gen_only('host_account__company_companyipwhitelist', 'uid', 'is_enabled'), + *_gen_only('business_account', 'account_privileges'), + *_gen_only('business_account__parent_company', 'uid'), + *_gen_only( + 'business_account__parent_company__company_companyipwhitelist', + 'uid', + 'is_enabled', + ), + *_gen_only( + 'payment_plan', + 'uid', + 'recovery_locked_at', + ), + ), + }, } @@ -46,6 +46,7 @@ from authentication.exceptions.email_token import EmailTokenNotFound from authentication.exceptions.user import ( DomainNotFound, EmailNotConfirmed, + EmailSendFailed, PasswordsDoNotMatch, UserAlreadyExists, WrongEmail, @@ -236,6 +237,8 @@ class UserAPIView(APIView): return Response({'detail': f'{exc}'}, status=status.HTTP_400_BAD_REQUEST) except DomainNotFound: return Response({'detail': _('Email not found')}, status=status.HTTP_400_BAD_REQUEST) + except EmailSendFailed as exc: + return Response({'detail': f'{exc}'}, status=status.HTTP_400_BAD_REQUEST) except Exception as exc: logger.exception(exc) return Response( @@ -315,7 +318,9 @@ class BusinessHostAPIView(APIView): except AdminCreateForbidden as err: return Response({'detail': str(err)}, status=status.HTTP_403_FORBIDDEN) except DomainNotFound: - return Response({'detail': _('Email sending error: email not found')}, status=status.HTTP_400_BAD_REQUEST) + return Response( + {'detail': _('Email sending error: email not found')}, status=status.HTTP_400_BAD_REQUEST + ) except Exception as err: return Response({'detail': f'{err}'}, status=status.HTTP_400_BAD_REQUEST) @@ -361,13 +366,14 @@ class ReinviteBusinessAccountAPIView(APIView): """Reinvite business account including generation of a new password""" try: business_account = BusinessAccountSelector.filter_by_email( - email, - BusinessAccountService.get_company_name(request.user) + email, BusinessAccountService.get_company_name(request.user) ) BusinessHostService(request.user).reinvite_business_account(business_account=business_account) return Response({'detail': _('Business account has been reinvited')}, status=status.HTTP_200_OK) except DomainNotFound: - return Response({'detail': _('Email sending error: email not found')}, status=status.HTTP_400_BAD_REQUEST) + return Response( + {'detail': _('Email sending error: email not found')}, status=status.HTTP_400_BAD_REQUEST + ) except LetterNotFound as exc: return Response({'detail': f'{exc}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) except LetterUnknownException as exc: @@ -433,11 +439,15 @@ class ChangeBusinessAccountPassAPIView(APIView): BusinessAccountService.get_company_name(self.request.user), ) if not business_account: - return Response({'detail': _('Business account not found')}, status=status.HTTP_400_BAD_REQUEST) + return Response( + {'detail': _('Business account not found')}, status=status.HTTP_400_BAD_REQUEST + ) BusinessAccountService(business_account).update_user_password( request.user, data['password_1'], data['password_2'] ) - return Response({'detail': _('Business account password has been updated')}, status=status.HTTP_200_OK) + return Response( + {'detail': _('Business account password has been updated')}, status=status.HTTP_200_OK + ) except (UnconfirmedUserChangePass, PasswordsDoNotMatch, AdminPasswordChangeForbidden) as exc: return Response({'detail': str(exc)}, status=status.HTTP_403_FORBIDDEN) except Exception as exc: @@ -647,7 +657,7 @@ class HostInvitationAPIView(APIView): request=AccountDataUpdateSerializer, responses={200: BusinessAccountDataSerializer}, ) - def put(self, request, user_email : str, *args, **kwargs): + def put(self, request, user_email: str, *args, **kwargs): """Update company sub-user.""" try: serializer = AccountDataUpdateSerializer(data=request.data) @@ -460,7 +460,8 @@ if (SENTRY_URL := env.str('SENTRY_URL', '')) and RELEASE and ENVIRONMENT: 'PromptLengthExceeded', 'InvalidParameterError', 'UnsupportedSize', - 'OutputSensitiveImageContentError' + 'OutputSensitiveImageContentError', + 'InputImageSensitiveContentError', ], ) @@ -97,24 +97,7 @@ def invalid_password_error_handler(request, exc: InvalidPassword): return api.create_response(request, {'message': _('Wrong password')}, status=401) -urlpatterns = ( - [ - path('admin/', admin.site.urls), - path('api/v1/ml_models/', include('ml_model.urls', namespace='ml_model')), - path('api/v1/auth/', include('authentication.urls')), - path('api/v1/payments/', include('payments.urls')), - path('api/v1/reports/', include('reports.urls')), - path('api/v1/chats/', include('tools.chats.urls')), - path('api/v1/media/', include('tools.media.urls')), - path('api/v1/public/', include('tools.public_api.urls')), - path('api/v1/api/', api.urls), - path('api/v1/', compatibility_api.urls), - path('api/v1/v2/', compatibility_api_v2.urls), - path('public/', public_api.urls), - ] - + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) - + public_urlpatterns -) +urlpatterns = [] if settings.DEBUG: urlpatterns += [ @@ -137,3 +120,22 @@ if settings.DEBUG: compatibility_api.docs_url = '/docs' compatibility_api_v2.docs_url = '/docs' public_api.docs_url = '/docs' + +urlpatterns += ( + [ + path('admin/', admin.site.urls), + path('api/v1/ml_models/', include('ml_model.urls', namespace='ml_model')), + path('api/v1/auth/', include('authentication.urls')), + path('api/v1/payments/', include('payments.urls')), + path('api/v1/reports/', include('reports.urls')), + path('api/v1/chats/', include('tools.chats.urls')), + path('api/v1/media/', include('tools.media.urls')), + path('api/v1/public/', include('tools.public_api.urls')), + path('api/v1/api/', api.urls), + path('api/v1/', compatibility_api.urls), + path('api/v1/v2/', compatibility_api_v2.urls), + path('public/', public_api.urls), + ] + + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) + + public_urlpatterns +) \ No newline at end of file @@ -0,0 +1,4 @@ +from lib.exporters.base import BaseExporter +from lib.exporters.excel import ExcelExporter + +__all__ = ['BaseExporter', 'ExcelExporter'] @@ -0,0 +1,9 @@ +from abc import ABC, abstractmethod + +from django.http import HttpResponse + + +class BaseExporter(ABC): + @abstractmethod + def export(self) -> HttpResponse: + raise NotImplementedError @@ -0,0 +1,38 @@ +from collections.abc import Iterable, Sequence + +from django.http import HttpResponse +from openpyxl import Workbook +from openpyxl.utils import get_column_letter + +from lib.exporters.base import BaseExporter + + +class ExcelExporter(BaseExporter): + CONTENT_TYPE = 'application/ms-excel' + SHEET_TITLE = 'Sheet' + COLUMN_WIDTH = 150 / 7 + + def get_headers(self) -> Sequence: + raise NotImplementedError + + def get_rows(self) -> Iterable[Sequence]: + raise NotImplementedError + + def get_filename(self) -> str: + raise NotImplementedError + + def export(self) -> HttpResponse: + wb = Workbook() + sheet = wb.active + sheet.title = self.SHEET_TITLE + sheet.append(list(self.get_headers())) + for row in self.get_rows(): + sheet.append(list(row)) + + for index in range(1, sheet.max_column + 1): + sheet.column_dimensions[get_column_letter(index)].width = self.COLUMN_WIDTH + + response = HttpResponse(content_type=self.CONTENT_TYPE) + response['Content-Disposition'] = f'attachment; filename={self.get_filename()}' + wb.save(response) + return response @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-07-20 11:08+0300\n" +"POT-Creation-Date: 2026-08-10 11:02+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -20,7 +20,7 @@ msgstr "" "n%10<=4 && (n%100<12 || n%100>14) ? 1 : n%10==0 || (n%10>=5 && n%10<=9) || " "(n%100>=11 && n%100<=14)? 2 : 3);\n" -#: authentication/exceptions/business_account.py:6 authentication/views.py:436 +#: authentication/exceptions/business_account.py:6 authentication/views.py:443 msgid "Business account not found" msgstr "Сотрудник не найден" @@ -140,6 +140,11 @@ msgstr "Пользователь уже существует" msgid "Domain not found" msgstr "Домен не найден" +#: authentication/exceptions/user.py:41 +msgid "Failed to send the email. Verify that the email exists and is available" +msgstr "" +"Не удалось отправить письмо. Убедитесь, что email существует и доступен" + #: 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" @@ -205,9 +210,9 @@ msgstr "Бизнес Группы" #: authentication/models/business_host.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:124 payments/models/invoice.py:15 -#: payments/models/payment.py:26 payments/models/payment_plan.py:43 +#: authentication/models/user_vk.py:12 payments/admin.py:41 +#: payments/admin.py:178 payments/models/invoice.py:15 +#: payments/models/payment.py:26 payments/models/payment_plan.py:42 #: tools/media/models.py:108 msgid "User" msgstr "Пользователь" @@ -383,7 +388,7 @@ msgstr "Имя" msgid "Last name" msgstr "Фамилия" -#: authentication/models/user.py:133 +#: authentication/models/user.py:133 payments/admin.py:144 msgid "Email" msgstr "Email" @@ -511,31 +516,27 @@ msgstr "Неверный или истёкший refresh токен" msgid "User is already confirmed" msgstr "Аккаунт уже подтвержден" -#: authentication/routes/v2.py:36 authentication/views.py:489 +#: authentication/routes/v2.py:36 authentication/views.py:499 msgid "Could not confirm email, please try again." msgstr "Невозможно подтвердить email, попробуйте позже" -#: authentication/security.py:34 -#, fuzzy -#| msgid "Hidden" +#: authentication/security.py:36 msgid "Forbidden" -msgstr "Скрытый" +msgstr "Запрещено" -#: authentication/security.py:47 +#: authentication/security.py:49 msgid "Access token is expired" msgstr "Срок действия токена доступа истек" -#: authentication/security.py:49 -#, fuzzy -#| msgid "Access token is expired" +#: authentication/security.py:51 msgid "Access token invalid" -msgstr "Срок действия токена доступа истек" +msgstr "Токен доступа недействителен" -#: authentication/security.py:62 +#: authentication/security.py:79 msgid "User not found" msgstr "Пользователь не найден" -#: authentication/security.py:97 +#: authentication/security.py:114 msgid "Access token expired or does not exist" msgstr "Токен доступа просрочен или не существует" @@ -549,7 +550,7 @@ msgstr "" msgid "Host user is not registered for this account" msgstr "Пользователь бизнес-аккаунта не зарегистрирован для этого аккаунта" -#: authentication/selectors/user_selector.py:75 +#: authentication/selectors/user_selector.py:77 msgid "No user with this uid found" msgstr "Не найден пользователь с данным ID" @@ -561,52 +562,52 @@ msgstr "Бизнес-аккаунт для данного юзера не най msgid "Invited account can either accept or reject an invitation" msgstr "Приглашенный аккаунт может принять или отклонить приглашение" -#: authentication/services/email_service.py:52 +#: authentication/services/email_service.py:57 msgid "Error occured when proceed email sending" msgstr "Случилась ошибка во время отправки email" -#: authentication/services/user_services.py:167 +#: authentication/services/user_services.py:169 msgid "No user like this in a database" msgstr "Такой пользователь отсутствует" -#: authentication/services/user_services.py:184 +#: authentication/services/user_services.py:186 msgid "token is not provided" msgstr "" -#: authentication/services/user_services.py:208 +#: authentication/services/user_services.py:210 msgid "No email token provided" msgstr "Токен не получен" -#: authentication/services/user_services.py:218 +#: authentication/services/user_services.py:220 msgid "Passwords do not match" msgstr "Пароли не совпадают" -#: authentication/services/user_services.py:256 +#: authentication/services/user_services.py:258 msgid "Current password is wrong" msgstr "Текущий пароль неверен" -#: authentication/views.py:133 authentication/views.py:242 -#: authentication/views.py:348 authentication/views.py:381 +#: authentication/views.py:134 authentication/views.py:245 +#: authentication/views.py:353 authentication/views.py:387 msgid "Server error occured" msgstr "Случилась серверная ошибка" -#: authentication/views.py:238 +#: authentication/views.py:239 msgid "Email not found" msgstr "Email не найден" -#: authentication/views.py:318 authentication/views.py:370 +#: authentication/views.py:322 authentication/views.py:375 msgid "Email sending error: email not found" msgstr "Ошибка отправки письма: email не найден" -#: authentication/views.py:340 +#: authentication/views.py:345 msgid "Business account has been deleted" msgstr "Сотрудник успешно удален" -#: authentication/views.py:368 +#: authentication/views.py:372 msgid "Business account has been reinvited" msgstr "Повторное приглашение сотруднику успешно отправлено" -#: authentication/views.py:440 +#: authentication/views.py:449 msgid "Business account password has been updated" msgstr "Пароль сотрудника успешно обновлен" @@ -633,12 +634,10 @@ 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" +msgstr "Некорректный JSON" -#: messages/serializers.py:44 ml_model/exceptions.py:86 +#: messages/serializers.py:44 ml_model/exceptions.py:89 #: tools/chats/schemas.py:23 #, python-format msgid "The file size cannot exceed %(max_mb_size)d MB" @@ -653,16 +652,16 @@ msgstr "Версия %(version)s уже имеет входные данные msgid "Neuron Models" msgstr "Нейронные Модели" -#: ml_model/exceptions.py:20 +#: ml_model/exceptions.py:21 msgid "The model is currently disabled. Please try again later." msgstr "" "Модель в настоящее время неактивна. Пожалуйста, повторите попытку позже." -#: ml_model/exceptions.py:25 +#: ml_model/exceptions.py:26 msgid "Your request was blocked by our moderation system" msgstr "Ваш запрос был заблокирован нашей системой модерации" -#: ml_model/exceptions.py:35 +#: ml_model/exceptions.py:36 #, python-format msgid "" "Image size %(cw)dx%(ch)d is not supported. Please rotate image to " @@ -671,25 +670,25 @@ msgstr "" "Размер изображения %(cw)dx%(ch)d не поддерживается. Пожалуйста, переверните " "до %(rw)dx%(rh)d" -#: ml_model/exceptions.py:38 +#: ml_model/exceptions.py:39 #, python-format msgid "Image size %(cw)sx%(ch)s is not supported. Required size: %(rw)sx%(rh)s" msgstr "" "Размер изображения %(cw)sx%(ch)s не поддерживается. Требуемый размер: " "%(rw)sx%(rh)s" -#: ml_model/exceptions.py:47 +#: ml_model/exceptions.py:49 #, python-format msgid "Image exceeds the maximum allowed pixel count (%(max_pixels)d)." msgstr "" "Размер изображения превышает максимально допустимое количество пикселей " "(%(max_pixels)d)." -#: ml_model/exceptions.py:53 +#: ml_model/exceptions.py:56 msgid "The model is not responding" msgstr "Модель не отвечает" -#: ml_model/exceptions.py:62 +#: ml_model/exceptions.py:65 #, python-format msgid "" "The attached file format is not supported. Available formats: " @@ -698,96 +697,108 @@ msgstr "" "Формат вложенного файла не поддерживается. Доступные форматы: " "%(available_extensions)s." -#: ml_model/exceptions.py:68 +#: ml_model/exceptions.py:71 msgid "The file may be corrupted. Please try another one." msgstr "Возможно, файл повреждён. Попробуйте загрузить другой файл." -#: ml_model/exceptions.py:73 +#: ml_model/exceptions.py:76 msgid "File Uploading Not supported" msgstr "Загрузка файлов не поддерживается" -#: ml_model/exceptions.py:78 +#: ml_model/exceptions.py:81 msgid "Unable to recognize the file" msgstr "Не удаётся распознать файл" -#: ml_model/exceptions.py:91 +#: ml_model/exceptions.py:94 msgid "The length of the context has been exceeded." msgstr "Длина контекста превышена." -#: ml_model/exceptions.py:96 +#: ml_model/exceptions.py:99 msgid "Jinja template not found" msgstr "Jinja-шаблон не найден" -#: ml_model/exceptions.py:101 +#: ml_model/exceptions.py:104 msgid "There was an unknown error while rendering a template" msgstr "При рендеринге шаблона произошла неизвестная ошибка" -#: ml_model/exceptions.py:106 +#: ml_model/exceptions.py:109 msgid "The neuron model does not exist" msgstr "Нейронная модель не существует" -#: ml_model/exceptions.py:114 +#: ml_model/exceptions.py:117 #, python-format msgid "The %(file_type)s is not attached" msgstr "Файл (%(file_type)s) не прикреплен" -#: ml_model/exceptions.py:119 +#: ml_model/exceptions.py:122 msgid "No image content found in response. Try a different request" msgstr "В промпте отсутствует описание изображения. Попробуйте другой запрос" -#: ml_model/exceptions.py:124 +#: ml_model/exceptions.py:127 msgid "" "The model could not analyze your request. Please rephrase it and try again" msgstr "" "Модель не смогла проанализировать ваш запрос. Перефразируйте его и " "попробуйте снова" -#: ml_model/exceptions.py:129 +#: ml_model/exceptions.py:132 msgid "Image analysis error. Please try another image." msgstr "Ошибка анализа изображения. Попробуйте другую картинку." -#: ml_model/exceptions.py:134 +#: ml_model/exceptions.py:137 msgid "Use style type AUTO or GENERAL when a style preset is selected" msgstr "При выбранном стиле используйте тип стиля AUTO или GENERAL" -#: ml_model/exceptions.py:139 +#: ml_model/exceptions.py:142 msgid "Prediction interrupted. Please retry again" msgstr "Генерация прервана. Пожалуйста, повторите попытку еще раз" -#: ml_model/exceptions.py:154 +#: ml_model/exceptions.py:159 #, python-format msgid "Prompt is too long. Maximum length is %(max_length)s characters." msgstr "Промпт слишком длинный. Максимальная длина — %(max_length)s символов." -#: ml_model/exceptions.py:161 +#: ml_model/exceptions.py:162 +msgid "Prompt is too long" +msgstr "Промпт слишком длинный" + +#: ml_model/exceptions.py:167 msgid "" "Service is currently unavailable due to high demand. Please try again later" msgstr "" -"Сервис временно недоступен из-за высокой нагрузки. Пожалуйста, попробуйте " -"позже" +"Сервис временно недоступен из-за высокой нагрузки. Пожалуйста, попробуйте позже" -#: ml_model/exceptions.py:169 +#: ml_model/exceptions.py:172 +msgid "Service is temporarily unavailable. Please try again later" +msgstr "Сервис временно недоступен. Пожалуйста, попробуйте позже" + +#: ml_model/exceptions.py:192 #, python-format msgid "%(feature)s is available only in paid plan." msgstr "%(feature)s доступно только в платном тарифном плане." -#: ml_model/exceptions.py:176 +#: ml_model/exceptions.py:199 msgid "Face not found in the image. Please try another image with a face." msgstr "Не найдено лицо на картинке. Попробуйте другую картинку с лицом." -#: ml_model/exceptions.py:181 +#: ml_model/exceptions.py:204 msgid "The input image may contain real person." msgstr "Загруженное изображение может содержать реального человека." -#: ml_model/exceptions.py:186 +#: ml_model/exceptions.py:209 msgid "The generated image may contain private or prohibited content" msgstr "Готовое изображение может содержать приватный или запрещённый контент" -#: ml_model/exceptions.py:195 +#: ml_model/exceptions.py:214 +msgid "The input image may contain private or prohibited content" +msgstr "" +"Загруженное изображение может содержать приватный или запрещённый контент" + +#: ml_model/exceptions.py:223 msgid "not specified" msgstr "не указана" -#: ml_model/exceptions.py:197 +#: ml_model/exceptions.py:224 #, python-format msgid "" "Version \"%(version)s\" is not available. Available versions: " @@ -853,7 +864,7 @@ msgstr "Теги" msgid "Neuron Model" msgstr "Нейронная Модель" -#: ml_model/models.py:166 ml_model/models.py:413 payments/admin.py:130 +#: ml_model/models.py:166 ml_model/models.py:413 payments/admin.py:184 msgid "Model" msgstr "Модель" @@ -1082,26 +1093,25 @@ msgstr "Инструкция Модели" msgid "Model Instructions" msgstr "Инструкции Моделей" -#: ml_model/selectors/ml_models_selector.py:114 +#: ml_model/selectors/ml_models_selector.py:116 msgid "no model by this id" msgstr "Не найдено моделей по этому ID" -#: ml_model/services/FileService.py:110 tools/media/apis.py:280 -#: tools/public_api/views/ml_service.py:56 +#: ml_model/services/FileService.py:110 #: tools/public_api/views/providers/openai_compatible.py:208 msgid "Voice not found." msgstr "Голос не найден." -#: ml_model/services/chatgpt.py:244 +#: ml_model/services/chatgpt.py:245 msgid "Image is ready" msgstr "Изображение готово" -#: ml_model/services/chatgpt.py:360 ml_model/services/claude.py:266 -#: ml_model/services/grok.py:190 +#: ml_model/services/chatgpt.py:361 ml_model/services/claude.py:277 +#: ml_model/services/grok.py:191 msgid "File analysis" msgstr "Анализ файлов" -#: ml_model/services/chatgpt.py:382 ml_model/services/chatgpt_5.py:133 +#: ml_model/services/chatgpt.py:383 ml_model/services/chatgpt_5.py:133 msgid "The \"Use code\" option cannot be used together with an attached image." msgstr "" "Нельзя одновременно использовать параметр «Использовать код» вместе с " @@ -1128,24 +1138,36 @@ msgstr "3К разрешение не поддерживается для это msgid "No image given for improving" msgstr "Нет изображения для улучшения" -#: ml_model/tasks.py:144 +#: ml_model/tasks.py:184 msgid "Lyrics is too long" msgstr "Текст песни слишком длинный" +#: ml_model/validators.py:48 tools/public_api/routes/providers/openai.py:58 +msgid "The request must not be empty" +msgstr "Запрос не должен быть пустым" + #: ml_model/views.py:65 msgid "Model data cannot be retrieved" msgstr "Невозможно получить данные модели" -#: payments/admin.py:35 payments/admin.py:76 payments/admin.py:122 +#: payments/admin.py:39 payments/admin.py:98 payments/admin.py:175 msgid "You can search by user email, exacted company name" msgstr "" "Вы можете осуществлять поиск по e-mail пользователя, точному названию " "компании" -#: payments/admin.py:40 payments/admin.py:127 +#: payments/admin.py:44 payments/admin.py:181 msgid "Missing" msgstr "Отсутствующий" +#: payments/admin.py:157 payments/models/user_payment_method.py:23 +msgid "Gateway" +msgstr "Шлюз" + +#: payments/admin.py:161 payments/models/user_payment_method.py:24 +msgid "Payment method UID" +msgstr "UID платёжного метода" + #: payments/apps.py:11 payments/models/payment.py:60 msgid "Payments" msgstr "Платежи" @@ -1167,6 +1189,34 @@ msgstr "" msgid "The payer does not exist" msgstr "Плательщик не существует" +#: payments/exceptions/subscription_recovery.py:6 +msgid "Active payment method not found" +msgstr "Активный способ оплаты не найден" + +#: payments/exceptions/subscription_recovery.py:11 +msgid "Subscription recovery is already in progress" +msgstr "Восстановление подписки уже выполняется" + +#: payments/models/attempt.py:12 payments/models/user_payment_method.py:48 +msgid "Payment Method" +msgstr "Платежный метод" + +#: payments/models/attempt.py:15 +msgid "Cancel Reason" +msgstr "Причина отмены" + +#: payments/models/attempt.py:16 +msgid "In Cycle" +msgstr "" + +#: payments/models/attempt.py:22 +msgid "Payment Attempt" +msgstr "Попытка платежа" + +#: payments/models/attempt.py:23 +msgid "Payment Attempts" +msgstr "Попытки платежа" + #: payments/models/invoice.py:23 msgid "Generative Model" msgstr "Генеративная модель" @@ -1195,56 +1245,60 @@ msgstr "Статус" msgid "Payment" msgstr "Платеж" -#: payments/models/payment_plan.py:13 +#: payments/models/payment_plan.py:12 msgid "Price" msgstr "Цена" -#: payments/models/payment_plan.py:17 +#: payments/models/payment_plan.py:16 msgid "Tokens per plan" msgstr "Токенов за план" -#: payments/models/payment_plan.py:20 +#: payments/models/payment_plan.py:19 msgid "Is corporate" msgstr "Корпоративный" -#: payments/models/payment_plan.py:21 +#: payments/models/payment_plan.py:20 msgid "Individual" msgstr "Индивидуальный" -#: payments/models/payment_plan.py:22 +#: payments/models/payment_plan.py:21 msgid "Is visible" msgstr "Видимый" -#: payments/models/payment_plan.py:34 payments/models/payment_plan.py:49 +#: payments/models/payment_plan.py:33 payments/models/payment_plan.py:48 #: payments/models/payment_plan_feature.py:16 msgid "Payment Plan" msgstr "Платежный План" -#: payments/models/payment_plan.py:35 +#: payments/models/payment_plan.py:34 msgid "Payment Plans" msgstr "Платежные Планы" -#: payments/models/payment_plan.py:51 +#: payments/models/payment_plan.py:50 msgid "Last payment at" msgstr "Последнее время платежа" -#: payments/models/payment_plan.py:52 +#: payments/models/payment_plan.py:51 msgid "Next payment at" msgstr "Следующее время платежа" -#: payments/models/payment_plan.py:56 payments/models/user_payment_method.py:25 -msgid "Payment Method" -msgstr "Платежный метод" +#: payments/models/payment_plan.py:56 +msgid "Last recovery payment id" +msgstr "ID последнего платежа восстановления" #: payments/models/payment_plan.py:62 +msgid "Recovery locked at" +msgstr "Время блокировки восстановления" + +#: payments/models/payment_plan.py:65 msgid "Current balance" msgstr "Текущий баланс" -#: payments/models/payment_plan.py:68 +#: payments/models/payment_plan.py:71 msgid "Referral balance" msgstr "Реферальный баланс" -#: payments/models/payment_plan.py:89 payments/models/payment_plan.py:90 +#: payments/models/payment_plan.py:98 payments/models/payment_plan.py:99 msgid "User Balance" msgstr "Баланс пользователя" @@ -1316,63 +1370,67 @@ msgstr "Активация Промокода" msgid "Promocode Activations" msgstr "Активации Промокодов" -#: payments/models/user_payment_method.py:9 +#: payments/models/user_payment_method.py:10 msgid "Bank Card" msgstr "Банковская карта" -#: payments/models/user_payment_method.py:10 +#: payments/models/user_payment_method.py:11 msgid "Mir Pay" msgstr "Mir Pay" -#: payments/models/user_payment_method.py:11 +#: payments/models/user_payment_method.py:12 msgid "Sberbank" msgstr "Сбербанк" -#: payments/models/user_payment_method.py:12 +#: payments/models/user_payment_method.py:13 msgid "YooMoney" msgstr "ЮMoney" -#: payments/models/user_payment_method.py:13 +#: payments/models/user_payment_method.py:14 msgid "T-bank" msgstr "Т-банк" -#: payments/models/user_payment_method.py:14 +#: payments/models/user_payment_method.py:15 msgid "SBP" msgstr "СБП" -#: payments/models/user_payment_method.py:16 -msgid "Gateway" -msgstr "Шлюз" +#: payments/models/user_payment_method.py:20 +msgid "User Plan Info" +msgstr "Информация о плане пользователя" -#: payments/models/user_payment_method.py:17 -msgid "Payment method UID" -msgstr "UID платёжного метода" - -#: payments/models/user_payment_method.py:18 tools/media/models.py:78 +#: payments/models/user_payment_method.py:25 tools/media/models.py:78 msgid "Meta" msgstr "Метаданные" -#: payments/models/user_payment_method.py:19 -msgid "Attempts" -msgstr "Попытки" - #: payments/models/user_payment_method.py:26 +msgid "Active" +msgstr "Активен" + +#: payments/models/user_payment_method.py:27 +msgid "Primary" +msgstr "" + +#: payments/models/user_payment_method.py:49 msgid "Payment Methods" msgstr "Платежные методы" -#: payments/routes/v1.py:93 +#: payments/routes/v1.py:110 msgid "You do not have an active subscription to cancel" msgstr "У вас нет активной подписки для отмены" -#: payments/routes/v1.py:94 +#: payments/routes/v1.py:111 msgid "The recurring payment is successfully cancelled" msgstr "Автоплатежи успешно отключены" -#: payments/routes/v1.py:144 +#: payments/routes/v1.py:129 +msgid "Payment could not be completed, please try again later" +msgstr "Не удалось провести платёж, пожалуйста, попробуйте позже" + +#: payments/routes/v1.py:199 msgid "Expenses" msgstr "Затраты" -#: payments/routes/v1.py:148 +#: payments/routes/v1.py:203 msgid "Refills" msgstr "Пополнения" @@ -1422,8 +1480,8 @@ msgstr "Публичный API" msgid "Media" msgstr "Медиа" -#: tools/chats/apis.py:201 tools/media/apis.py:229 -#: tools/public_api/views/base.py:102 +#: tools/chats/apis.py:219 tools/media/apis.py:246 +#: tools/public_api/views/base.py:108 msgid "" "An unexpected generation error has occurred. Please try again later or use a " "different model" @@ -1431,7 +1489,7 @@ msgstr "" "Произошла непредвиденная ошибка при генерации. Пожалуйста попробуйте позже " "или используйте другую модель" -#: tools/chats/apis.py:257 +#: tools/chats/apis.py:275 msgid "The message has already been deleted" msgstr "Сообщение уже было удалено" @@ -1444,25 +1502,23 @@ msgstr "Чат %(id)s" msgid "Chat" msgstr "Чат" -#: tools/chats/routes/v1.py:37 tools/public_api/routes/v1.py:66 -#, fuzzy -#| msgid "User not found" +#: tools/chats/routes/v1.py:39 tools/public_api/routes/v1.py:68 msgid "Stream not found" -msgstr "Пользователь не найден" +msgstr "Стрим не найден" -#: tools/chats/routes/v1.py:51 +#: tools/chats/routes/v1.py:53 msgid "Chat not found" msgstr "Чат не найден" -#: tools/chats/routes/v1.py:54 tools/public_api/routes/v1.py:106 +#: tools/chats/routes/v1.py:56 tools/public_api/routes/v1.py:108 msgid "Stream not supported for this model" msgstr "Стриминг не поддерживается для этой модели" -#: tools/chats/routes/v1.py:58 +#: tools/chats/routes/v1.py:60 msgid "Stream already in progress" msgstr "" -#: tools/chats/schemas.py:34 tools/public_api/views/ml_service.py:88 +#: tools/chats/schemas.py:34 tools/public_api/views/ml_service.py:60 msgid "Invalid info payload" msgstr "Некорректные данные в поле info" @@ -1470,7 +1526,7 @@ msgstr "Некорректные данные в поле info" msgid "Stream timeout" msgstr "" -#: tools/media/apis.py:221 +#: tools/media/apis.py:238 msgid "" "Temporary issues with the service, we are already working on a solution." msgstr "Временные неполадки с сервисом, мы уже работаем над их решением." @@ -1557,21 +1613,12 @@ msgid "API Keys" msgstr "API Ключи" #: tools/public_api/routes/providers/openai.py:19 -#, fuzzy -#| msgid "Missing required parameter: model_id" msgid "Missing required parameter: input" -msgstr "Отсутствует обязательный параметр: 'model_id'" +msgstr "Отсутствует обязательный параметр: 'input'" #: tools/public_api/routes/providers/openai.py:24 -#, fuzzy -#| msgid "Invalid info payload" msgid "Invalid input payload" -msgstr "Некорректные данные в поле info" - -#: 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 "Запрос не должен быть пустым" +msgstr "Некорректные данные в поле input" #: tools/public_api/routes/providers/openai.py:85 #: tools/public_api/views/providers/elevenlabs_compatible.py:147 @@ -1581,10 +1628,8 @@ 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 "Загрузка файлов не поддерживается" +msgstr "Поддерживается только стриминг" #: tools/public_api/routes/providers/openai.py:120 #: tools/public_api/views/providers/openai_compatible.py:61 @@ -1599,41 +1644,33 @@ msgstr "" msgid "message_uuid is not provided" msgstr "" -#: tools/public_api/routes/v1.py:31 +#: tools/public_api/routes/v1.py:33 msgid "No API Key in Authorization header" msgstr "" -#: tools/public_api/routes/v1.py:43 -#, fuzzy -#| msgid "API Key not found" +#: tools/public_api/routes/v1.py:45 msgid "API key not found" msgstr "API-ключ не найден" -#: tools/public_api/routes/v1.py:46 -#, fuzzy -#| msgid "Access token is expired" +#: tools/public_api/routes/v1.py:48 msgid "API key expired" -msgstr "Срок действия токена доступа истек" +msgstr "Срок действия API-ключа истёк" -#: tools/public_api/routes/v1.py:48 -#, fuzzy -#| msgid "Key limit exceeded" +#: tools/public_api/routes/v1.py:50 msgid "API key limit exceeded" -msgstr "Превышен лимит по ключу" +msgstr "Превышен лимит API-ключа" -#: tools/public_api/routes/v1.py:62 tools/public_api/routes/v1.py:96 -#, fuzzy -#| msgid "Host user is not registered for this account" +#: tools/public_api/routes/v1.py:64 tools/public_api/routes/v1.py:98 msgid "API key is not available for this account type" -msgstr "Пользователь бизнес-аккаунта не зарегистрирован для этого аккаунта" +msgstr "API-ключ недоступен для этого типа аккаунта" -#: tools/public_api/routes/v1.py:104 tools/public_api/views/base.py:68 +#: tools/public_api/routes/v1.py:106 tools/public_api/views/base.py:69 msgid "Model is blocked by outdating or temporary block, please retry later" msgstr "" "Модель заблокирована, т.к закончила обновляться или временно заблокирована, " "попробуйте позже" -#: tools/public_api/views/base.py:63 +#: tools/public_api/views/base.py:64 msgid "Key limit exceeded" msgstr "Превышен лимит по ключу" @@ -1678,6 +1715,9 @@ msgstr "Название голоса успешно обновлено" msgid "Preset voices are shared and cannot be deleted. Use your own voice id." msgstr "Пресеты общие и не удаляются. Используйте id собственного голоса." +#~ msgid "Attempts" +#~ msgstr "Попытки" + #, python-format #~ msgid "This video duration is not allowed for %(quality)s quality." #~ msgstr "" @@ -1724,9 +1764,6 @@ msgstr "Пресеты общие и не удаляются. Используй #~ msgid "Is recurrent" #~ msgstr "Рекуррентный" -#~ msgid "Payment Datetime" -#~ msgstr "Дата и время платежа" - #~ msgid "Recurring Payment" #~ msgstr "Автоплатеж" @@ -1,4 +1,5 @@ import json +import re from enum import StrEnum import logging import time @@ -11,9 +12,11 @@ from messages.services.message_service import MessageService from ml_model.exceptions import ( FileExtensionNotSupported, GenerationException, + InputImageSensitiveContentError, + OutputSensitiveImageContentError, RealPersonDetectedError, RequestBlocked, - OutputSensitiveImageContentError, + PromptLengthExceeded, ) from poller.models import Proxy from tools.chats.domain import RawSSEChunk @@ -113,6 +116,26 @@ class BytedanceModelArkAdapter: return 'video_url' return 'image_url' + @classmethod + def _handle_error_response(cls, resp: httpx.Response) -> None: + if resp.status_code == 413: + raise PromptLengthExceeded + + if 'Input length' in resp.text and 'exceeds the maximum length' in resp.text: + match = re.search(r"Input length (\d+) exceeds the maximum length (\d+)", resp.text) + if match: + max_length = int(match.group(2)) + raise PromptLengthExceeded(max_length=max_length) + raise PromptLengthExceeded + + logger.error( + 'Bytedance request failed status=%s route=%s body=%s', + resp.status_code, + cls.CONTENT_TYPE_TO_ENDPOINT.get(BytedanceContentType.CHAT), + resp.text, + ) + raise GenerationException + @classmethod def _raise_by_error_payload(cls, data: dict[str, Any], choices: list[dict[str, Any]]) -> None: choice_reasons = {str(choice.get('finish_reason', '')).lower() for choice in choices} @@ -207,12 +230,7 @@ class BytedanceModelArkAdapter: ) as client: resp = client.post(cls.CONTENT_TYPE_TO_ENDPOINT[BytedanceContentType.CHAT], json=payload) if resp.status_code >= 400: - logger.error( - 'Bytedance request failed status=%s route=%s body=%s', - resp.status_code, - cls.CONTENT_TYPE_TO_ENDPOINT[BytedanceContentType.CHAT], - resp.text, - ) + cls._handle_error_response(resp) try: data: BytedanceChatResponse = resp.json() except Exception as exc: @@ -278,13 +296,7 @@ class BytedanceModelArkAdapter: 'POST', cls.CONTENT_TYPE_TO_ENDPOINT[BytedanceContentType.CHAT], json=payload ) as resp: if resp.status_code >= 400: - logger.error( - 'Bytedance request failed status=%s route=%s body=%s', - resp.status_code, - cls.CONTENT_TYPE_TO_ENDPOINT[BytedanceContentType.CHAT], - resp.text, - ) - raise GenerationException + cls._handle_error_response(resp) usage: BytedanceUsage = {} for line in resp.iter_lines(): if not line: @@ -365,6 +377,8 @@ class BytedanceModelArkAdapter: if error_code := data.get('error', {}).get('code', ''): if error_code == 'OutputImageSensitiveContentDetected': raise OutputSensitiveImageContentError + if error_code == 'InputImageSensitiveContentDetected': + raise InputImageSensitiveContentError if image_data := data.get('data'): urls = [item.get('url') for item in image_data if isinstance(item, dict) and item.get('url')] @@ -409,6 +423,8 @@ class BytedanceModelArkAdapter: return data if error_code := data.get('error', {}).get('code', None): match error_code: + case 'InputImageSensitiveContentDetected': + raise InputImageSensitiveContentError case 'InputImageSensitiveContentDetected.PrivacyInformation': raise RealPersonDetectedError case _: @@ -70,14 +70,15 @@ class OpenrouterAdapter: try: data_obj = json.loads(data) - content_chunk = data_obj['choices'][0]['delta'].get('content') or '' - reasoning_chunk = data_obj['choices'][0]['delta'].get('reasoning') or '' - if content_chunk: - content += content_chunk - yield RawSSEChunk(event='token', data={'content': content_chunk}) - if reasoning_chunk: - reasoning += reasoning_chunk - yield RawSSEChunk(event='think', data={'content': reasoning_chunk}) + if data_obj.get('choices'): + content_chunk = data_obj['choices'][0].get('delta', {}).get('content') or '' + reasoning_chunk = data_obj['choices'][0].get('delta', {}).get('reasoning') or '' + if content_chunk: + content += content_chunk + yield RawSSEChunk(event='token', data={'content': content_chunk}) + if reasoning_chunk: + reasoning += reasoning_chunk + yield RawSSEChunk(event='think', data={'content': reasoning_chunk}) if data_obj.get('usage'): input_tokens = data_obj['usage']['prompt_tokens'] output_tokens = data_obj['usage']['completion_tokens'] @@ -1,21 +1,33 @@ import re import subprocess import zipfile +from functools import wraps from uuid import UUID import docx2txt +import filetype import fitz import openpyxl from io import BytesIO +from PIL import Image +from django.core.files.images import get_image_dimensions from django.db.models.fields.files import FieldFile from django.utils.translation import gettext as _ from authentication.models import CustomUserModel -from ml_model.exceptions import InvalidParameterError, UnrecognizedFileError +from ml_model.exceptions import ( + CorruptedFileError, + FileExtensionNotSupported, + ImageTooLargeError, + InvalidParameterError, + UnrecognizedFileError, +) from tools.media.models import Voice, Preset, PresetKind +# FIXME: Переработать сервисы по работе с файлами. Возможно, прибегнуть к использованию миксинов + class FileProcessingService: @classmethod @@ -110,3 +122,54 @@ class FileProcessingService: raise InvalidParameterError(_('Voice not found.')) from exc return voice.file + +class ImageFileProcessingService: + ALLOWED_EXTENSIONS = ['PNG', 'JPG', 'JPEG', 'WEBP'] + + def __init__(self, image: FieldFile) -> None: + self.image = image + + @staticmethod + def __reset_image(func): + @wraps(func) + def wrapper(self, *args, **kwargs): + try: + return func(self, *args, **kwargs) + finally: + self.image.seek(0) + + return wrapper + + @__reset_image + def get_bytes(self, size: int | None = None) -> bytes: + return self.image.read(size) + + def get_kind(self, file_bytes: bytes): + kind = filetype.guess(file_bytes) + if not kind: + raise CorruptedFileError + if kind.extension.upper() not in self.ALLOWED_EXTENSIONS: + raise FileExtensionNotSupported(self.ALLOWED_EXTENSIONS) + return kind + + @__reset_image + def get_dimensions(self, max_pixels: int) -> tuple[int, int]: + try: + w, h = get_image_dimensions(self.image) + if not (w and h): + raise CorruptedFileError + if w * h > max_pixels: + raise ImageTooLargeError(max_pixels) + return w, h + except Image.DecompressionBombError: + raise ImageTooLargeError(max_pixels) + + def get_normalized_image(self, file_bytes: bytes) -> BytesIO: + normalized_image = BytesIO(file_bytes) + with Image.open(normalized_image) as source_image: + img = source_image.convert('RGBA') + normalized_image = BytesIO() + img.save(normalized_image, format='PNG') + img.close() + normalized_image.seek(0) + return normalized_image @@ -47,9 +47,9 @@ class Chatgpt(Chatgpt_4, StreamSimpleService, OpenAIStreamMixin): 'input': Decimal('0.0025'), # $5 / 1M tokens 'output': Decimal('0.015'), # $30 / 1M tokens 'web_search': { - 'low': Decimal('5'), # 1 call - 'medium': Decimal('5'), # 1 call - 'high': Decimal('5'), # 1 call + 'low': Decimal('5'), # $0.01 / call + 'medium': Decimal('5'), # $0.01 / call + 'high': Decimal('5'), # $0.01 / call }, 'code_interpreter': Decimal('15'), # 1 call 'generated_image': Decimal('10.2'), @@ -58,31 +58,31 @@ class Chatgpt(Chatgpt_4, StreamSimpleService, OpenAIStreamMixin): 'input': Decimal('0.0025'), # $5 / 1M tokens 'output': Decimal('0.015'), # $30 / 1M tokens 'web_search': { - 'low': Decimal('5'), # 1 call - 'medium': Decimal('5'), # 1 call - 'high': Decimal('5'), # 1 call + 'low': Decimal('5'), # $0.01 / call + 'medium': Decimal('5'), # $0.01 / call + 'high': Decimal('5'), # $0.01 / call }, 'code_interpreter': Decimal('15'), # 1 call 'generated_image': Decimal('10.2'), }, 'gpt-5.6-luna': { - 'input': Decimal('0.0005'), # $1 / 1M tokens - 'output': Decimal('0.003'), # $6 / 1M tokens + 'input': Decimal('0.0001'), # $0.2 / 1M tokens + 'output': Decimal('0.0006'), # $1.2 / 1M tokens 'web_search': { - 'low': Decimal('5'), # 1 call - 'medium': Decimal('5'), # 1 call - 'high': Decimal('5'), # 1 call + 'low': Decimal('5'), # $0.01 / call + 'medium': Decimal('5'), # $0.01 / call + 'high': Decimal('5'), # $0.01 / call }, 'code_interpreter': Decimal('15'), # 1 call 'generated_image': Decimal('10.2'), }, 'gpt-5.6-terra': { - 'input': Decimal('0.00125'), # $2.5 / 1M tokens - 'output': Decimal('0.0075'), # $15 / 1M tokens + 'input': Decimal('0.001'), # $2 / 1M tokens + 'output': Decimal('0.006'), # $12 / 1M tokens 'web_search': { - 'low': Decimal('5'), # 1 call - 'medium': Decimal('5'), # 1 call - 'high': Decimal('5'), # 1 call + 'low': Decimal('5'), # $0.01 / call + 'medium': Decimal('5'), # $0.01 / call + 'high': Decimal('5'), # $0.01 / call }, 'code_interpreter': Decimal('15'), # 1 call 'generated_image': Decimal('10.2'), @@ -493,7 +493,9 @@ class Chatgpt(Chatgpt_4, StreamSimpleService, OpenAIStreamMixin): Decimal(serper_sources * 250) / Decimal(2.7) * self.TOKENS_COST[model_name]['input'] ) if info.get('code_interpreter'): - json_data['tools'].append({'type': 'code_interpreter', 'container': {'type': 'auto'}}) + json_data['tools'].append( + {'type': 'code_interpreter', 'container': {'type': 'auto', 'memory_limit': '1g'}} + ) messages[-1]['content'] += ' the python tool ' predicted_input_price += self.TOKENS_COST[model_name]['code_interpreter'] if ctx['image']: @@ -67,18 +67,18 @@ class Chatgpt_4(SimpleService): 'input': Decimal('0.0003'), 'output': Decimal('0.0003'), 'web_search': { - 'low': Decimal('12.5'), # 1 call - 'medium': Decimal('13.75'), # 1 call - 'high': Decimal('15'), # 1 call + 'low': Decimal('5'), # $0.01 / call + 'medium': Decimal('5'), # $0.01 / call + 'high': Decimal('5'), # $0.01 / call }, }, 'gpt-4o': { 'input': Decimal('0.005'), 'output': Decimal('0.005'), 'web_search': { - 'low': Decimal('15'), # 1 call - 'medium': Decimal('17.5'), # 1 call - 'high': Decimal('25'), # 1 call + 'low': Decimal('5'), # $0.01 / call + 'medium': Decimal('5'), # $0.01 / call + 'high': Decimal('5'), # $0.01 / call }, }, 'gpt-oss-120b': {'input': Decimal('0.0002'), 'output': Decimal('0.0002')}, @@ -566,7 +566,7 @@ class Chatgpt_4(SimpleService): 'input': messages, 'tools': [ { - 'type': 'web_search_preview', + 'type': 'web_search', 'search_context_size': search_context_size, 'user_location': {'type': 'approximate', 'country': 'RU'}, } @@ -26,9 +26,9 @@ class Chatgpt_5(Chatgpt_4): 'input': Decimal('0.000625'), 'output': Decimal('0.005'), 'web_search': { - 'low': Decimal('5'), # 1 call - 'medium': Decimal('5'), # 1 call - 'high': Decimal('5'), # 1 call + 'low': Decimal('5'), # $0.01 / call + 'medium': Decimal('5'), # $0.01 / call + 'high': Decimal('5'), # $0.01 / call }, 'code_interpreter': Decimal('15'), }, @@ -36,9 +36,9 @@ class Chatgpt_5(Chatgpt_4): 'input': Decimal('0.000125'), 'output': Decimal('0.001'), 'web_search': { - 'low': Decimal('5'), # 1 call - 'medium': Decimal('5'), # 1 call - 'high': Decimal('5'), # 1 call + 'low': Decimal('5'), # $0.01 / call + 'medium': Decimal('5'), # $0.01 / call + 'high': Decimal('5'), # $0.01 / call }, 'code_interpreter': Decimal('15'), }, @@ -51,9 +51,9 @@ class Chatgpt_5(Chatgpt_4): 'input': Decimal('0.000625'), 'output': Decimal('0.005'), 'web_search': { - 'low': Decimal('5'), # 1 call - 'medium': Decimal('5'), # 1 call - 'high': Decimal('5'), # 1 call + 'low': Decimal('5'), # $0.01 / call + 'medium': Decimal('5'), # $0.01 / call + 'high': Decimal('5'), # $0.01 / call }, 'code_interpreter': Decimal('15'), # 1 call }, @@ -61,9 +61,9 @@ class Chatgpt_5(Chatgpt_4): 'input': Decimal('0.0075'), 'output': Decimal('0.06'), 'web_search': { - 'low': Decimal('5'), # 1 call - 'medium': Decimal('5'), # 1 call - 'high': Decimal('5'), # 1 call + 'low': Decimal('5'), # $0.01 / call + 'medium': Decimal('5'), # $0.01 / call + 'high': Decimal('5'), # $0.01 / call }, }, 'gpt-5.1-codex-max': { @@ -82,9 +82,9 @@ class Chatgpt_5(Chatgpt_4): 'input': Decimal('0.000875'), 'output': Decimal('0.007'), 'web_search': { - 'low': Decimal('5'), # 1 call - 'medium': Decimal('5'), # 1 call - 'high': Decimal('5'), # 1 call + 'low': Decimal('5'), # $0.01 / call + 'medium': Decimal('5'), # $0.01 / call + 'high': Decimal('5'), # $0.01 / call }, 'code_interpreter': Decimal('15'), # 1 call }, @@ -239,7 +239,9 @@ class Chatgpt_5(Chatgpt_4): 'gpt-5', 'gpt-5.1', ): - json_data['tools'].append({'type': 'code_interpreter', 'container': {'type': 'auto'}}) + json_data['tools'].append( + {'type': 'code_interpreter', 'container': {'type': 'auto', 'memory_limit': '1g'}} + ) messages[-1]['content'] += 'the python tool' input_tokens, output_tokens, response = self.call_openai_api( proxy=proxy, endpoint='responses', json_data=json_data @@ -21,9 +21,9 @@ class Chatgpt_5_4(Chatgpt): 'input': Decimal('0.00125'), 'output': Decimal('0.0075'), 'web_search': { - 'low': Decimal('5'), - 'medium': Decimal('5'), - 'high': Decimal('5'), + 'low': Decimal('5'), # $0.01 / call + 'medium': Decimal('5'), # $0.01 / call + 'high': Decimal('5'), # $0.01 / call }, 'code_interpreter': Decimal('15'), 'generated_image': Decimal('10.2'), @@ -32,9 +32,9 @@ class Chatgpt_5_4(Chatgpt): 'input': Decimal('0.0075'), 'output': Decimal('0.045'), 'web_search': { - 'low': Decimal('5'), - 'medium': Decimal('5'), - 'high': Decimal('5'), + 'low': Decimal('5'), # $0.01 / call + 'medium': Decimal('5'), # $0.01 / call + 'high': Decimal('5'), # $0.01 / call }, 'generated_image': Decimal('10.2'), }, @@ -227,7 +227,11 @@ class Claude(SerperMixin, StreamSimpleService): return memory def _build_callback_data(self, input_message: Message) -> dict[str, Any]: - return {'provider': {'order': ['anthropic']}, **input_message.info, 'tools': []} + return { + **input_message.info, + 'provider': {'order': ['anthropic'], 'allow_fallbacks': False}, + 'tools': [], + } def _prepare_messages( self, input_message: Message, version_slug: str, callback_data: dict @@ -26,12 +26,12 @@ class Deepseek(SimpleService): # 'output': Decimal('0'), # }, 'deepseek/deepseek-v4-pro': { - 'input': Decimal('1050') / 1_000_000, - 'output': Decimal('2200') / 1_000_000, + 'input': Decimal('261') / 1_000_000, # $0.87 / 1M tokens + 'output': Decimal('522') / 1_000_000, # $1.74 / 1M tokens }, - 'deepseek/deepseek-v4-flash': { - 'input': Decimal('100') / 1_000_000, - 'output': Decimal('175') / 1_000_000, + 'deepseek/deepseek-v4-flash-0731': { + 'input': Decimal('24') / 1_000_000, # $0.08 / 1M tokens + 'output': Decimal('75.6') / 1_000_000, # $0.252 / 1M tokens }, } @@ -62,6 +62,7 @@ class Deepseek(SimpleService): callback_data = { **info, + 'provider': {'order': ['digitalocean'], 'allow_fallbacks': False}, } messages = [ {'role': 'system', 'content': system_prompt}, @@ -75,7 +75,7 @@ class Flux_2(SimpleService): height = input_message.info.pop('height', 1024) output_mp = math.ceil((width*height) / 1_000_000) callback_data = { - 'prompt': self.translate_prompt(input_message.content), + 'prompt': input_message.content, 'aspect_ratio': 'custom', 'width': width, 'height': height, @@ -99,8 +99,8 @@ class Gemini(SimpleService): raise ModelVersionNotAvailable(version_slug, self.TOKENS_COST) version = f'google/{version_slug}' callback_data = { - 'provider': {'order': ['Google AI Studio']}, **input_message.info, + 'provider': {'order': ['google-ai-studio'], 'allow_fallbacks': False}, } messages = self.get_chat_history() messages.append({'role': 'user', 'content': input_message.content}) @@ -79,8 +79,8 @@ class Gemini_3_1(StreamSimpleService): raise ModelVersionNotAvailable(version_slug, self.TOKENS_COST) model_slug = f'google/{version_slug}:online' callback_data = { - 'provider': {'order': ['Google AI Studio']}, **input_message.info, + 'provider': {'order': ['google-ai-studio'], 'allow_fallbacks': False}, } messages, embedding_tokens = self._prepare_messages(input_message) @@ -103,8 +103,8 @@ class Gemini_3_1(StreamSimpleService): raise ModelVersionNotAvailable(version_slug, self.TOKENS_COST) model_slug = f'google/{version_slug}:online' callback_data = { - 'provider': {'order': ['Google AI Studio']}, **input_message.info, + 'provider': {'order': ['google-ai-studio'], 'allow_fallbacks': False}, } messages, embedding_tokens = self._prepare_messages(input_message) input_tokens = output_tokens = 0 @@ -44,7 +44,7 @@ class Geminiimage(SimpleService): def make(self, input_message: Message, save: bool = True) -> list[Message]: if input_message.content: callback_data = dict( - {'prompt': self.translate_prompt(input_message.content), **input_message.info} + {'prompt': input_message.content, **input_message.info} ) start_time = time.time() images = replicate_run('google/gemini-2.5-flash-image', callback_data) @@ -41,8 +41,8 @@ class Gemma(SimpleService): def make(self, input_message: Message, save: bool = True) -> list[Message]: callback_data = { - 'provider': {'order': ['DeepInfra']}, **input_message.info, + 'provider': {'order': ['deepinfra'], 'allow_fallbacks': False}, } messages = self.get_chat_history() messages.append({'role': 'user', 'content': input_message.content}) @@ -53,8 +53,8 @@ class Grok_4_1_Fast(SimpleService): def make(self, input_message: Message, save: bool = True) -> list[Message]: callback_data = { - 'provider': {'order': ['xAI']}, **input_message.info, + 'provider': {'order': ['xai'], 'allow_fallbacks': False}, } messages = self.get_chat_history() messages.append({'role': 'user', 'content': input_message.content}) @@ -1,19 +1,16 @@ import time -import requests - from datetime import timedelta from decimal import Decimal from io import BytesIO +from pathlib import Path from typing import Any +import filetype +import requests from django.core.files import File from messages.models import Message -from ml_model.exceptions import ( - ImageContentNotFound, - GenerationException, - RequestBlocked, -) +from ml_model.exceptions import CorruptedFileError, FileExtensionNotSupported from ml_model.services.base import SimpleService from ml_model.tasks import replicate_run from payments.exceptions.insufficient_balance import InsufficientBalance @@ -51,12 +48,23 @@ class Grok_Image(SimpleService): raise InsufficientBalance(balance, self.TOKENS_COST) callback_data = dict( { - 'prompt': f"{self.translate_prompt(input_message.content)}\n{self.OPTIMIZATION_PROMPT}", + 'prompt': f'{input_message.content}\n{self.OPTIMIZATION_PROMPT}', **input_message.info, } ) if image := input_message.file: - callback_data.update({'image': image.url}) + kind = filetype.guess(image.read(20)) + image.seek(0) + name_ext = Path(image.name).suffix[1:].upper() + if name_ext == 'DNG': + extension = 'DNG' + elif not kind: + raise CorruptedFileError + else: + extension = kind.extension.upper() + if extension not in (extensions := ['DNG', 'JPG', 'JPEG', 'PNG', 'WEBP']): + raise FileExtensionNotSupported(extensions) + callback_data.update({'image': image}) start_time = time.time() images = replicate_run('xai/grok-imagine-image', callback_data) process_time = timedelta(seconds=(time.time() - start_time)) @@ -68,14 +68,14 @@ class Grok_Image_Ultra(SimpleService): file_bytes = input_message.file.read() kind = filetype.guess(file_bytes[:20]) extension = kind.extension - if extension.upper() not in (extensions := ['JPG', 'JPEG', 'PNG', 'WEBP']): + if extension.upper() not in (extensions := ['JPG', 'JPEG', 'JFIF', 'PNG', 'WEBP']): raise FileExtensionNotSupported(extensions) file_width, file_height = get_image_dimensions(BytesIO(file_bytes)) if file_width and file_height: input_mp = math.ceil((file_width * file_height) / 1_000_000) else: input_mp = 1 - callback_data.update({'image': input_message.file.url}) + callback_data.update({'image': input_message.file}) input_message.file.close() if (balance := PaymentPlanSelector(self.store.user).get_current_balance()) < ( @@ -10,7 +10,7 @@ import requests from django.core.files import File from messages.models import Message -from ml_model.exceptions import RequestBlocked, GenerationException +from ml_model.exceptions import GenerationException, PromptLengthExceeded, RequestBlocked from ml_model.services.base import SimpleService from ml_model.tasks import replicate_run @@ -42,12 +42,14 @@ class Grok_Imagine_Video(SimpleService): return [msg] def make(self, input_message: Message, save: bool = True) -> list[Message]: + if len(input_message.content or '') > 2000: + raise PromptLengthExceeded(max_length=2000) duration = input_message.info.get('duration', 5) if (balance := PaymentPlanSelector(self.store.user).get_current_balance()) < ( cost := self.TOKENS_COST * duration ): raise InsufficientBalance(balance, cost) - callback_data = dict({'prompt': self.translate_prompt(input_message.content), **input_message.info}) + callback_data = dict({'prompt': input_message.content, **input_message.info}) if image := input_message.file: callback_data.update({'image': image.url}) start_time = time.time() @@ -66,7 +66,10 @@ class Llama(SimpleService): if version_slug is None or version_slug not in self.TOKENS_COST: raise ModelVersionNotAvailable(version_slug, self.TOKENS_COST) version = f'meta-llama/{version_slug}' - callback_data = {'provider': {'order': ['DeepInfra']}, **input_message.info} + callback_data = { + **input_message.info, + 'provider': {'order': ['deepinfra'], 'allow_fallbacks': False}, + } messages = self.get_chat_history() messages.append({'role': 'user', 'content': input_message.content}) image = input_message.file @@ -17,9 +17,9 @@ from payments.selectors.payment_plan_selector import PaymentPlanSelector class Ltx(SimpleService): TOKENS_COST = { - '1080p': Decimal('12'), - '2k': Decimal('24'), - '4k': Decimal('48'), + '1080p': Decimal('18'), # $0.06 / sec + '2k': Decimal('36'), # $0.12 / sec + '4k': Decimal('72'), # $0.24 / sec } @classmethod @@ -55,7 +55,10 @@ class Mistral(SimpleService): def make(self, input_message: Message, save: bool = True) -> list[Message]: version = 'mistralai/mistral-small-3.1-24b-instruct' - callback_data = {'provider': {'order': ['Parasail']}, **input_message.info} + callback_data = { + **input_message.info, + 'provider': {'order': ['parasail'], 'allow_fallbacks': False}, + } messages = self.get_chat_history() messages.append({'role': 'user', 'content': input_message.content}) image = input_message.file @@ -4,6 +4,7 @@ from typing import Any import httpx from django.conf import settings +from ml_model.exceptions import OpenAIResponseError, ServiceTemporaryUnavailableError from poller.models import Proxy type OpenAIEvent = dict[str, Any] @@ -26,6 +27,8 @@ class OpenAIStreamMixin: input_tokens, output_tokens = yield from self._stream_request( client, 'POST', json=payload, state=state ) + except ServiceTemporaryUnavailableError: + raise except Exception: if not state['response_id']: raise @@ -95,7 +98,9 @@ class OpenAIStreamMixin: return self._get_response_id(event) case 'response.output_text.delta': return self._get_delta(event) - case 'response.completed' | 'response.incomplete' | 'response.failed': + case 'response.failed': + raise ServiceTemporaryUnavailableError from self._get_response_error(event) + case 'response.completed' | 'response.incomplete': return self._get_usage(event) case _: return '' @@ -109,3 +114,11 @@ class OpenAIStreamMixin: def _get_response_id(self, event: OpenAIEvent) -> str: return event.get('response', {}).get('id', '') + + def _get_response_error(self, event: OpenAIEvent) -> OpenAIResponseError: + error = event.get('error') or (event.get('response') or {}).get('error') or {} + return OpenAIResponseError( + event_type=event.get('type', 'unknown'), + code=error.get('code', 'unknown'), + message=error.get('message', 'Unknown OpenAI response error'), + ) @@ -69,7 +69,10 @@ class Perplexity(SimpleService): if version_slug is None or version_slug not in self.TOKENS_COST: raise ModelVersionNotAvailable(version_slug, self.TOKENS_COST) version = f'perplexity/{version_slug}' - callback_data = {'provider': {'order': ['Perplexity']}, **input_message.info} + callback_data = { + **input_message.info, + 'provider': {'order': ['perplexity'], 'allow_fallbacks': False}, + } messages = self.get_chat_history() messages.append({'role': 'user', 'content': input_message.content}) try: @@ -54,7 +54,7 @@ class Pixverse(SimpleService): raise InsufficientBalance(balance, cost) thinking_types = {'авто': 'auto', 'выкл.': 'disabled', 'вкл.': 'enabled'} callback_data = { - 'prompt': self.translate_prompt(input_message.content), + 'prompt': input_message.content, 'quality': quality, 'thinking_type': thinking_types[input_message.info.pop('thinking_type', 'авто').lower()], **input_message.info, @@ -54,7 +54,10 @@ class Qwen(SimpleService): if version_slug is None or version_slug not in self.TOKENS_COST: raise ModelVersionNotAvailable(version_slug, self.TOKENS_COST) version = f'qwen/{version_slug}' - callback_data = {'provider': {'order': ['DeepInfra']}, **input_message.info} + callback_data = { + **input_message.info, + 'provider': {'order': ['deepinfra'], 'allow_fallbacks': False}, + } messages = self.get_chat_history() messages.insert( 0, @@ -58,7 +58,10 @@ class Qwen_235B(SimpleService): if version_slug is None or version_slug not in self.TOKENS_COST: raise ModelVersionNotAvailable(version_slug, self.TOKENS_COST) version = f'qwen/{version_slug}' - callback_data = {'provider': {'order': ['DeepInfra']}, **input_message.info} + callback_data = { + **input_message.info, + 'provider': {'order': ['deepinfra'], 'allow_fallbacks': False}, + } messages = self.get_chat_history() messages.insert( 0, @@ -26,8 +26,8 @@ class Qwen_3_7(StreamSimpleService): COEFFICIENT = Decimal('300.0') TOKENS_COST = { - 'qwen3.7-max': {'input': Decimal('750'), 'output': Decimal('2250')}, - 'qwen3.7-plus': {'input': Decimal('120'), 'output': Decimal('480')}, + 'qwen3.7-max': {'input': Decimal('442.5'), 'output': Decimal('1327.5')}, # $1.475 / $4.425 + 'qwen3.7-plus': {'input': Decimal('96'), 'output': Decimal('384')}, # $0.32 / $1.28 } MAX_OUTPUT_TOKENS = 30_000 @@ -38,7 +38,10 @@ class Qwen_3_Max_Thinking(SimpleService): def make(self, input_message: Message, save: bool = True) -> list[Message]: start_time = time.time() - callback_data = {'provider': {'order': ['alibaba']}, **input_message.info} + callback_data = { + **input_message.info, + 'provider': {'order': ['alibaba'], 'allow_fallbacks': False}, + } messages = self.get_chat_history() messages.append({'role': 'user', 'content': input_message.content}) result = openrouter_run('qwen/qwen3-max-thinking:online', messages, callback_data, 'Qwen') @@ -20,12 +20,12 @@ from ml_model.tasks import bytedance_model_ark_run class Reve(SimpleService): # Reve временно не работает на репликейте. Временно используем сидрим - TEMPORARY_PROVIDER_MODEL = 'seedream-5-0-260128' + TEMPORARY_PROVIDER_MODEL = 'seedream-5-0-lite-260128' PRICE = { - '2K': Decimal('25'), - '3K': Decimal('50'), - '4K': Decimal('100'), + '2K': Decimal('17.5'), # $0.035 / image (seedream-5-0-lite-260128) + '3K': Decimal('17.5'), # $0.035 / image + '4K': Decimal('17.5'), # $0.035 / image } # PRICE = { # 'create': Decimal('12.5'), @@ -76,7 +76,7 @@ class Reve(SimpleService): raise InvalidParameterError(f'Unsupported size: {size}') callback_data = { - 'prompt': self.translate_prompt(input_message.content), + 'prompt': input_message.content, **input_message.info, 'size': size, 'watermark': False, @@ -7,12 +7,12 @@ from typing import Any import requests from django.utils.translation import gettext as _ from django.core.files import File -from replicate.exceptions import ModelError from messages.models import Message from ml_model.adapters.bytedance_model_ark import BytedanceContentType from ml_model.exceptions import InvalidParameterError from ml_model.exceptions import ModelVersionNotAvailable +from ml_model.services.FileService import ImageFileProcessingService from ml_model.services.base import SimpleService from ml_model.tasks import bytedance_model_ark_run from payments.exceptions.insufficient_balance import InsufficientBalance @@ -22,18 +22,18 @@ from payments.selectors.payment_plan_selector import PaymentPlanSelector class Seedream(SimpleService): TOKEN_COST = { 'seedream-boosted': { - '2K': Decimal('25'), - '3K': Decimal('50'), - '4K': Decimal('100'), + '2K': Decimal('17.5'), # $0.035 / image + '3K': Decimal('17.5'), # $0.035 / image + '4K': Decimal('17.5'), # $0.035 / image }, 'seedream-4.5': { - '2K': Decimal('25'), - '4K': Decimal('100'), + '2K': Decimal('20'), # $0.04 / image + '4K': Decimal('20'), # $0.04 / image }, } VERSION_MAPPING = { - 'seedream-boosted': 'seedream-5-0-260128', + 'seedream-boosted': 'seedream-5-0-lite-260128', 'seedream-4.5': 'seedream-4-5-251128', } @@ -94,6 +94,11 @@ class Seedream(SimpleService): **input_message.info, } if image := input_message.file: + image_processor = ImageFileProcessingService(image) + file_bytes = image_processor.get_bytes(20) + image_processor.get_kind(file_bytes) + image_processor.get_dimensions(max_pixels=36000000) + image_processor.image.close() callback_data.update({'image': image.url}) images = bytedance_model_ark_run( @@ -3,4 +3,4 @@ from ml_model.services import Minimaxmusic class Suno(Minimaxmusic): - TOKENS_COST = Decimal('17.5') + TOKENS_COST = Decimal('15') # $0.03 * 100 * 5 @@ -57,7 +57,7 @@ class Wan(SimpleService): input_message.file.close() callback_data = dict( { - 'prompt': self.translate_prompt(input_message.content), + 'prompt': input_message.content, 'image': image, 'resolution': resolution, 'duration': duration, @@ -40,6 +40,7 @@ class UnsupportedSize(Exception): self.current_size | self.required_size ) + class ImageTooLargeError(Exception): def __init__(self, max_pixels: int) -> None: self.max_pixels = max_pixels @@ -49,6 +50,7 @@ class ImageTooLargeError(Exception): 'max_pixels': self.max_pixels } + class ModelTimeoutError(Exception): def __str__(self): return _('The model is not responding') @@ -147,14 +149,17 @@ class InvalidParameterError(Exception): def __str__(self) -> str: return str(self.error_text) + class PromptLengthExceeded(Exception): - def __init__(self, max_length: int = 3000) -> None: + def __init__(self, max_length: int | None = None) -> None: self.max_length = max_length def __str__(self) -> str: - return _('Prompt is too long. Maximum length is %(max_length)s characters.') % { - 'max_length': self.max_length - } + if self.max_length: + return _('Prompt is too long. Maximum length is %(max_length)s characters.') % { + 'max_length': self.max_length + } + return _('Prompt is too long') class ServiceHighDemandError(Exception): @@ -162,6 +167,23 @@ class ServiceHighDemandError(Exception): return _('Service is currently unavailable due to high demand. Please try again later') +class ServiceTemporaryUnavailableError(Exception): + def __str__(self) -> str: + return _('Service is temporarily unavailable. Please try again later') + + +class OpenAIResponseError(Exception): + def __init__(self, event_type: str, code: str, message: str) -> None: + self.event_type = event_type + self.code = code + self.message = message + + def __str__(self) -> str: + return ( + f'OpenAI streaming error: event_type={self.event_type}, code={self.code}, message={self.message}' + ) + + class PaidPlanRequiredError(Exception): def __init__(self, feature: str) -> None: self.feature = feature @@ -187,6 +209,11 @@ class OutputSensitiveImageContentError(Exception): return _('The generated image may contain private or prohibited content') +class InputImageSensitiveContentError(Exception): + def __str__(self) -> str: + return _('The input image may contain private or prohibited content') + + class ModelVersionNotAvailable(Exception): def __init__(self, version: str | None, available_versions: Iterable[str]) -> None: self.version = version @@ -194,9 +221,7 @@ class ModelVersionNotAvailable(Exception): def __str__(self) -> str: version_label = self.version if self.version is not None else _('not specified') - return _( - 'Version "%(version)s" is not available. Available versions: %(available_versions)s.' - ) % { + return _('Version "%(version)s" is not available. Available versions: %(available_versions)s.') % { 'version': version_label, 'available_versions': ', '.join(self.available_versions), } @@ -6,15 +6,21 @@ import time # import uuid from io import BytesIO +from pathlib import PurePosixPath from typing import IO, Any, Dict import deepl import httpx +import rawpy import redis import replicate import requests +from PIL import Image from celery import shared_task from deepl.translator import TextResult +from django.core.files.base import ContentFile +from django.core.files.storage import Storage +from django.db.models.fields.files import FieldFile from django.utils.translation import gettext as _ from replicate.exceptions import ModelError from requests import Response @@ -28,6 +34,7 @@ from ml_model.exceptions import ( DeploymentDisabled, ExceededContextLengthError, FaceNotFoundError, + FileExtensionNotSupported, GenerationException, ImageAnalysisError, ImageContentNotFound, @@ -36,6 +43,7 @@ from ml_model.exceptions import ( ModelTimeoutError, PredictionInterruptedError, RequestBlocked, + ServiceHighDemandError, ) from poller.models import Proxy @@ -115,10 +123,52 @@ def transcript_audio(payload: dict[str, Any]): ) +def _prepare_replicate_image(image: FieldFile) -> tuple[str, tuple[Storage, str] | None]: + suffix = PurePosixPath(image.name).suffix.lower() + + if suffix == '.jfif': + # JFIF already contains JPEG data, so copy it without decoding or re-encoding. + image.open('rb') + image.seek(0) + jpeg_name = str(PurePosixPath(image.name).with_suffix('.jpg')) + try: + saved_name = image.storage.save(jpeg_name, image) + finally: + image.close() + elif suffix == '.dng': + image.open('rb') + image.seek(0) + try: + with rawpy.imread(image) as raw: + rgb = raw.postprocess() + buf = BytesIO() + Image.fromarray(rgb).save(buf, format='PNG') + buf.seek(0) + png_name = str(PurePosixPath(image.name).with_suffix('.png')) + saved_name = image.storage.save(png_name, ContentFile(buf.getvalue())) + finally: + image.close() + else: + return image.url, None + + try: + image_url = image.storage.url(saved_name) + except Exception: + image.storage.delete(saved_name) + + raise + + return image_url, (image.storage, saved_name) + + @shared_task def replicate_run(callback_url: str, payload: dict[str, Any]): replicate_client = replicate.Client(settings.REPLICATE_API_KEY) + temporary_file = None try: + if isinstance(image := payload.get('image'), FieldFile): + payload['image'], temporary_file = _prepare_replicate_image(image) + return replicate_client.run( ref=callback_url, input=payload, @@ -126,7 +176,25 @@ def replicate_run(callback_url: str, payload: dict[str, Any]): except ModelError as exc: prediction_error = getattr(getattr(exc, 'prediction', None), 'error', '') or '' error_text = str(exc) - if any(error in error_text for error in ('E005', 'E006', 'sexual', 'NSFW')): + if 'ModelRateLimitError' in error_text or 'E003' in error_text: + raise ServiceHighDemandError from exc + if ( + 'Music upload failed' in error_text + and 'audio format' in error_text + and 'is not supported' in error_text + ): + raise FileExtensionNotSupported(('MP3', 'WAV')) from exc + if any( + error in error_text + for error in ( + 'E005', + 'E006', + 'sexual', + 'NSFW', + 'illegal material', + 'Content flagged', + ) + ): raise RequestBlocked if 'PA' in error_text: raise PredictionInterruptedError @@ -147,6 +215,10 @@ def replicate_run(callback_url: str, payload: dict[str, Any]): if 'PROMPT_TOO_LONG' in error_text: raise ExceededContextLengthError raise GenerationException from exc + finally: + if temporary_file: + storage, file_name = temporary_file + storage.delete(file_name) @shared_task @@ -0,0 +1,11 @@ +from django.utils.translation import gettext as _ + + +class ActivePaymentMethodNotFound(Exception): + def __str__(self) -> str: + return _('Active payment method not found') + + +class DuplicateRecoveryAttempt(Exception): + def __str__(self) -> str: + return _('Subscription recovery is already in progress') @@ -0,0 +1,4 @@ +from payments.exporters.invoice_daily_by_model import InvoiceDailyByModelExporter +from payments.exporters.payment_daily import PaymentDailyExporter + +__all__ = ['InvoiceDailyByModelExporter', 'PaymentDailyExporter'] @@ -0,0 +1,51 @@ +from collections import defaultdict +from datetime import date, datetime, time, timedelta + +from django.db.models import Count +from django.db.models.functions import TruncDate +from django.utils import timezone + +from lib.exporters import ExcelExporter +from payments.models import Invoice + + +class InvoiceDailyByModelExporter(ExcelExporter): + SHEET_TITLE = 'Invoices by model' + + def __init__(self, date_from: date, date_to: date): + self.date_from = date_from + self.date_to = date_to + self._days = [date_from + timedelta(days=offset) for offset in range((date_to - date_from).days + 1)] + + def get_headers(self): + return ['Модель', *[day.strftime('%d.%m.%Y') for day in self._days]] + + def get_filename(self) -> str: + return f'invoices_by_model_daily_{self.date_from}_{self.date_to}.xlsx' + + def get_rows(self): + tz = timezone.get_current_timezone() + start = timezone.make_aware(datetime.combine(self.date_from, time.min), tz) + end = timezone.make_aware( + datetime.combine(self.date_to + timedelta(days=1), time.min), + tz, + ) + + rows = ( + Invoice.objects.filter( + created_at__gte=start, + created_at__lt=end, + model__isnull=False, + ) + .annotate(day=TruncDate('created_at', tzinfo=tz)) + .values('model__slug', 'day') + .annotate(count=Count('id')) + .order_by('model__slug', 'day') + ) + + matrix: dict[str, dict[date, int]] = defaultdict(lambda: {day: 0 for day in self._days}) + for row in rows: + matrix[row['model__slug']][row['day']] = row['count'] + + for slug in sorted(matrix): + yield slug, *[matrix[slug][day] for day in self._days] @@ -0,0 +1,49 @@ +from datetime import date, datetime, time, timedelta +from decimal import Decimal + +from django.db import models +from django.db.models import Count, Sum +from django.db.models.functions import Coalesce, TruncDate +from django.utils import timezone + +from lib.exporters import ExcelExporter +from payments.models import Payment + + +class PaymentDailyExporter(ExcelExporter): + SHEET_TITLE = 'Payments by day' + + def __init__(self, date_from: date, date_to: date): + self.date_from = date_from + self.date_to = date_to + + def get_headers(self): + return ['Дата', 'Кол-во платежей', 'Сумма'] + + def get_filename(self) -> str: + return f'payments_daily_{self.date_from}_{self.date_to}.xlsx' + + def get_rows(self): + tz = timezone.get_current_timezone() + start = timezone.make_aware(datetime.combine(self.date_from, time.min), tz) + end = timezone.make_aware( + datetime.combine(self.date_to + timedelta(days=1), time.min), + tz, + ) + + rows = ( + Payment.objects.filter( + status=Payment.SUCCEEDED, + created_at__gte=start, + created_at__lt=end, + ) + .annotate(day=TruncDate('created_at', tzinfo=tz)) + .values('day') + .annotate( + count=Count('uid'), + total=Coalesce(Sum('amount'), Decimal(0), output_field=models.DecimalField()), + ) + .order_by('day') + ) + for row in rows: + yield row['day'].strftime('%d.%m.%Y'), row['count'], row['total'] @@ -27,8 +27,7 @@ def _parse_model_slug(value: str, model_slugs: set[str]) -> str: close = difflib.get_close_matches(model_slug, model_slugs, n=1) if close: raise argparse.ArgumentTypeError( - f'NeuronModel со slug "{model_slug}" не найдена. ' - f'Возможно, вы имели в виду: {close[0]}' + f'NeuronModel со slug "{model_slug}" не найдена. Возможно, вы имели в виду: {close[0]}' ) raise argparse.ArgumentTypeError(f'NeuronModel со slug "{model_slug}" не найдена.') @@ -81,11 +80,14 @@ def _build_migration_source( return f"""# Generated by makemigration_payment_features on {datetime.now():%Y-%m-%d %H:%M} import math +import logging from decimal import Decimal from django.db import migrations from django.db.models import Max +logger = logging.getLogger(__name__) + def {func_name}(apps, schema_editor): PaymentPlan = apps.get_model('payments', 'PaymentPlan') @@ -95,7 +97,11 @@ def {func_name}(apps, schema_editor): price = Decimal('{price}') measurement_unit = '{measurement_unit}' price_threshold = {price_threshold} - model = NeuronModel.objects.get(slug='{model_slug}') + try: + model = NeuronModel.objects.get(slug='{model_slug}') + except NeuronModel.DoesNotExist: + logger.warning('The model %s was not found. Migration will do nothing.', '{model_slug}') + return category = model.category max_order_by_plan_id = {{ @@ -247,4 +253,3 @@ class Command(BaseCommand): return int(raw) except ValueError: self.stderr.write('Введите целое число.') - @@ -1,11 +1,14 @@ # Generated by makemigration_payment_features on 2026-07-28 15:33 import math +import logging from decimal import Decimal from django.db import migrations from django.db.models import Max +logger = logging.getLogger(__name__) + def add_flux_3_payment_features(apps, schema_editor): PaymentPlan = apps.get_model('payments', 'PaymentPlan') @@ -15,7 +18,11 @@ def add_flux_3_payment_features(apps, schema_editor): price = Decimal('9.5') measurement_unit = 'file' price_threshold = 0 - model = NeuronModel.objects.get(slug='flux_3') + try: + model = NeuronModel.objects.get(slug='flux_3') + except NeuronModel.DoesNotExist: + logger.warning('The model %s was not found. Migration will do nothing.', 'flux_3') + return category = model.category max_order_by_plan_id = { @@ -49,7 +56,6 @@ def add_flux_3_payment_features(apps, schema_editor): class Migration(migrations.Migration): - dependencies = [ ('payments', '0031_remove_paymentmethod_attempts_and_more'), ] @@ -1,11 +1,14 @@ # Generated by makemigration_payment_features on 2026-07-28 15:34 import math +import logging from decimal import Decimal from django.db import migrations from django.db.models import Max +logger = logging.getLogger(__name__) + def add_grok_image_ultra_payment_features(apps, schema_editor): PaymentPlan = apps.get_model('payments', 'PaymentPlan') @@ -15,7 +18,11 @@ def add_grok_image_ultra_payment_features(apps, schema_editor): price = Decimal('45') measurement_unit = 'file' price_threshold = 0 - model = NeuronModel.objects.get(slug='grok_image_ultra') + try: + model = NeuronModel.objects.get(slug='grok_image_ultra') + except NeuronModel.DoesNotExist: + logger.warning('The model %s was not found. Migration will do nothing.', 'grok_image_ultra') + return category = model.category max_order_by_plan_id = { @@ -49,7 +56,6 @@ def add_grok_image_ultra_payment_features(apps, schema_editor): class Migration(migrations.Migration): - dependencies = [ ('payments', '0032_add_flux_3_payment_features'), ] @@ -1,11 +1,14 @@ # Generated by makemigration_payment_features on 2026-07-28 17:27 +import logging import math from decimal import Decimal from django.db import migrations from django.db.models import Max +logger = logging.getLogger(__name__) + def add_flux_payment_features(apps, schema_editor): PaymentPlan = apps.get_model('payments', 'PaymentPlan') @@ -15,6 +18,7 @@ def add_flux_payment_features(apps, schema_editor): try: model = NeuronModel.objects.get(slug='flux') except NeuronModel.DoesNotExist: + logger.warning('The model %s was not found. Migration will do nothing.', 'flux') return price = Decimal('9.5') @@ -53,7 +57,6 @@ def add_flux_payment_features(apps, schema_editor): class Migration(migrations.Migration): - dependencies = [ ('payments', '0033_add_grok_image_ultra_payment_features'), ] @@ -0,0 +1,28 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('payments', '0034_add_flux_payment_features'), + ] + + operations = [ + migrations.AddField( + model_name='paymentplanuserinfo', + name='last_recovery_payment_id', + field=models.UUIDField( + blank=True, + null=True, + verbose_name='Last recovery payment id', + ), + ), + migrations.AddField( + model_name='paymentplanuserinfo', + name='recovery_locked_at', + field=models.DateTimeField( + blank=True, + null=True, + verbose_name='Recovery locked at', + ), + ), + ] @@ -0,0 +1,3 @@ +from payments.mixins.admin import DateRangeExportAdminMixin + +__all__ = ['DateRangeExportAdminMixin'] @@ -0,0 +1,52 @@ +from datetime import date, timedelta + +from django.contrib.admin import helpers +from django.http import HttpRequest, HttpResponse +from django.template.response import TemplateResponse +from django.utils import timezone + +from lib.exporters import BaseExporter + + +class DateRangeExportAdminMixin: + TEMPLATE = 'admin/payments/export_daily.html' + + def export_by_date_range( + self, + request: HttpRequest, + queryset, + *, + exporter_class: type[BaseExporter], + action_name: str, + title: str, + ) -> HttpResponse: + date_from = request.POST.get('date_from') or (timezone.localdate() - timedelta(days=6)).isoformat() + date_to = request.POST.get('date_to') or timezone.localdate().isoformat() + error = None + + if 'apply' in request.POST: + try: + parsed_from = date.fromisoformat(date_from) + parsed_to = date.fromisoformat(date_to) + except ValueError: + error = 'Некорректный формат даты.' + else: + if parsed_from > parsed_to: + error = 'Дата «С» не может быть позже «По».' + else: + return exporter_class(parsed_from, parsed_to).export() + + return TemplateResponse( + request, + self.TEMPLATE, + { + **self.admin_site.each_context(request), + 'title': title, + 'queryset': queryset, + 'action_checkbox_name': helpers.ACTION_CHECKBOX_NAME, + 'action_name': action_name, + 'date_from': date_from, + 'date_to': date_to, + 'error': error, + }, + ) @@ -1,7 +1,6 @@ from datetime import datetime from django.contrib.auth import get_user_model -from django.contrib.postgres.fields import ArrayField from django.db import models from django.utils.translation import gettext_lazy as _ @@ -50,6 +49,18 @@ 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')) + # FIXME: вынести в абстракцию попыток + last_recovery_payment_id = models.UUIDField( + blank=True, + null=True, + verbose_name=_('Last recovery payment id'), + ) + # FIXME: вынести в абстракцию попыток + recovery_locked_at = models.DateTimeField( + blank=True, + null=True, + verbose_name=_('Recovery locked at'), + ) current_token_balance = models.DecimalField( max_digits=100, decimal_places=10, verbose_name=_('Current balance') ) @@ -1,31 +1,26 @@ -import orjson - import calendar import logging from collections import defaultdict from datetime import date, timedelta from decimal import Decimal -from django.db.models.aggregates import Count -from django.utils.translation import gettext_lazy as _ - +import orjson from dateutil.relativedelta import relativedelta from django.db.models import CharField, F, Func, Prefetch, Q, Sum, Value +from django.db.models.aggregates import Count 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.exceptions.business_host_exceptions.access_denied import AccessDenied from authentication.models import CustomUserModel from authentication.security import SyncAuthBearer -from authentication.exceptions.business_host_exceptions.access_denied import AccessDenied -from payments.models import ( - Invoice, - Payment, - PaymentPlan, - PaymentPlanFeature, - PaymentMethod, +from payments.exceptions.subscription_recovery import ( + ActivePaymentMethodNotFound, + DuplicateRecoveryAttempt, ) +from payments.models import Invoice, Payment, PaymentMethod, PaymentPlan, PaymentPlanFeature from payments.schema import UserBalance from payments.schemas import ( ExpensesParamsSchema, @@ -33,11 +28,13 @@ from payments.schemas import ( NewSubscriptionSchema, PaymentLinkSchema, PaymentPlanSchema, + RestoreSubscriptionLockSchema, + RestoreSubscriptionSchema, ) 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 +from payments.typing import IntervalStrategyEnum, SourceStrategyEnum router = Router(auth=SyncAuthBearer(), tags=['payments']) @@ -103,7 +100,7 @@ def handle_yookassa_webhook(request): @router.post('revoke-recurring-payment', tags=['payments/revoke-recurring-payment']) def revoke_recurring_payment(request): - deactivate_count = PaymentMethodService(request.auth).deactivate_payment_methods() + deactivate_count = PaymentMethodService(request.auth).deactivate_payment_methods(notify='revoke') logger.info( 'Recurring payment revoked by user: email=%s deactivated_methods=%s', request.auth.email, @@ -114,6 +111,44 @@ def revoke_recurring_payment(request): return 200, {'detail': _('The recurring payment is successfully cancelled')} +@router.post( + 'restore-subscription', + tags=['payments/restore-subscription'], + response=RestoreSubscriptionSchema, +) +def restore_subscription(request): + payment_service = PaymentService(request.auth) + try: + payment = payment_service.restore_subscription() + except ActivePaymentMethodNotFound as exc: + raise HttpError(400, str(exc)) from exc + except DuplicateRecoveryAttempt as exc: + raise HttpError(403, str(exc)) from exc + except Exception as exc: + logger.exception('Subscription recovery failed: email=%s', request.auth.email) + raise HttpError(500, _('Payment could not be completed, please try again later')) from exc + + try: + if result := payment_service.yookassa_payment_polling(payment): + return RestoreSubscriptionSchema(**result) + if result := payment_service.db_payment_polling(payment.id): + return RestoreSubscriptionSchema(**result) + return RestoreSubscriptionSchema(ok=True, external=False, internal=False) + finally: + payment_service.release_subscription_recovery_lock() + + +@router.get( + 'restore-subscription/blocked', + tags=['payments/restore-subscription'], + response=RestoreSubscriptionLockSchema, +) +def get_restore_subscription_blocked(request): + return RestoreSubscriptionLockSchema( + blocked=PaymentService(request.auth).is_subscription_recovery_blocked(), + ) + + @router.get('expenses', tags=['payments/expenses'], response=list[ExpensesSchema]) def list_expenses(request, data: ExpensesParamsSchema = Query(...)): try: @@ -1,4 +1,5 @@ import logging +from typing import Literal from django.conf import settings from django.db import transaction @@ -29,7 +30,11 @@ class PaymentMethodService: elif gateway == 'yoo_money': metadata = {'account_number': yookassa_payment_method.account_number} elif gateway == 'sbp': - metadata = {'sbp_operation_id': yookassa_payment_method.sbp_operation_id} + metadata = { + 'sbp_operation_id': yookassa_payment_method.sbp_operation_id, + 'bic': yookassa_payment_method.payer_bank_details.bic, + 'bank_id': yookassa_payment_method.payer_bank_details.bank_id, + } else: metadata = {} payment_method, created = PaymentMethod.objects.update_or_create( @@ -64,7 +69,7 @@ class PaymentMethodService: self.user.email, method.uid, cancel_reason, - method.total_attempts+1, + method.total_attempts + 1, ) return payment_attempt @@ -83,19 +88,26 @@ class PaymentMethodService: return True return False - def deactivate_payment_methods(self) -> int: + def deactivate_payment_methods( + self, *, notify: Literal['revoke', 'failed_renewal'] | None = 'failed_renewal' + ) -> int: + # FIXME 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: + if deactivated and notify is not None: + user = self.user def _send(): try: - EmailService.send_revoke_recurring_email(self.user.email) + if notify == 'revoke': + EmailService.send_revoke_recurring_email(user) + else: + EmailService.send_failed_subscription_renewal_email(user) except Exception: - logger.exception('Failed to send revoke recurring email') + logger.exception('Failed to send subscription deactivation email') transaction.on_commit(_send) return deactivated @@ -80,7 +80,7 @@ class PaymentPlanService: plan = pp.plan if plan.price <= 0: return False - if plan.individual or plan.is_corporate or pp.next_payment_at is None: + if plan.individual or pp.next_payment_at is None: return True if hasattr(pp, 'primary_methods'): @@ -109,4 +109,4 @@ class PaymentPlanService: 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 + ) @@ -1,7 +1,7 @@ import hashlib import logging -from datetime import timedelta - +import time +from datetime import datetime, timedelta from decimal import Decimal from uuid import UUID, uuid4 @@ -14,8 +14,13 @@ from yookassa.domain.response import PaymentResponse as YookassaPaymentResponse from authentication.models import CustomUserModel from authentication.services.email_service import EmailService +from payments.exceptions.subscription_recovery import ( + ActivePaymentMethodNotFound, + DuplicateRecoveryAttempt, +) from payments.models.payment import Payment as PaymentModel from payments.models.payment_plan import PaymentPlan, PaymentPlanUserInfo +from payments.models.user_payment_method import PaymentMethod from payments.services.payment_method_service import PaymentMethodService from payments.services.referral_account import ReferralAccountService @@ -25,6 +30,8 @@ logger = logging.getLogger(__name__) class PaymentService: Configuration.account_id = settings.YOOKASSA_ACCOUNT_ID Configuration.secret_key = settings.YOOKASSA_SECRET_KEY + RECOVERY_LOCK_TTL = timedelta(minutes=2) + RECOVERY_BACKOFF_SECONDS = (0.25, 0.75, 1.0, 3.0) def __init__(self, user: CustomUserModel): self.user = user @@ -59,6 +66,126 @@ class PaymentService: ) return payment.confirmation.confirmation_url + def restore_subscription(self) -> YookassaPaymentResponse: + payment_method, generation = self._reserve_subscription_recovery() + plan = self.user.payment_plan.plan + try: + # FIXME: вынести сборку payload создания платежа в общий метод с create_payment_link + payment = YookassaPayment.create( + { + 'amount': {'value': f'{plan.price}', 'currency': 'RUB'}, + 'payment_method_id': payment_method.payment_method_id, + 'receipt': { + 'customer': {'email': self.user.email}, + 'items': [ + { + 'description': str(plan), + 'amount': {'value': f'{plan.price}', 'currency': 'RUB'}, + 'vat_code': 1, + 'quantity': '1', + } + ], + }, + 'description': str(self.user.uid), + 'capture': True, + 'metadata': { + 'plan_uid': str(plan.uid), + 'recovery_generation': generation, + }, + }, + idempotency_key=hashlib.sha256( + f'recovery:{self.user.uid}:{plan.uid}:{generation}'.encode() + ).hexdigest(), + ) + except Exception: + self.release_subscription_recovery_lock() + raise + logger.info( + 'Subscription recovery payment created: payment_id=%s email=%s method_uid=%s status=%s', + payment.id, + self.user.email, + payment_method.uid, + payment.status, + ) + return payment + + def yookassa_payment_polling(self, payment: YookassaPaymentResponse) -> dict[str, bool] | None: + current = payment + for delay in (0, *self.RECOVERY_BACKOFF_SECONDS): + if delay: + time.sleep(delay) + current = YookassaPayment.find_one(str(payment.id)) + if current.status == PaymentModel.SUCCEEDED: + return None + if current.status == PaymentModel.CANCELLED: + self._close_recovery_generation(current) + return {'ok': False, 'external': False, 'internal': False} + return {'ok': False, 'external': True, 'internal': False} + + def db_payment_polling(self, payment_id: UUID | str) -> dict[str, bool] | None: + for delay in (0, *self.RECOVERY_BACKOFF_SECONDS): + if delay: + time.sleep(delay) + if PaymentModel.objects.filter(uid=payment_id, status=PaymentModel.SUCCEEDED).exists(): + return None + return {'ok': False, 'external': False, 'internal': True} + + def is_subscription_recovery_blocked(self) -> bool: + locked_at = self.user.payment_plan.recovery_locked_at + return bool(locked_at) and not self._is_recovery_lock_stale(locked_at) + + def release_subscription_recovery_lock(self) -> None: + PaymentPlanUserInfo.objects.filter(pk=self.user.payment_plan.pk).update(recovery_locked_at=None) + + def _reserve_subscription_recovery(self) -> tuple[PaymentMethod, str]: + active_methods = self.user.payment_plan.active_methods + if not active_methods: + raise ActivePaymentMethodNotFound + + with transaction.atomic(): + info = PaymentPlanUserInfo.objects.select_for_update().get(pk=self.user.payment_plan.pk) + if info.recovery_locked_at and not self._is_recovery_lock_stale(info.recovery_locked_at): + logger.info('Duplicate subscription recovery skipped: email=%s', self.user.email) + raise DuplicateRecoveryAttempt + + info.recovery_locked_at = timezone.now() + info.save(update_fields=['recovery_locked_at']) + generation = str(info.last_recovery_payment_id or 'none') + + return active_methods[0], generation + + @classmethod + def _is_recovery_lock_stale(cls, locked_at: datetime) -> bool: + return locked_at <= timezone.now() - cls.RECOVERY_LOCK_TTL + + def _close_recovery_generation(self, payment: YookassaPaymentResponse) -> None: + # FIXME: улучшить механизм синхронизации, сделать механизм уведомлений в системе для + # отслеживания ивента пополнения баланса + metadata = payment.metadata or {} + payment_generation = metadata.get('recovery_generation') + if payment_generation is None: + return + if payment.status not in {PaymentModel.SUCCEEDED, PaymentModel.CANCELLED}: + return + + payment_generation = str(payment_generation) + try: + expected_last = None if payment_generation == 'none' else UUID(payment_generation) + except ValueError: + return + + updated = PaymentPlanUserInfo.objects.filter( + pk=self.user.payment_plan.pk, + last_recovery_payment_id=expected_last, + ).update(last_recovery_payment_id=UUID(str(payment.id))) + if not updated: + logger.info( + 'Stale recovery generation close skipped: payment_id=%s email=%s generation=%s', + payment.id, + self.user.email, + payment_generation, + ) + def do_payment(self, payment: YookassaPaymentResponse) -> PaymentModel: from payments.services.payment_plan_service import PaymentPlanService @@ -66,6 +193,7 @@ class PaymentService: payment_instance, should_process = self.save_payment(payment) if not should_process: return payment_instance + self._close_recovery_generation(payment) logger.info( 'Processing payment webhook: payment_id=%s email=%s status=%s', payment.id, @@ -73,10 +201,9 @@ class PaymentService: payment.status, ) 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) + is_recurring = bool(payment.metadata.get('recurring', False)) + buying_tokens = self._calculate_buying_tokens(payment_instance.plan, is_recurring) + self._handle_succeeded_payment(payment, payment_instance.plan, is_recurring) 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) @@ -98,12 +225,14 @@ class PaymentService: 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, recurring: bool = False + ) -> None: + # FIXME 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( - next_payment_at=timezone.now() + timedelta(days=30) - ) + next_payment_at = timezone.now() + timedelta(days=30) + PaymentPlanUserInfo.objects.filter(user=self.user).update(next_payment_at=next_payment_at) logger.info( 'Recurring payment method saved: email=%s method_uid=%s next_payment_at_set=true', self.user.email, @@ -111,24 +240,35 @@ class PaymentService: ) else: if not plan.individual: - PaymentPlanUserInfo.objects.filter(user=self.user).update( - next_payment_at=timezone.now() + timedelta(days=30) - ) + next_payment_at = timezone.now() + timedelta(days=30) + PaymentPlanUserInfo.objects.filter(user=self.user).update(next_payment_at=next_payment_at) logger.info( 'Recurring schedule updated without saved method: email=%s next_payment_at_set=true', self.user.email, ) else: + next_payment_at = None PaymentPlanUserInfo.objects.filter(user=self.user).update(next_payment_at=None) logger.info( 'Recurring schedule cleared: email=%s reason=individual_plan', self.user.email, ) - PaymentMethodService(self.user).deactivate_payment_methods() + PaymentMethodService(self.user).deactivate_payment_methods(notify=None) logger.info( 'Recurring payment methods deactivated after succeeded payment: email=%s', self.user.email ) + if recurring and next_payment_at is not None: + user = self.user + + def _send_successful_recurring_email(): + try: + EmailService.send_successful_recurring_email(user, next_payment_at) + except Exception: + logger.exception('Failed to send successful recurring email') + + transaction.on_commit(_send_successful_recurring_email) + def _handle_canceled_payment(self, payment: YookassaPaymentResponse) -> None: from payments.services.payment_plan_service import PaymentPlanService @@ -0,0 +1,34 @@ +{% extends "admin/base_site.html" %} + +{% block content %} +
+ {% csrf_token %} + + + {% for obj in queryset %} + + {% endfor %} + +
+ {% if error %} +

{{ error }}

+ {% endif %} +
+
+ + +
+
+
+
+ + +
+
+
+ +
+ +
+
+{% endblock %} @@ -0,0 +1,21 @@ + + + + + Аккаунт успешно удален + + +

Здравствуйте, {{ greeting }}!

+ + {% if subscription_cancelled %} +

Аккаунт успешно удален, подписка была отменена автоматически.

+ {% else %} +

Аккаунт успешно удален.

+ {% endif %} + +

Спасибо, что были с нами!

+ +

С уважением,
+ Команда AIR

+ + @@ -0,0 +1,19 @@ + + + + + Не удалось продлить подписку на AIR + + +

Здравствуйте, {{ greeting }}!

+ +

Нам не удалось продлить вашу подписку на платформе AIR. Вы можете вновь оформить подписку самостоятельно, + перейдя по ссылке
+ https://app.air.fail

+ +

Спасибо, что остаётесь с нами!

+ +

С уважением,
+ Команда AIR

+ + @@ -1,12 +1,21 @@ - + Отмена подписки на платформе AIR -

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

-

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

+

Здравствуйте, {{ greeting }}!

+ +

Подписка успешно отменена.

+ +

Что это значит для вас:

+

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

+ +

Спасибо, что остаётесь с нами!

+ +

С уважением,
+ Команда AIR

- \ No newline at end of file + @@ -0,0 +1,23 @@ + + + + + Ваша подписка на AIR продлена автоматически + + +

Здравствуйте, {{ greeting }}!

+ +

Напоминаем, что срок действия вашей подписки на платформе AIR подошел к концу. Согласно условиям вашего тарифного + плана, подписка продлена автоматически до {{ next_payment_at }}.

+ +

Что это значит для вас:

+

— Доступ ко всем функциям платформы сохранится без перерыва;
+ — Вам не нужно предпринимать никаких действий — продление произойдёт автоматически;
+ — Условия тарифа остаются прежними.

+ +

Спасибо, что остаётесь с нами!

+ +

С уважением,
+ Команда AIR

+ + @@ -0,0 +1,407 @@ +from datetime import timedelta +from types import SimpleNamespace +from unittest.mock import patch +from uuid import UUID, uuid4 + +from django.utils import timezone + +from core import tests as core_tests +from payments.models import Payment, PaymentMethod, PaymentPlan, PaymentPlanUserInfo +from payments.services.payment_service import PaymentService + + +class RestoreSubscriptionAPITest(core_tests.BaseAuthorizedAPITest): + ENDPOINT = '/api/v1/payments/restore-subscription' + + @classmethod + def setUpTestData(cls) -> None: + PaymentPlan.objects.update_or_create(price=0, tokens_per_plan=10, defaults={}) + super().setUpTestData() + + @classmethod + def setup_test_data(cls) -> None: + cls.plan = PaymentPlan.objects.create(price=1000, tokens_per_plan=100) + cls.user.payment_plan.plan = cls.plan + cls.user.payment_plan.save() + cls.payment_method = PaymentMethod.objects.create( + user_plan_info=cls.user.payment_plan, + gateway=PaymentMethod.GatewayChoices.BANK_CARD, + payment_method_id=uuid4(), + metadata={}, + active=True, + primary=True, + ) + + def _payment(self, status: str, payment_id: str | None = None) -> SimpleNamespace: + return SimpleNamespace( + amount=SimpleNamespace(value=self.plan.price), + description=str(self.user.uid), + id=payment_id or str(uuid4()), + metadata={'plan_uid': str(self.plan.uid), 'recovery': True}, + status=status, + ) + + def _create_local_payment(self, payment_id: str) -> Payment: + return Payment.objects.create( + uid=payment_id, + user=self.user, + amount=self.plan.price, + plan=self.plan, + status=Payment.SUCCEEDED, + description=str(self.user.uid), + ) + + def _apply_recovery_webhook(self, payment: SimpleNamespace) -> None: + PaymentService(self.user)._close_recovery_generation(payment) + + def test_unauthorized_status_code(self) -> None: + response = self.client.post(self.ENDPOINT) + + self.assertEqual(response.status_code, 401) + self.assertEqual(response.json(), {'detail': 'Unauthorized'}) + + @patch('payments.services.payment_service.time.sleep') + @patch('payments.services.payment_service.YookassaPayment.create') + def test_authorized_status_code(self, create_payment_mock, sleep_mock) -> None: + yookassa_payment = self._payment('succeeded') + create_payment_mock.return_value = yookassa_payment + self._create_local_payment(yookassa_payment.id) + + response = self.post() + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {'ok': True, 'external': False, 'internal': False}) + sleep_mock.assert_not_called() + + @patch('payments.services.payment_service.time.sleep') + @patch('payments.services.payment_service.YookassaPayment.create') + def test_uses_primary_active_payment_method_first(self, create_payment_mock, sleep_mock) -> None: + newer_payment_method = PaymentMethod( + user_plan_info=self.user.payment_plan, + gateway=PaymentMethod.GatewayChoices.BANK_CARD, + payment_method_id=uuid4(), + metadata={}, + active=True, + primary=False, + ) + PaymentMethod.objects.bulk_create([newer_payment_method]) + yookassa_payment = self._payment('succeeded') + create_payment_mock.return_value = yookassa_payment + self._create_local_payment(yookassa_payment.id) + + self.post() + + payment_data = create_payment_mock.call_args.args[0] + self.assertEqual(payment_data['payment_method_id'], self.payment_method.payment_method_id) + self.assertEqual(payment_data['metadata']['recovery'], True) + + @patch('payments.services.payment_service.time.sleep') + @patch('payments.services.payment_service.YookassaPayment.create') + def test_uses_most_recent_active_payment_method(self, create_payment_mock, sleep_mock) -> None: + PaymentMethod.objects.filter(pk=self.payment_method.pk).update(primary=False) + newer_payment_method = PaymentMethod( + user_plan_info=self.user.payment_plan, + gateway=PaymentMethod.GatewayChoices.BANK_CARD, + payment_method_id=uuid4(), + metadata={}, + active=True, + primary=False, + ) + PaymentMethod.objects.bulk_create([newer_payment_method]) + yookassa_payment = self._payment('succeeded') + create_payment_mock.return_value = yookassa_payment + self._create_local_payment(yookassa_payment.id) + + self.post() + + payment_data = create_payment_mock.call_args.args[0] + self.assertEqual(payment_data['payment_method_id'], newer_payment_method.payment_method_id) + + def test_returns_bad_request_without_active_payment_method(self) -> None: + PaymentMethod.objects.update(active=False) + + response = self.post() + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json(), {'detail': 'Active payment method not found'}) + + @patch('payments.services.payment_service.time.sleep') + @patch('payments.services.payment_service.YookassaPayment.create') + def test_returns_not_ok_when_payment_is_canceled(self, create_payment_mock, sleep_mock) -> None: + create_payment_mock.return_value = self._payment('canceled') + + response = self.post() + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {'ok': False, 'external': False, 'internal': False}) + sleep_mock.assert_not_called() + self.user.payment_plan.refresh_from_db() + self.assertIsNotNone(self.user.payment_plan.last_recovery_payment_id) + + @patch('payments.services.payment_service.time.sleep') + @patch('payments.services.payment_service.YookassaPayment.find_one') + @patch('payments.services.payment_service.YookassaPayment.create') + def test_returns_not_ok_when_payment_stays_pending( + self, + create_payment_mock, + find_one_mock, + sleep_mock, + ) -> None: + pending_payment = self._payment('pending') + create_payment_mock.return_value = pending_payment + find_one_mock.return_value = pending_payment + + response = self.post() + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {'ok': False, 'external': True, 'internal': False}) + self.assertEqual(sleep_mock.call_count, 4) + self.assertEqual(find_one_mock.call_count, 4) + + @patch('payments.services.payment_service.time.sleep') + @patch('payments.services.payment_service.YookassaPayment.find_one') + @patch('payments.services.payment_service.YookassaPayment.create') + def test_returns_ok_when_pending_becomes_succeeded_and_local_payment_exists( + self, + create_payment_mock, + find_one_mock, + sleep_mock, + ) -> None: + pending_payment = self._payment('pending') + succeeded_payment = self._payment('succeeded', payment_id=pending_payment.id) + create_payment_mock.return_value = pending_payment + find_one_mock.return_value = succeeded_payment + self._create_local_payment(pending_payment.id) + + response = self.post() + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {'ok': True, 'external': False, 'internal': False}) + sleep_mock.assert_called_once_with(0.25) + + @patch('payments.services.payment_service.time.sleep') + @patch('payments.services.payment_service.YookassaPayment.create') + def test_returns_internal_true_when_local_payment_is_missing( + self, + create_payment_mock, + sleep_mock, + ) -> None: + create_payment_mock.return_value = self._payment('succeeded') + + response = self.post() + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {'ok': False, 'external': False, 'internal': True}) + self.assertEqual(sleep_mock.call_count, 4) + + @patch('payments.services.payment_service.time.sleep') + @patch('payments.services.payment_service.YookassaPayment.create') + def test_returns_forbidden_when_recovery_locked( + self, + create_payment_mock, + sleep_mock, + ) -> None: + self.user.payment_plan.recovery_locked_at = timezone.now() + self.user.payment_plan.save(update_fields=['recovery_locked_at']) + + response = self.post() + + self.assertEqual(response.status_code, 403) + self.assertEqual(response.json(), {'detail': 'Subscription recovery is already in progress'}) + create_payment_mock.assert_not_called() + + @patch('payments.services.payment_service.time.sleep') + @patch('payments.services.payment_service.YookassaPayment.create') + def test_releases_lock_after_request( + self, + create_payment_mock, + sleep_mock, + ) -> None: + yookassa_payment = self._payment('succeeded') + create_payment_mock.return_value = yookassa_payment + self._create_local_payment(yookassa_payment.id) + + response = self.post() + + self.assertEqual(response.status_code, 200) + self.user.payment_plan.refresh_from_db() + self.assertIsNone(self.user.payment_plan.recovery_locked_at) + self.assertIsNone(self.user.payment_plan.last_recovery_payment_id) + + self._apply_recovery_webhook(yookassa_payment) + self.user.payment_plan.refresh_from_db() + self.assertEqual(self.user.payment_plan.last_recovery_payment_id, UUID(str(yookassa_payment.id))) + + @patch('payments.services.payment_service.time.sleep') + @patch('payments.services.payment_service.YookassaPayment.create') + def test_allows_request_when_recovery_lock_is_stale( + self, + create_payment_mock, + sleep_mock, + ) -> None: + yookassa_payment = self._payment('succeeded') + create_payment_mock.return_value = yookassa_payment + self._create_local_payment(yookassa_payment.id) + self.user.payment_plan.recovery_locked_at = timezone.now() - timedelta(minutes=2) + self.user.payment_plan.save(update_fields=['recovery_locked_at']) + + response = self.post() + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {'ok': True, 'external': False, 'internal': False}) + self.user.payment_plan.refresh_from_db() + self.assertIsNone(self.user.payment_plan.recovery_locked_at) + + @patch('payments.services.payment_service.time.sleep') + @patch('payments.services.payment_service.YookassaPayment.create') + def test_retries_same_idempotency_key_when_local_is_missing( + self, + create_payment_mock, + sleep_mock, + ) -> None: + yookassa_payment = self._payment('succeeded') + create_payment_mock.return_value = yookassa_payment + + first_response = self.post() + self.user.payment_plan.refresh_from_db() + self.assertIsNone(self.user.payment_plan.last_recovery_payment_id) + self.assertIsNone(self.user.payment_plan.recovery_locked_at) + + self._create_local_payment(yookassa_payment.id) + second_response = self.post() + + self.assertEqual(first_response.status_code, 200) + self.assertEqual(first_response.json(), {'ok': False, 'external': False, 'internal': True}) + self.assertEqual(second_response.status_code, 200) + self.assertEqual(second_response.json(), {'ok': True, 'external': False, 'internal': False}) + self.assertEqual(create_payment_mock.call_count, 2) + self.assertEqual( + create_payment_mock.call_args_list[0].kwargs['idempotency_key'], + create_payment_mock.call_args_list[1].kwargs['idempotency_key'], + ) + self.user.payment_plan.refresh_from_db() + self.assertIsNone(self.user.payment_plan.last_recovery_payment_id) + + @patch('payments.services.payment_service.time.sleep') + @patch('payments.services.payment_service.YookassaPayment.create') + def test_webhook_closes_generation_after_internal_true( + self, + create_payment_mock, + sleep_mock, + ) -> None: + first_payment = self._payment('succeeded') + second_payment = self._payment('succeeded') + create_payment_mock.side_effect = [first_payment, second_payment] + + first_response = self.post() + self.assertEqual(first_response.json(), {'ok': False, 'external': False, 'internal': True}) + + self._create_local_payment(first_payment.id) + self._apply_recovery_webhook(first_payment) + self.user.payment_plan.refresh_from_db() + self.assertEqual(self.user.payment_plan.last_recovery_payment_id, UUID(str(first_payment.id))) + + self._create_local_payment(second_payment.id) + second_response = self.post() + + self.assertEqual(second_response.status_code, 200) + self.assertEqual(second_response.json(), {'ok': True, 'external': False, 'internal': False}) + self.assertEqual(create_payment_mock.call_count, 2) + self.assertNotEqual( + create_payment_mock.call_args_list[0].kwargs['idempotency_key'], + create_payment_mock.call_args_list[1].kwargs['idempotency_key'], + ) + + @patch('payments.services.payment_service.time.sleep') + @patch('payments.services.payment_service.YookassaPayment.create') + def test_duplicate_webhook_skips_recovery_close( + self, + create_payment_mock, + sleep_mock, + ) -> None: + yookassa_payment = self._payment('succeeded') + create_payment_mock.return_value = yookassa_payment + self._create_local_payment(yookassa_payment.id) + + self.post() + self._apply_recovery_webhook(yookassa_payment) + self.user.payment_plan.refresh_from_db() + self.assertEqual(self.user.payment_plan.last_recovery_payment_id, UUID(str(yookassa_payment.id))) + + PaymentPlanUserInfo.objects.filter(pk=self.user.payment_plan.pk).update( + last_recovery_payment_id=None + ) + self.user.payment_plan.refresh_from_db() + self.assertIsNone(self.user.payment_plan.last_recovery_payment_id) + + PaymentService(self.user).do_payment(yookassa_payment) + self.user.payment_plan.refresh_from_db() + self.assertIsNone(self.user.payment_plan.last_recovery_payment_id) + + @patch('payments.services.payment_service.time.sleep') + @patch('payments.services.payment_service.YookassaPayment.create') + def test_creates_new_payment_when_previous_recovery_already_applied( + self, + create_payment_mock, + sleep_mock, + ) -> None: + first_payment = self._payment('succeeded') + second_payment = self._payment('succeeded') + create_payment_mock.side_effect = [first_payment, second_payment] + self._create_local_payment(first_payment.id) + + first_response = self.post() + self._apply_recovery_webhook(first_payment) + self._create_local_payment(second_payment.id) + second_response = self.post() + + self.assertEqual(first_response.status_code, 200) + self.assertEqual(first_response.json(), {'ok': True, 'external': False, 'internal': False}) + self.assertEqual(second_response.status_code, 200) + self.assertEqual(second_response.json(), {'ok': True, 'external': False, 'internal': False}) + self.assertEqual(create_payment_mock.call_count, 2) + self.assertNotEqual( + create_payment_mock.call_args_list[0].kwargs['idempotency_key'], + create_payment_mock.call_args_list[1].kwargs['idempotency_key'], + ) + + @patch('payments.services.payment_service.time.sleep') + @patch('payments.services.payment_service.YookassaPayment.create') + def test_creates_new_payment_after_canceled_recovery( + self, + create_payment_mock, + sleep_mock, + ) -> None: + canceled_payment = self._payment('canceled') + succeeded_payment = self._payment('succeeded') + create_payment_mock.side_effect = [canceled_payment, succeeded_payment] + self._create_local_payment(succeeded_payment.id) + + first_response = self.post() + self.user.payment_plan.refresh_from_db() + self.assertEqual(self.user.payment_plan.last_recovery_payment_id, UUID(str(canceled_payment.id))) + self.assertIsNone(self.user.payment_plan.recovery_locked_at) + + second_response = self.post() + + self.assertEqual(first_response.status_code, 200) + self.assertEqual(first_response.json(), {'ok': False, 'external': False, 'internal': False}) + self.assertEqual(second_response.status_code, 200) + self.assertEqual(second_response.json(), {'ok': True, 'external': False, 'internal': False}) + self.assertEqual(create_payment_mock.call_count, 2) + self.assertNotEqual( + create_payment_mock.call_args_list[0].kwargs['idempotency_key'], + create_payment_mock.call_args_list[1].kwargs['idempotency_key'], + ) + + def test_restore_subscription_blocked_endpoint(self) -> None: + response = self.get(endpoint='/api/v1/payments/restore-subscription/blocked') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {'blocked': False}) + + self.user.payment_plan.recovery_locked_at = timezone.now() + self.user.payment_plan.save(update_fields=['recovery_locked_at']) + + response = self.get(endpoint='/api/v1/payments/restore-subscription/blocked') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {'blocked': True}) @@ -8,6 +8,7 @@ from django.utils.translation import gettext_lazy as _ from ordered_model.admin import OrderedInlineModelAdminMixin, OrderedModelAdmin from authentication.admin import CustomUserModelAdmin +from payments.mixins import DateRangeExportAdminMixin from payments.models import ( Invoice, Payment, @@ -18,15 +19,17 @@ from payments.models import ( PromoCodeActivation, PaymentMethod, ) +from payments.exporters import InvoiceDailyByModelExporter, PaymentDailyExporter from payments.models.attempt import PaymentAttempt from payments.models.referral_account import ReferralAccount, ReferralInvite @admin.register(Payment) -class PaymentAdmin(admin.ModelAdmin): +class PaymentAdmin(DateRangeExportAdminMixin, admin.ModelAdmin): list_display = ['uid', '_user', 'created_at', 'status'] raw_id_fields = ['user'] date_hierarchy = 'created_at' + actions = ['export_daily_totals'] search_fields = [ 'uid', @@ -41,6 +44,16 @@ class PaymentAdmin(admin.ModelAdmin): return _('Missing') return str(obj.user) + @admin.action(description='Выгрузить суммы платежей по дням') + def export_daily_totals(self, request, queryset): + return self.export_by_date_range( + request, + queryset, + exporter_class=PaymentDailyExporter, + action_name='export_daily_totals', + title='Выгрузить суммы платежей по дням', + ) + @admin.register(PaymentPlan) class PaymentPlanAdmin(OrderedInlineModelAdminMixin, admin.ModelAdmin): @@ -108,7 +121,7 @@ class PaymentPlanUserInfoAdmin(admin.ModelAdmin): @admin.register(PaymentPlanFeature) class PaymentPlanFeatureAdmin(OrderedModelAdmin): list_display = ('plan', 'model', 'move_up_down_links') - list_filter = ('plan', 'model__category') + list_filter = ('plan__tokens_per_plan', 'model__category') class PaymentAttemptInline(admin.TabularInline): @@ -151,18 +164,16 @@ class PaymentAttemptAdmin(admin.ModelAdmin): @admin.register(Invoice) -class InvoiceAdmin(admin.ModelAdmin): +class InvoiceAdmin(DateRangeExportAdminMixin, admin.ModelAdmin): list_display = ['_user', '_model'] - raw_id_fields = ['user', 'message', 'model'] - search_fields = [ 'user__email', 'user__host_account__company_name', 'user__business_account__parent_company__company_name', ] - search_help_text = _('You can search by user email, exacted company name') + actions = ['export_daily_by_model'] @admin.display(description=_('User')) def _user(self, obj): @@ -174,6 +185,16 @@ class InvoiceAdmin(admin.ModelAdmin): def _model(self, obj): return str(obj.model) + @admin.action(description='Выгрузить кол-во инвойсов по моделям и дням') + def export_daily_by_model(self, request, queryset): + return self.export_by_date_range( + request, + queryset, + exporter_class=InvoiceDailyByModelExporter, + action_name='export_daily_by_model', + title='Выгрузить кол-во инвойсов по моделям и дням', + ) + @admin.register(PromoCode) class PromoCodeAdmin(admin.ModelAdmin): @@ -49,6 +49,16 @@ class PaymentLinkSchema(Schema): payment_url: str +class RestoreSubscriptionSchema(Schema): + ok: bool + external: bool + internal: bool + + +class RestoreSubscriptionLockSchema(Schema): + blocked: bool + + class UserPlanDetailSchema(Schema): uid: UUID plan: PaymentPlanSchema @@ -84,5 +94,3 @@ class ExpensesParamsSchema(Schema): class ExpensesSchema(Schema): source: str amount: condecimal(max_digits=10, decimal_places=2) - - @@ -30,4 +30,4 @@ def clear_recurrent_on_individual_plan_assignment( if not instance.plan.individual: return PaymentPlanUserInfo.objects.filter(pk=instance.pk).update(next_payment_at=None) - PaymentMethodService(instance.user).deactivate_payment_methods() \ No newline at end of file + PaymentMethodService(instance.user).deactivate_payment_methods(notify=None) \ No newline at end of file @@ -50,7 +50,6 @@ def execute_recurring_payments() -> None: next_payment_at__lte=timezone.now(), plan__price__gt=0, plan__individual=False, - plan__is_corporate=False, user__is_deleted=False, methods__primary=True, methods__active=True, @@ -133,7 +132,6 @@ def revoke_recurring_payments() -> None: 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() @@ -141,24 +139,28 @@ def revoke_recurring_payments() -> None: ) canceled_count = 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) - .exclude( - methods__primary=True, - methods__active=True, - ) - .distinct() - .update( - next_payment_at=None, - plan_id=free_regular_plan.pk, - current_token_balance=0, + free_plans = { + False: PaymentPlan.objects.get(price=0, is_corporate=False), + True: PaymentPlan.objects.get(price=0, is_corporate=True), + } + for is_corporate, free_plan in free_plans.items(): + corp_qs = qs.filter(plan__is_corporate=is_corporate) + qs_iter = corp_qs.values_list('uid', flat=True).iterator(chunk_size=CHUNK_SIZE) + while uids := list(islice(qs_iter, CHUNK_SIZE)): + with transaction.atomic(): + canceled_count += ( + corp_qs.filter(uid__in=uids) + .exclude( + methods__primary=True, + methods__active=True, + ) + .distinct() + .update( + next_payment_at=None, + plan_id=free_plan.pk, + current_token_balance=0, + ) ) - ) - logger.info('Revoke recurring finished: free_regular=%s', canceled_count) + logger.info('Revoke recurring finished: canceled=%s', canceled_count) finally: cache.delete(lock_key) @@ -28,12 +28,13 @@ from ml_model.exceptions import ( FileUploadUnsupported, ImageAnalysisError, ImageTooLargeError, + InputImageSensitiveContentError, InvalidParameterError, ModelVersionNotAvailable, + OutputSensitiveImageContentError, PaidPlanRequiredError, PromptLengthExceeded, RequestBlocked, - OutputSensitiveImageContentError, TemplateNotFound, TemplateUnknownException, UnrecognizedFileError, @@ -196,6 +197,7 @@ class MessagesAPIView(APIView): UnrecognizedFileError, InvalidParameterError, ModelVersionNotAvailable, + InputImageSensitiveContentError, OutputSensitiveImageContentError, ImageTooLargeError, ) as exc: @@ -1,3 +1,4 @@ +import logging from decimal import Decimal from celery import shared_task @@ -14,6 +15,8 @@ from tools.chats.services.sse_chunk_service import SSEChunkService from tools.chats.services.sse_store import PublicSSEStoreService, SSEStoreService from tools.public_api.models import APIKey, APIStore +logger = logging.getLogger(__name__) + def _run_stream( store: SSEStoreService, @@ -51,6 +54,7 @@ def _run_stream( event_id += 1 store.push(SSEChunkService.done(event_id, exc.value or ''), ttl=settings.SSE_DONE_STREAM_TTL) except Exception as exc: + logger.exception(f'Model streaming failed: {(exc.__cause__ or exc)!r}') if event_id < 2: message.is_sent = False message.save(update_fields=['is_sent']) @@ -23,14 +23,15 @@ from ml_model.exceptions import ( ImageAnalysisError, ImageContentNotFound, ImageTooLargeError, + InputImageSensitiveContentError, InvalidParameterError, InvalidStyleCombinationError, ModelCouldNotInterpretPrompt, ModelVersionNotAvailable, + OutputSensitiveImageContentError, PromptLengthExceeded, RealPersonDetectedError, RequestBlocked, - OutputSensitiveImageContentError, ServiceHighDemandError, UnrecognizedFileError, UnsupportedSize, @@ -39,7 +40,7 @@ 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 +from .models import Audio, Image, Video, VoiceClone logger = logging.getLogger(__name__) @@ -219,6 +220,7 @@ class MediaAPIView(APIView): UnrecognizedFileError, FaceNotFoundError, RealPersonDetectedError, + InputImageSensitiveContentError, OutputSensitiveImageContentError, ImageTooLargeError, ) as exc: @@ -266,40 +268,6 @@ class ModelVideosAPIView(MediaAPIView): class ModelAudiosAPIVIew(MediaAPIView): manager = Audio - @extend_schema( - parameters=[ - OpenApiParameter('model', str, 'path', required=True), - ], - request=MessageSerializer, - responses={ - 201: MessageSerializer(many=True), - }, - ) - def post(self, request: Request, model: str, *args, **kwargs) -> Response: - data = get_request_data(request) - if not (request.FILES.get('file') or data.get('file')): - voice_id = data.pop('voice_id', None) - preset_id = data.pop('preset_id', None) - - try: - if voice_id: - voice = Voice.objects.get(pk=voice_id, user=request.user) - transcription = voice.transcription - elif preset_id: - voice = Preset.objects.get(uid=preset_id) - transcription = voice.metadata.get('transcription', '') - else: - return super().post(request, model, *args, **kwargs) - except (Voice.DoesNotExist, Preset.DoesNotExist): - return Response( - {'detail': _('Voice not found.')}, - status=HTTP_400_BAD_REQUEST, - ) - - info = data['info'] - data.update({'file': voice.file, 'info': {'transcription': transcription, **info}}) - return super().post(request, model, data=data, *args, **kwargs) - class ModelVoiceCloneAPIView(MediaAPIView): manager = VoiceClone @@ -1,6 +1,7 @@ import json import logging +from django.http import QueryDict from django.utils.translation import gettext_lazy as _ from drf_spectacular.utils import extend_schema from rest_framework import status @@ -13,7 +14,6 @@ from ml_model.models import NeuronModel from ml_model.selectors.ml_models_selector import NeuronModelSelector from ml_model.selectors.param_selector import ParamSelector from ml_model.serializers import ModelParameterSerializer -from tools.media.models import Preset, Voice from tools.public_api.models import APIStore from tools.public_api.selectors.api_key import APIKeySelector from tools.public_api.views.base import BaseGenerationView @@ -35,34 +35,6 @@ class AudioView(BaseGenerationView): output_content_type = ContentTypes.AUDIO description = 'Get Audio Generation from model in URL slug. Only POST Requests.' - def post(self, request, model_slug, *args, **kwargs): - if not (request.FILES.get('file') or request.data.get('file')): - api_key_value = request.headers.get('Authorization') - if (split_api_key := api_key_value.split())[0] == 'Bearer': - api_key_value = split_api_key[-1] - user = APIKeySelector.get_user_by_key(key_value=api_key_value) - voice_id = str(request.data.pop('voice_id', '')) - try: - if not voice_id: - return super().post(request, model_slug, *args, **kwargs) - elif voice_id.isdigit(): - voice = Voice.objects.get(pk=voice_id, user=user) - # transcription = voice.transcription - else: - voice = Preset.objects.get(uid=voice_id) - # transcription = voice.metadata.get('transcription', '') - except (Voice.DoesNotExist, Preset.DoesNotExist): - return Response( - {'detail': _('Voice not found.')}, - status=HTTP_400_BAD_REQUEST, - ) - request.data.update( - { - 'file': voice.file, # 'info': {'transcription': transcription, **request.data['info']} - } - ) - return super().post(request, model_slug, *args, **kwargs) - class VideoView(BaseGenerationView): output_content_type = ContentTypes.VIDEO @@ -83,18 +55,21 @@ class VoiceView(BaseGenerationView): info = request.data.get('info', {}) if isinstance(info, str): try: - info = json.loads(info) + info = json.loads(info) if info else {} except json.JSONDecodeError: return Response({'detail': _('Invalid info payload')}, status=HTTP_400_BAD_REQUEST) + if not isinstance(info, dict): + info = {} if voice_id.isdigit(): - info.update({'voice_id': int(voice_id)}) + info['voice_id'] = int(voice_id) else: - info.update({'preset_id': voice_id}) - ct = getattr(request, 'content_type', '') - if 'multipart/form-data' in ct: + info['preset_id'] = voice_id + if isinstance(request.data, QueryDict): + request.data._mutable = True request.data['info'] = json.dumps(info, ensure_ascii=False) + request.data._mutable = False else: - request.data["info"] = info + request.data['info'] = info return super().post(request, model_slug, *args, **kwargs) @@ -15,7 +15,7 @@ HF_API_KEY=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 GOOGLE_API_KEY=AIzaSyBf9el4d_CY610zjCcesKxKL70BLfl57OM MISTRAL_API_KEY=CYtZSCQXZFzHcpJvWOjWNx4EHjf5kWQc DEEPL_API_KEY=4bb58b98-ca95-5978-9be0-ed437df6c15c:fx -SERPER_API_KEY=ed8e0dbcc26dacf3f7f99fbc8b3add9ada0c793e +SERPER_API_KEY=301101dad80f91df00bf4ff60a63c8a71922e2b7 FLUX_API_KEY=dccaf377-aecf-4cf0-aff4-dde47cee340d OPENROUTER_API_KEY=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 BYTEDANCE_MODEL_ARK_API_KEY=ark-151e9e89-7275-4dbf-bbb3-d2b32bb69d61-3ca5b @@ -49,6 +49,7 @@ dependencies = [ "pypdf2==3.0.1", "python-dateutil==2.9.0.post0", "python-docx==1.1.2", + "rawpy==0.27.0", "redis>=6.4.0", "replicate==1.0.4", "sentry-sdk[django]==2.39.0", @@ -308,6 +308,7 @@ dependencies = [ { name = "pypdf2" }, { name = "python-dateutil" }, { name = "python-docx" }, + { name = "rawpy" }, { name = "redis" }, { name = "replicate" }, { name = "sentry-sdk", extra = ["django"] }, @@ -374,6 +375,7 @@ requires-dist = [ { name = "pypdf2", specifier = "==3.0.1" }, { name = "python-dateutil", specifier = "==2.9.0.post0" }, { name = "python-docx", specifier = "==1.1.2" }, + { name = "rawpy", specifier = "==0.27.0" }, { name = "redis", specifier = ">=6.4.0" }, { name = "replicate", specifier = "==1.0.4" }, { name = "sentry-sdk", extras = ["django"], specifier = "==2.39.0" }, @@ -3152,6 +3154,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "rawpy" +version = "0.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/15/92324724209650167c8c8b0c8c0006c99d07494b9b41f7d6435a37737323/rawpy-0.27.0.tar.gz", hash = "sha256:45251e46c1d891a62919a4ac200a9828f825e3c59f89cea2f1daee0900ff15ec", size = 560718, upload-time = "2026-05-07T08:29:23.974Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/97/46415db86271d977390f607d7a7733d86abb65f2dee29ab81441e092b3d4/rawpy-0.27.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8722fcd00b242e404ab8297d1f7bdd7d0bbe2a30c41a70815cbec74eb4583bed", size = 2051102, upload-time = "2026-05-07T08:28:56.466Z" }, + { url = "https://files.pythonhosted.org/packages/d0/33/206372f73d215c4a379a91081c8445f352b752eab85613445af79cc86855/rawpy-0.27.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a0192f3ce982ea06cdb82196e96dca9e9d252f8715d2b363bcbb79553e2e89e", size = 2945220, upload-time = "2026-05-07T08:28:57.99Z" }, + { url = "https://files.pythonhosted.org/packages/85/fc/6f26df1bc1663366727665f082b00b8cdd70e555e5dad43208dd58fd08bd/rawpy-0.27.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2262088bd4fd768edd7576857a9215cfff5e84d61942336401ee5d9ca8f677e0", size = 2941195, upload-time = "2026-05-07T08:28:59.61Z" }, + { url = "https://files.pythonhosted.org/packages/56/fb/3c754322c080477633644e453bdaebfeb5177249859c37302ad392a93a0e/rawpy-0.27.0-cp312-cp312-win_amd64.whl", hash = "sha256:2965d8c70af1b4ed6177a098871de1cc9204854278b9260d1e79639df40391ae", size = 913608, upload-time = "2026-05-07T08:29:01.593Z" }, + { url = "https://files.pythonhosted.org/packages/f5/02/aec7b4f6befd4752f23a5710bc7687631588006b17c4f9dc3a5198ae12b6/rawpy-0.27.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7758606d5de8497a695511fbace40cb80138329ed0ad7a530d51bdc07eef1a6b", size = 2050987, upload-time = "2026-05-07T08:29:03.374Z" }, + { url = "https://files.pythonhosted.org/packages/f0/29/ff05f8ebd08fa99c185b60b0088c7950016f0ac1e7e56042e5dbe67fd85c/rawpy-0.27.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1f4a1846247deff8b4c84b5b400541603d48be90bd36fa6d9a1f5fada023cae", size = 2944727, upload-time = "2026-05-07T08:29:05.115Z" }, + { url = "https://files.pythonhosted.org/packages/5c/49/84f15204f80a75ccd6f36b5530878339231faa334fbc47c83a5b45de19cf/rawpy-0.27.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:45b1b08a8d6fdc0139fea4c1389c17cb6f3d01b0581e71eef1337949a486550c", size = 2940136, upload-time = "2026-05-07T08:29:06.803Z" }, + { url = "https://files.pythonhosted.org/packages/b6/18/49c498d363ba3c3935872244b27e01b514d68ea531c325cff173b849ec81/rawpy-0.27.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e42d6218b507584b41ffdb86e1a6d8e2672bfe284a59df1dfdc57b3f8a27ffe", size = 913591, upload-time = "2026-05-07T08:29:08.378Z" }, + { url = "https://files.pythonhosted.org/packages/fc/67/b5aa2517dd4e1c0d0f1644e7c24c78a896df7835aeaf3f20abd31a2455b1/rawpy-0.27.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cd80c608d741aed9dc014cdac2af3f83796bc73148dbb7b0ec1fa2ba715d7a53", size = 2050437, upload-time = "2026-05-07T08:29:09.918Z" }, + { url = "https://files.pythonhosted.org/packages/8e/23/ec4e4dc5550e96d931ea5d0c3266fc42a9ce18b621572de24f0e683d02c9/rawpy-0.27.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb018c5d3273d09245bf8d4a7c6da9f2c4191b1ed04f45af4fcf978e2b2372ae", size = 2945830, upload-time = "2026-05-07T08:29:11.613Z" }, + { url = "https://files.pythonhosted.org/packages/b8/48/64e97637398494e6b74f8388c5a17d726219a5ddc9f7c283ff9f77849a05/rawpy-0.27.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a949e9258af1d915b4316b77c8de9d9637d3624e6fe85a5bfb6cb4de15f485d6", size = 2940100, upload-time = "2026-05-07T08:29:13.647Z" }, + { url = "https://files.pythonhosted.org/packages/4d/01/1854b2f789d7a6d94152ba451bdaadae0010d4247099d9c2719b381d4b6b/rawpy-0.27.0-cp314-cp314-win_amd64.whl", hash = "sha256:3cac371c3b6302eb88bd1f33d43c7a92b5e553fdff14258b564f81e173a68c26", size = 940931, upload-time = "2026-05-07T08:29:15.604Z" }, +] + [[package]] name = "redis" version = "6.4.0"