@@ -3,6 +3,7 @@ from authentication.exceptions.business_host_exceptions.already_account import ( ) from authentication.exceptions.business_host_exceptions.already_has_plan import ( AlreadyHasPlan, + InviteeHasPlan ) from authentication.exceptions.business_host_exceptions.already_host import ( AlreadyHost, @@ -10,6 +11,7 @@ from authentication.exceptions.business_host_exceptions.already_host import ( __all__ = ( 'AlreadyHasPlan', + 'InviteeHasPlan', 'AlreadyHost', 'AlreadyAccount', ) @@ -1,17 +1,19 @@ -from authentication.exceptions.business_host_exceptions.base_already import ( - BaseAlready, -) -from django.utils.translation import gettext_lazy as _ +from django.utils.translation import gettext as _ -class AlreadyHasPlan(BaseAlready): - def msg(self): - return dict( - message=_( - 'You already have an active tariff plan. You must request a ' - 'cancellation of your current tariff plan, after which ' - 'you will be able to create a Corporate Account.' - ), - user_id=self.user.uid, - plan_id=self.user.payment_plan.plan.uid, +class AlreadyHasPlan(Exception): + def __str__(self) -> str: + return _( + 'You already have an active tariff plan. You must request a ' + 'cancellation of your current tariff plan (via the \"Report an error\" button), after which ' + 'you will be able to create a Corporate Account' + ) + + +class InviteeHasPlan(Exception): + def __str__(self) -> str: + return _( + 'Invitee already has an active tariff plan. Invitee must request a ' + 'cancellation of his current tariff plan (via the \"Report an error\" button), after which ' + 'you will be able to invite him' ) @@ -89,6 +89,10 @@ class BusinessUserHost(BaseModel): is_log_history_enabled = models.BooleanField(default=False, verbose_name=_('Log history enabled')) + @property + def display_name(self) -> str: + return self.company_name or self.user.email + @property def accounts(self) -> QuerySet[BusinessAccount]: return self.accounts @@ -7,10 +7,7 @@ from rest_framework.request import Request from authentication.exceptions import business_host_exceptions from authentication.exceptions.business_account import BusinessAccountNotFound -from authentication.exceptions.business_host_exceptions import ( - AlreadyAccount, - AlreadyHasPlan, -) +from authentication.exceptions.business_host_exceptions import AlreadyAccount, InviteeHasPlan from authentication.exceptions.business_host_exceptions.access_denied import AccessDenied from authentication.exceptions.business_host_exceptions.already_host import ( AlreadyHost, @@ -95,7 +92,7 @@ class BusinessHostService: if AccountStatusSelector(user).is_business_account() and not user.is_deleted: raise AlreadyAccount(user) if PaymentPlanSelector(user).is_plan_paid(): - raise AlreadyHasPlan(user) + raise InviteeHasPlan(user) except CustomUserModel.DoesNotExist: return self.create_account( email=email, @@ -1,11 +1,13 @@ import logging +from typing import Any, Sequence import dns.resolver 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.html import format_html, strip_tags +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 @@ -13,8 +15,7 @@ from authentication.exceptions.user import DomainNotFound from authentication.models import BusinessAccount, BusinessUserHost from authentication.models.user import CustomUserModel from authentication.services.email_token_service import EmailTokenService -from ml_model.services.minio_service import MinIOService -from reports.models.error_report import ErrorReport +from reports.domain import Report logger = logging.getLogger(__name__) @@ -28,16 +29,18 @@ class EmailService: self.user.save() return self.user.is_subscribed_to_emails - def send_email(self, subject: str, message: str, user_email: str): + @staticmethod + def send_email(subject: str, message: str, user_emails: Sequence[str]) -> None: try: - email_domain = user_email.split('@')[-1] - dns.resolver.resolve(email_domain, 'MX') + for user_email in user_emails: + email_domain = user_email.split('@')[-1] + dns.resolver.resolve(email_domain, 'MX') send_mail( subject=subject, message=strip_tags(message), html_message=message, from_email=settings.EMAIL_HOST_USER, - recipient_list=(user_email,), + recipient_list=user_emails, auth_password=settings.EMAIL_HOST_PASSWORD, ) except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN, dns.exception.Timeout): @@ -46,27 +49,29 @@ class EmailService: logger.exception(exc) raise Exception(_('Error occured when proceed email sending')) + @staticmethod + def _render_letter_template(template_name: str, context: dict[str, Any]) -> SafeString: + try: + template = get_template(f'{template_name}.html') + html_message = template.render(context=context) + except TemplateDoesNotExist: + raise LetterNotFound + except Exception as exc: + raise LetterUnknownException from exc + return html_message + def send_reg_conf_email(self): token = EmailTokenService(self.user).generate_user_token() + html_message = self._render_letter_template( + template_name='authentication/account_confirmation', + context={ + 'email': self.user.email, + 'confirmation_url': settings.USER_CONFIRMATION_URL, + 'confirmation_token': token.key, + }, + ) self.send_email( - subject='Подтверждение аккаунта', - message=format_html( - """ - Добро пожаловать на платформу AIR! - - Ваша почта на платформе - {} - Для того, чтобы подтвердить регистрацию - перейдите по ссылке ниже: - [Нажмите для активации] - Если ссылка не открывается по кнопке, попробуйте вставить ссылку в адресную строку: - {}?token={} - """, - self.user.email, - settings.USER_CONFIRMATION_URL, - token.key, - settings.USER_CONFIRMATION_URL, - token.key, - ), - user_email=self.user.email, + subject='Подтверждение аккаунта', message=html_message, user_emails=(self.user.email,) ) def send_password_reset_email(self): @@ -74,106 +79,82 @@ class EmailService: self.send_email( subject='Сброс пароля', message=f'{settings.USER_PASSWORD_RESET_URL}?token={token.key}', - user_email=self.user.email, + user_emails=(self.user.email,), ) - def send_error_email(self, error: ErrorReport): - message = f""" - Пользователь {error.author.email} сообщил об ошибке: - {error.report_text} - Скриншоты ошибки (если пользователь их приложил) - во вложении - """ - + def send_error_email(self, report: Report) -> None: + html_message = self._render_letter_template( + template_name='authentication/error_email', + context={'email': self.user.email, 'report_text': report.message}, + ) mail = EmailMessage( - subject=f'Ошибка у пользователя {error.author.email}!', - body=message, + subject=f'Ошибка у пользователя {self.user.email}!', + body=html_message, from_email=settings.EMAIL_HOST_USER, to=settings.ERROR_EMAIL_RECIPIENTS, - reply_to=(error.author.email,), + # reply_to=(error.author.email,), ) - if error.additional_images is not None: - for _, img in error.additional_images.items(): - image = MinIOService().get_object('air-errors', img) - mail.attach(img, image) - + for img in report.attachments: + mail.attach(img.name, img.read(), img.content_type) + img.close() mail.send(fail_silently=True) def send_copr_purchase_email(self, host: BusinessUserHost): - message = f""" - Пользователь {host.user.email} зарегистрировал корпоративный аккаунт: - - Компания: {host.company_name} - Сектор: {host.company_sector} - ИНН: {host.ITN} - ОГРН: {host.PSRN} - Контактное лицо: - {host.preferred_name}, {host.job_title} - {host.corporate_phone} - {host.corporate_email} - """ - - mail = EmailMessage( + html_message = self._render_letter_template( + template_name='authentication/corporate_purchase_email', context={'host': host} + ) + self.send_email( subject='Новый корпоративный аккаунт', - body=message, - from_email=settings.EMAIL_HOST_USER, - to=('a.ippolitov@air.fail', 't.bikbov@air.fail'), + message=html_message, + user_emails=('a.ippolitov@air.fail', 't.bikbov@air.fail'), ) - mail.send(fail_silently=True) def send_corporate_greeting_email(self, account: BusinessAccount, password: str | None = None): if self.user.host_account is None: raise Exception(_('Regular users cannot send introductory letters')) token = EmailTokenService(account.user).generate_user_token() - try: - template = get_template('authentication/corporate_greeting_email.html') - html_message = template.render( - context={ - 'company_name': self.user.host_account.company_name, - 'invitation_url': settings.INVITATION_RESPONSE_URL, - 'token': token.key, - 'email': account.user.email, - 'password': password, - } - ) - except TemplateDoesNotExist: - raise LetterNotFound - except Exception as exc: - raise LetterUnknownException from exc + html_message = self._render_letter_template( + template_name='authentication/corporate_greeting_email', + context={ + 'business_account': account, + 'invitation_url': settings.INVITATION_RESPONSE_URL, + 'token': token.key, + 'password': password, + }, + ) self.send_email( - subject='Ваш аккаунт на платформе AIR', message=html_message, user_email=account.user.email + subject='Ваш аккаунт на платформе AIR', message=html_message, user_emails=(account.user.email,) ) def send_reinvited_email(self, account: BusinessAccount, password: str | None = None) -> None: - try: - template = get_template('authentication/reinvited_employee_letter.html') - html_message = template.render(context={'email': account.user.email, 'password': password}) - except TemplateDoesNotExist: - raise LetterNotFound - except Exception as exc: - raise LetterUnknownException from exc + html_message = self._render_letter_template( + template_name='authentication/reinvited_employee_letter', + context={'email': account.user.email, 'password': password}, + ) self.send_email( - subject='Ваш аккаунт на платформе AIR', message=html_message, user_email=account.user.email + subject='Ваш аккаунт на платформе AIR', message=html_message, user_emails=(account.user.email,) ) - def send_corporate_invitation_email(self, account: BusinessAccount): + def send_corporate_invitation_email(self, account: BusinessAccount) -> None: if self.user.host_account is None: raise Exception(_('Regular users cannot send invitation letters')) token = EmailTokenService(account.user).generate_user_token() - message = format_html( - """ - Добрый день, {}! - Компания {} приглашает вас получить доступ - к корпоративному аккаунту на нашей платформе. - Чтобы ответить на приглашение - перейдите по ссылке ниже: - [Нажмите для подтверждения] - Если ссылка не открывается по кнопке, попробуйте вставить ссылку в адресную строку: - {}?token={} - """, - account.user.email, - self.user.host_account.company_name, - settings.INVITATION_RESPONSE_URL, - token.key, - settings.INVITATION_RESPONSE_URL, - token.key, + html_message = self._render_letter_template( + template_name='authentication/corporate_invitation_email', + context={ + 'email': account.user.email, + 'host': self.user.host_account, + 'invite_url': settings.INVITATION_RESPONSE_URL, + 'token': token.key, + }, + ) + self.send_email('Приглашение на платформу AIR', html_message, (account.user.email,)) + + @classmethod + def send_low_balance_email(cls, host: BusinessUserHost) -> None: + html_message = cls._render_letter_template( + template_name='authentication/low_balance_email', context={'host': host} + ) + cls.send_email( + f'AIR: баланс корпоративного аккаунта ниже {host.token_cap}', html_message, host.token_cap_emails ) - self.send_email('', message, account.user.email) @@ -0,0 +1,16 @@ + + + + + Подтверждение аккаунта + + +

Добро пожаловать на платформу AIR!

+
+

Ваша почта на платформе - {{ email }}

+

Для того, чтобы подтвердить регистрацию - перейдите по ссылке ниже:

+

[Нажмите для активации]

+

Если ссылка не открывается по кнопке, попробуйте вставить ссылку в адресную строку:

+

{{ confirmation_url }}?token={{ confirmation_token }}

+ + \ No newline at end of file @@ -7,7 +7,7 @@

Добро пожаловать на платформу AIR!

- Этот аккаунт был создан для вас {{ company_name }} в рабочих целях.
+ Этот аккаунт был создан для вас {{ business_account.parent_company.display_name }} в рабочих целях.
Вы можете использовать все ресурсы корпоративного аккаунта — оплачивать подписку нет необходимости.

@@ -18,8 +18,7 @@ {{ invitation_url }}?token={{ token }}

Ваши данные для входа:
- E-mail: {{ email }}
+ E-mail: {{ business_account.user.email }}
Пароль: {{ password }}

-

Всегда с вами, команда AIR

@@ -0,0 +1,18 @@ + + + + + Приглашение на платформу AIR + + +

Добрый день, {{ email }}!

+ +

{% if host.company_name %}Компания{% else %}Пользователь{% endif %} {{ host.display_name }} приглашает вас получить доступ к корпоративному + аккаунту на нашей платформе.

+

Чтобы ответить на приглашение – перейдите по ссылке ниже:

+

Нажмите для подтверждения

+ +

Если ссылка не открывается по кнопке, вставьте её в адресную строку + браузера:
{{ invite_url }}?token={{ token }}

+ + @@ -0,0 +1,23 @@ + + + + + Новый корпоративный аккаунт + + +

Пользователь {{ host.user.email }} зарегистрировал корпоративный аккаунт:

+ +

Компания: {{ host.company_name|default:"Отсутствует" }}

+

Сектор: {{ host.company_sector }}

+

ИНН: {{ host.ITN|default:"Отсутствует" }}

+

ОГРН: {{ host.PSRN|default:"Отсутствует" }}

+ +

Контактное лицо:

+

+ {{ host.preferred_name }}, {{ host.job_title }}
+ {{ host.corporate_phone }}
+ {{ host.corporate_email }} +

+ + + @@ -0,0 +1,5 @@ +Пользователь {{ email }} сообщил об ошибке: + +{{ report_text }} + +Скриншоты ошибки (если пользователь их приложил) - во вложении @@ -0,0 +1,10 @@ + + + + + AIR: баланс корпоративного аккаунта ниже {{ host.token_cap }} + + +

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

+ + \ No newline at end of file @@ -8,6 +8,5 @@

Новые данные для входа на платформу AIR:

E-mail: {{ email }}

Пароль: {{ password }}

-

Всегда с вами, команда AIR

@@ -1,6 +1,7 @@ import logging.config from pathlib import Path +from PIL import ImageFile from celery.schedules import crontab from environs import Env @@ -257,6 +258,7 @@ MINIO_PRIVATE_BUCKETS = [ 'air-stories', 'air-profiles', 'air-models', + 'air-media-presets' ] MINIO_STATIC_FILES_BUCKET = 'air-static' MINIO_PRIVATE_BUCKETS.append(MINIO_STATIC_FILES_BUCKET) @@ -336,11 +338,8 @@ YANDEX_CLOUD_ID = env.str('YANDEX_CLOUD_ID', 'defaultapikey') OPENAI_PROXY_HOST = env.str('OPENAI_PROXY_HOST', 'neuron-proxy:8080') UPSCALE_MULTIPLIER_HOST = env.str('UPSCALE_MULTIPLIER_HOST', 'packet:8080') - -MAX_UPLOAD_SIZE_PER_MODEL = { - 'raifgpt': 50, - 'default': 8, -} +# FILES +ImageFile.LOAD_TRUNCATED_IMAGES = True # Payments @@ -473,7 +472,7 @@ if (SENTRY_URL := env.str('SENTRY_URL', '')) and RELEASE and ENVIRONMENT: transaction_style='url', middleware_spans=True, signals_spans=True, cache_spans=False ), ], - ignore_errors=['InsufficientBalance'] + ignore_errors=['InsufficientBalance', 'RequestBlocked'] ) CACHEOPS_REDIS = env.str('CACHEOPS_REDIS', CACHES['default']['LOCATION']) @@ -487,6 +486,7 @@ if CACHEOPS_REDIS: 'tools.chats.*': {'ops': 'all', 'timeout': 60 * 60}, 'tools.media.*': {'ops': 'all', 'timeout': 60 * 60}, 'payments.paymentplan': {'ops': 'all', 'timeout': 60 * 60}, + 'payments.invoice': {'ops': 'all', 'timeout': 60 * 60 * 24 * 7}, 'messages.*': {'ops': 'all', 'timeout': 60 * 60}, 'reports.*': {'ops': 'all', 'timeout': 60 * 60}, 'token_blacklist.outstandingtoken': {'ops': 'get', 'timeout': 60 * 60 * 24}, @@ -51,17 +51,16 @@ def healthz_status(request): urlpatterns = [] -if settings.DEBUG: - urlpatterns += [ - path('api/v1/schema/', SpectacularAPIView.as_view(), name='schema'), - path( - 'api/v1/schema/swagger-ui/', - SpectacularSwaggerView.as_view(url_name='schema'), - ), - ] - urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) - api.docs_url = '/docs' - compatibility_api.docs_url = '/docs' +urlpatterns += [ + path('api/v1/schema/', SpectacularAPIView.as_view(), name='schema'), + path( + 'api/v1/schema/swagger-ui/', + SpectacularSwaggerView.as_view(url_name='schema'), + ), +] +urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) +api.docs_url = '/docs' +compatibility_api.docs_url = '/docs' urlpatterns += [ path('api/v1/healthz/', healthz_status), @@ -0,0 +1,20 @@ +from dataclasses import dataclass +from io import BytesIO +from typing import Optional + + +@dataclass +class File: + name: str + stream: BytesIO + content_type: str + size: int + + def read(self, *args, **kwargs) -> bytes: + return self.stream.read(*args, **kwargs) + + def seek(self, *args, **kwargs) -> Optional[int]: + return self.stream.seek(*args, **kwargs) + + def close(self) -> None: + return self.stream.close() @@ -2,13 +2,13 @@ # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. -# +# #, fuzzy msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-11-05 13:41+0300\n" +"POT-Creation-Date: 2025-12-16 19:50+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -30,15 +30,25 @@ msgstr "Сотрудник не найден" msgid "You do not have sufficient rights to perform this action" msgstr "У вас недостаточно прав для выполнения этого действия" -#: authentication/exceptions/business_host_exceptions/already_has_plan.py:11 +#: authentication/exceptions/business_host_exceptions/already_has_plan.py:7 msgid "" "You already have an active tariff plan. You must request a cancellation of " -"your current tariff plan, after which you will be able to create a Corporate " -"Account." +"your current tariff plan (via the \"Report an error\" button), after which " +"you will be able to create a Corporate Account" msgstr "" "У вас уже активен тарифный план. Необходимо запросить аннулирование текущего " -"тарифного плана, после чего у вас появится возможность создать Корпоративный " -"Аккаунт" +"тарифного плана (через кнопку \"Сообщить об ошибке\"), после чего у вас " +"появится возможность создать Корпоративный Аккаунт" + +#: authentication/exceptions/business_host_exceptions/already_has_plan.py:16 +msgid "" +"Invitee already has an active tariff plan. Invitee must request a " +"cancellation of his current tariff plan (via the \"Report an error\" " +"button), after which you will be able to invite him" +msgstr "" +"У приглашенного уже активен тарифный план. Приглашенному необходимо " +"запросить аннулирование текущего тарифного плана (через кнопку \"Сообщить об " +"ошибке\"), после чего у вас появится возможность его пригласить" #: authentication/exceptions/business_host_exceptions/not_allowed_ip.py:6 msgid "Current IP not allowed in this context" @@ -66,7 +76,7 @@ msgstr "Токен не получен" msgid "Wrong email" msgstr "Неверный email" -#: authentication/exceptions/user.py:11 backend/urls.py:46 +#: authentication/exceptions/user.py:11 backend/urls.py:45 msgid "Wrong password" msgstr "Неверный пароль" @@ -135,8 +145,9 @@ msgid "Child Business Accounts" msgstr "Дочерние Бизнес Аккаунты" #: authentication/models/business_group.py:8 ml_model/models.py:17 -#: ml_model/models.py:37 ml_model/models.py:61 ml_model/models.py:269 +#: ml_model/models.py:37 ml_model/models.py:61 ml_model/models.py:268 #: payments/models/payment_plan.py:27 tools/chats/models.py:9 +#: tools/media/models.py:41 msgid "Title" msgstr "Название" @@ -149,8 +160,8 @@ msgid "Business Groups" msgstr "Бизнес Группы" #: authentication/models/business_host.py:22 -#: authentication/models/email_token.py:13 authentication/models/user.py:225 -#: authentication/models/user.py:226 authentication/models/user_telegram.py:22 +#: authentication/models/email_token.py:13 authentication/models/user.py:222 +#: authentication/models/user.py:223 authentication/models/user_telegram.py:22 #: authentication/models/user_vk.py:12 payments/admin.py:35 #: payments/admin.py:89 payments/models/invoice.py:15 #: payments/models/payment.py:26 payments/models/payment_plan.py:61 @@ -161,7 +172,7 @@ msgstr "Пользователь" msgid "Affiliated by" msgstr "Кем привлечена" -#: authentication/models/business_host.py:36 authentication/models/user.py:138 +#: authentication/models/business_host.py:36 authentication/models/user.py:135 #: authentication/models/whitelist.py:16 ml_model/models.py:167 #: payments/models/promocode.py:85 msgid "Is active" @@ -232,11 +243,11 @@ msgstr "Приватные модели" msgid "Log history enabled" msgstr "История логов включена" -#: authentication/models/business_host.py:109 +#: authentication/models/business_host.py:113 msgid "Business Account" msgstr "Бизнес Аккаунт" -#: authentication/models/business_host.py:110 +#: authentication/models/business_host.py:114 msgid "Business Accounts" msgstr "Бизнес Аккаунты" @@ -304,7 +315,7 @@ msgstr "Админ" msgid "Security" msgstr "Безопасность" -#: authentication/models/email_token.py:16 ml_model/models.py:271 +#: authentication/models/email_token.py:16 ml_model/models.py:270 msgid "Key" msgstr "Ключ" @@ -316,48 +327,44 @@ msgstr "Email Токен" msgid "Email Tokens" msgstr "Email Токены" -#: authentication/models/user.py:126 authentication/models/user_telegram.py:9 +#: authentication/models/user.py:124 authentication/models/user_telegram.py:9 msgid "First name" msgstr "Имя" -#: authentication/models/user.py:133 authentication/models/user_telegram.py:10 +#: authentication/models/user.py:131 authentication/models/user_telegram.py:10 msgid "Last name" msgstr "Фамилия" -#: authentication/models/user.py:135 authentication/models/user_telegram.py:11 -msgid "Username" -msgstr "Имя пользователя" - -#: authentication/models/user.py:136 +#: authentication/models/user.py:133 msgid "Email" msgstr "Email" -#: authentication/models/user.py:140 +#: authentication/models/user.py:137 msgid "Is staff" msgstr "Административный" -#: authentication/models/user.py:141 +#: authentication/models/user.py:138 msgid "Is superuser" msgstr "Суперюзер" -#: authentication/models/user.py:142 +#: authentication/models/user.py:139 msgid "Is email confirmed" msgstr "Email подтвержден" -#: authentication/models/user.py:143 +#: authentication/models/user.py:140 msgid "Is subscribed" msgstr "Подписан на уведомления" -#: authentication/models/user.py:145 +#: authentication/models/user.py:142 msgid "Picture name" msgstr "Имя аватара" -#: authentication/models/user.py:147 tools/chats/models.py:13 +#: authentication/models/user.py:144 tools/chats/models.py:13 #: tools/public_api/models.py:45 msgid "Is deleted" msgstr "Удален" -#: authentication/models/user.py:152 authentication/models/utm.py:21 +#: authentication/models/user.py:149 authentication/models/utm.py:21 msgid "UTM" msgstr "UTM" @@ -369,6 +376,10 @@ msgstr "Телеграм ID" msgid "Is bot" msgstr "Является ботом" +#: authentication/models/user_telegram.py:11 +msgid "Username" +msgstr "Имя пользователя" + #: authentication/models/user_telegram.py:12 msgid "Language" msgstr "Язык" @@ -462,13 +473,13 @@ msgstr "Пользователь не найден" msgid "Access token expired or does not exist" msgstr "Токен доступа просрочен или не существует" -#: authentication/selectors/business_host_selector.py:42 -#: authentication/selectors/business_host_selector.py:85 +#: authentication/selectors/business_host_selector.py:41 +#: authentication/selectors/business_host_selector.py:84 msgid "User haven't rights to access host account information" msgstr "" "У пользователя недостаточно прав для просмотра информации бизнес-аккаунта" -#: authentication/selectors/business_host_selector.py:60 +#: authentication/selectors/business_host_selector.py:59 msgid "Host user is not registered for this account" msgstr "Пользователь бизнес-аккаунта не зарегистрирован для этого аккаунта" @@ -494,19 +505,19 @@ msgstr "Пароли не совпадают" msgid "You cannot change the password of an unconfirmed e-mail user." msgstr "Вы не можете изменить пароль неподтвержденного по e-mail пользователя." -#: authentication/services/business_host_service.py:159 +#: authentication/services/business_host_service.py:155 msgid "No user_email is provided" msgstr "" -#: authentication/services/email_service.py:47 +#: authentication/services/email_service.py:50 msgid "Error occured when proceed email sending" msgstr "Случилась ошибка во время отправки email" -#: authentication/services/email_service.py:125 +#: authentication/services/email_service.py:114 msgid "Regular users cannot send introductory letters" msgstr "Обычные пользователи не могут отсылать письма" -#: authentication/services/email_service.py:160 +#: authentication/services/email_service.py:140 msgid "Regular users cannot send invitation letters" msgstr "Обычные пользователи не могут отправлять письма для приглашений" @@ -534,44 +545,45 @@ msgstr "Пароли не совпадают" msgid "Current password is wrong" msgstr "Текущий пароль неверен" -#: authentication/views.py:121 authentication/views.py:230 -#: authentication/views.py:326 authentication/views.py:357 +#: authentication/views.py:120 authentication/views.py:229 +#: authentication/views.py:325 authentication/views.py:356 msgid "Server error occured" msgstr "Случилась серверная ошибка" -#: authentication/views.py:226 +#: authentication/views.py:225 msgid "Email not found" msgstr "Email не найден" -#: authentication/views.py:322 +#: authentication/views.py:321 msgid "Business account has been deleted" msgstr "Сотрудник успешно удален" -#: authentication/views.py:346 +#: authentication/views.py:345 msgid "Business account has been reinvited" msgstr "Повторное приглашение сотруднику успешно отправлено" -#: authentication/views.py:469 +#: authentication/views.py:455 msgid "Could not confirm email, please try again." msgstr "Невозможно подтвердить email, попробуйте позже" -#: backend/urls.py:36 +#: backend/urls.py:35 msgid "Requested object does not exists" msgstr "" -#: backend/urls.py:41 +#: backend/urls.py:40 msgid "Token is invalid" msgstr "" -#: backend/urls.py:51 -msgid "Wrong username" -msgstr "Неверное имя пользователя" - -#: messages/serializers.py:50 +#: messages/serializers.py:44 #, python-format msgid "The file size cannot exceed %(max_mb_size)d MB" msgstr "Файл не может быть размером больше %(max_mb_size)d мегабайт" +#: ml_model/admin.py:136 +#, python-format +msgid "Version %(version)s already has input with the same type: %(type)s" +msgstr "Версия %(version)s уже имеет входные данные с таким же типом: %(type)s" + #: ml_model/apps.py:9 ml_model/models.py:148 msgid "Neuron Models" msgstr "Нейронные Модели" @@ -586,6 +598,7 @@ msgid "Your request was blocked by our moderation system" msgstr "Ваш запрос был заблокирован нашей системой модерации" #: ml_model/exceptions.py:33 +#, python-format msgid "" "Image size %(cw)dx%(ch)d is not supported. Please rotate image to " "%(rw)dx%(rh)d" @@ -594,6 +607,7 @@ msgstr "" "до %(rw)dx%(rh)d" #: ml_model/exceptions.py:37 +#, python-format msgid "Image size %(cw)sx%(ch)s is not supported. Required size: %(rw)sx%(rh)s" msgstr "" "Размер изображения %(cw)sx%(ch)s не поддерживается. Требуемый размер: " @@ -628,8 +642,21 @@ msgstr "При рендеринге шаблона произошла неизв msgid "The neuron model does not exist" msgstr "Нейронная модель не существует" +#: ml_model/exceptions.py:80 +#, python-format +msgid "The %(file_type)s is not attached" +msgstr "Файл (%(file_type)s) не прикреплен" + +#: ml_model/exceptions.py:85 +msgid "No image content found in response. Try a different request" +msgstr "В промпте отсутствует описание изображения. Попробуйте другой запрос" + +#: ml_model/exceptions.py:90 +msgid "Use style type AUTO or GENERAL when a style preset is selected" +msgstr "При выбранном стиле используйте тип стиля AUTO или GENERAL" + #: ml_model/models.py:18 ml_model/models.py:38 ml_model/models.py:70 -#: ml_model/models.py:182 +#: ml_model/models.py:182 tools/media/models.py:43 msgid "Slug" msgstr "Ярлык" @@ -661,7 +688,7 @@ msgstr "Теги модели" msgid "Alternative Titles" msgstr "Альтернативные названия" -#: ml_model/models.py:68 ml_model/models.py:181 ml_model/models.py:270 +#: ml_model/models.py:68 ml_model/models.py:181 ml_model/models.py:269 #: payments/models/payment.py:52 msgid "Description" msgstr "Описание" @@ -682,7 +709,7 @@ msgstr "Теги" msgid "Neuron Model" msgstr "Нейронная Модель" -#: ml_model/models.py:156 ml_model/models.py:403 payments/admin.py:95 +#: ml_model/models.py:156 ml_model/models.py:402 payments/admin.py:95 msgid "Model" msgstr "Модель" @@ -716,7 +743,7 @@ msgstr "Версии" msgid "Link to versions" msgstr "Привязка к версиям" -#: ml_model/models.py:221 reports/models/error_report.py:10 +#: ml_model/models.py:221 msgid "Text" msgstr "Текст" @@ -748,12 +775,12 @@ msgstr "ZIP архив" msgid "Audio" msgstr "Аудио" -#: ml_model/models.py:234 ml_model/models.py:273 +#: ml_model/models.py:234 ml_model/models.py:272 #: payments/models/promocode.py:41 msgid "Type" msgstr "Тип" -#: ml_model/models.py:236 ml_model/models.py:284 +#: ml_model/models.py:236 ml_model/models.py:283 msgid "Required" msgstr "Обязательный" @@ -770,158 +797,153 @@ msgstr "Модель" msgid "Model Inputs" msgstr "Входящий поток модели" -#: ml_model/models.py:252 +#: ml_model/models.py:251 msgid "Integer" msgstr "Целое число" -#: ml_model/models.py:253 +#: ml_model/models.py:252 msgid "Float" msgstr "Вещественное число" -#: ml_model/models.py:254 +#: ml_model/models.py:253 msgid "String" msgstr "Строка" -#: ml_model/models.py:257 +#: ml_model/models.py:256 msgid "List" msgstr "Список" -#: ml_model/models.py:261 +#: ml_model/models.py:260 msgid "Float range" msgstr "Вещественный диапазон" -#: ml_model/models.py:265 +#: ml_model/models.py:264 msgid "Integer range" msgstr "Целочисленный диапазон" -#: ml_model/models.py:267 +#: ml_model/models.py:266 msgid "Logical" msgstr "Логический" -#: ml_model/models.py:280 +#: ml_model/models.py:279 msgid "Values" msgstr "Значения" -#: ml_model/models.py:281 +#: ml_model/models.py:280 msgid "" "These values can contain different interfaces and default value optional" msgstr "" "Значения могут содержать различные интерфейс и, опционально, значение по " "умолчанию" -#: ml_model/models.py:283 +#: ml_model/models.py:282 msgid "Hidden" msgstr "Скрытый" -#: ml_model/models.py:289 +#: ml_model/models.py:288 #, python-format msgid "Parameter of %(model_title)s" msgstr "Параметр %(model_title)s" -#: ml_model/models.py:292 +#: ml_model/models.py:291 msgid "Parameter" msgstr "Параметр" -#: ml_model/models.py:293 +#: ml_model/models.py:292 msgid "Parameters" msgstr "Параметры" -#: ml_model/models.py:298 +#: ml_model/models.py:297 msgid "Fixed" msgstr "Фикса" -#: ml_model/models.py:299 +#: ml_model/models.py:298 msgid "Per generation second" msgstr "За секунду генерации" -#: ml_model/models.py:300 +#: ml_model/models.py:299 msgid "Per one text token" msgstr "За один текстовый токен" -#: ml_model/models.py:301 +#: ml_model/models.py:300 msgid "Per image pixel" msgstr "За один пиксель" -#: ml_model/models.py:304 +#: ml_model/models.py:303 msgid "By input data" msgstr "По входящим данным" -#: ml_model/models.py:305 +#: ml_model/models.py:304 msgid "By output data" msgstr "По исходящим данным" -#: ml_model/models.py:306 +#: ml_model/models.py:305 msgid "By all data" msgstr "По всем данным" -#: ml_model/models.py:311 +#: ml_model/models.py:310 msgid "Strategy" msgstr "Стратегия" -#: ml_model/models.py:316 +#: ml_model/models.py:315 msgid "Interaction Type" msgstr "Тип взаимодействия" -#: ml_model/models.py:321 payments/models/invoice.py:19 +#: ml_model/models.py:320 payments/models/invoice.py:19 msgid "Cost" msgstr "Цена" -#: ml_model/models.py:322 +#: ml_model/models.py:321 msgid "In RUB, per specified strategy" msgstr "В рублях, за указанную стратегию" -#: ml_model/models.py:327 +#: ml_model/models.py:326 msgid "Coefficient" msgstr "Коэффициент" -#: ml_model/models.py:328 +#: ml_model/models.py:327 msgid "Cost multiplier" msgstr "Цена" -#: ml_model/models.py:335 +#: ml_model/models.py:334 msgid "Rate" msgstr "Ставка" -#: ml_model/models.py:339 +#: ml_model/models.py:338 msgid "Payment Rule" msgstr "Платежное правило" -#: ml_model/models.py:340 +#: ml_model/models.py:339 msgid "Payment Rules" msgstr "Платежные правила" -#: ml_model/models.py:401 +#: ml_model/models.py:400 msgid "Descriptor" msgstr "Дескриптор" -#: ml_model/models.py:407 +#: ml_model/models.py:406 #, python-format msgid "Instruction of %(model_title)s" msgstr "Инструкция %(model_title)s" -#: ml_model/models.py:410 +#: ml_model/models.py:409 msgid "Model Instruction" msgstr "Инструкция Модели" -#: ml_model/models.py:411 +#: ml_model/models.py:410 msgid "Model Instructions" msgstr "Инструкции Моделей" -#: ml_model/selectors/ml_models_selector.py:106 +#: ml_model/selectors/ml_models_selector.py:122 msgid "no model by this id" msgstr "Не найдено моделей по этому ID" -#: ml_model/services/chatgpt.py:187 -msgid "Unable to recognize the image. (Supported formats are PNG, JPG, JPEG)" -msgstr "" -"Невозможно распознать изображение. (Поддерживаемые форматы: PNG, JPG, JPEG)" - -#: ml_model/services/chatgpt.py:212 +#: ml_model/services/chatgpt.py:161 msgid "No matching version found" msgstr "Соответствующая версия не найдена" -#: ml_model/services/minio_service.py:35 ml_model/services/minio_service.py:53 -#: ml_model/services/minio_service.py:61 ml_model/services/minio_service.py:70 +#: ml_model/services/minio_service.py:36 ml_model/services/minio_service.py:54 +#: ml_model/services/minio_service.py:62 ml_model/services/minio_service.py:71 msgid "Unknown bucket destination" msgstr "Неизвестный бакет для загрузки" @@ -935,7 +957,9 @@ msgstr "Невозможно получить данные модели" #: payments/admin.py:33 payments/admin.py:67 payments/admin.py:87 msgid "You can search by user email, exacted company name" -msgstr "Вы можете осуществлять поиск по e-mail пользователя, точному названию компании" +msgstr "" +"Вы можете осуществлять поиск по e-mail пользователя, точному названию " +"компании" #: payments/admin.py:38 payments/admin.py:92 msgid "Missing" @@ -1086,6 +1110,14 @@ msgstr "Платежный метод" msgid "Payment Methods" msgstr "Платежные методы" +#: payments/routes/v1.py:70 +msgid "Expenses" +msgstr "Затраты" + +#: payments/routes/v1.py:71 +msgid "Refills" +msgstr "Пополнения" + #: payments/selectors/model_payment_selector.py:25 msgid "Messages for this model are not registered in a selector" msgstr "" @@ -1126,22 +1158,6 @@ msgstr "Прокси" msgid "Proxies" msgstr "Прокси" -#: reports/models/error_report.py:9 -msgid "Author" -msgstr "Автор" - -#: reports/models/error_report.py:11 -msgid "Attachments" -msgstr "Вложения" - -#: reports/models/error_report.py:14 -msgid "User Report" -msgstr "Пользовательский репорт" - -#: reports/models/error_report.py:15 -msgid "User Reports" -msgstr "Пользовательские репорты" - #: tools/apps.py:9 msgid "Tools" msgstr "Инструменты" @@ -1162,7 +1178,7 @@ msgstr "Публичный API" msgid "Media" msgstr "Медиа" -#: tools/chats/apis.py:178 tools/media/apis.py:161 +#: tools/chats/apis.py:182 tools/media/apis.py:168 #: tools/public_api/views/base.py:103 msgid "" "Error occured when create generation. It may cause NSFW-content not allowed, " @@ -1180,6 +1196,18 @@ msgstr "Чат %(id)s" msgid "Chat" msgstr "Чат" +#: tools/media/models.py:48 +msgid "File" +msgstr "Файл" + +#: tools/media/models.py:59 +msgid "Preset" +msgstr "Пресет" + +#: tools/media/models.py:60 +msgid "Presets" +msgstr "Пресеты" + #: tools/public_api/exceptions.py:7 msgid "Upgrade token limit on your api-key" msgstr "Необходимо повысить лимит токенов у API-ключа" @@ -1226,6 +1254,27 @@ msgstr "Отсутствует обязательный параметр: 'messa msgid "Model not found" msgstr "Модель не найдена" +#~ msgid "Author" +#~ msgstr "Автор" + +#~ msgid "Attachments" +#~ msgstr "Вложения" + +#~ msgid "User Report" +#~ msgstr "Пользовательский репорт" + +#~ msgid "User Reports" +#~ msgstr "Пользовательские репорты" + +#~ msgid "" +#~ "Unable to recognize the image. (Supported formats are PNG, JPG, JPEG)" +#~ msgstr "" +#~ "Невозможно распознать изображение. (Поддерживаемые форматы: PNG, JPG, " +#~ "JPEG)" + +#~ msgid "Wrong username" +#~ msgstr "Неверное имя пользователя" + #~ msgid "Achievement" #~ msgstr "Достижение" @@ -0,0 +1,18 @@ +# Generated by Django 5.0.11 on 2025-11-16 16:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('msgs', '0009_delete_messageerror'), + ] + + operations = [ + migrations.AddIndex( + model_name='message', + index=models.Index(fields=['object_id', '-created_at'], name='msgs_messag_object__09670f_idx'), + ), + ] @@ -72,4 +72,7 @@ class Message(models.Model): verbose_name = 'Сообщение' verbose_name_plural = 'Сообщения' ordering = ['-created_at'] - indexes = [models.Index(fields=['content_type', 'object_id'])] + indexes = [ + models.Index(fields=['content_type', 'object_id']), + models.Index(fields=['object_id', '-created_at']), + ] @@ -3,13 +3,33 @@ from uuid import uuid4 from django.contrib.auth import get_user_model from django.contrib.contenttypes.fields import GenericRelation from django.db import models -from django.db.models import QuerySet +from django.db.models import F, Max, OuterRef, QuerySet, Subquery from ml_model.models import NeuronModel from .message import Message +class ToolsObjectsManager(models.Manager): + def get_queryset(self): + queryset = super().get_queryset() + + last_message_query = ( + Message.objects.filter( + content_type__app_label=self.model._meta.app_label, + content_type__model=self.model._meta.model_name, + ) + .values('object_id') + .annotate(last_created=Max('created_at')) + ) + + return queryset.annotate( + last_message_created_at=Subquery( + last_message_query.filter(object_id=OuterRef('pk')).values('last_created') + ) + ).order_by(F('last_message_created_at').desc(nulls_last=True)) + + class BaseStore(models.Model): """ Abstract schema of base store. Store is a storage for store messages and create relations with user(s) and models @@ -26,6 +46,9 @@ class BaseStore(models.Model): related_query_name='%(app_label)s_%(class)ss_messages', ) + objects = models.Manager() + tobjects = ToolsObjectsManager() + class Meta: abstract = True @@ -1,6 +1,5 @@ from typing import Dict, Any -from backend import settings from django.utils.translation import gettext_lazy as _ from rest_framework import serializers @@ -40,15 +39,9 @@ class MessageSerializer(serializers.ModelSerializer): def validate(self, data: Dict[str, Any]) -> Dict[str, Any]: file = data.get('file') - version = data.get('info', {}).get('version', 'default') - max_mb_size = settings.MAX_UPLOAD_SIZE_PER_MODEL.get( - version, - settings.MAX_UPLOAD_SIZE_PER_MODEL['default'] - ) + max_mb_size = 50 if file and file.size > (max_mb_size << 10 << 10): - raise ValidationError( - _('The file size cannot exceed %(max_mb_size)d MB') % {'max_mb_size': max_mb_size} - ) + raise ValidationError(_('The file size cannot exceed %(max_mb_size)d MB') % {'max_mb_size': max_mb_size}) return data def to_representation(self, instance): @@ -0,0 +1,17 @@ +# Generated by Django 5.0.11 on 2025-11-14 10:09 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('ml_model', '0051_modelinstruction'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='modelinput', + unique_together=set(), + ), + ] @@ -1,6 +1,6 @@ from uuid import UUID -from django.db.models import Prefetch, Q +from django.db.models import Prefetch, Q, Exists, OuterRef from django.utils.translation import gettext_lazy as _ from authentication.models.choices import InvitationStatus @@ -9,6 +9,8 @@ from authentication.selectors.user_selector import UserSelector from ml_model.exceptions import NeuronModelNotExist from ml_model.models import ModelParameter, NeuronModel from ml_model.serializers import NeuronModelSerializer, NeuronModelsSerializer +from tools.chats.models import Chat +from tools.media.models import Image, Video class NeuronModelSelector: @@ -52,14 +54,28 @@ class NeuronModelSelector: serialize: bool = True, hidden: bool = False, ): - models = NeuronModel.objects.prefetch_related(Prefetch('model_modelstats')).all() if self.user.is_anonymous: + models = NeuronModel.objects.prefetch_related(Prefetch('model_modelstats')).filter(model_settings__is_active=True) return NeuronModelsSerializer(models, many=True) user_type = UserSelector(self.user).check_account_type() - models = NeuronModel.objects.filter( - Q(private_models_hosts__isnull=True) - | Q(private_models_hosts__accounts__user=self.user) - | Q(private_models_hosts__user=self.user) + models = NeuronModel.objects.annotate( + has_chat_msgs=Exists(Chat.objects.filter(user=self.user, model=OuterRef('uid'), messages__isnull=False)), + has_image_msgs=Exists(Image.objects.filter(user=self.user, model=OuterRef('uid'), messages__isnull=False)), + has_video_msgs=Exists(Video.objects.filter(user=self.user, model=OuterRef('uid'), messages__isnull=False)), + ).filter( + ( + Q(private_models_hosts__isnull=True) + | Q(private_models_hosts__accounts__user=self.user) + | Q(private_models_hosts__user=self.user) + ) + & ( + Q(model_settings__is_active=True) + | ( + Q(has_chat_msgs=True) + | Q(has_image_msgs=True) + | Q(has_video_msgs=True) + ) + ) ) if ( user_type == 'business_account' @@ -1,4 +1,5 @@ from ml_model.services.chatgpt import Chatgpt +from ml_model.services.chatgpt_5 import Chatgpt_5 from ml_model.services.claude import Claude from ml_model.services.codellama import Codellama from ml_model.services.dalle import Dalle @@ -7,35 +8,50 @@ from ml_model.services.deepseek import Deepseek from ml_model.services.djourney import Djourney from ml_model.services.epicphotogasm import Epicphotogasm from ml_model.services.flux import Flux +from ml_model.services.flux_2 import Flux_2 +from ml_model.services.fluxkrea import Fluxkrea from ml_model.services.fluxlorafast import Fluxlorafast from ml_model.services.fluxproultra import Fluxproultra from ml_model.services.gemini import Gemini from ml_model.services.geminiimage import Geminiimage from ml_model.services.gptimage import Gptimage -from ml_model.services.sora import Sora -from ml_model.services.speedance import Speedance from ml_model.services.granite import Granite from ml_model.services.grok import Grok +from ml_model.services.hailuo import Hailuo +from ml_model.services.hunyuan import Hunyuan from ml_model.services.iconic import Iconic +from ml_model.services.ideogram import Ideogram from ml_model.services.kandinsky import Kandinsky +from ml_model.services.kling import Kling +from ml_model.services.leonardo import Leonardo from ml_model.services.lightning import Lightning from ml_model.services.llama import Llama from ml_model.services.logoai import Logoai +from ml_model.services.lyria import Lyria from ml_model.services.midjourney import Midjourney +from ml_model.services.minimaxvideo import Minimaxvideo +from ml_model.services.minimaxmusic import Minimaxmusic from ml_model.services.mistral import Mistral from ml_model.services.musicgen import Musicgen from ml_model.services.nanobanana import Nanobanana from ml_model.services.perplexity import Perplexity from ml_model.services.pulid import Pulid from ml_model.services.qwen import Qwen +from ml_model.services.qwen_235B import Qwen_235B from ml_model.services.raifgpt import Raifgpt from ml_model.services.ray import Ray from ml_model.services.recraft import Recraft +from ml_model.services.reve import Reve +from ml_model.services.runway import Runway from ml_model.services.sdxlemoji import Sdxlemoji +from ml_model.services.seedream import Seedream +from ml_model.services.sora import Sora +from ml_model.services.speedance import Speedance from ml_model.services.stablediffusion import Stablediffusion +from ml_model.services.stablemusic import Stablemusic +from ml_model.services.suno import Suno from ml_model.services.upscaleai import Upscaleai from ml_model.services.veo import Veo from ml_model.services.vicuna import Vicuna from ml_model.services.wan import Wan from ml_model.services.whisper import Whisper -from ml_model.services.qwen_235B import Qwen_235B @@ -4,6 +4,7 @@ import logging import re import subprocess import time +import zipfile from concurrent.futures import ThreadPoolExecutor, as_completed @@ -17,14 +18,12 @@ from datetime import timedelta from decimal import Decimal from io import BufferedReader, BytesIO from math import ceil -from pathlib import Path from typing import Generator, List, Optional, Dict, Any, Tuple import docx2txt import filetype import httpx import tiktoken -from django.core.files.uploadedfile import UploadedFile from langchain.chains import ConversationChain from langchain_core.chat_history import InMemoryChatMessageHistory from langchain_core.messages import ( @@ -37,14 +36,14 @@ from langchain_core.prompts.prompt import PromptTemplate from langchain_core.runnables import RunnableWithMessageHistory from langchain_openai.chat_models import ChatOpenAI from langchain_text_splitters import RecursiveCharacterTextSplitter -from PIL import Image, UnidentifiedImageError +from PIL import Image from redis.commands.search.document import Document from redis.commands.search.query import Query from backend import settings from messages.models import BaseStore, Message from ml_model.constants import TEMPORARY_TEST_TEXT -from ml_model.exceptions import GenerationException +from ml_model.exceptions import FileExtensionNotSupported from ml_model.models import ( ModelConfiguration, NeuronModel @@ -70,10 +69,6 @@ class Chatgpt(SimpleService): 'input': Decimal('0.0022'), 'output': Decimal('0.0022'), }, - 'o1-preview': { - 'input': Decimal('0.03'), - 'output': Decimal('0.03'), - }, 'gpt-4o-mini': { 'input': Decimal('0.0003'), 'output': Decimal('0.0003'), @@ -92,35 +87,6 @@ class Chatgpt(SimpleService): 'high': Decimal('25') # 1 call } }, - 'gpt-4.5-preview': { - 'input': Decimal('0.075'), - 'output': Decimal('0.075'), - }, - 'gpt-5': { - '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 - }, - 'code_interpreter': Decimal('15') - }, - 'gpt-5-mini': { - '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 - }, - 'code_interpreter': Decimal('15') - }, - 'gpt-5-nano': { - 'input': Decimal('0.000025'), - 'output': Decimal('0.0002'), - 'code_interpreter': Decimal('15') - }, 'gpt-oss-120b': { 'input': Decimal('0.0002'), 'output': Decimal('0.0002') @@ -133,6 +99,14 @@ class Chatgpt(SimpleService): } } + TOKEN_LIMITS = { + 'o3-mini': 100_000, + 'gpt-4o-mini': 64_000, + 'gpt-4o': 64_000, + 'gpt-4.5-preview': 64_000, + 'gpt-oss-120b': 65_500, + } + def __init__(self, store: BaseStore) -> None: super().__init__(store) self.logger = logging.getLogger(self.__class__.__name__) @@ -156,158 +130,48 @@ class Chatgpt(SimpleService): image_size = None normalized_image = None embedding_tokens = 0 + chunks = [] if file: - file_extension = Path(file.name).suffix - if file_extension == '.pdf': - raw_text = self.get_pdf_data(file) - text = re.sub(r'\n{2,}', '\n', raw_text) - chunks = self.split_text_to_chunks(text) - elif file_extension in ('.doc', '.docx'): - raw_text = self.get_word_data(file_extension, file) - text = re.sub(r'\n{2,}', '\n', raw_text) - chunks = self.split_text_to_chunks(text) - elif file_extension == '.xlsx': - chunks = self.split_text_to_chunks(self.get_xlsx_data(file)) - else: - image = file - if image: try: - kind = filetype.guess(input_message.file.read(20)) - mime = kind.mime if kind else 'application/octet-stream' - normalized_image = Image.open(image) - format = 'jpeg' if kind.extension == 'jpg' else kind.extension - buf = BytesIO() - normalized_image.save(buf, format=format) - image_url = f'data:{mime};base64,{base64.b64encode(buf.getvalue()).decode("utf-8")}' - buf.close() - image_size = normalized_image.size - image_data = {'type': 'image_url', 'image_url': {'url': image_url}} - input_content.append(image_data) - except UnidentifiedImageError: - raise Exception(_('Unable to recognize the image. (Supported formats are PNG, JPG, JPEG)')) + file_bytes = input_message.file.read() + kind = filetype.guess(file_bytes[:20]) + raw_file_extension = kind.extension + file_extension = self._get_file_extension(raw_file_extension, file_bytes) + if file_extension in ('pdf', 'doc', 'docx', 'xlsx'): + chunks = self._get_file_data(file_extension, file_bytes) + else: + image = file + mime = kind.mime if kind else 'application/octet-stream' + normalized_image, image_size, image_data = self._get_image_data(mime, file_bytes, file_extension) + input_content.append(image_data) + except: + raise FileExtensionNotSupported(['PDF', 'DOC', 'DOCX', 'XLSX', 'JPG', 'JPEG', 'PNG']) for proxy in Proxy.objects.all(): self.llm = ChatOpenAI( model=model_name, http_client=httpx.Client(proxy=f'{proxy.protocol}://{proxy.address}'), ) - if model_name in ( - 'o1-preview', - 'o1-mini', - ): - self.llm.temperature = 1 - self.llm.model_kwargs = { - 'presence_penalty': info.pop('presence_penalty', 0), - 'top_p': info.pop('top_p', 1), - } - if info.get('web_search'): - del info['web_search'] - else: - self.llm.temperature = info.pop('temperature', 0.5) - self.llm.model_kwargs = { - 'presence_penalty': info.pop('presence', 0), - 'top_p': info.pop('top_p', 0.5), - } + self.llm.temperature = info.pop('temperature', 0.5) + self.llm.model_kwargs = { + 'presence_penalty': info.pop('presence', 0), + 'top_p': info.pop('top_p', 0.5), + } self.llm.tiktoken_model_name = 'gpt-4' if model_name not in self.TOKENS_COST.keys(): raise Exception(_('No matching version found')) chat_history = self.get_chat_history(model_name=model_name) chat_history.add_message(HumanMessage(content=input_message.content)) - if model_name in ('o1-preview', 'o1-mini'): - chat_history.messages.pop(0) conversation = RunnableWithMessageHistory( runnable=self.llm, get_session_history=lambda _: chat_history, ) llm_input = [SystemMessage(content=user_system_prompt), HumanMessage(content=input_content)] - input_embedding_tokens = 0 - if file and not image: - if sum([len(chunk.content) for chunk in chunks]) > 20_000: - input_tokens = self.count_text_tokens([*chat_history.messages, *llm_input, *chunks[:10]]) - input_embedding_tokens = len(chunks) * 600 - else: - input_tokens = self.count_text_tokens([*chat_history.messages, *llm_input, *chunks]) - elif image: - input_tokens = self.count_text_tokens(llm_input) - else: - input_tokens = self.count_text_tokens([*chat_history.messages, *llm_input]) + input_tokens, input_embedding_tokens = self._get_input_tokens(file, image, chunks, chat_history, llm_input) output_tokens = 0 self.assert_enough_balance( input_tokens, image_size, model=self.llm.model_name, embedding_tokens=input_embedding_tokens ) - if model_name in ('gpt-5', 'gpt-5-mini', 'gpt-5-nano'): - system = chat_history.messages.pop(0) - messages = [ - {'role': 'user' if isinstance(msg, HumanMessage) else 'assistant', 'content': msg.content} - for msg in chat_history.messages - ] - messages.insert(0, {'role': 'system', 'content': system.content}) - messages.insert(0, {'role': 'system', 'content': user_system_prompt}) - if image: - messages[-1]['content'] = [ - {'type': 'input_text', 'text': input_message.content}, - {'type': 'input_image', 'image_url': image_data['image_url']['url']} - ] - elif file: - if sum([len(chunk.content) for chunk in chunks]) > 20_000: - document_name = chunks[0].content.partition(f':{chr(10)}')[2].split(f'{chr(10)}')[0][:100] - embedding_tokens, file_data = self.get_large_file_data(chunks, proxy, input_message.content) - messages[-1]['content'] = self.make_embeddings_prompt( - document_name=document_name, section_texts=file_data, question=input_message.content - ) - else: - messages[-1]['content'] = (f'Используй системный промпт. Содержание файла: ' - f'{chunks}. Вопрос: {input_message.content}') - json_data = { - 'model': model_name, - 'input': messages, - 'tools': [], - 'instructions': 'Форматирование — обязательное требование. Выполняй строго по правилам:\\n\\n1) ' - 'Используй реальные символы новой строки. Не выводи "\\\\n" как текст — вставляй ' - 'переносы (символ новой строки).\\n2) Между абзацами ставь ОДНУ пустую строку ' - '(то есть два символа новой строки подряд: \\\\n\\\\n).\\n3) Для списков — каждый пункт на ' - 'отдельной строке; между списком и текстом — пустая строка.\\n4) ' - 'Любые блоки/куски/фрагменты кода СТРОГО ' - 'в тройных бэктиках (```) с указанием наименования языка программирования, ' - 'с пустой строкой перед и после блока/куска/фрагмента кода.' - '5) Не используй HTML.\\n6) Если формат неверный — перепиши ответ и ' - 'верни исправленный вариант.' - } - if info.get('reasoning'): - json_data['reasoning'] = {} - reasoning_data = { - 'Минимальный': 'minimal', - 'Низкий': 'low', - 'Средний': 'medium', - 'Высокий': 'high' - } - json_data['reasoning']['effort'] = reasoning_data[info['reasoning']] - json_data['reasoning']['summary'] = 'auto' - if info.get('reasoning') == 'Минимальный': - info.pop('web_search', None) - info.pop('code_interpreter', None) - if model_name == 'gpt-5-nano': - info.pop('web_search', None) - if info.get('web_search', 'Отключено') != 'Отключено': - search_context_size, json_data = self.get_web_search_data( - info.get('web_search', 'Средний контекст'), - model_name, - messages - ) - info['web_search'] = search_context_size - if info.get('code_interpreter') is True: - json_data['tools'].append( - { - 'type': 'code_interpreter', - 'container': {'type': 'auto'} - } - ) - messages[-1]['content'] += 'the python tool' - input_tokens, output_tokens, response = self.call_openai_api( - proxy=proxy, - endpoint='responses', - json_data=json_data - ) - elif model_name == 'gpt-oss-120b': + if model_name == 'gpt-oss-120b': system = chat_history.messages.pop(0) messages = [ {'role': 'user' if isinstance(msg, HumanMessage) else 'assistant', 'content': msg.content} @@ -346,7 +210,7 @@ class Chatgpt(SimpleService): response = AIMessage(content=content.replace('\\n', '\n')) else: raise Exception('GPT not answer correctly, please retry later') - elif model_name in ('o3-mini', 'gpt-4.5-preview'): + elif model_name == 'o3-mini': system = chat_history.messages.pop(0) messages = [ {'role': 'user' if isinstance(msg, HumanMessage) else 'assistant', 'content': msg.content} @@ -375,15 +239,13 @@ class Chatgpt(SimpleService): } input_tokens, output_tokens, response = self.call_openai_api(proxy=proxy, endpoint='chat/completions',json_data=json_data) elif info.get('web_search', 'Отключено') != 'Отключено': - if model_name not in ('o1-preview', 'o1-mini'): - system = chat_history.messages.pop(0) + system = chat_history.messages.pop(0) messages = [ {'role': 'user' if isinstance(msg, HumanMessage) else 'assistant', 'content': msg.content} for msg in chat_history.messages ] - if model_name not in ('o1-preview', 'o1-mini'): - messages.insert(0, {'role': 'system', 'content': system.content}) - messages.insert(0, {'role': 'system', 'content': user_system_prompt}) + messages.insert(0, {'role': 'system', 'content': system.content}) + messages.insert(0, {'role': 'system', 'content': user_system_prompt}) search_context_size, json_data = self.get_web_search_data( info.get('web_search', 'Средний контекст'), model_name, @@ -439,9 +301,6 @@ class Chatgpt(SimpleService): config={'configurable': {'session_id': 'default'}}, ) else: - # Somehow this chain doesn't support Vision, even though ChatOpenAI (above) does. - if model_name in ('o1-preview', 'o1-mini'): - llm_input.pop(0) response = conversation.invoke( {'input': llm_input}, config={'configurable': {'session_id': 'default'}}, @@ -454,7 +313,7 @@ class Chatgpt(SimpleService): if ( image and normalized_image - and model_name not in ('o3-mini', 'gpt-4.5-preview', 'gpt-5', 'gpt-5-mini', 'gpt-5-nano') + and model_name != 'o3-mini' ): self.logger.info(f'Input количество токенов БЕЗ картинки {model_name} - {input_tokens}') input_tokens += self.count_image_tokens(normalized_image.size, model_name) @@ -492,17 +351,7 @@ class Chatgpt(SimpleService): is_sent=True, ).order_by('-created_at')[1:] - token_limits = { - 'o3-mini': 100_000, - 'o1-preview': 100_000, - 'gpt-4o-mini': 64_000, - 'gpt-4o': 64_000, - 'gpt-4.5-preview': 64_000, - 'gpt-5': 200_000, - 'gpt-5-mini': 200_000, - 'gpt-5-nano': 200_000, - 'gpt-oss-120b': 65_500, - } + token_limits = self.TOKEN_LIMITS tokens = 0 history: List[BaseMessage] = [] for message in air_messages.iterator(5): @@ -516,20 +365,19 @@ class Chatgpt(SimpleService): tokens += self.count_text_tokens(air_message) history.append(air_message[0]) memory = InMemoryChatMessageHistory() - if model_name not in ('o1-preview', 'o1-mini'): - memory.add_message(SystemMessage( + memory.add_message(SystemMessage( + content=( + 'Think step by step. Use full context. Prioritize depth, clarity, and justification. ' + 'Be thorough and expansive.' + ) + )) + memory.add_message(SystemMessage( content=( - 'Think step by step. Use full context. Prioritize depth, clarity, and justification. ' - 'Be thorough and expansive.' + 'Отныне все ответы должны быть представлены как единая строка (str). Не использовать никаких ' + 'структурированных форматов, таких как JSON, словари (dict) или списки (list). ' + 'Любая информация должна быть преобразована в простой строковый текст (str).' ) )) - memory.add_message(SystemMessage( - content=( - 'Отныне все ответы должны быть представлены как единая строка (str). Не использовать никаких ' - 'структурированных форматов, таких как JSON, словари (dict) или списки (list). ' - 'Любая информация должна быть преобразована в простой строковый текст (str).' - ) - )) memory.add_messages(list(reversed(history))) return memory @@ -614,6 +462,54 @@ class Chatgpt(SimpleService): return total_tokens + def _get_file_extension(self, raw_file_extension: str, file_bytes: bytes) -> str: + if raw_file_extension == 'zip': + signatures = { + 'xlsx': 'xl/workbook.xml', + 'docx': 'word/document.xml' + } + with zipfile.ZipFile(BytesIO(file_bytes), 'r') as zip_file: + namelist = zip_file.namelist() + for format_name, required_file in signatures.items(): + if required_file in namelist: + return format_name + raise + return raw_file_extension + + def _get_image_data(self, mime: str, file_bytes: bytes, file_extension: str) -> Tuple: + normalized_image = Image.open(BytesIO(file_bytes)) + format = 'jpeg' if file_extension == 'jpg' else file_extension + buf = BytesIO() + normalized_image.save(buf, format=format) + image_url = f'data:{mime};base64,{base64.b64encode(buf.getvalue()).decode("utf-8")}' + buf.close() + image_size = normalized_image.size + image_data = {'type': 'image_url', 'image_url': {'url': image_url}} + return normalized_image, image_size, image_data + + def _get_file_data(self, file_extension: str, file_bytes: bytes) -> list[HumanMessage]: + is_word = file_extension in ('doc', 'docx') + method_name = 'word' if is_word else file_extension + operation = getattr(self, f'get_{method_name}_data') + text = operation(file_extension, file_bytes) if is_word else operation(file_bytes) + if file_extension != 'xlsx': + text = re.sub(r'\n{2,}', '\n', text) + return self.split_text_to_chunks(text) + + def _get_input_tokens(self, file, image, chunks, chat_history, llm_input): + input_embedding_tokens = 0 + if file and not image: + if sum([len(chunk.content) for chunk in chunks]) > 20_000: + input_tokens = self.count_text_tokens([*chat_history.messages, *llm_input, *chunks[:10]]) + input_embedding_tokens = len(chunks) * 600 + else: + input_tokens = self.count_text_tokens([*chat_history.messages, *llm_input, *chunks]) + elif image: + input_tokens = self.count_text_tokens(llm_input) + else: + input_tokens = self.count_text_tokens([*chat_history.messages, *llm_input]) + return input_tokens, input_embedding_tokens + def get_large_file_data(self, chunks, proxy, user_content): redis_client = redis.Redis(host=settings.REDIS_HOST, port=settings.REDIS_PORT, db=0) embedding_tokens = 0 @@ -666,14 +562,13 @@ class Chatgpt(SimpleService): } return search_context_size, json_data - def get_pdf_data(self, pdf_file: UploadedFile) -> str: + def get_pdf_data(self, pdf_data: bytes) -> str: """ Extracting text from pdf-file :param pdf_file: uploaded pdf file :return: pdf-file content """ try: - pdf_data = pdf_file.read() doc = fitz.open(stream=pdf_data, filetype="pdf") raw_text = '' for page_number, page in enumerate(doc, start=1): @@ -686,14 +581,14 @@ class Chatgpt(SimpleService): return f"Ошибка: Файл поврежден или не может быть прочитан." return f'Содержимое файла: {raw_text.strip()}' - def get_xlsx_data(self, xlsx_file: UploadedFile) -> str: + def get_xlsx_data(self, xlsx_data: bytes) -> str: """ Extracting text from xlsx-file :param xlsx_file: uploaded xlsx file :return: xlsx_file content """ try: - xlsx_content = BytesIO(xlsx_file.read()) + xlsx_content = BytesIO(xlsx_data) workbook = openpyxl.load_workbook(xlsx_content) raw_text = '' for sheet_name in workbook.sheetnames: @@ -704,7 +599,7 @@ class Chatgpt(SimpleService): raw_text = 'Произошла ошибка во время чтения файла' return f'Содержимое файла: {raw_text}' - def get_word_data(self, extension: str, word_file: UploadedFile) -> str: + def get_word_data(self, extension: str, word_data: bytes) -> str: """ Extracting text from word-file :param extension: extension of uploaded word file @@ -712,17 +607,16 @@ class Chatgpt(SimpleService): :return: word-file content """ try: - file_content = word_file.read() - if extension == '.docx': - text = docx2txt.process(BytesIO(file_content)) - elif extension == '.doc': + if extension == 'docx': + text = docx2txt.process(BytesIO(word_data)) + elif extension == 'doc': process = subprocess.Popen( ['antiword', '-w', '0', '-'], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) - text, _ = process.communicate(input=file_content) + text, _ = process.communicate(input=word_data) text = text.decode('utf-8') else: text = '' @@ -0,0 +1,225 @@ +import time +from datetime import timedelta +from decimal import Decimal + +import filetype +from langchain_core.messages import HumanMessage, SystemMessage + +from messages.models import Message +from ml_model.exceptions import FileExtensionNotSupported +from ml_model.services import Chatgpt +from poller.models import Proxy + + +class Chatgpt_5(Chatgpt): + TOKENS_COST = { + 'gpt-5': { + '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 + }, + 'code_interpreter': Decimal('15') + }, + 'gpt-5-mini': { + '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 + }, + 'code_interpreter': Decimal('15') + }, + 'gpt-5-nano': { + 'input': Decimal('0.000025'), + 'output': Decimal('0.0002'), + 'code_interpreter': Decimal('15') + }, + 'gpt-5.1': { + '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 + }, + 'code_interpreter': Decimal('15') # 1 call + }, + 'gpt-5-pro': { + '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 + }, + }, + 'gpt-5.1-codex-max': { + 'input': Decimal('0.000625'), + 'output': Decimal('0.005'), + }, + 'gpt-5.1-codex': { + 'input': Decimal('0.000625'), + 'output': Decimal('0.005'), + }, + 'gpt-5-codex': { + 'input': Decimal('0.000625'), + 'output': Decimal('0.005'), + }, + 'gpt-5.2': { + '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 + }, + 'code_interpreter': Decimal('15') # 1 call + }, + } + + TOKEN_LIMITS = {key: 200_000 for key in TOKENS_COST.keys()} + + def make( + self, + input_message: Message, + save: bool = True, + ) -> list[Message]: + start_time = time.time() + info = input_message.info.copy() + model_name = info.pop('version', 'gpt-5') + user_system_prompt = info.pop('system_prompt', '') + input_content = [{'type': 'text', 'text': input_message.content or ''}] + file = input_message.file + image = None + image_size = None + embedding_tokens = 0 + chunks = [] + if file: + try: + file_bytes = input_message.file.read() + kind = filetype.guess(file_bytes[:20]) + raw_file_extension = kind.extension + file_extension = self._get_file_extension(raw_file_extension, file_bytes) + if file_extension in ('pdf', 'doc', 'docx', 'xlsx'): + chunks = self._get_file_data(file_extension, file_bytes) + else: + image = file + mime = kind.mime if kind else 'application/octet-stream' + _, image_size, image_data = self._get_image_data(mime, file_bytes, file_extension) + except Exception: + raise FileExtensionNotSupported(['PDF', 'DOC', 'DOCX', 'XLSX', 'JPG', 'JPEG', 'PNG']) + chat_history = self.get_chat_history(model_name=model_name) + chat_history.add_message(HumanMessage(content=input_message.content)) + llm_input = [SystemMessage(content=user_system_prompt), HumanMessage(content=input_content)] + input_tokens, input_embedding_tokens = self._get_input_tokens(file, image, chunks, chat_history, llm_input) + self.assert_enough_balance( + input_tokens, image_size, model=model_name, embedding_tokens=input_embedding_tokens + ) + for proxy in Proxy.objects.all(): + system = chat_history.messages.pop(0) + messages = [ + {'role': 'user' if isinstance(msg, HumanMessage) else 'assistant', 'content': msg.content} + for msg in chat_history.messages + ] + messages.insert(0, {'role': 'system', 'content': system.content}) + messages.insert(0, {'role': 'system', 'content': user_system_prompt}) + if image: + messages[-1]['content'] = [ + {'type': 'input_text', 'text': input_message.content}, + {'type': 'input_image', 'image_url': image_data['image_url']['url']} + ] + elif file: + if sum([len(chunk.content) for chunk in chunks]) > 20_000: + document_name = chunks[0].content.partition(f':{chr(10)}')[2].split(f'{chr(10)}')[0][:100] + embedding_tokens, file_data = self.get_large_file_data(chunks, proxy, input_message.content) + messages[-1]['content'] = self.make_embeddings_prompt( + document_name=document_name, section_texts=file_data, question=input_message.content + ) + else: + messages[-1]['content'] = (f'Используй системный промпт. Содержание файла: ' + f'{chunks}. Вопрос: {input_message.content}') + json_data = { + 'model': model_name, + 'input': messages, + 'tools': [], + 'instructions': ( + "Форматирование — обязательное требование. Выполняй строго по правилам:\n\n" + "1) Используй реальные символы новой строки, не выводи '\\n' как текст — вставляй переносы.\n\n" + "2) Абзацы: между абзацами ставь две пустые строки (два символа новой строки подряд).\n\n" + "3) Нумерованные и маркированные списки: каждый пункт на отдельной строке;\n" + " между списком и текстом оставляй две пустые строки.\n\n" + "4) Блоки кода: любые фрагменты кода выделяй тройными бэктиками (```) с указанием языка программирования;\n" + " перед и после блока оставляй две пустые строки.\n\n" + "5) Заголовки абзацев: делай крупным, используя Markdown '####' (например, '### Заголовок');\n" + " выделяй жирным (**Заголовок**); оставляй две пустые строки перед и после заголовка.\n\n" + "6) Используй Markdown для всего форматирования, не используй HTML.\n\n" + "7) Исправление формата: если формат неверный, перепиши ответ и верни исправленный вариант.\n\n" + "Строго разделяй текст на абзацы с жирными заголовками;\n" + "нумерованные и маркированные списки выводи с переносами строк;\n" + "блоки кода — с тройными бэктиками и указанием языка;\n" + "не выводи '\\n' как текст, используйте реальные переносы строк;\n" + "добавляй две пустые строки между абзацами и блоками для улучшения читаемости." + ) + } + if info.get('reasoning'): + json_data['reasoning'] = {} + reasoning_data = { + 'Минимальный': 'minimal', + 'Низкий': 'low', + 'Средний': 'medium', + 'Высокий': 'high', + 'Сверхвысокий': 'xhigh' + } + json_data['reasoning']['effort'] = reasoning_data[info['reasoning']] + json_data['reasoning']['summary'] = 'auto' + if info.get('reasoning') == 'Минимальный': + info.pop('web_search', None) + info.pop('code_interpreter', None) + if model_name == 'gpt-5.2' and info.get('verbosity', 'Отключено') != 'Отключено': + verbosity_data = { + 'Низкий': 'low', + 'Средний': 'medium', + 'Высокий': 'high', + } + json_data['text'] = {'verbosity': verbosity_data[info['verbosity']]} + if model_name in ('gpt-5-nano', 'gpt-5-codex', 'gpt-5.1-codex', 'gpt-5.1-codex-max'): + info.pop('web_search', None) + if info.get('web_search', 'Отключено') != 'Отключено': + search_context_size, json_data = self.get_web_search_data( + info.get('web_search', 'Средний контекст'), + model_name, + messages + ) + info['web_search'] = search_context_size + if info.get('code_interpreter') is True and model_name in ('gpt-5-mini', 'gpt-5-nano', 'gpt-5', 'gpt-5.1'): + json_data['tools'].append( + { + 'type': 'code_interpreter', + 'container': {'type': 'auto'} + } + ) + messages[-1]['content'] += 'the python tool' + input_tokens, output_tokens, response = self.call_openai_api( + proxy=proxy, + endpoint='responses', + json_data=json_data + ) + self.logger.info(f'Input количество токенов для {model_name} - {input_tokens}') + self.logger.info(f'Output количество токенов для {model_name} - {output_tokens}') + self.logger.info(f'Embedding количество токенов для {model_name} - {embedding_tokens}') + self.logger.info(f'Общее количество токенов для {model_name} - {input_tokens + output_tokens + embedding_tokens}') + process_time = timedelta(seconds=time.time() - start_time) + self.handle_invoice( + self.neuron_model, + input_tokens, + output_tokens, + model_name, + info, + embedding_tokens + ) + msgs = self.save_results([response], process_time, save) + return msgs @@ -0,0 +1,92 @@ +import base64 +import math +import time +from datetime import timedelta +from decimal import Decimal +from io import BytesIO + +import filetype +import requests +from django.core.files import File +from django.core.files.images import get_image_dimensions + +from messages.models import Message +from ml_model.services.base import SimpleService +from ml_model.tasks import replicate_run + + +class Flux_2(SimpleService): + TOKENS_COST = { + 'flux-2-pro': { + 'run': Decimal('4.5'), + 'input_mp': Decimal('4.5'), + 'output_mp': Decimal('4.5'), + }, + 'flux-2-dev': { + 'input_mp': Decimal('3.6'), + 'output_mp': Decimal('3.6'), + }, + 'flux-2-flex': { + 'input_mp': Decimal('18'), + 'output_mp': Decimal('18'), + }, + } + + def calculate_price(self, version: str, input_mp: int, output_mp: int) -> Decimal: + version_price = self.TOKENS_COST[version] + price = ( + version_price.get('run', Decimal('0')) + + version_price['input_mp'] * input_mp + + version_price['output_mp'] * output_mp + ) + return price.quantize(Decimal('0.1'), rounding='ROUND_UP') + + def save_results( + self, + prompt: str, + images: list, + time: timedelta, + save: bool = True, + ) -> list[Message]: + messages: list[Message] = [] + for image in images: + messages.append( + Message( + content_object=self.store, + elapsed_time=time, + content=prompt, + file=File(BytesIO(requests.get(image).content), '.png'), + ) + ) + if save: + return Message.objects.bulk_create(messages) + return messages + + def make(self, input_message: Message, save: bool = True) -> list[Message]: + start_time = time.time() + version = input_message.info.get('version', 'flux-2-pro') + width = input_message.info.pop('width', 1024) + 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), + 'aspect_ratio': 'custom', + 'width': width, + 'height': height, + **input_message.info, + } + input_mp = 0 + if input_message.file: + file_width, file_height = get_image_dimensions(input_message.file) + input_mp = math.ceil((file_width*file_height) / 1_000_000) + kind = filetype.guess(input_message.file.read(20)) + mime = kind.mime if kind else 'application/octet-stream' + input_message.file.seek(0) + image = f'data:{mime};base64,{base64.b64encode(input_message.file.read()).decode("utf-8")}' + input_message.file.close() + callback_data.update({'input_images': [image]}) + images = [replicate_run(f'black-forest-labs/{version}', callback_data)] + process_time = timedelta(seconds=(time.time() - start_time)) + self.handle_invoice(input_message.content_object.model, version=version, input_mp=input_mp, output_mp=output_mp) + msgs = self.save_results(input_message.content, images, process_time, save) + return msgs @@ -0,0 +1,89 @@ +import base64 +import time +from datetime import timedelta +from decimal import Decimal +from io import BytesIO + +import filetype +import requests +from django.core.files import File + +from messages.models import Message +from ml_model.models import ( + NeuronModel, +) +from ml_model.services.base import SimpleService +from ml_model.tasks import replicate_run + + +class Fluxkrea(SimpleService): + """ + Flux Service + contains abstract method make, which makes a generation + """ + + TOKENS_COST = { + 'flux-krea-dev': { + 'input_imgs': Decimal('7.5'), + }, + } + + def calculate_price(self, input_message: Message, version: str) -> Decimal: + price_map = self.TOKENS_COST[version] + price = price_map['input_imgs'] + if image_count := input_message.info.get('num_outputs'): + price = price * image_count + return price.quantize(Decimal('0.1'), rounding='ROUND_UP') + + _CALLBACK_BASE = 'black-forest-labs/' + + @property + def neuron_model(self): + return NeuronModel.objects.get(title='Flux') + + def save_results( + self, + prompt: str, + images: list, + time: timedelta, + save: bool = True, + ) -> list[Message]: + messages: list[Message] = [] + for image in images: + messages.append( + Message( + content_object=self.store, + elapsed_time=time, + content=prompt, + file=File(BytesIO(requests.get(image).content), '.png'), + ) + ) + if save: + return Message.objects.bulk_create(messages) + return messages + + def make(self, input_message: Message, save: bool = True) -> list[Message]: + start_time = time.time() + version = input_message.info.get('version') + callback_data = dict( + { + 'prompt': self.translate_prompt(input_message.content), + **input_message.info, + } + ) + if input_message.file: + kind = filetype.guess(input_message.file.read(20)) + mime = kind.mime if kind else 'application/octet-stream' + input_message.file.seek(0) + image = f'data:{mime};base64,{base64.b64encode(input_message.file.read()).decode("utf-8")}' + input_message.file.close() + callback_data.update({'image': image}) + runner = replicate_run( + f'{self._CALLBACK_BASE}{callback_data.get("version", "flux-krea-dev")}', + callback_data, + ) + images = runner if isinstance(runner, list) else [runner] + process_time = timedelta(seconds=(time.time() - start_time)) + self.handle_invoice(input_message.content_object.model, input_message=input_message, version=version) + msgs = self.save_results(input_message.content, images, process_time, save) + return msgs @@ -47,13 +47,19 @@ class Gemini(SimpleService): 'input': Decimal('30'), 'output': Decimal('120'), }, + 'gemini-3-pro-preview': { + 'input': Decimal('600'), + 'output': Decimal('3600'), + 'input_imgs': Decimal('0'), + 'highest_prices': {'input': Decimal('1200'), 'output': Decimal('5400')}, + }, } def calculate_price( self, version: str, input_tokens: int, output_tokens: int, image: FieldFile ) -> Decimal: price_map = self.TOKENS_COST[version.split('/')[1]] - if version.split('/')[1] == 'gemini-2.5-pro' and input_tokens > 200_000: + if version.split('/')[1] in ('gemini-2.5-pro', 'gemini-3-pro-preview') and input_tokens > 200_000: price = ( input_tokens * price_map['highest_prices']['input'] / 1_000_000 + output_tokens * price_map['highest_prices']['output'] / 1_000_000 @@ -5,9 +5,10 @@ from io import BytesIO import requests from django.core.files import File +from replicate.exceptions import ModelError from messages.models import Message -from ml_model.exceptions import ModelTimeoutError +from ml_model.exceptions import ModelTimeoutError, ImageContentNotFound from ml_model.services.base import SimpleService from ml_model.tasks import replicate_run @@ -37,15 +38,19 @@ 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} - ) - start_time = time.time() - images = replicate_run('google/gemini-2.5-flash-image', callback_data) - process_time = timedelta(seconds=(time.time() - start_time)) - self.handle_invoice( - input_message.content_object.model, num_images=input_message.info.get('num_images', 1) - ) - msgs = self.save_results(input_message.content, process_time, images, save) - return msgs + try: + callback_data = dict( + {'prompt': self.translate_prompt(input_message.content), **input_message.info} + ) + start_time = time.time() + images = replicate_run('google/gemini-2.5-flash-image', callback_data) + process_time = timedelta(seconds=(time.time() - start_time)) + self.handle_invoice( + input_message.content_object.model, num_images=input_message.info.get('num_images', 1) + ) + msgs = self.save_results(input_message.content, process_time, images, save) + return msgs + except ModelError as exc: + if exc.prediction.error == 'No image content found in response': + raise ImageContentNotFound raise ModelTimeoutError @@ -0,0 +1,70 @@ +import base64 +import time +from datetime import timedelta +from decimal import Decimal +from io import BytesIO + +import filetype +import requests +from django.core.files import File + +from messages.models import Message +from ml_model.services.base import SimpleService +from ml_model.tasks import replicate_run + +from payments.exceptions.insufficient_balance import InsufficientBalance +from payments.selectors.payment_plan_selector import PaymentPlanSelector + + +class Hailuo(SimpleService): + TOKENS_COST = { + 'hailuo-2.3': { + '768p': Decimal('84'), + '1080p': Decimal('147') + }, + 'hailuo-2.3-fast': { + '768p': Decimal('57'), + '1080p': Decimal('99') + } + } + + def calculate_price(self, version: str, resolution: str) -> Decimal: + price = self.TOKENS_COST[version][resolution] + return price.quantize(Decimal('0.1'), rounding='ROUND_UP') + + def save_results(self, content: str, t: timedelta, video: str, save: bool = True) -> list[Message]: + msg = Message( + content=content, + content_object=self.store, + elapsed_time=t, + file=File(BytesIO(requests.get(video).content), '.mp4'), + ) + if save: + return Message.objects.bulk_create([msg]) + return [msg] + + def make(self, input_message: Message, save: bool = True) -> list[Message]: + version = input_message.info.get('version', 'hailuo-2.3-fast') + resolution = input_message.info.get('resolution', '768p') + if ( + (balance := PaymentPlanSelector(self.store.user).get_current_balance()) + < (cost := self.TOKENS_COST[version][resolution]) + ): + raise InsufficientBalance(balance, cost) + callback_data = {'prompt': self.translate_prompt(input_message.content), 'duration': 6, **input_message.info} + if input_message.file: + kind = filetype.guess(input_message.file.read(20)) + mime = kind.mime if kind else 'application/octet-stream' + input_message.file.seek(0) + image = f'data:{mime};base64,{base64.b64encode(input_message.file.read()).decode("utf-8")}' + input_message.file.close() + callback_data.update({'first_frame_image': image}) + start_time = time.time() + video = replicate_run( + f'minimax/{version}', + callback_data + ) + process_time = timedelta(seconds=(time.time() - start_time)) + self.handle_invoice(input_message.content_object.model, version=version, resolution=resolution) + msgs = self.save_results(input_message.content, process_time, video, save) + return msgs @@ -0,0 +1,53 @@ +import time +from datetime import timedelta +from decimal import Decimal +from io import BytesIO + +import requests +from django.core.files import File + +from messages.models import Message +from ml_model.services.base import SimpleService +from ml_model.tasks import replicate_run + +from payments.exceptions.insufficient_balance import InsufficientBalance +from payments.selectors.payment_plan_selector import PaymentPlanSelector + + +class Hunyuan(SimpleService): + + PRICE = Decimal('1.575') + + _CALLBACK = 'tencent/hunyuan-video:6c9132aee14409cd6568d030453f1ba50f5f3412b844fe67f78a9eb62d55664f' + + def calculate_price(self, process_time: timedelta) -> Decimal: + price = self.PRICE * Decimal(process_time.total_seconds()) + return price.quantize(Decimal('0.1'), rounding='ROUND_UP') + + def save_results(self, content: str, t: timedelta, video: str, save: bool = True) -> list[Message]: + msg = Message( + content=content, + content_object=self.store, + elapsed_time=t, + file=File(BytesIO(requests.get(video).content), '.mp4'), + ) + if save: + return Message.objects.bulk_create([msg]) + return [msg] + + def make(self, input_message: Message, save: bool = True) -> list[Message]: + if ( + (balance := PaymentPlanSelector(self.store.user).get_current_balance()) + < (cost := Decimal('378')) + ): + raise InsufficientBalance(balance, cost) + callback_data = { + 'prompt': self.translate_prompt(input_message.content), + **input_message.info, + } + start_time = time.time() + video = replicate_run(self._CALLBACK, callback_data) + process_time = timedelta(seconds=(time.time() - start_time)) + self.handle_invoice(input_message.content_object.model, process_time=process_time) + msgs = self.save_results(input_message.content, process_time, video, save) + return msgs @@ -0,0 +1,84 @@ +import time +from datetime import timedelta +from decimal import Decimal +from io import BytesIO + +import requests +from django.core.files import File + +from messages.models import Message +from ml_model.exceptions import InvalidStyleCombinationError +from ml_model.models import ( + NeuronModel, +) +from ml_model.services.base import SimpleService +from ml_model.tasks import replicate_run + + +class Ideogram(SimpleService): + """ + Flux Service + contains abstract method make, which makes a generation + """ + + TOKENS_COST = { + 'ideogram-v3-turbo': { + 'input_imgs': Decimal('9'), + }, + } + + _CALLBACK_BASE = 'ideogram-ai/' + + def calculate_price(self, input_message: Message, version: str) -> Decimal: + price_map = self.TOKENS_COST[version] + price = price_map['input_imgs'] + return price.quantize(Decimal('0.1'), rounding='ROUND_UP') + + @property + def neuron_model(self): + return NeuronModel.objects.get(title='Flux') + + def save_results( + self, + prompt: str, + images: list, + time: timedelta, + save: bool = True, + ) -> list[Message]: + messages: list[Message] = [] + for image in images: + messages.append( + Message( + content_object=self.store, + elapsed_time=time, + content=prompt, + file=File(BytesIO(requests.get(image).content), '.png'), + ) + ) + if save: + return Message.objects.bulk_create(messages) + return messages + + def make(self, input_message: Message, save: bool = True) -> list[Message]: + start_time = time.time() + version = input_message.info.get('version') + if ( + input_message.info.get('style_preset', 'None') != 'None' + and input_message.info.get('style_type', 'None') not in ('None', 'Auto', 'General') + ): + raise InvalidStyleCombinationError + callback_data = dict( + { + 'prompt': self.translate_prompt(input_message.content), + **input_message.info, + } + ) + runner = replicate_run( + f'{self._CALLBACK_BASE}{callback_data.get("version", "ideogram-v3-turbo")}', + callback_data, + ) + images = runner if isinstance(runner, list) else [runner] + process_time = timedelta(seconds=(time.time() - start_time)) + self.handle_invoice(input_message.content_object.model, input_message=input_message, version=version) + msgs = self.save_results(input_message.content, images, process_time, save) + return msgs @@ -0,0 +1,58 @@ +import base64 +import time +from datetime import timedelta +from decimal import Decimal +from io import BytesIO + +import filetype +import requests +from django.core.files import File +import logging +from messages.models import Message +from ml_model.services.base import SimpleService +from ml_model.tasks import replicate_run + +from payments.exceptions.insufficient_balance import InsufficientBalance +from payments.selectors.payment_plan_selector import PaymentPlanSelector + + +class Kling(SimpleService): + TOKENS_COST = { + 'standard': Decimal('15'), + 'pro': Decimal('27') + } + + def calculate_price(self, mode: str, duration: int) -> Decimal: + price = self.TOKENS_COST[mode] * duration + return price.quantize(Decimal('0.1'), rounding='ROUND_UP') + + def save_results(self, content: str, t: timedelta, video: str, save: bool = True) -> list[Message]: + msg = Message( + content=content, + content_object=self.store, + elapsed_time=t, + file=File(BytesIO(requests.get(video).content), '.mp4'), + ) + if save: + return Message.objects.bulk_create([msg]) + return [msg] + + def make(self, input_message: Message, save: bool = True) -> list[Message]: + mode = input_message.info.pop('mode', 'standard') + duration = input_message.info.get('duration', 5) + if (balance := PaymentPlanSelector(self.store.user).get_current_balance()) < (cost := self.TOKENS_COST[mode] * duration): + raise InsufficientBalance(balance, cost) + callback_data = dict({'prompt': self.translate_prompt(input_message.content), 'mode': mode, **input_message.info}) + if input_message.file: + kind = filetype.guess(input_message.file.read(20)) + mime = kind.mime if kind else 'application/octet-stream' + input_message.file.seek(0) + image = f'data:{mime};base64,{base64.b64encode(input_message.file.read()).decode("utf-8")}' + input_message.file.close() + callback_data.update({'start_image': image}) + start_time = time.time() + video = replicate_run('kwaivgi/kling-v2.1', callback_data) + process_time = timedelta(seconds=(time.time() - start_time)) + self.handle_invoice(input_message.content_object.model, mode=mode, duration=duration) + msgs = self.save_results(input_message.content, process_time, video, save) + return msgs @@ -0,0 +1,82 @@ +import base64 +import time +from datetime import timedelta +from decimal import Decimal +from io import BytesIO + +import filetype +import requests +from django.core.files import File + +from messages.models import Message +from ml_model.models import ( + NeuronModel, +) +from ml_model.services.base import SimpleService +from ml_model.tasks import replicate_run + + +class Leonardo(SimpleService): + """ + Flux Service + contains abstract method make, which makes a generation + """ + + TOKENS_COST = { + 'lucid-origin': { + 'input_imgs': Decimal('1.5'), + }, # 1k images + } + + _CALLBACK_BASE = 'leonardoai/' + + def calculate_price(self, input_message: Message, version: str) -> Decimal: + price_map = self.TOKENS_COST[version] + price = price_map['input_imgs'] / 1_000 + if num_images := input_message.info.get('num_images'): + price = price * num_images + return price.quantize(Decimal('0.1'), rounding='ROUND_UP') + + @property + def neuron_model(self): + return NeuronModel.objects.get(title='Flux') + + def save_results( + self, + prompt: str, + images: list, + time: timedelta, + save: bool = True, + ) -> list[Message]: + messages: list[Message] = [] + for image in images: + messages.append( + Message( + content_object=self.store, + elapsed_time=time, + content=prompt, + file=File(BytesIO(requests.get(image).content), '.png'), + ) + ) + if save: + return Message.objects.bulk_create(messages) + return messages + + def make(self, input_message: Message, save: bool = True) -> list[Message]: + start_time = time.time() + version = input_message.info.get('version') + callback_data = dict( + { + 'prompt': self.translate_prompt(input_message.content), + **input_message.info, + } + ) + runner = replicate_run( + f'{self._CALLBACK_BASE}{callback_data.get("version", "lucid-origin")}', + callback_data, + ) + images = runner if isinstance(runner, list) else [runner] + process_time = timedelta(seconds=(time.time() - start_time)) + self.handle_invoice(input_message.content_object.model, input_message=input_message, version=version) + msgs = self.save_results(input_message.content, images, process_time, save) + return msgs @@ -0,0 +1,49 @@ +import time +from datetime import timedelta +from decimal import Decimal +from io import BytesIO + +import requests +from django.core.files import File + +from messages.models import Message +from ml_model.services.base import SimpleService +from ml_model.tasks import replicate_run +from payments.exceptions.insufficient_balance import InsufficientBalance +from payments.selectors.payment_plan_selector import PaymentPlanSelector + + +class Lyria(SimpleService): + TOKENS_COST = Decimal('0.6') # per 1 sec of output audio + + def calculate_price(self, duration: int) -> Decimal: + price = self.TOKENS_COST * duration + return price.quantize(Decimal('0.1'), rounding='ROUND_UP') + + def save_results(self, content: str, t: timedelta, audio: str, save: bool = True) -> list[Message]: + msg = Message( + content=content, + content_object=self.store, + elapsed_time=t, + file=File(BytesIO(requests.get(audio).content), '.mp3'), + ) + if save: + return Message.objects.bulk_create([msg]) + return [msg] + + def make(self, input_message: Message, save: bool = True) -> list[Message]: + if ( + (balance := PaymentPlanSelector(self.store.user).get_current_balance()) + < (cost := self.TOKENS_COST * 32) + ): + raise InsufficientBalance(balance, cost) + callback_data = { + 'prompt': self.translate_prompt(input_message.content), + **input_message.info + } + start_time = time.time() + video = replicate_run(f'google/lyria-2', callback_data) + process_time = timedelta(seconds=(time.time() - start_time)) + self.handle_invoice(input_message.content_object.model, duration=32) + msgs = self.save_results(input_message.content, process_time, video, save) + return msgs @@ -0,0 +1,54 @@ +import time +from datetime import timedelta +from decimal import Decimal +from io import BytesIO + +import requests +from django.core.files import File + +from messages.models import Message +from ml_model.services.base import SimpleService +from ml_model.tasks import replicate_run + +from payments.exceptions.insufficient_balance import InsufficientBalance +from payments.selectors.payment_plan_selector import PaymentPlanSelector +from tools.media.models import Preset + + +class Minimaxmusic(SimpleService): + TOKENS_COST = Decimal('10.5') + + def calculate_price(self) -> Decimal: + return self.TOKENS_COST + + def save_results(self, content: str, t: timedelta, video: str, save: bool = True) -> list[Message]: + msg = Message( + content=content, + content_object=self.store, + elapsed_time=t, + file=File(BytesIO(requests.get(video).content), '.mp3'), + ) + if save: + return Message.objects.bulk_create([msg]) + return [msg] + + def make(self, input_message: Message, save: bool = True) -> list[Message]: + speaker = input_message.info.get('speaker', 'russian_1').lower() + instrumental = input_message.info.get('instrumental', 'classical').lower() + if (balance := PaymentPlanSelector(self.store.user).get_current_balance()) < self.TOKENS_COST: + raise InsufficientBalance(balance, self.TOKENS_COST) + callback_data = {'lyrics': input_message.content, **input_message.info} + if speaker: + file_url = Preset.objects.get(slug=speaker).file.url + callback_data.update({'voice_file': file_url}) + if input_message.file: + callback_data.update({'instrumental_file': input_message.file.url}) + else: + file_url = Preset.objects.get(slug=instrumental).file.url + callback_data.update({'instrumental_file': file_url}) + start_time = time.time() + video = replicate_run(f'minimax/music-01', callback_data) + process_time = timedelta(seconds=(time.time() - start_time)) + self.handle_invoice(input_message.content_object.model) + msgs = self.save_results(input_message.content, process_time, video, save) + return msgs @@ -0,0 +1,55 @@ +import base64 +import time +from datetime import timedelta +from decimal import Decimal +from io import BytesIO + +import filetype +import requests +from django.core.files import File + +from messages.models import Message +from ml_model.services.base import SimpleService +from ml_model.tasks import replicate_run + +from payments.exceptions.insufficient_balance import InsufficientBalance +from payments.selectors.payment_plan_selector import PaymentPlanSelector + + +class Minimaxvideo(SimpleService): + TOKENS_COST = { + 'video-01': Decimal('150'), + } + + def calculate_price(self, version: str) -> Decimal: + return self.TOKENS_COST[version] + + def save_results(self, content: str, t: timedelta, video: str, save: bool = True) -> list[Message]: + msg = Message( + content=content, + content_object=self.store, + elapsed_time=t, + file=File(BytesIO(requests.get(video).content), '.mp4'), + ) + if save: + return Message.objects.bulk_create([msg]) + return [msg] + + def make(self, input_message: Message, save: bool = True) -> list[Message]: + version = input_message.info.pop('version', 'video-01') + if (balance := PaymentPlanSelector(self.store.user).get_current_balance()) < self.TOKENS_COST[version]: + raise InsufficientBalance(balance, self.TOKENS_COST[version]) + callback_data = dict({'prompt': input_message.content, **input_message.info}) + if input_message.file: + kind = filetype.guess(input_message.file.read(20)) + mime = kind.mime if kind else 'application/octet-stream' + input_message.file.seek(0) + image = f'data:{mime};base64,{base64.b64encode(input_message.file.read()).decode("utf-8")}' + input_message.file.close() + callback_data.update({'subject_reference': image}) + start_time = time.time() + video = replicate_run(f'minimax/{version}', callback_data) + process_time = timedelta(seconds=(time.time() - start_time)) + self.handle_invoice(input_message.content_object.model, version) + msgs = self.save_results(input_message.content, process_time, video, save) + return msgs @@ -20,6 +20,7 @@ class MinIOService: 'air-errors', 'air-welcome-pic', 'air-messages', + 'air-media-presets' ] def __init__(self): @@ -3,21 +3,33 @@ import time from datetime import timedelta from decimal import Decimal from io import BytesIO +from typing import Optional import filetype import requests from django.core.files import File +from replicate.exceptions import ModelError from messages.models import Message +from ml_model.exceptions import ImageContentNotFound from ml_model.services.base import SimpleService from ml_model.tasks import replicate_run class Nanobanana(SimpleService): - TOKENS_COST = Decimal('19.5') + TOKENS_COST = { + 'nano-banana': Decimal('19.5'), + 'nano-banana-pro': { + '1K': Decimal('45'), + '2K': Decimal('45'), + '4K': Decimal('90'), + } + } - def calculate_price(self) -> Decimal: - return self.TOKENS_COST + def calculate_price(self, version: str, resolution: Optional[str]) -> Decimal: + if resolution: + return self.TOKENS_COST[version][resolution] + return self.TOKENS_COST[version] def save_results( self, @@ -38,7 +50,8 @@ class Nanobanana(SimpleService): def make(self, input_message: Message, save: bool = True) -> list[Message]: start_time = time.time() - version = input_message.info.get('version') + version = input_message.info.get('version', 'nano-banana') + resolution = input_message.info.get('resolution', '2K') if version == 'nano-banana-pro' else None callback_data = dict( { 'prompt': self.translate_prompt(input_message.content), @@ -52,8 +65,13 @@ class Nanobanana(SimpleService): image = f'data:{mime};base64,{base64.b64encode(input_message.file.read()).decode("utf-8")}' input_message.file.close() callback_data.update({'image_input': [image]}) - image = replicate_run('google/nano-banana', callback_data) + try: + image = replicate_run(f'google/{version}', callback_data) + except ModelError as exc: + if exc.prediction.error == 'No image content found in response': + raise ImageContentNotFound + raise process_time = timedelta(seconds=(time.time() - start_time)) - self.handle_invoice(input_message.content_object.model) + self.handle_invoice(input_message.content_object.model, version=version, resolution=resolution) msgs = self.save_results(input_message.content, image, process_time, save) return msgs @@ -72,7 +72,7 @@ class Raifgpt(Chatgpt): if file_extension == '.pdf': raw_text = self.get_pdf_data(file) elif file_extension in ('.doc', '.docx'): - raw_text = self.get_word_data(file_extension, file) + raw_text = self.get_word_data(file_extension[1:], file.read()) else: raise FileExtensionNotSupported(['PDF', 'DOC', 'DOCX']) text = re.sub(r'\n{2,}', '\n', raw_text) @@ -0,0 +1,64 @@ +import base64 +import time +from datetime import timedelta +from decimal import Decimal +from io import BytesIO + +import filetype +import requests +from django.core.files import File + +from messages.models import Message +from ml_model.services.base import SimpleService +from ml_model.tasks import replicate_run + + +class Reve(SimpleService): + PRICE = { + 'create': Decimal('7.5'), + 'edit-fast': Decimal('3') + } + + def calculate_price(self, type: str) -> Decimal: + return self.PRICE[type].quantize(Decimal('0.1'), rounding='ROUND_UP') + + def save_results( + self, + prompt: str, + image: str, + time: timedelta, + save: bool = True, + ) -> list[Message]: + messages: list[Message] = [] + messages.append( + Message( + content_object=self.store, + elapsed_time=time, + content=prompt, + file=File(BytesIO(requests.get(image).content), '.png'), + ) + ) + if save: + return Message.objects.bulk_create(messages) + return messages + + def make(self, input_message: Message, save: bool = True) -> list[Message]: + start_time = time.time() + callback_data = { + 'prompt': self.translate_prompt(input_message.content), + **input_message.info, + } + type = 'create' + if input_message.file: + kind = filetype.guess(input_message.file.read(20)) + mime = kind.mime if kind else 'application/octet-stream' + input_message.file.seek(0) + image = f'data:{mime};base64,{base64.b64encode(input_message.file.read()).decode("utf-8")}' + input_message.file.close() + callback_data.update({'image': image}) + type = 'edit-fast' + image = replicate_run(f'reve/{type}', callback_data) + process_time = timedelta(seconds=(time.time() - start_time)) + self.handle_invoice(input_message.content_object.model, type=type) + msgs = self.save_results(input_message.content, image, process_time, save) + return msgs @@ -0,0 +1,67 @@ +import base64 +import time +from datetime import timedelta +from decimal import Decimal +from io import BytesIO + +import filetype +import requests +from django.core.files import File + +from messages.models import Message +from ml_model.exceptions import FileNotProvided +from ml_model.services.base import SimpleService +from ml_model.tasks import replicate_run + +from payments.exceptions.insufficient_balance import InsufficientBalance +from payments.selectors.payment_plan_selector import PaymentPlanSelector + + +class Runway(SimpleService): + + TOKENS_COST = { + 'gen4-turbo': Decimal('15'), + } + + def calculate_price(self, version: str, duration: int) -> Decimal: + price = self.TOKENS_COST[version] * duration + return price.quantize(Decimal('0.1'), rounding='ROUND_UP') + + def save_results(self, content: str, t: timedelta, video: str, save: bool = True) -> list[Message]: + msg = Message( + content=content, + content_object=self.store, + elapsed_time=t, + file=File(BytesIO(requests.get(video).content), '.mp4'), + ) + if save: + return Message.objects.bulk_create([msg]) + return [msg] + + def make(self, input_message: Message, save: bool = True) -> list[Message]: + version = input_message.info.pop('version', 'gen4-turbo') + duration = input_message.info.get('duration', 5) + file = input_message.file + if ( + (balance := PaymentPlanSelector(self.store.user).get_current_balance()) + < (cost := self.calculate_price(version, duration)) + ): + raise InsufficientBalance(balance, cost) + if not file: + raise FileNotProvided('Image') + kind = filetype.guess(file.read(100)) + mime = kind.mime if kind else 'application/octet-stream' + file.seek(0) + media = f'data:{mime};base64,{base64.b64encode(file.read()).decode("utf-8")}' + file.close() + callback_data = { + 'prompt': self.translate_prompt(input_message.content), + **input_message.info, + 'image': media + } + start_time = time.time() + video = replicate_run(f'runwayml/{version}', callback_data) + process_time = timedelta(seconds=(time.time() - start_time)) + self.handle_invoice(input_message.content_object.model, duration=duration, version=version) + msgs = self.save_results(input_message.content, process_time, video, save) + return msgs @@ -0,0 +1,60 @@ +import base64 +import logging +import time +from datetime import timedelta +from decimal import Decimal +from io import BytesIO + +import filetype +import requests +from django.core.files import File + +from messages.models import Message +from ml_model.models import ModelCategory, ModelInput, ModelParameter +from ml_model.services.base import SimpleService +from ml_model.tasks import replicate_run + + +class Seedream(SimpleService): + PRICE = Decimal('9') + + def calculate_price(self, max_images: int) -> Decimal: + return self.PRICE.quantize(Decimal('0.1'), rounding='ROUND_UP') * max_images + + def save_results(self, prompt: str, images: list, time: timedelta, save: bool = True) -> list[Message]: + messages: list[Message] = [] + for image in images: + messages.append( + Message( + content_object=self.store, + elapsed_time=time, + content=prompt, + file=File(BytesIO(requests.get(image).content), '.png'), + ) + ) + if save: + return Message.objects.bulk_create(messages) + return messages + + def make(self, input_message: Message, save: bool = True) -> list[Message]: + start_time = time.time() + callback_data = { + 'prompt': self.translate_prompt(input_message.content), + 'size': 'custom', + **input_message.info, + } + if input_message.info.get('story_mode', False): + callback_data.update({'sequential_image_generation': 'auto', 'max_images': 5}) + callback_data['prompt'] = f'Generate a sequence of multiple images. {callback_data["prompt"]}' + if input_message.file: + kind = filetype.guess(input_message.file.read(20)) + mime = kind.mime if kind else 'application/octet-stream' + input_message.file.seek(0) + image = f'data:{mime};base64,{base64.b64encode(input_message.file.read()).decode("utf-8")}' + input_message.file.close() + callback_data.update({'image_input': [image]}) + images = replicate_run('bytedance/seedream-4', callback_data) + process_time = timedelta(seconds=(time.time() - start_time)) + self.handle_invoice(input_message.content_object.model, max_images=len(images)) + msgs = self.save_results(input_message.content, images, process_time, save) + return msgs @@ -10,6 +10,7 @@ from django.conf import settings from django.core.files import File from messages.models import Message +from ml_model.exceptions import RequestBlocked from ml_model.services.base import SimpleService from poller.models import Proxy @@ -85,13 +86,15 @@ class Stablediffusion(SimpleService): result = client.post( f'https://api.replicate.com/v1/models/stability-ai/{model_name}/predictions', json={'input': callback_data}, - ) - while result.json()['status'] not in ('succeeded', 'failed', 'canceled'): - result = client.get(result.json()['urls']['get']) - if result.json()['status'] in ('failed', 'canceled'): - logger.error(result.json()['logs']) + ).json() + while result['status'] not in ('succeeded', 'failed', 'canceled'): + result = client.get(result['urls']['get']).json() + if result['status'] in ('failed', 'canceled'): + if 'E005' in result['logs']: + raise RequestBlocked + logger.error(result['logs']) raise Exception('No answer from Stable Diffusion, please retry later') - link = result.json()['output'] + link = result['output'] process_time = timedelta(seconds=(time.time() - start_time)) @@ -0,0 +1,45 @@ +import time +from datetime import timedelta +from decimal import Decimal +from io import BytesIO + +import requests +from django.core.files import File + +from messages.models import Message +from ml_model.services.base import SimpleService +from ml_model.tasks import replicate_run +from payments.exceptions.insufficient_balance import InsufficientBalance +from payments.selectors.payment_plan_selector import PaymentPlanSelector + + +class Stablemusic(SimpleService): + PRICE = Decimal('80') + + def calculate_price(self) -> Decimal: + return self.PRICE + + def save_results(self, prompt: str, link: str, time: timedelta, save: bool = True) -> list[Message]: + messages: list[Message] = [] + messages.append( + Message( + content_object=self.store, + elapsed_time=time, + content=prompt, + file=File(BytesIO(requests.get(link).content), '.mp3'), + ) + ) + if save: + return Message.objects.bulk_create(messages) + return messages + + def make(self, input_message: Message, save: bool = True) -> list[Message]: + if (balance := PaymentPlanSelector(self.store.user).get_current_balance()) < (cost := self.PRICE): + raise InsufficientBalance(balance, cost) + callback_data = {'prompt': input_message.content, **input_message.info} + start_time = time.time() + result = replicate_run('stability-ai/stable-audio-2.5', callback_data) + process_time = timedelta(seconds=(time.time() - start_time)) + self.handle_invoice(input_message.content_object.model) + msgs = self.save_results(input_message.content, result, process_time, save) + return msgs @@ -0,0 +1,6 @@ +from decimal import Decimal +from ml_model.services import Minimaxmusic + + +class Suno(Minimaxmusic): + TOKENS_COST = Decimal('17.5') @@ -1,5 +1,6 @@ from django.contrib import admin -from django.http.response import HttpResponse as HttpResponse +from django.core.exceptions import ValidationError +from django.utils.translation import gettext_lazy as _ from import_export.admin import ExportActionModelAdmin, ImportExportMixin from ordered_model.admin import ( OrderedInlineModelAdminMixin, @@ -123,6 +124,21 @@ class NeuronModelAdmin( return obj.category.title return 'Не присвоена' + def save_formset(self, request, form, formset, change): + if formset.model == ModelInput: + existed = set() + for input in formset.cleaned_data: + if input['DELETE']: + continue + for version in input['versions']: + if (pair := (version.pk, input['type'])) in existed: + raise ValidationError( + _('Version %(version)s already has input with the same type: %(type)s') + % {'version': version.name, 'type': _(input['type'].capitalize())} + ) + existed.add(pair) + super().save_formset(request, form, formset, change) + @admin.register(ModelTag) class ModelTagAdmin(ImportExportMixin, ExportActionModelAdmin, OrderedModelAdmin): @@ -70,3 +70,21 @@ class TemplateUnknownException(Exception): class NeuronModelNotExist(Exception): def __str__(self): return _('The neuron model does not exist') + + +class FileNotProvided(Exception): + def __init__(self, file_type: str) -> None: + self.file_type = file_type + + def __str__(self) -> str: + return _('The %(file_type)s is not attached') % {'file_type': _(self.file_type).lower()} + + +class ImageContentNotFound(Exception): + def __str__(self): + return _('No image content found in response. Try a different request') + + +class InvalidStyleCombinationError(Exception): + def __str__(self) -> str: + return _('Use style type AUTO or GENERAL when a style preset is selected') @@ -244,7 +244,6 @@ class ModelInput(ModelDepends, ModelVersionsDepends): class Meta: verbose_name = _('Model Input') verbose_name_plural = _('Model Inputs') - unique_together = ('model', 'type') class ModelParameter(ModelDepends, ModelVersionsDepends, OrderedModel): @@ -1,11 +1,21 @@ +import calendar +from datetime import timedelta, date from decimal import Decimal -from ninja import Router +from dateutil.relativedelta import relativedelta +from django.db.models import Sum, F, Value, Func, CharField +from django.db.models.functions import TruncDay, TruncMonth, TruncYear, Round +from django.utils import timezone +from django.utils.translation import gettext as _ +from ninja import Router, Query from ninja.errors import HttpError from authentication.security import SyncAuthBearer +from payments.models import Invoice, Payment from payments.schema import UserBalance +from payments.schemas import ExpensesParamsSchema, ExpensesSchema from payments.selectors.payment_plan_selector import PaymentPlanSelector +from payments.typing import IntervalStrategyEnum, SourceStrategyEnum router = Router(auth=SyncAuthBearer(), tags=['payments']) @@ -21,3 +31,48 @@ def get_user_balance(request): return UserBalance(current_token_balance=current_balance) except Exception as exc: raise HttpError(401, f'{exc}') + + +@router.get('expenses', tags=['payments/expenses'], response=list[ExpensesSchema]) +def list_expenses(request, data: ExpensesParamsSchema = Query(...)): + try: + qs = Invoice.objects.filter(user=request.auth) + current_day = date.today() + start_time = date.min + end_time = date.max + match data.interval_strategy: + case IntervalStrategyEnum.CURRENT_MONTH: + start_time = current_day.replace(day=1) + end_time = current_day.replace(day=calendar.monthrange(current_day.year, current_day.month)[-1]) + case IntervalStrategyEnum.CURRENT_WEEK: + start_time = current_day - timedelta(days=current_day.weekday()) + end_time = start_time + timedelta(days=6) + case IntervalStrategyEnum.PREVIOUS_MONTH: + start_time = current_day.replace(day=1) - relativedelta(months=1) + end_time = start_time.replace(day=calendar.monthrange(start_time.year, start_time.month)[-1]) + case IntervalStrategyEnum.CUSTOM_PRESET: + start_time = data.start + end_time = data.end + qs = qs.filter(created_at__date__range=(start_time, end_time)) + match data.source_strategy: + case SourceStrategyEnum.CATEGORIES: + qs = qs.values('model__category__title').annotate(source=F('model__category__title'), amount=Round(Sum('cost'))) + case SourceStrategyEnum.MODELS: + qs = qs.values('model__title').annotate(source=F('model__title'), amount=Round((Sum('cost')))) + case date_type if date_type in (SourceStrategyEnum.DAYS, SourceStrategyEnum.MONTHS, SourceStrategyEnum.YEARS): + trunc_map = {'days': TruncDay, 'months': TruncMonth, 'years': TruncYear} + field_name = date_type[:-1] + qs = qs.values( + **{field_name:trunc_map[date_type]('created_at')}).annotate( + source = Func(F(field_name), Value('dd.MM.yyyy'), function='to_char', output_field=CharField()), + amount=Round(Sum('cost')) + ) + case SourceStrategyEnum.DAYS.BUDGET: + expenses = {'source': _('Expenses'), 'amount': qs.aggregate(amount=Round(Sum('cost')))['amount']} + refills = {'source': _('Refills'), 'amount': Payment.objects.filter(user=request.auth).aggregate(amount=Round(Sum('amount')))['amount']} + return [expenses, refills] + case _: + raise NotImplementedError + return qs + except Exception as exc: + raise HttpError(400, f'{exc}') @@ -1,10 +1,11 @@ from datetime import date -from typing import List +from typing import List, Optional from uuid import UUID -from ninja import Schema, ModelSchema -from pydantic import field_validator, condecimal +from ninja import Schema, ModelSchema, Query +from pydantic import field_validator, condecimal, field_serializer +from payments.typing import IntervalStrategyEnum, SourceStrategyEnum from payments.models import PromoCode @@ -35,6 +36,24 @@ class PromoCodeSchema(ModelSchema): model = PromoCode exclude = ('activated_by',) - @field_validator('code', check_fields=False) - def check_code(cls, value: str): + @field_serializer('code', check_fields=False) + def serialize_code(self, value: str) -> str: return value.strip() + + +class ExpensesParamsSchema(Schema): + interval_strategy: IntervalStrategyEnum | None = None + source_strategy: SourceStrategyEnum | None = None + start: Optional[date] = Query( + default=date.min, + example="2025-12-17", + ) + end: Optional[date] = Query( + default=date.min, + example="2025-12-07", + ) + + +class ExpensesSchema(Schema): + source: str + amount: condecimal(max_digits=10, decimal_places=2) @@ -23,7 +23,7 @@ class ReferralSerializer(serializers.ModelSerializer): class Meta: model = get_user_model() - fields = ('joined_at', 'profile_picture_link') + fields = ('email', 'joined_at', 'profile_picture_link') class ReferralAccountSerializer(serializers.ModelSerializer): @@ -94,11 +94,6 @@ class InvoiceSerializer(serializers.ModelSerializer): exclude = ('user', 'id') -class ExpenseSerializer(serializers.Serializer): - source = serializers.CharField() - amount = serializers.DecimalField(10, 2) - - class PromoCodeSerializer(serializers.ModelSerializer): class Meta: model = PromoCode @@ -20,12 +20,7 @@ def send_low_balance_message(): token_cap__gt=F('user__payment_plan__current_token_balance'), ) for host in hosts: - for email in host.token_cap_emails: - EmailService(None).send_email( - f'AIR: баланс корпоративного аккаунта ниже {host.token_cap}', - 'Для пополнения обратитесь по контактам, указанным в договоре', - email, - ) + EmailService.send_low_balance_email(host) @shared_task @@ -0,0 +1,17 @@ +from enum import Enum + + +class IntervalStrategyEnum(str, Enum): + CURRENT_MONTH = 'current_month' + CURRENT_WEEK = 'current_week' + PREVIOUS_MONTH = 'previous_month' + CUSTOM_PRESET = 'custom_preset' + + +class SourceStrategyEnum(str, Enum): + CATEGORIES = 'categories' + MODELS = 'models' + DAYS = 'days' + MONTHS = 'months' + YEARS = 'years' + BUDGET = 'budget' @@ -4,7 +4,6 @@ from payments import views urlpatterns = [ path('history', views.PaymentAPIView.as_view(), name='history'), - path('expenses', views.ExpensesAPIView.as_view(), name='expenses'), path( 'referral-account', views.ReferralAccountAPIView.as_view(), @@ -1,11 +1,8 @@ import logging -from datetime import date, datetime, timedelta from django.core.exceptions import ObjectDoesNotExist -from django.db.models import F, Sum, functions from django.db.models.query import QuerySet from django.db.transaction import atomic -from django.utils import timezone from drf_spectacular.utils import ( OpenApiParameter, OpenApiResponse, @@ -23,14 +20,13 @@ from authentication.selectors.user_selector import UserSelector from payments.exceptions.PlanIsFree import PlanIsFree from payments.exceptions.payer_not_found import PayerNotFound from payments.models import Invoice -from payments.models.payment import Payment, PaymentPlan +from payments.models.payment import PaymentPlan from payments.permissions import IsAllowedToPay from payments.selectors.payment_method_selector import PaymentMethodSelector from payments.selectors.payment_plan_selector import PaymentPlanSelector from payments.selectors.payment_selector import PaymentSelector from payments.serializers import ( DeletePaymentMethodSerializer, - ExpenseSerializer, InvoiceSerializer, NewSubsriptionSerializer, PaymentLinkSerializer, @@ -188,122 +184,6 @@ class InvoicesAPIView(ListAPIView): return Invoice.objects.filter(user=self.request.user) -class ExpensesAPIView(APIView): - permission_classes = [ - IsAuthenticated, - ] - - @extend_schema( - parameters=[ - OpenApiParameter( - 'source_strategy', - str, - enum=[ - 'categories', - 'models', - 'days', - 'months', - 'years', - ], - ), - OpenApiParameter( - 'interval_strategy', - str, - enum=[ - 'current_month', - 'current_week', - 'previous_month', - 'custom_preset', - ], - ), - OpenApiParameter( - 'from', - date, - description='Применяется, если в interval_strategy выбрано custom_preset. Обозначает время ОТ которого нужно выбрать отчет', - ), - OpenApiParameter( - 'to', - date, - description='Применяется, если в interval_strategy выбрано custom_preset. Обозначает время ДО которого нужно выбрать отчет', - ), - ], - responses={ - 200: ExpenseSerializer(many=True), - }, - ) - def get(self, request, *args, **kwargs): - """List token expenses, group by time period, categories, or models.""" - qs = Invoice.objects.filter(user=request.user) - match request.query_params.get('interval_strategy'): - case 'current_month': - qs = qs.filter( - created_at__range=[ - timezone.now().replace(day=1), - timezone.now(), - ] - ) - case 'current_week': - qs = qs.filter( - created_at__range=[ - timezone.now() - timedelta(days=timezone.now().weekday()), - timezone.now() - timedelta(days=timezone.now().weekday()) + timedelta(days=6), - ] - ) - case 'previous_month': - qs = qs.filter( - created_at__range=[ - (timezone.now().replace(day=1) - timedelta(days=1)).replace(day=1), - timezone.now().replace(day=1) - timedelta(days=1), - ] - ) - case 'custom_preset': - qs = qs.filter( - created_at__range=[ - datetime.strptime( - request.query_params.get('from', '01.01.00'), - '%d.%m.%y', - ), - datetime.strptime( - request.query_params.get('to', '01.01.50'), - '%d.%m.%y', - ), - ] - ) - match request.query_params.get('source_strategy'): - case 'categories': - qs = qs.values('model__category__title').annotate( - source=F('model__category__title'), - amount=functions.Round(Sum('cost')), - ) - case 'models': - qs = qs.values('model__title').annotate( - source=F('model__title'), - amount=functions.Round((Sum('cost'))), - ) - case 'days': - qs = qs.values(day=functions.TruncDay('created_at')).annotate( - source=F('day'), amount=functions.Round(Sum('cost')) - ) - case 'months': - qs = qs.values(month=functions.TruncMonth('created_at')).annotate( - source=F('month'), amount=functions.Round(Sum('cost')) - ) - case 'years': - qs = qs.values(year=functions.TruncYear('created_at')).annotate( - source=F('year'), amount=functions.Round(Sum('cost')) - ) - case _: - dct = [{}, {}] - dct[1] = qs.aggregate(amount=functions.Round(Sum('cost'))) - dct[0] = Payment.objects.filter(user=request.user).aggregate( - amount=functions.Round(Sum('amount')) - ) - dct[1]['source'] = 'Затраты' - dct[0]['source'] = 'Пополнения' - return Response(ExpenseSerializer(data=dct).initial_data, 200) - return Response(ExpenseSerializer(qs, many=True).data, 200) - - class ReferralAccountAPIView(APIView): def get(self, request: Request, *args, **kwargs): return Response(ReferralAccountSerializer(request.user.referral_account).data) @@ -1,40 +0,0 @@ -import datetime - -from django.core.management.base import BaseCommand - -from reports.admin import RequestReponseLog, RequestReponseLogAdmin - - -class Command(BaseCommand): - help = 'Collect reports and create an Excel file' - - def add_arguments(self, parser): - parser.add_argument( - 'from_datetime', - type=str, - help='Start date and time (YYYY-MM-DD HH:MM:SS)', - ) - parser.add_argument( - 'to_datetime', - type=str, - help='End date and time (YYYY-MM-DD HH:MM:SS)', - default=datetime.datetime.now().strftime('%Y-%M-%D %H:%M:%S'), - ) - - def handle(self, *args, **kwargs): - from_datetime = datetime.datetime.fromisoformat(kwargs['from_datetime']) - if 'to_datetime' in kwargs: - to_datetime = datetime.datetime.fromisoformat(kwargs['to_datetime']) - else: - to_datetime = datetime.datetime.now() - - logs = RequestReponseLog.objects.filter(created_at__range=(from_datetime, to_datetime)) - - if logs.exists(): - wb = RequestReponseLogAdmin.create_xlsx_report(logs) - file_name = f'reports_{from_datetime}_{to_datetime}.xlsx' - wb.save(file_name) - self.stdout.write(self.style.SUCCESS('Successfully created file')) - else: - self.stdout.write(self.style.WARNING('No logs found in the specified time range')) - return file_name @@ -0,0 +1,23 @@ +# Generated by Django 5.0.11 on 2025-12-13 09:14 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('reports', '0002_alter_errorreport_options_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='requestreponselog', + name='request_user', + ), + migrations.DeleteModel( + name='ErrorReport', + ), + migrations.DeleteModel( + name='RequestReponseLog', + ), + ] @@ -1,2 +0,0 @@ -from .error_report import ErrorReport -from .log import RequestReponseLog @@ -1,15 +0,0 @@ -from django.contrib.auth import get_user_model -from django.db import models -from django.utils.translation import gettext_lazy as _ - -from core.models import BaseModel - - -class ErrorReport(BaseModel): - author = models.ForeignKey(get_user_model(), on_delete=models.CASCADE, verbose_name=_('Author')) - report_text = models.TextField(verbose_name=_('Text')) - additional_images = models.JSONField(null=True, verbose_name=_('Attachments')) - - class Meta: - verbose_name = _('User Report') - verbose_name_plural = _('User Reports') @@ -1,28 +0,0 @@ -from django.db import models - -from authentication.models import CustomUserModel -from core.models import BaseModel - - -class RequestReponseLog(BaseModel): - method = models.CharField(max_length=10, verbose_name='Метод запроса') - path = models.CharField(max_length=255, verbose_name='Эндпойнт запроса') - from_ip = models.GenericIPAddressField(verbose_name='От IP адреса') - request_body = models.TextField(null=True, blank=True, verbose_name='Тело запроса') - request_user = models.ForeignKey( - CustomUserModel, - verbose_name='От пользователя', - on_delete=models.CASCADE, - null=True, - blank=True, - ) - response_body = models.TextField(null=True, blank=True, verbose_name='Тело ответа') - status_code = models.IntegerField(verbose_name='Код ответа') - - def __str__(self): - return f'{self.created_at} - {self.method} {self.path} - {self.status_code}' - - class Meta: - verbose_name = 'Неудачный запрос' - verbose_name_plural = 'Неудачные запросы' - ordering = ['-created_at'] @@ -8,9 +8,11 @@ router = Router(auth=SyncAuthBearer(), tags=['reports']) @router.post('business-support/', response={201: None}) -def send_business_support_email(request, report_text: Form[str], images: File[list[UploadedFile]] = []): +def send_business_support_email( + request, report_text: Form[str], company_name: Form[str], images: File[list[UploadedFile]] = [] +): mail = EmailMessage( - subject=f'Бизнес-запрос пользователя {request.auth.email}', + subject=f'Бизнес-запрос пользователя {request.auth.email} (Компания: {company_name})', body=report_text, from_email=settings.EMAIL_HOST_USER, to=[settings.BUSINESS_SUPPORT_MAIL_RECIPIENT], @@ -1,34 +0,0 @@ -from datetime import datetime - -from rest_framework.request import Request - -from authentication.models import CustomUserModel -from authentication.services.email_service import EmailService -from ml_model.services.minio_service import MinIOService -from reports.models.error_report import ErrorReport -from reports.serializers import NewErrorReportSerializer -from reports.utils import create_original_image - - -class ErrorReportService: - def __init__(self, user: CustomUserModel): - self.user = user - - def create(self, request: Request): - serializer = NewErrorReportSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - - image_names = [] - if images := serializer.validated_data.get('images', False): - for img in images: - file, filename = create_original_image(img, self.user.email, datetime.now().timestamp()) - res_name = MinIOService().put_object(file, filename, 'air-errors') - image_names.append(res_name) - report = ErrorReport.objects.create( - author=self.user, - report_text=serializer.validated_data['report_text'], - ) - if image_names != []: - report.additional_images = {f'img_{i}': val for i, val in enumerate(image_names)} - report.save() - EmailService(self.user).send_error_email(report) @@ -1,67 +0,0 @@ -from django.contrib import admin -from django.db.models import QuerySet -from django.http import HttpResponse -from openpyxl import Workbook - -from reports.models import ErrorReport, RequestReponseLog - - -@admin.register(ErrorReport) -class ErrorReportAdmin(admin.ModelAdmin): - date_hierarchy = 'created_at' - search_fields = ['created_at'] - readonly_fields = ['created_at'] - list_display = ['author', 'report_text'] - raw_id_fields = ['author'] - - -@admin.register(RequestReponseLog) -class RequestReponseLogAdmin(admin.ModelAdmin): - search_fields = ['created_at', 'response_body'] - verbose_name = 'Запрос и ответ' - verbose_name_plural = 'Запросы и ответы' - list_display = [ - 'request_body', - 'request_user', - 'response_body', - 'status_code', - ] - raw_id_fields = ['request_user'] - actions = ['download_xlsx_logs'] - - @admin.action(description='Скачать выбранные логи в XLSX') - def download_xlsx_logs(self, request, qs: QuerySet[RequestReponseLog]): - wb = self.create_xlsx_report(qs=qs) - response = HttpResponse(content_type='application/ms-excel') - response['Content-Disposition'] = 'attachment; filename=errors.xlsx' - wb.save(response) - return response - - @staticmethod - def create_xlsx_report(qs: QuerySet[RequestReponseLog]) -> Workbook: - wb = Workbook() - sheet = wb.active - sheet.append( - [ - 'Метод', - 'Путь', - 'IP-адрес', - 'Тело запроса', - 'Пользователь запроса', - 'Тело ответа', - 'Код статуса', - ] - ) - for s in qs: - sheet.append( - [ - s.method, - s.path, - s.from_ip, - s.request_body, - s.request_user.email if s.request_user else '', - s.response_body, - s.status_code, - ] - ) - return wb @@ -0,0 +1,9 @@ +from dataclasses import dataclass + +from core.domain import File + + +@dataclass +class Report: + message: str + attachments: list[File] @@ -1,32 +0,0 @@ -import logging - -from rest_framework.request import Request -from rest_framework.response import Response - -from authentication.models.user import CustomUserModel -from reports.models import RequestReponseLog - -logger = logging.getLogger(__name__) - - -class RequestResponseMiddleware: - def __init__(self, get_response): - self.get_response = get_response - - def __call__(self, request: Request): - response = self.get_response(request) - self.log_request_response(request, response) - return response - - def log_request_response(self, request: Request, response: Response): - if response.status_code in (400, 500): - log_entry = RequestReponseLog( - method=request.method, - path=request.path, - from_ip=request.META.get('REMOTE_ADDR'), - request_body=str(request.POST), - request_user=request.user if isinstance(request.user, CustomUserModel) else None, - response_body=response.content, - status_code=response.status_code, - ) - log_entry.save() @@ -1,9 +0,0 @@ -from io import BytesIO -from typing import BinaryIO, Tuple - - -def create_original_image(data: BinaryIO, user: str, date: float) -> Tuple[BytesIO, str]: - file = BytesIO(data.read()) - filename = f'{user}-{date}.png' - - return file, filename @@ -4,8 +4,10 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView +from authentication.services.email_service import EmailService +from reports.domain import Report +from core.domain import File from reports.serializers import NewErrorReportSerializer -from reports.services.error_report_service import ErrorReportService class SendErrorReportEmailAPIView(APIView): @@ -15,7 +17,14 @@ class SendErrorReportEmailAPIView(APIView): def post(self, request, *args, **kwargs): """Send new error report from form data.""" try: - ErrorReportService(self.request.user).create(request) + serializer = NewErrorReportSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + data = serializer.validated_data + report = Report( + message=data['report_text'], + attachments=[File(file.name, file.file, file.content_type, file.size) for file in data.get('images', [])] + ) + EmailService(request.user).send_error_email(report) return Response({'detail': 'error report sent'}, status=status.HTTP_201_CREATED) except Exception as err: return Response({'detail': f'{err}'}, status=status.HTTP_400_BAD_REQUEST) @@ -0,0 +1,17 @@ +# Generated by Django 5.0.11 on 2025-11-16 16:40 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('chats', '0003_alter_chat_options_alter_chat_created_at_and_more'), + ] + + operations = [ + migrations.AlterModelOptions( + name='chat', + options={'verbose_name': 'Chat', 'verbose_name_plural': 'Chats'}, + ), + ] @@ -26,6 +26,7 @@ class ChatAdmin(admin.ModelAdmin): ] actions = ['download_chats_info'] list_per_page = 10 + ordering = ('-created_at',) @admin.display(description='Кол-во сообщений') def messages_count(self, obj: Chat): @@ -13,14 +13,20 @@ from rest_framework.status import ( HTTP_400_BAD_REQUEST, HTTP_402_PAYMENT_REQUIRED, HTTP_500_INTERNAL_SERVER_ERROR, - HTTP_503_SERVICE_UNAVAILABLE + HTTP_503_SERVICE_UNAVAILABLE, ) from rest_framework.views import APIView from messages.models import Message from messages.serializers import MessageSerializer -from ml_model.exceptions import DeploymentDisabled, TemplateNotFound, TemplateUnknownException, \ - FileExtensionNotSupported, ExceededContextLengthError, RequestBlocked +from ml_model.exceptions import ( + DeploymentDisabled, + ExceededContextLengthError, + FileExtensionNotSupported, + TemplateNotFound, + TemplateUnknownException, + RequestBlocked, +) from ml_model.services.base import SimpleService from payments.exceptions.insufficient_balance import InsufficientBalance from tools.chats.models import Chat @@ -37,7 +43,7 @@ class ChatsAPIView(ListCreateAPIView): serializer_class = ChatSerializer def get_queryset(self): - chats = Chat.objects.filter(user=self.request.user, is_deleted=False) + chats = Chat.tobjects.filter(user=self.request.user, is_deleted=False) if self.request.query_params.get('model'): chats = chats.filter(model__slug=self.request.query_params.get('model')) return chats @@ -154,9 +160,7 @@ class MessagesAPIView(APIView): output_messages = service(chat).make(input_message) except DeploymentDisabled as exc: return Response( - { - 'detail': f'{exc}' - }, + {'detail': f'{exc}'}, status=HTTP_503_SERVICE_UNAVAILABLE, ) except (FileExtensionNotSupported, ExceededContextLengthError, RequestBlocked) as exc: @@ -183,7 +187,7 @@ class MessagesAPIView(APIView): output_messages.insert(0, input_message) return Response(MessageSerializer(output_messages, many=True).data, 201) else: - return Response(data=serializer.errors, status=400) + return Response({'detail': '; '.join(serializer.errors['non_field_errors'])}, 400) class MessageAPIView(APIView): @@ -19,4 +19,3 @@ class Chat(MultipleStore): class Meta: verbose_name = _('Chat') verbose_name_plural = _('Chats') - ordering = ['-created_at'] @@ -0,0 +1,28 @@ +# Generated by Django 5.0.11 on 2025-12-12 14:31 + +import django_minio_backend.models +import tools.media.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('media', '0003_alter_audio_options_alter_image_options_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='Preset', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=50, verbose_name='Title')), + ('slug', models.SlugField(unique=True, verbose_name='Slug')), + ('file', models.FileField(blank=True, null=True, storage=django_minio_backend.models.MinioBackend(bucket_name='air-media-presets'), upload_to=tools.media.models.message_file_upload, verbose_name='File')), + ], + options={ + 'verbose_name': 'Preset', + 'verbose_name_plural': 'Presets', + }, + ), + ] @@ -2,7 +2,7 @@ from django.contrib import admin from messages.inlines import MessageInline -from .models import Audio, Image, Video +from .models import Audio, Image, Video, Preset @admin.register(Image) @@ -33,3 +33,9 @@ class AudioAdmin(admin.ModelAdmin): MessageInline, ] list_per_page = 10 + + +@admin.register(Preset) +class PresetAdmin(admin.ModelAdmin): + list_display = ('title', 'slug') + search_fields = ('title', 'slug') @@ -2,19 +2,24 @@ import logging import sys from django.utils.translation import gettext_lazy as _ - from drf_spectacular.utils import OpenApiParameter, extend_schema from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from rest_framework.status import HTTP_402_PAYMENT_REQUIRED, HTTP_400_BAD_REQUEST +from rest_framework.status import HTTP_400_BAD_REQUEST, HTTP_402_PAYMENT_REQUIRED from rest_framework.views import APIView from messages.models import Message from messages.serializers import MessageSerializer +from ml_model.exceptions import ( + RequestBlocked, + UnsupportedSize, + FileNotProvided, + ImageContentNotFound, + InvalidStyleCombinationError +) from ml_model.models import NeuronModel from ml_model.services.base import SimpleService from payments.exceptions.insufficient_balance import InsufficientBalance -from ml_model.exceptions import UnsupportedSize, RequestBlocked from .models import Audio, Image, Video @@ -54,7 +59,7 @@ class GalleryAPIView(APIView): """ messages_ids = ( - self.manager.objects.filter(user=request.user) + self.manager.tobjects.filter(user=request.user) .prefetch_related('messages') .values_list('messages', flat=True) ) @@ -153,7 +158,9 @@ class MediaAPIView(APIView): input_message.save() if isinstance(exc, InsufficientBalance): return Response({'detail': f'{exc}'}, status=HTTP_402_PAYMENT_REQUIRED) - if isinstance(exc, (UnsupportedSize, RequestBlocked)): + if isinstance(exc, ( + UnsupportedSize, RequestBlocked, FileNotProvided, ImageContentNotFound, InvalidStyleCombinationError + )): return Response({'detail': f'{exc}'}, status=HTTP_400_BAD_REQUEST) return Response( { @@ -165,7 +172,7 @@ class MediaAPIView(APIView): ) return Response(MessageSerializer(output_messages, many=True).data, 201) else: - return Response(data=serializer.errors, status=400) + return Response({'detail': '; '.join(serializer.errors['non_field_errors'])}, 400) class ModelImagesAPIView(MediaAPIView): @@ -1,4 +1,7 @@ +from django.db import models from django.db.models import QuerySet +from django_minio_backend import MinioBackend +from django.utils.translation import gettext_lazy as _ from messages.models import Message, MultipleStore @@ -28,3 +31,30 @@ class Audio(Gallery): class Meta: verbose_name = 'Хранилище аудио' verbose_name_plural = 'Хранилища аудио' + + +def message_file_upload(instance: 'Preset', filename: str): + return f'{filename}' + + +class Preset(models.Model): + title = models.CharField(max_length=50, verbose_name=_('Title')) + slug = models.SlugField( + verbose_name=_('Slug'), + unique=True, + max_length=50, + ) + file = models.FileField( + verbose_name=_('File'), + storage=MinioBackend(bucket_name='air-media-presets'), + upload_to=message_file_upload, + null=True, + blank=True, + ) + + def __str__(self) -> str: + return self.title + + class Meta: + verbose_name = _('Preset') + verbose_name_plural = _('Presets') @@ -6,6 +6,7 @@ __pycache__/ # works files .env venv/ +.venv/ virtualenv/ air_reports/ .python-version @@ -33,7 +33,7 @@ services: condition: service_started migrator: - restart: on-failure:1 + restart: on-failure:3 container_name: migrator volumes: - .:/code