@@ -1,3 +1,5 @@ +from decimal import Decimal + from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError from django.db import models @@ -62,6 +64,14 @@ class BusinessAccount(BaseModel): ) return super().clean() + @property + def limit(self) -> Decimal | None: + return self.group.token_limit if self.group else self.token_limit + + @property + def accepted(self) -> bool: + return self.acceptance_status == InvitationStatus.ACCEPTED + def save(self, *args, **kwargs) -> None: self.clean() super().save(*args, **kwargs) @@ -149,11 +149,6 @@ class CustomUserModel(AbstractBaseUser, PermissionsMixin, BaseModel): to=UTM, on_delete=models.SET_NULL, related_name='users', verbose_name=_('UTM'), null=True, blank=True ) - @property - def balance(self): - pp = self.payment_plan - return pp.current_token_balance + pp.referral_balance - @property def account_type(self): if self.host: @@ -211,6 +206,21 @@ class CustomUserModel(AbstractBaseUser, PermissionsMixin, BaseModel): except ObjectDoesNotExist: return None + @property + def balance(self): + pp = self.payment_plan_details + return pp.current_token_balance + pp.referral_balance + + @property + def payment_plan_details(self): + if emp := self.employee: + return emp.parent_company.user.payment_plan + return self.payment_plan + + @property + def plan(self): + return self.payment_plan_details.plan + def is_corporate(self): return self.account_type == 'business_host' @@ -44,13 +44,8 @@ class UserSelector: @classmethod def detail(cls, user: CustomUserModel, provider: str) -> UserDetailSerializer | CustomUserModel: # TODO: refactor this hook - if user.account_type in ( - 'business_account', - 'business_security', - 'business_admin', - ): - user.show_balance = user.business_account.show_balance - user.payment_plan.plan = user.business_account.parent_company.user.payment_plan.plan + if emp := user.employee: + user.show_balance = emp.show_balance refresh_token = OutstandingToken.objects.filter(user=user).order_by('-created_at').first() if not refresh_token or refresh_token.expires_at < timezone.now() and provider == 'yandex': refresh = str(RefreshToken.for_user(user)) @@ -107,18 +102,6 @@ class UserSelector: elif account_type == 'sec': return 'business_security' - def check_model_availability(self, model_title: str) -> bool: - user_type = self.check_account_type() - if ( - user_type == 'business_account' - and self.user.business_account.acceptance_status == InvitationStatus.ACCEPTED - ): - allowed_models = self.user.business_account.parent_company.allowed_models - - return model_title in allowed_models - - return True - @classmethod def get_new_users_by_date(self, register_date: date, date_offset: int = 0): """Get newly registered users with further offset if needed""" @@ -39,6 +39,7 @@ from authentication.services.email_service import EmailService from authentication.services.utm_service import UTMService from authentication.utils import get_client_ip from ml_model.services.minio_service import MinIOService +from payments.services.payment_method_service import PaymentMethodService from payments.services.referral_account import ReferralAccountService logger = logging.getLogger(__name__) @@ -274,6 +275,8 @@ class UserService: if self.user.account_type in ('business_admin', 'business_security', 'business_account'): self.user.business_account.delete() + PaymentMethodService(self.user).delete_payment_method() + self.user.is_deleted = True self.user.is_confirmed = False self.user.save() @@ -116,7 +116,7 @@ class HasInviteListFilter(admin.SimpleListFilter): @admin.register(CustomUserModel) class CustomUserModelAdmin(UserAdmin, ExportActionModelAdmin): - list_display = ['email', '_balance', 'created_at'] + list_display = ['email', 'created_at'] date_hierarchy = 'created_at' ordering = ('email',) search_fields = ['email', 'utm__utm_source'] @@ -155,12 +155,10 @@ class CustomUserModelAdmin(UserAdmin, ExportActionModelAdmin): qs = super().get_queryset(request) return qs.annotate(payments_count=Count('payments')) - @admin.display(description='Баланс') - def _balance(self, obj: CustomUserModel): - return obj.payment_plan.current_token_balance - @admin.action(description='Сделать выгрузку юзеров') def download_users(self, request, qs: QuerySet[CustomUserModel]): + from payments.selectors.payment_plan_selector import PaymentPlanSelector + wb = Workbook() sheet = wb.active sheet.append(['Email', 'Дата регистрации', 'Текущий баланс', 'Тип аккаунта']) @@ -169,7 +167,7 @@ class CustomUserModelAdmin(UserAdmin, ExportActionModelAdmin): [ user.email, f'{user.created_at}', - user.balance, + PaymentPlanSelector(user).get_current_balance(), user.account_type, ] ) @@ -1,7 +1,7 @@ # Authentication mapper from django.db.models import Prefetch -from ml_model.models import NeuronModel +from payments.models.payment_plan_feature import PaymentPlanFeature def _gen_only(chain: str, *fields: str): @@ -12,13 +12,36 @@ PATH_PREFETCH_MAP = { '/api/v1/auth/me': { 'select': ( 'host_account', + 'host_account__company_companyipwhitelist', + 'user_promocode', + 'business_account', + 'business_account__parent_company', + 'business_account__parent_company__company_companyipwhitelist', + 'business_account__parent_company__user__payment_plan', 'business_account__parent_company__user__payment_plan__plan', + 'business_account__parent_company__user__payment_plan__method', + 'payment_plan', 'payment_plan__plan', + 'payment_plan__method', ), 'prefetch': ( Prefetch( - 'business_account__parent_company__user__payment_plan__plan', - queryset=NeuronModel.objects.only('slug'), + 'payment_plan__plan__features', + queryset=PaymentPlanFeature.objects.select_related('model').only( + 'plan_id', + 'model_id', + 'model__slug', + 'model__uid', + ), + ), + Prefetch( + 'business_account__parent_company__user__payment_plan__plan__features', + queryset=PaymentPlanFeature.objects.select_related('model').only( + 'plan_id', + 'model_id', + 'model__slug', + 'model__uid', + ), ), 'social_auth', ), @@ -34,30 +57,73 @@ PATH_PREFETCH_MAP = { 'is_confirmed', 'is_subscribed_to_emails', 'profile_picture_name', + *_gen_only( + 'user_promocode', + 'uid', + 'code', + 'promocode_type', + 'function_call', + 'is_personal', + 'is_active', + ), *_gen_only( 'business_account', 'uid', - 'parent_company__uid', + 'parent_company_id', 'show_balance', 'account_privileges', - 'parent_company__user__uid', ), - *_gen_only('payment_plan', 'uid', 'last_payment_at'), - *_gen_only('payment_plan__plan', 'uid', 'price', 'tokens_per_plan'), + *_gen_only('host_account', 'uid'), + *_gen_only('host_account__company_companyipwhitelist', 'uid', 'is_enabled'), + *_gen_only( + 'business_account__parent_company', + 'uid', + ), + *_gen_only( + 'business_account__parent_company__company_companyipwhitelist', + 'uid', + 'is_enabled', + ), + *_gen_only( + 'payment_plan', + 'uid', + 'last_payment_at', + 'next_payment_at', + 'method_id', + ), + *_gen_only( + 'payment_plan__plan', + 'uid', + 'price', + 'tokens_per_plan', + 'is_corporate', + 'individual', + ), *_gen_only( 'business_account__parent_company__user__payment_plan', 'uid', 'last_payment_at', - 'plan__uid', - 'plan__price', - 'plan__tokens_per_plan', + 'next_payment_at', + 'method_id', + ), + *_gen_only( + 'business_account__parent_company__user__payment_plan__plan', + 'uid', + 'price', + 'tokens_per_plan', + 'is_corporate', + 'individual', ), ), }, '/api/v1/payments/user-balance': { 'select': ( 'host_account', + 'host_account__company_companyipwhitelist', + 'business_account', 'business_account__group', + 'business_account__parent_company', + 'business_account__parent_company__company_companyipwhitelist', 'payment_plan', 'business_account__parent_company__user__payment_plan', ), @@ -66,27 +132,61 @@ PATH_PREFETCH_MAP = { 'uid', 'is_staff', 'is_superuser', - *_gen_only('business_account', 'account_privileges', 'acceptance_status', 'token_limit'), + *_gen_only('host_account', 'uid'), + *_gen_only('host_account__company_companyipwhitelist', 'uid', 'is_enabled'), + *_gen_only( + 'business_account', + 'token_limit', + 'group_id', + 'account_privileges', + ), *_gen_only('business_account__group', 'uid', 'token_limit'), - *_gen_only('payment_plan', 'uid', 'current_token_balance'), *_gen_only( - 'business_account__parent_company__user__payment_plan', 'uid', 'current_token_balance' + 'business_account__parent_company', + 'uid', + ), + *_gen_only( + 'business_account__parent_company__company_companyipwhitelist', + 'uid', + 'is_enabled', + ), + *_gen_only('payment_plan', 'uid', 'current_token_balance', 'referral_balance'), + *_gen_only( + 'business_account__parent_company__user__payment_plan', + 'uid', + 'current_token_balance', + 'referral_balance', ), ), }, '/api/v1/payments/plans': { 'select': ( 'host_account', + 'host_account__company_companyipwhitelist', + 'business_account', + 'business_account__parent_company__company_companyipwhitelist', 'payment_plan', - 'business_account__parent_company__user__payment_plan__plan', 'payment_plan__plan', 'payment_plan__method', ), - 'prefetch': ( - Prefetch( - 'business_account__parent_company__user__payment_plan__plan', - queryset=NeuronModel.objects.only('slug'), + 'prefetch': (), + 'only': ( + 'uid', + 'email', + 'is_staff', + 'is_superuser', + *_gen_only('host_account', 'uid'), + *_gen_only('host_account__company_companyipwhitelist', 'uid', 'is_enabled'), + *_gen_only('business_account', 'account_privileges'), + *_gen_only('business_account__parent_company', 'uid'), + *_gen_only( + 'business_account__parent_company__company_companyipwhitelist', + 'uid', + 'is_enabled', ), + *_gen_only('payment_plan', 'uid'), + *_gen_only('payment_plan__plan', 'uid', 'price'), + *_gen_only('payment_plan__method', 'uid'), ), }, } @@ -21,12 +21,13 @@ class IsBusinessHost(BasePermission): class IsBusinessAccount(BasePermission): def has_permission(self, request, view): - return request.user.business_account is not None + return request.user.employee is not None class IsBusinessSecurity(BasePermission): def has_permission(self, request: Request, view: APIView) -> bool: - return request.user.business_account.account_privileges == AccountPrivileges.SECURITY + emp = request.user.employee + return emp and emp.account_privileges == AccountPrivileges.SECURITY class IsTelegramAirBot(BasePermission): @@ -11,7 +11,9 @@ class DefaultUserResource(ModelResource): account_type = IEField() def dehydrate_balance(self, obj): - return obj.balance.quantize(Decimal('1.00')) + from payments.selectors.payment_plan_selector import PaymentPlanSelector + + return PaymentPlanSelector(obj).get_current_balance().quantize(Decimal('1.00')) def dehydrate_account_type(self, obj): return obj.account_type @@ -27,7 +29,9 @@ class ReferralUserResource(ModelResource): payments_count = IEField() def dehydrate_balance(self, obj): - return obj.balance.quantize(Decimal('1.00')) + from payments.selectors.payment_plan_selector import PaymentPlanSelector + + return PaymentPlanSelector(obj).get_current_balance().quantize(Decimal('1.00')) def dehydrate_referer(self, obj): return obj.invite.referer_account.owner.email if obj.invite else None @@ -1,7 +1,8 @@ from datetime import datetime from typing import Optional, Dict, List -from ninja import ModelSchema, Schema +from django.core.exceptions import ObjectDoesNotExist +from ninja import ModelSchema, Schema, Field from pydantic import UUID4 from social_django.models import UserSocialAuth @@ -30,11 +31,22 @@ class UserSchema(Schema): profile_picture_link: Optional[str] account_type: str token: Dict[str, str] - payment_plan: UserPlanDetailSchema + payment_plan: UserPlanDetailSchema = Field(..., alias='payment_plan_details') referral_code: Optional[PromoCodeSchema] = None is_social: bool social_auth: List[SocialAccountSchema] + @staticmethod + def resolve_is_active(obj: CustomUserModel) -> bool: + return obj.active + + @staticmethod + def resolve_referral_code(obj: CustomUserModel) -> PromoCodeSchema | None: + try: + return obj.user_promocode + except ObjectDoesNotExist: + return None + @staticmethod def resolve_profile_picture_link(obj: CustomUserModel): return obj.profile_picture_link @@ -140,7 +140,7 @@ class UserDetailSerializer(serializers.Serializer): profile_picture_link = serializers.SerializerMethodField() account_type = serializers.SerializerMethodField() token = serializers.SerializerMethodField() - payment_plan = UserPlanDetailSerializer() + payment_plan = UserPlanDetailSerializer(source='payment_plan_details') referral_code = PromoCodeSerializer(default=None, allow_null=True) is_social = serializers.BooleanField() social_auth = SocialAccountSerializer(many=True) @@ -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: 2026-05-24 01:30+0300\n" +"POT-Creation-Date: 2026-06-04 10:09+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -108,7 +108,7 @@ msgstr "При отправке письма произошла неизвест msgid "Email token not found. Please contact support" msgstr "E-mail токен не найден. Пожалуйста, свяжитесь со службой поддержки" -#: authentication/exceptions/email_token.py:6 +#: authentication/exceptions/email_token.py:6 authentication/routes/v2.py:32 msgid "No email token found" msgstr "Email токен не найден" @@ -116,7 +116,7 @@ msgstr "Email токен не найден" msgid "Wrong email" msgstr "Неверный email" -#: authentication/exceptions/user.py:11 backend/urls.py:47 +#: authentication/exceptions/user.py:11 backend/urls.py:49 msgid "Wrong password" msgstr "Неверный пароль" @@ -507,6 +507,14 @@ msgstr "Вайтлисты для отмены политик" msgid "Invalid or expired refresh token" msgstr "Неверный или истёкший refresh токен" +#: authentication/routes/v2.py:25 +msgid "User is already confirmed" +msgstr "Аккаунт уже подтвержден" + +#: authentication/routes/v2.py:37 authentication/views.py:489 +msgid "Could not confirm email, please try again." +msgstr "Невозможно подтвердить email, попробуйте позже" + #: authentication/security.py:35 #, fuzzy #| msgid "Hidden" @@ -602,15 +610,11 @@ msgstr "Повторное приглашение сотруднику успе msgid "Business account password has been updated" msgstr "Пароль сотрудника успешно обновлен" -#: authentication/views.py:489 -msgid "Could not confirm email, please try again." -msgstr "Невозможно подтвердить email, попробуйте позже" - -#: backend/urls.py:37 +#: backend/urls.py:39 msgid "Requested object does not exists" msgstr "" -#: backend/urls.py:42 +#: backend/urls.py:44 msgid "Token is invalid" msgstr "" @@ -764,11 +768,12 @@ msgstr "Не найдено лицо на картинке. Попробуйте msgid "The input image may contain real person." msgstr "Загруженное изображение может содержать реального человека." -#: ml_model/exceptions.py:185 +#: ml_model/exceptions.py:190 msgid "not specified" msgstr "не указана" -#: ml_model/exceptions.py:187 +#: ml_model/exceptions.py:192 +#, python-format msgid "" "Version \"%(version)s\" is not available. Available versions: " "%(available_versions)s." @@ -1062,18 +1067,20 @@ msgstr "Инструкции Моделей" msgid "no model by this id" msgstr "Не найдено моделей по этому ID" -#: ml_model/services/chatgpt.py:149 -msgid "No matching version found" -msgstr "Соответствующая версия не найдена" +#: ml_model/services/FileService.py:110 tools/media/apis.py:258 +#: tools/public_api/views/ml_service.py:56 +#: tools/public_api/views/providers/openai_compatible.py:209 +msgid "Voice not found." +msgstr "Голос не найден." -#: ml_model/services/chatgpt_5.py:130 ml_model/services/chatgpt_5_4.py:156 +#: ml_model/services/chatgpt_5.py:133 ml_model/services/chatgpt_5_4.py:159 #: ml_model/services/chatgpt_5_5.py:210 msgid "The \"Use code\" option cannot be used together with an attached image." msgstr "" "Нельзя одновременно использовать параметр «Использовать код» вместе с " "прикреплённым изображением." -#: ml_model/services/chatgpt_5_4.py:315 ml_model/services/chatgpt_5_5.py:362 +#: ml_model/services/chatgpt_5_4.py:318 ml_model/services/chatgpt_5_5.py:362 msgid "Image is ready" msgstr "Изображение готово" @@ -1104,11 +1111,11 @@ msgstr "" msgid "Unknown bucket destination" msgstr "Неизвестный бакет для загрузки" -#: ml_model/services/seedance_2_dreamina.py:108 +#: ml_model/services/seedance_2_dreamina.py:111 msgid "1080p output is not supported for Seedance Dreamina 2.0 Fast." msgstr "1080р разрешение не поддерживается для Seedance Dreamina 2.0 Fast." -#: ml_model/services/seedream.py:87 +#: ml_model/services/seedream.py:90 msgid "3K output is not supported for Seedream 4.5" msgstr "3К разрешение не поддерживается для Seedream 4.5" @@ -1348,19 +1355,19 @@ msgstr "Попытки" msgid "Payment Methods" msgstr "Платежные методы" -#: payments/routes/v1.py:94 +#: payments/routes/v1.py:92 msgid "You do not have an active subscription to cancel" msgstr "У вас нет активной подписки для отмены" -#: payments/routes/v1.py:95 +#: payments/routes/v1.py:93 msgid "The recurring payment is successfully cancelled" msgstr "Автоплатежи успешно отключены" -#: payments/routes/v1.py:145 +#: payments/routes/v1.py:143 msgid "Expenses" msgstr "Затраты" -#: payments/routes/v1.py:149 +#: payments/routes/v1.py:147 msgid "Refills" msgstr "Пополнения" @@ -1372,13 +1379,13 @@ msgstr "" msgid "Unknown account type" msgstr "Неизвестный тип аккаунта" -#: payments/tests/test_plans.py:23 payments/tests/test_plans.py:190 -#: payments/tests/test_plans.py:193 +#: payments/tests/test_plans.py:23 payments/tests/test_plans.py:205 +#: payments/tests/test_plans.py:208 msgid "Chat-bots" msgstr "Чат-боты" -#: payments/tests/test_plans.py:27 payments/tests/test_plans.py:191 -#: payments/tests/test_plans.py:196 +#: payments/tests/test_plans.py:27 payments/tests/test_plans.py:206 +#: payments/tests/test_plans.py:211 msgid "Images" msgstr "Изображения" @@ -1398,28 +1405,24 @@ msgstr "Прокси" msgid "Proxies" msgstr "Прокси" -#: tools/apps.py:9 +#: tools/apps.py:8 msgid "Tools" msgstr "Инструменты" -#: tools/apps.py:15 tools/chats/models.py:21 +#: tools/apps.py:14 tools/chats/models.py:21 msgid "Chats" msgstr "Чаты" -#: tools/apps.py:21 -msgid "Copywrite" -msgstr "Копирайт" - -#: tools/apps.py:32 +#: tools/apps.py:20 msgid "Public API" msgstr "Публичный API" -#: tools/apps.py:38 +#: tools/apps.py:26 msgid "Media" msgstr "Медиа" -#: tools/chats/apis.py:203 tools/media/apis.py:209 -#: tools/public_api/views/base.py:100 +#: tools/chats/apis.py:205 tools/media/apis.py:211 +#: tools/public_api/views/base.py:104 msgid "" "An unexpected generation error has occurred. Please try again later or use a " "different model" @@ -1427,7 +1430,7 @@ msgstr "" "Произошла непредвиденная ошибка при генерации. Пожалуйста попробуйте позже " "или используйте другую модель" -#: tools/chats/apis.py:259 +#: tools/chats/apis.py:261 msgid "The message has already been deleted" msgstr "Сообщение уже было удалено" @@ -1440,16 +1443,11 @@ msgstr "Чат %(id)s" msgid "Chat" msgstr "Чат" -#: tools/media/apis.py:176 +#: tools/media/apis.py:177 msgid "" "Temporary issues with the service, we are already working on a solution." msgstr "Временные неполадки с сервисом, мы уже работаем над их решением." -#: tools/media/apis.py:256 tools/public_api/views/ml_service.py:63 -#: tools/public_api/views/ml_service.py:107 -msgid "Voice not found." -msgstr "Голос не найден." - #: tools/media/models.py:45 msgid "Voice clone store" msgstr "Хранилище клонирования голоса" @@ -1506,8 +1504,8 @@ msgstr "Неизвестный файл" msgid "Voices" msgstr "Голоса" -#: tools/media/routes/v1.py:76 tools/public_api/views/voice.py:86 -#: tools/public_api/views/voice.py:98 +#: tools/media/routes/v1.py:76 tools/public_api/views/voice.py:100 +#: tools/public_api/views/voice.py:114 msgid "Voice not found" msgstr "Голос не найден" @@ -1531,48 +1529,86 @@ msgstr "API Ключ" msgid "API Keys" msgstr "API Ключи" -#: tools/public_api/views/base.py:63 +#: tools/public_api/views/base.py:65 msgid "Key limit exceeded" msgstr "Превышен лимит по ключу" -#: tools/public_api/views/base.py:68 +#: tools/public_api/views/base.py:70 msgid "Model is blocked by outdating or temporary block, please retry later" msgstr "" "Модель заблокирована, т.к закончила обновляться или временно заблокирована, " "попробуйте позже" -#: tools/public_api/views/base.py:75 +#: tools/public_api/views/base.py:77 msgid "The request must not be empty" msgstr "Запрос не должен быть пустым" -#: tools/public_api/views/ml_service.py:128 +#: tools/public_api/views/ml_service.py:88 +msgid "Invalid info payload" +msgstr "Некорректные данные в поле info" + +#: tools/public_api/views/providers/elevenlabs_compatible.py:133 +msgid "Missing required parameter: model_id" +msgstr "Отсутствует обязательный параметр: 'model_id'" + +#: tools/public_api/views/providers/elevenlabs_compatible.py:147 +#: tools/public_api/views/providers/openai_compatible.py:110 +#: tools/public_api/views/providers/openai_compatible.py:246 +msgid "Model not found" +msgstr "Модель не найдена" + +#: tools/public_api/views/providers/openai_compatible.py:61 msgid "You must provide a model parameter" msgstr "Необходимо указать параметр 'model'" -#: tools/public_api/views/ml_service.py:143 +#: tools/public_api/views/providers/openai_compatible.py:67 msgid "Missing required parameter: 'messages'" msgstr "Отсутствует обязательный параметр: 'messages'" -#: tools/public_api/views/ml_service.py:193 -msgid "Model not found" -msgstr "Модель не найдена" +#: tools/public_api/views/providers/openai_compatible.py:177 +msgid "Missing audio_sample." +msgstr "Отсутствует параметр audio_sample." + +#: tools/public_api/views/providers/openai_compatible.py:232 +#, python-format +msgid "Missing required parameter: %(param)s" +msgstr "Отсутствует обязательный параметр: %(param)s" + +#: tools/public_api/views/providers/openai_compatible.py:250 +msgid "Voice must be an object with id." +msgstr "Поле voice должно быть объектом с полем id." + +#: tools/public_api/views/providers/openai_compatible.py:256 +msgid "Voice id is empty." +msgstr "Идентификатор голоса не указан." -#: tools/public_api/views/voice.py:44 -msgid "Your voice has been uploaded successfully" -msgstr "Ваш голос успешно загружен" +#: tools/public_api/views/providers/openai_compatible.py:287 +msgid "Failed to fetch generated audio" +msgstr "Не удалось получить сгенерированное аудио" -#: tools/public_api/views/voice.py:77 +#: tools/public_api/views/voice.py:93 msgid "Preset voices are shared and cannot be edited. Use your own voice id." -msgstr "" -"Пресеты общие для всех — их нельзя редактировать. Укажите id своего голоса." +msgstr "Пресеты общие и не редактируются. Используйте id собственного голоса." -#: tools/public_api/views/voice.py:87 +#: tools/public_api/views/voice.py:103 msgid "Voice title updated successfully" msgstr "Название голоса успешно обновлено" -#: tools/public_api/views/voice.py:93 +#: tools/public_api/views/voice.py:109 msgid "Preset voices are shared and cannot be deleted. Use your own voice id." -msgstr "Пресеты общие для всех — их нельзя удалить. Укажите id своего голоса." +msgstr "Пресеты общие и не удаляются. Используйте id собственного голоса." + +#~ msgid "No matching version found" +#~ msgstr "Соответствующая версия не найдена" + +#~ msgid "Copywrite" +#~ msgstr "Копирайт" + +#~ msgid "Your voice has been uploaded successfully" +#~ msgstr "Ваш голос успешно загружен" + +#~ msgid "You cannot edit a voice by this id. Use your own voice_id." +#~ msgstr "Нельзя изменить голос по этому id. Используйте свой voice_id." #~ msgid "Available only in paid plan" #~ msgstr "Доступно только в платном тарифе" @@ -1660,9 +1696,6 @@ msgstr "Пресеты общие для всех — их нельзя удал #~ msgid "Issued achievement" #~ msgstr "Выданное достижение" -#~ msgid "Account is already confirmed" -#~ msgstr "Аккаунт уже подтвержден" - #~ msgid "Stories" #~ msgstr "Истории" @@ -79,9 +79,9 @@ class NeuronModelSelector: ) if ( user_type == 'business_account' - and self.user.business_account.acceptance_status == InvitationStatus.ACCEPTED + and self.user.employee.accepted ): - allowed_models = self.user.business_account.parent_company.allowed_models + allowed_models = self.user.employee.parent_company.allowed_models else: allowed_models = None if allowed_models is not None: @@ -94,19 +94,10 @@ class NeuronModelSelector: return models def get_model_accessible_status(self, model: NeuronModel) -> bool: - user_type = self.user.account_type - if user_type == 'business_account': - return ( - model.title in self.user.business_account.parent_company.allowed_models - and model.title in self.user.business_account.parent_company.user.payment_plan.plan.accessed_models.values_list('title', flat=True) - ) - else: - if user_type in ( - 'business_security', - 'business_admin', - ): - self.user.payment_plan.plan = self.user.business_account.parent_company.user.payment_plan.plan - return model in self.user.payment_plan.plan.accessed_models.all() + in_plan = model in self.user.plan.accessed_models + if self.user.account_type == 'business_account': + return model.title in self.user.employee.parent_company.allowed_models and in_plan + return in_plan def get_model_by_id(self, id: UUID, hidden: bool = False, **kwargs) -> NeuronModel: model = NeuronModel.objects.prefetch_related( @@ -10,10 +10,11 @@ import openpyxl from io import BytesIO from django.db.models.fields.files import FieldFile +from django.utils.translation import gettext as _ from authentication.models import CustomUserModel -from ml_model.exceptions import UnrecognizedFileError -from tools.media.models import Voice, Preset +from ml_model.exceptions import InvalidParameterError, UnrecognizedFileError +from tools.media.models import Voice, Preset, PresetKind class FileProcessingService: @@ -98,11 +99,14 @@ class FileProcessingService: user: CustomUserModel, default_voice_slug: str = 'russian_1', ) -> FieldFile: - if voice_id: - voice = Voice.objects.get(pk=voice_id, user=user) - elif preset_id: - voice = Preset.objects.get(uid=preset_id) - else: - voice = Preset.objects.get(slug=default_voice_slug) + try: + if voice_id: + voice = Voice.objects.get(pk=voice_id, user=user) + elif preset_id: + voice = Preset.objects.get(uid=preset_id, kind=PresetKind.VOICE) + else: + voice = Preset.objects.get(slug=default_voice_slug, kind=PresetKind.VOICE) + except (Voice.DoesNotExist, Preset.DoesNotExist) as exc: + raise InvalidParameterError(_('Voice not found.')) from exc return voice.file @@ -118,9 +118,7 @@ class Chatgpt_5_4(Chatgpt): if model_name is None or model_name not in self.TOKENS_COST: raise ModelVersionNotAvailable(model_name, self.TOKENS_COST) user_system_prompt = info.pop('system_prompt', '') - plan_info = self.store.user.payment_plan - is_regular_user = self.store.user.account_type == 'regular' - is_free_plan = is_regular_user and plan_info and plan_info.plan.price <= 0 + is_free_plan = self.store.user.plan.price <= 0 if is_free_plan and model_name == 'gpt-5.4-pro': raise PaidPlanRequiredError('ChatGPT 5.4 PRO') if is_free_plan: @@ -164,11 +164,7 @@ class Chatgpt_5_5(Chatgpt): chunks = [] text_chunks = [] predicted_input_price = 0 - is_free_plan = ( - self.store.user.account_type == 'regular' - and self.store.user.payment_plan - and self.store.user.payment_plan.plan.price <= 0 - ) + is_free_plan = self.store.user.plan.price <= 0 if is_free_plan: info.pop('code_interpreter', None) info.pop('verbosity', None) @@ -76,7 +76,7 @@ class Grok_4_1_Fast(SimpleService): + len(chunks) * 2100 * self.TOOLS_TOKEN_COSTS['text-embedding-3-small']['output'] ).quantize(Decimal('0.1'), rounding='ROUND_UP') if (balance := PaymentPlanSelector(self.store.user).get_current_balance()) < predict_price: - if self.store.user.payment_plan.plan.price <= 0: + if self.store.user.plan.price <= 0: return self.save_results( content='Файл не удаётся обработать — его размер больше максимально допустимого ' 'для вашего тарифа. Для продолжения выберите план с увеличенным лимитом.', @@ -1,135 +1,33 @@ -import time -import requests - -from datetime import timedelta -from decimal import Decimal -from io import BytesIO from typing import Any -from django.core.files import File -from django.utils.translation import gettext as _ - from messages.models import Message -from ml_model.exceptions import InvalidParameterError -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 ml_model.exceptions import ModelVersionNotAvailable +from ml_model.services.seedance_2_dreamina import Seedance_2_Dreamina -class Hunyuan(SimpleService): - UNIT_PRICE = Decimal('3') - MOTION_MODES = { - 'Плавное движение': 'smooth', - 'Обычное движение': 'normal', - } - STYLES = { - 'Без стиля': 'None', - 'Аниме': 'anime', - '3D анимация': '3d_animation', - 'Пластилин': 'clay', - 'Киберпанк': 'cyberpunk', - 'Комикс': 'comic', - } - PRICING_UNITS = { - '540p': { - 5: { - 'normal': 30, - 'smooth': 60, - }, - 8: { - 'normal': 60, - }, - }, - '720p': { - 5: { - 'normal': 40, - 'smooth': 80, - }, - 8: { - 'normal': 80, - }, - }, - '1080p': { - 5: { - 'normal': 80, - }, - }, +class Hunyuan(Seedance_2_Dreamina): + PROXY_VERSION_MAPPING = { + 'hunyuan-video': 'dreamina-seedance-2-0-fast', + 'hunyuan-video-pro': 'dreamina-seedance-2-0', } @classmethod - def predict_price(cls, content: str, file_exists: bool, info: dict[str, Any]) -> Decimal | None: - quality = info['quality'] - duration = info['duration'] - motion_mode_ru = info['motion_mode'] - sound_effect_switch = info['sound_effect_switch'] + def _remap_info(cls, info: dict[str, Any]) -> dict[str, Any]: + info = info.copy() + version = info.get('version', 'hunyuan-video') + if version in cls.PROXY_VERSION_MAPPING: + info['version'] = cls.PROXY_VERSION_MAPPING[version] + elif version not in cls.PROXY_VERSION_MAPPING.values(): + raise ModelVersionNotAvailable(version, cls.PROXY_VERSION_MAPPING) + return info + + @classmethod + def predict_price(cls, content: str, file_exists: bool, info: dict[str, Any]): try: - price = cls.UNIT_PRICE * cls.PRICING_UNITS[quality][duration][cls.MOTION_MODES[motion_mode_ru]] - except KeyError: + return super().predict_price(content, file_exists, cls._remap_info(info)) + except ModelVersionNotAvailable: return None - if sound_effect_switch: - price += 10 * cls.UNIT_PRICE - return price.quantize(Decimal('0.1'), rounding='ROUND_UP') - - def calculate_price( - self, quality: str, duration: int, motion_mode: str, sound_effect_switch: bool - ) -> Decimal: - price = self.UNIT_PRICE * self.PRICING_UNITS[quality][duration][motion_mode] - if sound_effect_switch: - price += 10 * self.UNIT_PRICE - 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]: - quality = input_message.info.get('quality') - duration = input_message.info.get('duration') - motion_mode_ru = input_message.info.pop('motion_mode') - motion_mode = self.MOTION_MODES.get(motion_mode_ru, 'normal') - sound_effect_switch = input_message.info.get('sound_effect_switch', False) - modes_for_duration = self.PRICING_UNITS[quality].get(duration) - if not modes_for_duration: - raise InvalidParameterError( - _('This video duration is not allowed for %(quality)s quality.') % {'quality': quality} - ) - units = modes_for_duration.get(motion_mode, {}) - if not units: - raise InvalidParameterError( - _('Smooth motion mode is available only for 5-second videos at 540p and 720p quality') - ) - elif sound_effect_switch: - units += 10 - if (balance := PaymentPlanSelector(self.store.user).get_current_balance()) < ( - cost := units * self.UNIT_PRICE - ): - raise InsufficientBalance(balance, cost) - callback_data = { - 'prompt': input_message.content, - 'motion_mode': motion_mode, - 'style': self.STYLES.get(input_message.info.pop('style', None), 'None'), - **input_message.info, - } - if image := input_message.file: - callback_data.update({'image': image.url}) - start_time = time.time() - video = replicate_run('pixverse/pixverse-v4', callback_data) - process_time = timedelta(seconds=(time.time() - start_time)) - self.handle_invoice( - input_message.content_object.model, - quality=quality, - duration=duration, - motion_mode=motion_mode, - sound_effect_switch=sound_effect_switch, - ) - msgs = self.save_results(input_message.content, process_time, video, save) - return msgs + input_message.info = self._remap_info(input_message.info) + return super().make(input_message, save) @@ -1,88 +1,33 @@ -import time -from datetime import timedelta -from decimal import Decimal -from io import BytesIO from typing import Any -import requests -from django.core.files import File - from messages.models import Message -from ml_model.exceptions import RequestBlocked, GenerationException -from ml_model.services.base import SimpleService -from ml_model.tasks import replicate_run +from ml_model.exceptions import ModelVersionNotAvailable +from ml_model.services.seedream import Seedream -class Leonardo(SimpleService): - TOKENS_COST = { - 'lucid-origin': { - 'input_units': Decimal('450'), - } +class Leonardo(Seedream): + PROXY_VERSION_MAPPING = { + 'lucid-origin': 'seedream-boosted', + 'lucid-pro': 'seedream-4.5', } - _CALLBACK_BASE = 'leonardoai/' - - def calculate_price(self, version: str, num_images: int, generation_mode: str) -> Decimal: - image_prices = {'standard': 18, 'ultra': 51} - price = ( - Decimal(f'{self.TOKENS_COST[version]["input_units"] / 1000 * image_prices[generation_mode]}') - * num_images - ) - return price.quantize(Decimal('0.1'), rounding='ROUND_UP') - @classmethod - def predict_price(cls, content: str, file_exists: bool, info: dict[str, Any]) -> Decimal | None: + def _remap_info(cls, info: dict[str, Any]) -> dict[str, Any]: + info = info.copy() version = info.get('version', 'lucid-origin') - num_images = info.get('num_images', 1) - generation_mode = info.get('generation_mode', 'standard') - image_prices = {'standard': 18, 'ultra': 51} - input_units = cls.TOKENS_COST[version]['input_units'] - price = (input_units / 1000 * image_prices[generation_mode]) * num_images - return price.quantize(Decimal('0.1'), rounding='ROUND_UP') + if version in cls.PROXY_VERSION_MAPPING: + info['version'] = cls.PROXY_VERSION_MAPPING[version] + elif version not in cls.PROXY_VERSION_MAPPING.values(): + raise ModelVersionNotAvailable(version, cls.PROXY_VERSION_MAPPING) + return info - 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 + @classmethod + def predict_price(cls, content: str, file_exists: bool, info: dict[str, Any]): + try: + return super().predict_price(content, file_exists, cls._remap_info(info)) + except ModelVersionNotAvailable: + return None def make(self, input_message: Message, save: bool = True) -> list[Message]: - start_time = time.time() - version = 'lucid-origin' - generation_mode = input_message.info.get('generation_mode', 'standard') - num_images = input_message.info.get('num_images', 1) - callback_data = dict( - { - 'prompt': self.translate_prompt(input_message.content), - **input_message.info, - } - ) - runner = replicate_run( - f'{self._CALLBACK_BASE}{version}', - 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, - version=version, - num_images=num_images, - generation_mode=generation_mode, - ) - msgs = self.save_results(input_message.content, images, process_time, save) - return msgs + input_message.info = self._remap_info(input_message.info) + return super().make(input_message, save) @@ -1,104 +1,33 @@ -import time -from datetime import timedelta -from decimal import Decimal -from io import BytesIO from typing import Any -import filetype -import requests -from django.core.files import File - from messages.models import Message -from ml_model.exceptions import RequestBlocked, GenerationException, FileExtensionNotSupported -from ml_model.services.FileService import FileProcessingService from ml_model.exceptions import ModelVersionNotAvailable -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 ml_model.services.seedance_2_dreamina import Seedance_2_Dreamina -class Seedance(SimpleService): - TOKENS_COST = { - 'seedance-2.0': { - 'non_video_in': { - '480p': Decimal('21'), - '720p': Decimal('51') - } - }, - 'seedance-2.0-fast': { - 'non_video_in': { - '480p': Decimal('18'), - '720p': Decimal('39') - }, - 'video_in': { - '480p': Decimal('33'), - '720p': Decimal('66') - } # 1 second - } +class Seedance(Seedance_2_Dreamina): + PROXY_VERSION_MAPPING = { + 'seedance-2.0-fast': 'dreamina-seedance-2-0-fast', + 'seedance-2.0': 'dreamina-seedance-2-0', } @classmethod - def predict_price(cls, content: str, file_exists: bool, info: dict[str, Any]) -> Decimal | None: - resolution = info['resolution'] - duration = info['duration'] - version = info['version'] - generation_type = 'video_in' if file_exists and version == 'seedance-2.0-fast' else 'non_video_in' - price = cls.TOKENS_COST[version][generation_type][resolution] * duration - return price.quantize(Decimal('0.1'), rounding='ROUND_UP') + def _remap_info(cls, info: dict[str, Any]) -> dict[str, Any]: + info = info.copy() + version = info.get('version', 'seedance-2.0-fast') + if version in cls.PROXY_VERSION_MAPPING: + info['version'] = cls.PROXY_VERSION_MAPPING[version] + elif version not in cls.PROXY_VERSION_MAPPING.values(): + raise ModelVersionNotAvailable(version, cls.PROXY_VERSION_MAPPING) + return info - def calculate_price(self, resolution: str, duration: int, version: str, generation_type: str) -> Decimal: - price = self.TOKENS_COST[version][generation_type][resolution] * 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] + @classmethod + def predict_price(cls, content: str, file_exists: bool, info: dict[str, Any]): + try: + return super().predict_price(content, file_exists, cls._remap_info(info)) + except ModelVersionNotAvailable: + return None def make(self, input_message: Message, save: bool = True) -> list[Message]: - version = input_message.info.pop('version', None) - if version is None or version not in self.TOKENS_COST: - raise ModelVersionNotAvailable(version, self.TOKENS_COST) - resolution = input_message.info.get('resolution', '720p') - duration = input_message.info.get('duration', 5) - file = input_message.file or None - file_extension = None - generation_type = 'non_video_in' - if file: - file_bytes = file.read() - kind = filetype.guess(file_bytes[:50]) - file_extension = FileProcessingService.get_file_extension(kind.extension, file_bytes) if kind else None - available_extensions = ('JPG', 'JPEG', 'PNG', 'WEBP', 'MP4') - if not file_extension or file_extension.upper() not in available_extensions: - raise FileExtensionNotSupported(available_extensions) - elif file_extension.upper() == 'MP4': - generation_type = 'video_in' - if version == 'seedance-2.0' and generation_type == 'video_in': - available_extensions = ('JPG', 'JPEG', 'PNG', 'WEBP') - raise FileExtensionNotSupported(available_extensions) - if (balance := PaymentPlanSelector(self.store.user).get_current_balance()) < ( - cost := self.TOKENS_COST[version][generation_type][resolution] * duration): - raise InsufficientBalance(balance, cost) - callback_data = dict({'prompt': input_message.content, **input_message.info}) - if file: - reference_type = ( - 'videos' if file_extension.upper() == 'MP4' - else 'images' - ) - callback_data.update({f'reference_{reference_type}': [file.url]}) - start_time = time.time() - video = replicate_run( - f'bytedance/{version}', callback_data - ) - process_time = timedelta(seconds=(time.time() - start_time)) - self.handle_invoice(input_message.content_object.model, resolution=resolution, duration=duration, version=version, generation_type=generation_type) - msgs = self.save_results(input_message.content, process_time, video, save) - return msgs + input_message.info = self._remap_info(input_message.info) + return super().make(input_message, save) @@ -1,8 +0,0 @@ -from rest_framework.permissions import BasePermission - -from authentication.selectors.user_selector import UserSelector - - -class IsModelAvailable(BasePermission): - def has_permission(self, request, view): - return UserSelector(request.user).check_model_availability(view.model_title) @@ -1,11 +1,8 @@ import logging from decimal import Decimal -from authentication.models.choices import InvitationStatus from authentication.models.user import CustomUserModel -from authentication.selectors.user_selector import UserSelector from payments.models.payment_plan import PaymentPlan -from payments.serializers import UserPaymentPlanSerializer logger = logging.getLogger(__name__) @@ -15,36 +12,12 @@ class PaymentPlanSelector: self.user = user def get_current_balance(self) -> Decimal: - if ( - self.user.account_type in ('business_account', 'business_admin', 'business_security') - ) and self.user.business_account.acceptance_status == InvitationStatus.ACCEPTED: - pp = self.user.business_account.parent_company.user.payment_plan - total_available = pp.current_token_balance + pp.referral_balance - limit = ( - self.user.business_account.group.token_limit - if self.user.business_account.group - else self.user.business_account.token_limit - ) - balance = min(total_available, limit) if limit is not None else total_available - else: - pp = self.user.payment_plan - balance = pp.current_token_balance + pp.referral_balance - + balance = self.user.balance + emp = self.user.employee + if emp and emp.limit is not None: + balance = min(balance, emp.limit) return balance - def get_user_balance(self): - user_type = UserSelector(self.user).check_account_type() - if ( - user_type == 'business_account' - or user_type == 'business_admin' - or user_type == 'business_security' - ) and self.user.business_account.acceptance_status == InvitationStatus.ACCEPTED: - plan = self.user.business_account.parent_company.user.payment_plan - else: - plan = self.user.payment_plan - - return UserPaymentPlanSerializer(plan) - def get_free_plan(self, corporate: bool = False) -> PaymentPlan: return PaymentPlan.objects.get_or_create(price=0, is_corporate=corporate)[0] @@ -1,10 +1,8 @@ from decimal import Decimal -from django.utils.translation import gettext_lazy as _ - from authentication.models.user import CustomUserModel -from authentication.selectors.user_selector import UserSelector from payments.exceptions.insufficient_balance import InsufficientBalance +from payments.selectors.payment_plan_selector import PaymentPlanSelector class ModelBillingService: @@ -12,30 +10,11 @@ class ModelBillingService: self.user = user def charge(self, amount: Decimal): - user_type = UserSelector(self.user).check_account_type() - if user_type in ['regular', 'business_host']: - plan = self.user.payment_plan - allowance = None - elif user_type in [ - 'business_account', - 'business_admin', - 'business_security', - ]: - plan = self.user.business_account.parent_company.user.payment_plan - allowance = ( - self.user.business_account.token_limit - if self.user.business_account.group is None - else self.user.business_account.group.token_limit - ) - else: - raise Exception(_('Unknown account type')) - - total_available = plan.current_token_balance + plan.referral_balance - if total_available < amount: - raise InsufficientBalance(total_available, amount) + plan = self.user.payment_plan_details + balance = PaymentPlanSelector(self.user).get_current_balance() - if allowance is not None and allowance < amount: - raise InsufficientBalance(allowance, amount) + if balance < amount: + raise InsufficientBalance(balance, amount) remainder = amount from_main = min(plan.current_token_balance, remainder) @@ -44,11 +23,11 @@ class ModelBillingService: if remainder > 0: plan.referral_balance -= remainder - if allowance is not None: - if self.user.business_account.group is not None: - self.user.business_account.group.token_limit -= amount - self.user.business_account.group.save() + if (emp := self.user.employee) and emp.limit is not None: + if emp.group is not None: + emp.group.token_limit -= amount + emp.group.save() else: - self.user.business_account.token_limit -= amount - self.user.business_account.save() + emp.token_limit -= amount + emp.save() plan.save() @@ -1,5 +1,8 @@ import logging +from django.conf import settings +from django.db.models import F + from authentication.models import CustomUserModel from payments.models.user_payment_method import PaymentMethod @@ -44,9 +47,27 @@ class PaymentMethodService: ) return payment_method - def delete_payment_method(self): - PaymentMethod.objects.filter(user_plan_info__user=self.user).delete() - logger.info( - 'Payment method deleted: email=%s', - self.user.email, + def inc_attempts(self) -> int: + return PaymentMethod.objects.filter(user_plan_info__user=self.user).update( + attempts=F('attempts') + 1 ) + + def compare_attempts_with_max(self, attempts: int) -> None: + if attempts >= settings.MAX_RECURRING_ATTEMPTS: + deleted = self.delete_payment_method() + if deleted: + logger.info( + 'Recurring payment method deleted due to attempts limit: email=%s attempts=%s', + self.user.email, + attempts, + ) + else: + logger.info( + 'Recurring payment method delete skipped after attempts limit, ' + 'payment method not found: email=%s attempts=%s', + self.user.email, + attempts, + ) + + def delete_payment_method(self) -> int: + return PaymentMethod.objects.filter(user_plan_info__user=self.user).delete()[0] @@ -44,9 +44,3 @@ class PaymentPlanService: ModelBillingService(self.user).charge(payment_amount) if model: return Invoice.objects.create(model=model, user=self.user, cost=payment_amount) - - def refill_user_plan_details(self): - payment_plan = self.user.payment_plan - original_plan = payment_plan.plan - payment_plan.current_token_balance = original_plan.tokens_per_plan - payment_plan.save() @@ -83,7 +83,11 @@ class PaymentService: PaymentPlanService(self.user).subscribe_user_to_plan(payment_instance.plan, buying_tokens) if ref_acc := self.user.referer_account: ReferralAccountService.apply_accrual(referer_account=ref_acc, payment=payment_instance) - elif payment.status == 'canceled' and payment.metadata.get('recurring'): + elif ( + payment.status == 'canceled' + and payment.metadata.get('recurring') + and self.user.payment_plan.is_recurring + ): self.handle_canceled_payment(payment) return payment_instance @@ -134,7 +138,6 @@ class PaymentService: ) def handle_canceled_payment(self, payment: YookassaPaymentResponse) -> None: - logger.error(f'Recurrent payment error: {payment.cancellation_details.reason}') temporary_cancel_reasons = ( 'call_issuer', 'expired_on_capture', @@ -143,23 +146,37 @@ class PaymentService: 'issuer_unavailable', 'payment_method_limit_exceeded', ) + payment_method_service = PaymentMethodService(self.user) if payment.cancellation_details.reason in temporary_cancel_reasons: - self.user.payment_plan.method.attempts += 1 - self.user.payment_plan.method.save() - logger.info( - 'Recurring payment canceled with retry: email=%s method_uid=%s attempts=%s reason=%s', - self.user.email, - self.user.payment_plan.method.uid, - self.user.payment_plan.method.attempts, - payment.cancellation_details.reason, - ) + updated = payment_method_service.inc_attempts() + if updated: + self.user.payment_plan.method.refresh_from_db(fields=['attempts']) + logger.info( + 'Recurring payment canceled with retry: email=%s method_uid=%s attempts=%s reason=%s', + self.user.email, + self.user.payment_plan.method.uid, + self.user.payment_plan.method.attempts, + payment.cancellation_details.reason, + ) + payment_method_service.compare_attempts_with_max(attempts=self.user.payment_plan.method.attempts) + else: + logger.info( + 'Recurring payment retry skipped, payment method not found: email=%s', + self.user.email, + ) else: - PaymentMethodService(self.user).delete_payment_method() - logger.info( - 'Recurring payment method deleted after cancel: email=%s reason=%s', - self.user.email, - payment.cancellation_details.reason, - ) + deleted = payment_method_service.delete_payment_method() + if deleted: + logger.info( + 'Recurring payment method deleted after cancel: email=%s reason=%s', + self.user.email, + payment.cancellation_details.reason, + ) + else: + logger.info( + 'Recurring payment method delete skipped, payment method not found: email=%s', + self.user.email, + ) def save_payment(self, payment: YookassaPaymentResponse) -> PaymentModel: plan = PaymentPlan.objects.get_or_none(uid=payment.metadata.get('plan_uid')) @@ -1,6 +1,7 @@ from decimal import Decimal -from authentication.models import CustomUserModel, BusinessUserHost, BusinessAccount, BusinessGroup +from authentication.models import BusinessUserHost, BusinessAccount, BusinessGroup, CustomUserModel +from authentication.models.choices import InvitationStatus from core.tests import BaseAuthorizedAPITest from payments.models import PaymentPlan @@ -62,7 +63,10 @@ class BalanceAPITest(BaseAuthorizedAPITest): hp.referral_balance = Decimal('0') hp.save() business_account = BusinessAccount.objects.create( - user=self.user, parent_company=self.host, acceptance_status='accepted', token_limit=Decimal('50') + user=self.user, + parent_company=self.host, + acceptance_status=InvitationStatus.ACCEPTED, + token_limit=Decimal('50'), ) balance = self.get().json()['current_token_balance'] self.assertEqual(Decimal(balance), business_account.token_limit) @@ -56,16 +56,45 @@ class PaymentPlanAdmin(OrderedInlineModelAdminMixin, admin.ModelAdmin): @admin.register(PaymentPlanUserInfo) class PaymentPlanUserInfoAdmin(admin.ModelAdmin): - list_display = ['user', 'current_token_balance', 'referral_balance', 'plan', 'updated_at', 'next_payment_at'] + list_display = [ + 'user', + 'current_token_balance', + 'referral_balance', + '_display_balance', + 'plan', + 'updated_at', + 'next_payment_at', + ] raw_id_fields = ['user'] search_fields = [ 'uid', + 'user__email', 'user__host_account__company_name', 'user__business_account__parent_company__company_name', ] search_help_text = _('You can search by user email, exacted company name') + def get_queryset(self, request): + return ( + super() + .get_queryset(request) + .select_related( + 'user', + 'plan', + 'user__payment_plan', + 'user__business_account', + 'user__business_account__group', + 'user__business_account__parent_company__user__payment_plan', + ) + ) + + @admin.display(description='Отображаемый баланс') + def _display_balance(self, obj: PaymentPlanUserInfo): + from payments.selectors.payment_plan_selector import PaymentPlanSelector + + return PaymentPlanSelector(obj.user).get_current_balance() + @admin.register(PaymentPlanFeature) class PaymentPlanFeatureAdmin(OrderedModelAdmin): @@ -34,21 +34,6 @@ def delete_recurrent_for_individual_plans( PaymentMethod.objects.filter(user_plan_info__plan=instance).delete() -@receiver(post_save, sender=PaymentMethod) -def delete_method_with_exceeded_attempts( - sender: Type[PaymentMethod], instance: PaymentMethod, created: bool, **kwargs -): - if instance.attempts >= settings.MAX_RECURRING_ATTEMPTS: - user_email = ( - PaymentPlanUserInfo.objects.filter(method=instance).values_list('user__email', flat=True).first() - ) - logger.info( - 'Payment method deleted due to attempts limit: email=%s', - user_email, - ) - instance.delete() - - @receiver(post_save, sender=PaymentPlanUserInfo) def clear_recurrent_on_individual_plan_assignment( sender: Type[PaymentPlanUserInfo], instance: PaymentPlanUserInfo, created: bool, **kwargs @@ -52,6 +52,7 @@ def execute_recurring_payments() -> None: plan__individual=False, plan__is_corporate=False, method__isnull=False, + user__is_deleted=False, ) .only( 'uid', @@ -0,0 +1,13 @@ +from .openai_compatible import ( + OpenAICompatibleAPIView, + OpenAIAudioSpeechAPIView, + OpenAIVoiceAPIView, + OpenAIVoiceDeleteAPIView, +) +from .elevenlabs_compatible import ( + ElevenlabsVoiceDeleteAPIView, + ElevenlabsVoiceEditAPIView, + ElevenlabsVoiceListAPIView, + ElevenlabsVoiceUploadAPIView, + ElevenlabsVoiceCloneAPIView, +) @@ -0,0 +1,167 @@ +import httpx +from uuid import uuid4 + +from django.http import HttpResponse +from django.db.models import Q +from django.utils.translation import gettext_lazy as _ +from rest_framework import status +from rest_framework.response import Response + +from ml_model.models import NeuronModel +from tools.public_api.permissions import HasElevenlabsAPIKey +from tools.public_api.views import VoiceView +from tools.public_api.views.voice import ( + PublicVoiceDetailAPIView, + PublicVoiceListAPIView, + PublicVoiceUploadAPIView, +) + + +class BaseElevenlabsAPIView: + permission_classes = (HasElevenlabsAPIKey,) + + def _authorization_headers(self, request): + return { + 'Authorization': request.headers.get('Xi-Api-Key') + or request.headers.get('Authorization', ''), + } + + def _proxy_request(self, request, data=None): + return type( + 'RequestProxy', + (), + { + 'data': data if data is not None else request.data, + 'headers': self._authorization_headers(request), + 'FILES': getattr(request, 'FILES', {}), + }, + )() + + +def _elevenlabs_error_response(response): + source_status_code = getattr(response, 'status_code', status.HTTP_400_BAD_REQUEST) + data = getattr(response, 'data', None) + detail = data.get('detail') if isinstance(data, dict) else data + + if isinstance(detail, dict): + msg = str(detail.get('message') or detail.get('detail') or detail or 'Request failed') + param = detail.get('param') + else: + msg = str(detail or data or 'Request failed') + param = None + + if source_status_code in (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN): + err_type, code = 'authentication_error', 'unauthorized' + response_status_code = source_status_code + else: + err_type, code = 'validation_error', 'invalid_parameters' + response_status_code = status.HTTP_400_BAD_REQUEST + + return Response( + { + 'detail': { + 'type': err_type, + 'code': code, + 'message': msg, + 'status': code, + 'request_id': str(uuid4()), + 'param': param, + } + }, + status=response_status_code, + ) + + +class ElevenlabsVoiceUploadAPIView(BaseElevenlabsAPIView, PublicVoiceUploadAPIView): + def post(self, request, *args, **kwargs): + data = request.data.copy() + data.update({'file': data.pop('files')[0], 'title': data.pop('name')[0]}) + response = super().post(self._proxy_request(request, data=data), *args, **kwargs) + if response.status_code >= 400: + return _elevenlabs_error_response(response) + voice = response.data + return Response({'voice_id': voice['id'], 'requires_verification': False}) + + +class ElevenlabsVoiceListAPIView(BaseElevenlabsAPIView, PublicVoiceListAPIView): + def get(self, request, *args, **kwargs): + response = super().get(self._proxy_request(request), *args, **kwargs) + if response.status_code >= 400: + return _elevenlabs_error_response(response) + voices = response.data + return Response( + { + 'voices': [ + { + 'voice_id': voice['id'], + 'name': voice['title'], + 'preview_url': voice['file'], + 'created_at_unix': voice['created_at'], + } + for voice in voices + ] + } + ) + + +class ElevenlabsVoiceEditAPIView(BaseElevenlabsAPIView, PublicVoiceDetailAPIView): + def post(self, request, *args, **kwargs): + data = request.data.copy() + data.update({'title': data.pop('name')[0]}) + response = super().patch(self._proxy_request(request, data=data), *args, **kwargs) + if response.status_code >= 400: + return _elevenlabs_error_response(response) + return Response({'status': 'ok'}) + + +class ElevenlabsVoiceDeleteAPIView(BaseElevenlabsAPIView, PublicVoiceDetailAPIView): + def delete(self, request, *args, **kwargs): + response = super().delete(self._proxy_request(request), *args, **kwargs) + if response.status_code >= 400: + return _elevenlabs_error_response(response) + return Response({'status': 'ok'}) + + +class ElevenlabsVoiceCloneAPIView(BaseElevenlabsAPIView, VoiceView): + def post(self, request, voice_id, *args, **kwargs): + # TODO: check for attachment text-files + data = request.data.copy() + model_ref = data.get('model_id') or data.get('model') + if not model_ref: + return _elevenlabs_error_response( + Response( + {'detail': _('Missing required parameter: model_id')}, status=status.HTTP_400_BAD_REQUEST + ) + ) + try: + model_slug = ( + NeuronModel.objects.filter( + Q(model_modelversions__slug=str(model_ref)) | Q(slug=str(model_ref)), + category__slug='voice', + ) + .get() + .slug + ) + except NeuronModel.DoesNotExist: + return _elevenlabs_error_response( + Response({'detail': _('Model not found')}, status=status.HTTP_400_BAD_REQUEST) + ) + data.update({'voice_id': voice_id}) + data.update({'content': data.pop('text')}) + data.update({'info': {'version': str(model_ref)}}) + data.pop('model_id', None) + data.pop('model', None) + result = super().post(self._proxy_request(request, data=data), model_slug) + if result.status_code >= 400: + return _elevenlabs_error_response(result) + file_url = result.data[0]['file'] + httpx_response = httpx.get(file_url) + if httpx_response.status_code >= 400: + return _elevenlabs_error_response( + Response({'detail': httpx_response.text}, status=httpx_response.status_code) + ) + return HttpResponse( + httpx_response.content, + status=httpx_response.status_code, + content_type=httpx_response.headers.get('content-type', 'application/octet-stream'), + ) @@ -0,0 +1,289 @@ +import base64 +import uuid +from datetime import datetime +from io import BytesIO + +import filetype +import httpx +from django.core.files.uploadedfile import InMemoryUploadedFile +from django.db.models import Q +from django.http import HttpResponse +from django.utils.translation import gettext_lazy as _ +from rest_framework import status +from rest_framework.renderers import JSONRenderer +from rest_framework.request import Request +from rest_framework.response import Response + +from ml_model.models import NeuronModel +from tools.public_api.selectors.api_key import APIKeySelector +from tools.public_api.views.base import BaseGenerationView +from tools.public_api.views.ml_service import VoiceView +from tools.public_api.views.voice import ( + PublicVoiceDetailAPIView, + PublicVoiceListAPIView, + PublicVoiceUploadAPIView, +) + + +def _get_api_user(request): + api_key_value = request.headers.get('Authorization', '') + if api_key_value.startswith('Bearer '): + api_key_value = api_key_value.split(' ', 1)[1] + return APIKeySelector.get_user_by_key(key_value=api_key_value) + + +def _openai_error( + message, + *, + param=None, + code=None, + err_type='invalid_request_error', + http_status=status.HTTP_400_BAD_REQUEST, +): + return Response( + {'error': {'message': str(message), 'type': err_type, 'param': param, 'code': code}}, + status=http_status, + ) + + +class _OctetStreamRenderer(JSONRenderer): + media_type = 'application/octet-stream' + format = 'octet-stream' + + +class OpenAICompatibleAPIView(BaseGenerationView): + def post(self, request: Request, *args, **kwargs): + data = request.data.copy() + + try: + version = data.pop('model') + except KeyError: + return _openai_error(_('You must provide a model parameter')) + content_lines = [] + file = None + messages = data.pop('messages', []) + if not messages: + return _openai_error( + _("Missing required parameter: 'messages'"), + param='messages', + code='missing_required_parameter', + ) + for m in messages: + role = m['role'].capitalize() + msg_content = m.get('content', []) + if isinstance(msg_content, str): + content_lines.append(f'[{role}] {msg_content}') + else: + for c in msg_content: + if c['type'] == 'text': + content_lines.append(f'[{role}] {c["text"]}') + elif c['type'] == 'image_url': + data_url = c['image_url']['url'] + encoded = data_url.split('base64')[-1] + buf = BytesIO(base64.b64decode(encoded)) + kind = filetype.guess(buf.read(20)) + buf.seek(0) + mime = kind.mime if kind else 'application/octet-stream' + ext = kind.extension if kind else 'bin' + file = InMemoryUploadedFile( + buf, + field_name='file', + name=f'api-file.{ext}', + content_type=mime, + size=buf.getbuffer().nbytes, + charset=None, + ) + content = '\n'.join(content_lines) + data['info'] = {key: data.pop(key) for key in data.copy().keys()} + data['info']['version'] = version + + try: + model_slug = ( + NeuronModel.objects.filter( + Q(model_modelversions__slug=data['info']['version']) | Q(slug=data['info']['version']), + category__slug='chat-bots', + ) + .get() + .slug + ) + except NeuronModel.DoesNotExist: + return _openai_error(_('Model not found')) + + data['content'] = content + data['file'] = file + + result = super().post( + type('Request', (Request,), {'data': data, 'headers': request.headers.copy()}), + model_slug, + *args, + **kwargs, + ) + if isinstance(result.data, dict): + return _openai_error(result.data['detail']) + + return Response( + { + 'id': str(uuid.uuid4()), + 'object': 'chat.completion', + 'created': int( + datetime.fromisoformat(result.data[0]['created_at'].replace('Z', '+00:00')).timestamp() + ), + 'model': version, + 'choices': [ + { + 'index': 0, + 'message': {'role': 'assistant', 'content': result.data[0]['content']}, + 'finish_reason': 'stop', + } + ], + 'service_tier': 'auto', + 'system_fingerprint': str(uuid.uuid4()), + 'usage': None, + }, + 201, + ) + + +class OpenAIVoiceAPIView(PublicVoiceUploadAPIView, PublicVoiceListAPIView): + def get(self, request, *args, **kwargs): + response = super().get(request, *args, **kwargs) + if response.status_code >= 400: + return response + data = [ + { + 'id': voice['id'], + 'created_at': voice['created_at'], + 'name': voice.get('title') or '', + 'object': 'audio.voice', + } + for voice in response.data + ] + ids = [item['id'] for item in data] + return Response( + { + 'data': data, + 'has_more': False, + 'object': 'list', + 'first_id': ids[0] if ids else None, + 'last_id': ids[-1] if ids else None, + } + ) + + def post(self, request, *args, **kwargs): + name = request.data.get('name') + sample = request.FILES.get('audio_sample') + if not sample: + return _openai_error(_('Missing audio_sample.'), param='audio_sample', code='missing_required_parameter') + + proxy = type( + 'RequestProxy', + (), + {'data': {'file': sample, 'title': name}, 'headers': request.headers}, + )() + response = super().post(proxy, *args, **kwargs) + if response.status_code >= 400: + detail = ( + response.data.get('detail', response.data) + if isinstance(response.data, dict) + else response.data + ) + return _openai_error(str(detail), http_status=response.status_code) + + return Response( + { + 'id': str(response.data['id']), + 'object': 'audio.voice', + 'created_at': response.data['created_at'], + 'name': response.data.get('title') or '', + }, + status=status.HTTP_201_CREATED, + ) + + +class OpenAIVoiceDeleteAPIView(PublicVoiceDetailAPIView): + def delete(self, request, voice_id: str, *args, **kwargs): + response = super().delete(request, voice_id, *args, **kwargs) + if response.status_code >= 400: + if response.status_code == status.HTTP_404_NOT_FOUND: + return _openai_error(_('Voice not found.'), param='voice_id', http_status=404) + detail = ( + response.data.get('detail', response.data) + if isinstance(response.data, dict) + else response.data + ) + return _openai_error(str(detail), param='voice_id', http_status=response.status_code) + return Response( + {'id': str(voice_id), 'object': 'audio.voice', 'deleted': True}, + status=status.HTTP_200_OK, + ) + + +class OpenAIAudioSpeechAPIView(VoiceView): + renderer_classes = (_OctetStreamRenderer,) + + def post(self, request, *args, **kwargs): + try: + model_ref = request.data['model'] + input_text = request.data['input'] + except KeyError as exc: + p = exc.args[0] + return _openai_error( + _('Missing required parameter: %(param)s') % {'param': p}, + param=p, + code='missing_required_parameter', + ) + try: + model_slug = ( + NeuronModel.objects.filter( + Q(model_modelversions__slug=model_ref) | Q(slug=model_ref), + category__slug='voice', + ) + .get() + .slug + ) + except NeuronModel.DoesNotExist: + return _openai_error(_('Model not found'), param='model') + voice_raw = request.data.get('voice') + if not isinstance(voice_raw, dict): + return _openai_error( + _('Voice must be an object with id.'), + param='voice', + code='invalid_type', + ) + voice_ref = voice_raw.get('id') + if voice_ref in (None, ''): + return _openai_error(_('Voice id is empty.'), param='voice', code='missing_required_parameter') + voice_ref = str(voice_ref).strip() + post_data = {'content': input_text, 'voice_id': voice_ref, 'info': {'version': model_ref}} + proxy = type( + 'RequestProxy', + (), + { + 'data': post_data, + 'headers': request.headers, + 'FILES': {}, + }, + )() + result = super().post(proxy, model_slug, *args, **kwargs) + if result.status_code >= 400: + detail = result.data.get('detail', result.data) if isinstance(result.data, dict) else result.data + st = result.status_code + if st == status.HTTP_402_PAYMENT_REQUIRED: + return _openai_error( + str(detail), + err_type='insufficient_quota', + code='billing_hard_limit_reached', + http_status=st, + ) + if st == status.HTTP_403_FORBIDDEN: + return _openai_error(str(detail), err_type='permission_error', http_status=st) + if st >= 500: + return _openai_error(str(detail), err_type='api_error', code='internal_error', http_status=st) + return _openai_error(str(detail), http_status=st) + r = httpx.get(result.data[0]['file'], timeout=120.0) + if r.status_code >= 400: + return _openai_error( + r.text or _('Failed to fetch generated audio'), + http_status=status.HTTP_502_BAD_GATEWAY, + ) + return HttpResponse(r.content, status=status.HTTP_200_OK, content_type='audio/mpeg') @@ -7,7 +7,21 @@ from .ml_service import ( CodeView, VoiceView, ParamView, - OpenAICompatibleAPIView ) from .user import UserInfoAPIView -from .voice import PublicVoiceViewSet +from .voice import ( + PublicVoiceUploadAPIView, + PublicVoiceListAPIView, + PublicVoiceDetailAPIView, +) +from .providers import ( + OpenAICompatibleAPIView, + OpenAIAudioSpeechAPIView, + OpenAIVoiceAPIView, + OpenAIVoiceDeleteAPIView, + ElevenlabsVoiceDeleteAPIView, + ElevenlabsVoiceEditAPIView, + ElevenlabsVoiceListAPIView, + ElevenlabsVoiceUploadAPIView, + ElevenlabsVoiceCloneAPIView, +) @@ -1,6 +1,7 @@ import logging import sys +from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ from rest_framework.response import Response from rest_framework.status import ( @@ -14,6 +15,7 @@ from rest_framework.views import APIView from messages.models import Message from messages.serializers import MessageSerializer from ml_model.choices import ContentTypes +from ml_model.exceptions import InvalidParameterError from ml_model.models import NeuronModel from ml_model.selectors.ml_models_selector import NeuronModelSelector from ml_model.serializers import PublicNeuronModelSerializer @@ -57,7 +59,7 @@ class BaseGenerationView(APIView): if (split_api_key := api_key_value.split())[0] == 'Bearer': api_key_value = split_api_key[-1] user = APIKeySelector.get_user_by_key(key_value=api_key_value) - balance = user.balance + balance = user.payment_plan.current_token_balance + user.payment_plan.referral_balance key = APIKey.objects.get(key=api_key_value) if key.token_limit is not None and key.token_limit < 1: return Response({'detail': _('Key limit exceeded')}, HTTP_403_FORBIDDEN) @@ -93,6 +95,8 @@ class BaseGenerationView(APIView): input_message.save() if isinstance(exc, InsufficientBalance): return Response({'detail': f'{exc}'}, status=HTTP_402_PAYMENT_REQUIRED) + elif isinstance(exc, (InvalidParameterError, ValidationError)): + return Response({'detail': f'{exc}'}, status=HTTP_400_BAD_REQUEST) logger.exception(exc) return Response( { @@ -106,7 +110,9 @@ class BaseGenerationView(APIView): msg.from_public_api = True msg.save() if key.token_limit is not None: - user_after = APIKeySelector.get_user_by_key(key_value=request.headers.get('Authorization', '')) - key.token_limit -= balance - user_after.balance + pp_after = APIKeySelector.get_user_by_key( + key_value=request.headers.get('Authorization', '') + ).payment_plan + key.token_limit -= balance - (pp_after.current_token_balance + pp_after.referral_balance) key.save() return Response(MessageSerializer(output_message, many=True).data, 201) @@ -1,16 +1,9 @@ -import base64 +import json import logging -import uuid -from datetime import datetime -from io import BytesIO -import filetype -from django.core.files.uploadedfile import InMemoryUploadedFile -from django.db.models import Q from django.utils.translation import gettext_lazy as _ from drf_spectacular.utils import extend_schema from rest_framework import status -from rest_framework.request import Request from rest_framework.response import Response from rest_framework.status import HTTP_400_BAD_REQUEST from rest_framework.views import APIView @@ -86,164 +79,25 @@ class VoiceView(BaseGenerationView): description = 'Get Voice Generation from model in URL slug. Only POST Requests.' def post(self, request, model_slug, *args, **kwargs): - if not (request.FILES.get('file') or request.data.get('file')): - api_key_value = request.headers.get('Authorization') - if (split_api_key := api_key_value.split())[0] == 'Bearer': - api_key_value = split_api_key[-1] - user = APIKeySelector.get_user_by_key(key_value=api_key_value) - voice_id = str(request.data.pop('voice_id', '')) + voice_id = str(request.data.get('voice_id', '')) + info = request.data.get('info', {}) + if isinstance(info, str): try: - if not voice_id: - voice = Preset.objects.get(slug='russian_1') - # transcription = voice.metadata.get('transcription', '') - elif voice_id.isdigit(): - voice = Voice.objects.get(pk=voice_id, user=user) - # transcription = voice.transcription - else: - voice = Preset.objects.get(uid=voice_id) - # transcription = voice.metadata.get('transcription', '') - except (Voice.DoesNotExist, Preset.DoesNotExist): - return Response( - {'detail': _('Voice not found.')}, - status=HTTP_400_BAD_REQUEST, - ) - request.data.update( - { - 'file': voice.file, # 'info': {'transcription': transcription, **request.data['info']} - } - ) + info = json.loads(info) + except json.JSONDecodeError: + return Response({'detail': _('Invalid info payload')}, status=HTTP_400_BAD_REQUEST) + if voice_id.isdigit(): + info.update({'voice_id': int(voice_id)}) + else: + info.update({'preset_id': voice_id}) + ct = getattr(request, 'content_type', '') + if 'multipart/form-data' in ct: + request.data['info'] = json.dumps(info, ensure_ascii=False) + else: + request.data["info"] = info return super().post(request, model_slug, *args, **kwargs) -class OpenAICompatibleAPIView(BaseGenerationView): - def post(self, request: Request, *args, **kwargs): - data = request.data.copy() - - try: - version = data.pop('model') - except KeyError: - return Response( - { - 'error': { - 'message': _('You must provide a model parameter'), - 'type': 'invalid_request_error', - 'param': None, - 'code': None, - } - }, - status=HTTP_400_BAD_REQUEST, - ) - content_lines = [] - file = None - messages = data.pop('messages', []) - if not messages: - return Response( - { - 'error': { - 'message': _("Missing required parameter: 'messages'"), - 'type': 'invalid_request_error', - 'param': 'messages', - 'code': 'missing_required_parameter', - } - }, - status=HTTP_400_BAD_REQUEST, - ) - for m in messages: - role = m['role'].capitalize() - msg_content = m.get('content', []) - if isinstance(msg_content, str): - content_lines.append(f'[{role}] {msg_content}') - else: - for c in msg_content: - if c['type'] == 'text': - content_lines.append(f'[{role}] {c["text"]}') - elif c['type'] == 'image_url': - data_url = c['image_url']['url'] - encoded = data_url.split('base64')[-1] - buf = BytesIO(base64.b64decode(encoded)) - kind = filetype.guess(buf.read(20)) - buf.seek(0) - mime = kind.mime if kind else 'application/octet-stream' - ext = kind.extension if kind else 'bin' - file = InMemoryUploadedFile( - buf, - field_name='file', - name=f'api-file.{ext}', - content_type=mime, - size=buf.getbuffer().nbytes, - charset=None, - ) - content = '\n'.join(content_lines) - data['info'] = {key: data.pop(key) for key in data.copy().keys()} - data['info']['version'] = version - - try: - model_slug = ( - NeuronModel.objects.filter( - Q(model_modelversions__slug=data['info']['version']) | Q(slug=data['info']['version']), - category__slug='chat-bots', - ) - .get() - .slug - ) - except NeuronModel.DoesNotExist: - return Response( - { - 'error': { - 'message': _('Model not found'), - 'type': 'invalid_request_error', - 'param': None, - 'code': None, - } - }, - status=HTTP_400_BAD_REQUEST, - ) - - data['content'] = content - data['file'] = file - - result = super().post( - type('Request', (Request,), {'data': data, 'headers': request.headers.copy()}), - model_slug, - *args, - **kwargs, - ) - if isinstance(result.data, dict): - return Response( - { - 'error': { - 'message': result.data['detail'], - 'type': 'invalid_request_error', - 'param': None, - 'code': None, - } - }, - status=HTTP_400_BAD_REQUEST, - ) - - return Response( - { - 'id': str(uuid.uuid4()), - 'object': 'chat.completion', - 'created': int( - datetime.fromisoformat(result.data[0]['created_at'].replace('Z', '+00:00')).timestamp() - ), - 'model': version, - 'choices': [ - { - 'index': 0, - 'message': {'role': 'assistant', 'content': result.data[0]['content']}, - 'finish_reason': 'stop', - } - ], - 'service_tier': 'auto', - 'system_fingerprint': str(uuid.uuid4()), - 'usage': None, - }, - 201, - ) - - class ParamView(APIView): @extend_schema(responses={200: ModelParameterSerializer}) def get(self, request, model_slug, *args, **kwargs): @@ -2,19 +2,19 @@ from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ from rest_framework import status from rest_framework.response import Response -from rest_framework.viewsets import ViewSet +from rest_framework.views import APIView from tools.media.models import Preset, PresetKind, Voice from tools.public_api.permissions import HasAPIKey from tools.public_api.serializers import ( - VoiceListItemSerializer, + VoiceItemSerializer, VoiceTitleUpdateSerializer, VoiceUploadSerializer, ) from tools.public_api.selectors.api_key import APIKeySelector -class PublicVoiceViewSet(ViewSet): +class BasePublicVoiceAPIView(APIView): authentication_classes = [] permission_classes = (HasAPIKey,) @@ -24,7 +24,9 @@ class PublicVoiceViewSet(ViewSet): api_key_value = api_key_value.split(' ', 1)[1] return APIKeySelector.get_user_by_key(key_value=api_key_value) - def create(self, request, *args, **kwargs): + +class PublicVoiceUploadAPIView(BasePublicVoiceAPIView): + def post(self, request, *args, **kwargs): user = self._get_api_user(request) serializer = VoiceUploadSerializer(data=request.data) serializer.is_valid(raise_exception=True) @@ -41,36 +43,50 @@ class PublicVoiceViewSet(ViewSet): except ValidationError as exc: return Response({'detail': ';\n'.join(exc.messages)}, status=status.HTTP_400_BAD_REQUEST) return Response( - {'detail': _('Your voice has been uploaded successfully')}, status=status.HTTP_201_CREATED + VoiceItemSerializer( + { + 'id': str(voice.pk), + 'title': voice.title, + 'file': voice.file.url, + 'created_at': int(voice.created_at.timestamp()), + } + ).data, + status=status.HTTP_201_CREATED, ) - def list(self, request, *args, **kwargs): + +class PublicVoiceListAPIView(BasePublicVoiceAPIView): + def get(self, request, *args, **kwargs): user = self._get_api_user(request) items = [] - for voice in Voice.objects.filter(user=user): + for voice in Voice.objects.filter(user=user, file__isnull=False): items.append( { 'id': str(voice.pk), 'title': voice.title, - 'file': voice.file.url if voice.file else None, + 'file': voice.file.url, + 'created_at': int(voice.created_at.timestamp()), # 'transcription': voice.transcription, } ) - for preset in Preset.objects.filter(kind=PresetKind.VOICE): + for preset in Preset.objects.filter(kind=PresetKind.VOICE, file__isnull=False): items.append( { 'id': str(preset.uid), 'title': preset.title, - 'file': preset.file.url if preset.file else None, + 'file': preset.file.url, + 'created_at': int(preset.created_at.timestamp()), # 'transcription': preset.metadata.get('transcription'), } ) - return Response(VoiceListItemSerializer(items, many=True).data, status=status.HTTP_200_OK) + return Response(VoiceItemSerializer(items, many=True).data, status=status.HTTP_200_OK) - def partial_update(self, request, voice_id: str, *args, **kwargs): + +class PublicVoiceDetailAPIView(BasePublicVoiceAPIView): + def patch(self, request, voice_id: str, *args, **kwargs): user = self._get_api_user(request) if not voice_id.isdigit(): return Response( @@ -79,14 +95,14 @@ class PublicVoiceViewSet(ViewSet): ) serializer = VoiceTitleUpdateSerializer(data=request.data) serializer.is_valid(raise_exception=True) - updated = Voice.objects.filter(pk=voice_id, user=user).update( - title=serializer.validated_data['title'] - ) - if not updated: + voice = Voice.objects.filter(pk=voice_id, user=user).first() + if not voice: return Response({'detail': _('Voice not found')}, status=status.HTTP_404_NOT_FOUND) + voice.title = serializer.validated_data['title'] + voice.save() return Response({'detail': _('Voice title updated successfully')}, status=status.HTTP_200_OK) - def destroy(self, request, voice_id: str, *args, **kwargs): + def delete(self, request, voice_id: str, *args, **kwargs): user = self._get_api_user(request) if not voice_id.isdigit(): return Response( @@ -7,8 +7,10 @@ from tools.public_api.models import APIKey class HasAPIKey(permissions.BasePermission): + auth_header = 'Authorization' + def has_permission(self, request, view): - api_key_value = request.headers.get('Authorization', '') + api_key_value = request.headers.get(self.auth_header, '') if ( (split_api_key := api_key_value.split()) and len(split_api_key) > 0 @@ -21,3 +23,7 @@ class HasAPIKey(permissions.BasePermission): if api_key.expires_at and api_key.expires_at < date.today(): raise PermissionDenied('API Key is expired') return api_key + + +class HasElevenlabsAPIKey(HasAPIKey): + auth_header = 'Xi-Api-Key' @@ -10,7 +10,7 @@ __all__ = [ 'APIKeyDeleteSerializer', 'VoiceUploadSerializer', 'VoiceTitleUpdateSerializer', - 'VoiceListItemSerializer', + 'VoiceItemSerializer', ] @@ -59,8 +59,9 @@ class VoiceTitleUpdateSerializer(serializers.Serializer): title = serializers.CharField(max_length=50) -class VoiceListItemSerializer(serializers.Serializer): +class VoiceItemSerializer(serializers.Serializer): id = serializers.CharField() title = serializers.CharField(allow_null=True, allow_blank=True) file = serializers.CharField() + created_at = serializers.IntegerField(required=False) # transcription = serializers.CharField(allow_null=True, allow_blank=True) @@ -1,28 +1,45 @@ from django.urls import path from tools.public_api import views -urlpatterns = [ - path('api-key', views.APIKeyView.as_view()), - path('me', views.UserInfoAPIView.as_view()), - path( - 'voices', - views.PublicVoiceViewSet.as_view({'get': 'list', 'post': 'create'}), - ), - path( - 'voices/', - views.PublicVoiceViewSet.as_view({'patch': 'partial_update', 'delete': 'destroy'}), - ), +openai_urlpatterns = [ path('openai/chat/completions', views.OpenAICompatibleAPIView.as_view()), path('openai/v1/chat/completions', views.OpenAICompatibleAPIView.as_view()), + path('openai/audio/voices/', views.OpenAIVoiceDeleteAPIView.as_view()), + path('openai/v1/audio/voices/', views.OpenAIVoiceDeleteAPIView.as_view()), + path('openai/audio/voices', views.OpenAIVoiceAPIView.as_view()), + path('openai/v1/audio/voices', views.OpenAIVoiceAPIView.as_view()), + path('openai/audio/speech', views.OpenAIAudioSpeechAPIView.as_view()), + path('openai/v1/audio/speech', views.OpenAIAudioSpeechAPIView.as_view()), ] +elevenlabs_urlpatterns = [ + path('elevenlabs/v1/voices', views.ElevenlabsVoiceListAPIView.as_view()), + path('elevenlabs/v1/voices/add', views.ElevenlabsVoiceUploadAPIView.as_view()), + path('elevenlabs/v1/voices//edit', views.ElevenlabsVoiceEditAPIView.as_view()), + path('elevenlabs/v1/voices/', views.ElevenlabsVoiceDeleteAPIView.as_view()), + path('elevenlabs/v1/text-to-speech/', views.ElevenlabsVoiceCloneAPIView.as_view()), +] + +urlpatterns = ( + [ + path('api-key', views.APIKeyView.as_view()), + path('me', views.UserInfoAPIView.as_view()), + path('voices', views.PublicVoiceListAPIView.as_view()), + path('voices/upload', views.PublicVoiceUploadAPIView.as_view()), + path('voices/', views.PublicVoiceDetailAPIView.as_view()), + ] + + openai_urlpatterns + + elevenlabs_urlpatterns +) + + for view in ( views.TextView, views.ImageView, views.AudioView, views.VideoView, views.CodeView, - views.VoiceView + views.VoiceView, ): urlpatterns.extend( [