@@ -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,12 +1,6 @@ -from authentication.exceptions.business_host_exceptions.base_already import ( - BaseAlready, -) +from django.utils.translation import gettext as _ -class AlreadyAccount(BaseAlready): - def msg(self) -> dict: - return dict( - message='user is already a business account for other company', - user_id=self.user.uid, - parent_company=self.user.business_account.parent_company.uid, - ) +class AlreadyAccount(Exception): + def __str__(self): + return _('User is already a business account for another company') @@ -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' ) @@ -1,12 +1,6 @@ -from authentication.exceptions.business_host_exceptions.base_already import ( - BaseAlready, -) +from django.utils.translation import gettext as _ -class AlreadyHost(BaseAlready): - def msg(self) -> dict: - return dict( - message='user already has a host account', - user_id=self.user.uid, - company_id=self.user.host_account.uid, - ) +class AlreadyHost(Exception): + def __str__(self): + return _('User already has a host account') @@ -1,12 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - # TODO: убрать кринж на рефакторинге - from authentication.models import CustomUserModel - - -class BaseAlready(Exception): - def __init__(self, user: 'CustomUserModel'): - self.user = user - - def msg(self): ... @@ -1,8 +1,4 @@ -from authentication.exceptions.business_host_exceptions.base_already import ( - BaseAlready, -) - -__all__ = ('BaseAlready',) +__all__ = () class InvalidPassword(Exception): ... @@ -4,3 +4,43 @@ from django.utils.translation import gettext as _ class BusinessAccountNotFound(Exception): def __str__(self): return _('Business account not found') + + +class AdminReinviteForbidden(Exception): + def __str__(self): + return _('You cannot reinvite other administrators') + + +class UnconfirmedUserChangePass(Exception): + def __str__(self): + return _('You cannot change the password of an unconfirmed e-mail user.') + + +class AdminPasswordChangeForbidden(Exception): + def __str__(self): + return _('Admin staff cannot change the password of another admin staff member') + + +class AdminUpdateForbidden(Exception): + def __str__(self): + return _('Admin staff cannot update other admin staff members') + + +class AdminCreateForbidden(Exception): + def __str__(self): + return _('Admin staff cannot create other admin staff members') + + +class AdminDeleteForbidden(Exception): + def __str__(self): + return _('Admin staff cannot delete other admin staff members') + + +class AdminPromoteForbidden(Exception): + def __str__(self): + return _('Admin staff cannot promote other staff members to admin') + + +class PasswordChangeRestricted(Exception): + def __str__(self): + return _('You can only change the password for regular employees or security staff members') @@ -11,6 +11,11 @@ class WrongPassword(Exception): return _('Wrong password') +class PasswordsDoNotMatch(Exception): + def __str__(self): + return _("Passwords don't match") + + class EmailNotConfirmed(Exception): def __str__(self): return _('User has not confirmed his email yet') @@ -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 @@ -1,16 +1,19 @@ -from decimal import Decimal from typing import Any, OrderedDict, Tuple from django.utils.translation import gettext_lazy as _ -from rest_framework.request import Request +from authentication.exceptions.business_account import ( + AdminPasswordChangeForbidden, + PasswordChangeRestricted, + UnconfirmedUserChangePass, +) +from authentication.exceptions.user import PasswordsDoNotMatch from authentication.models import ( BusinessAccount, BusinessUserHost, CustomUserModel, ) from authentication.models.choices import InvitationStatus -from authentication.serializers import ChangePasswordSerializer class BusinessAccountService: @@ -35,22 +38,13 @@ class BusinessAccountService: cls, user: CustomUserModel, host: BusinessUserHost, - status: Tuple[str, Any] | None = None, - account_privileges: Tuple[str, Any] | None = None, - token_limit: Decimal | None = None, + account_privileges: str, ): - account: BusinessAccount = BusinessAccount.objects.create( + account, _ = BusinessAccount.objects.update_or_create( user=user, parent_company=host, + defaults={'account_privileges': account_privileges}, ) - - if status is not None: - account.acceptance_status = status - if account_privileges is not None: - account.account_privileges = account_privileges - if token_limit is not None: - account.token_limit = token_limit - account.save() return cls(account) @classmethod @@ -63,9 +57,7 @@ class BusinessAccountService: @classmethod def get_company_name(cls, user: CustomUserModel): - if user.account_type == 'business_host': - return user.host_account - return user.business_account.parent_company + return user.host or user.employee.parent_company def update(self, data: OrderedDict) -> BusinessAccount: if data['status'] == InvitationStatus.ACCEPTED: @@ -86,34 +78,17 @@ class BusinessAccountService: def reject(self): self.update_status(InvitationStatus.REJECTED) - def update_limit(self, new_balance: Decimal | None): - self.account.token_limit = new_balance - self.account.save() - - def update_privileges(self, new_privileges: str): - self.account.account_privileges = new_privileges - self.account.save() - - def update_user_password(self, request: Request): - serializer = ChangePasswordSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - - if serializer.validated_data['password_1'] != serializer.validated_data['password_2']: - raise Exception(_("Passwords don't match")) - - if ( - self.account.user.account_type == 'business_security' - and request.user.account_type == 'business_security' - ): - raise Exception(_('You do not have sufficient rights to perform this action')) + def update_user_password(self, user: CustomUserModel, password_1: str, password_2: str): + if self.account.acceptance_status != InvitationStatus.ACCEPTED: + raise UnconfirmedUserChangePass - if self.account.user.account_type not in ('business_account', 'business_security'): - raise Exception(_('You do not have sufficient rights to perform this action')) + if password_1 != password_2: + raise PasswordsDoNotMatch - if self.account.acceptance_status != InvitationStatus.ACCEPTED: - raise Exception(_('You cannot change the password of an unconfirmed e-mail user.')) + if self.account.user.account_type == 'business_admin' == user.account_type: + raise AdminPasswordChangeForbidden - self.account.user.set_password(serializer.validated_data['password_1']) + self.account.user.set_password(password_1) self.account.user.save() def update_status(self, status: Tuple[str, Any]): @@ -1,17 +1,19 @@ from decimal import Decimal from typing import Any, Tuple +from uuid import UUID -from django.db import IntegrityError -from django.utils.translation import gettext_lazy as _ 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_account import ( + AdminCreateForbidden, + AdminDeleteForbidden, + AdminPromoteForbidden, + AdminReinviteForbidden, + AdminUpdateForbidden, + BusinessAccountNotFound, ) -from authentication.exceptions.business_host_exceptions.access_denied import AccessDenied +from authentication.exceptions.business_host_exceptions import AlreadyAccount, InviteeHasPlan from authentication.exceptions.business_host_exceptions.already_host import ( AlreadyHost, ) @@ -19,8 +21,8 @@ from authentication.models import ( BusinessAccount, BusinessUserHost, CustomUserModel, + BusinessGroup, ) -from authentication.models.choices import InvitationStatus from authentication.selectors.account_status_selector import ( AccountStatusSelector, ) @@ -32,13 +34,11 @@ from authentication.selectors.business_host_selector import ( ) from authentication.selectors.user_selector import UserSelector from authentication.serializers import ( - AccountDataUpdateSerializer, AddModelsSerializer, BusinessAccountDataSerializer, BusinessHostSerializer, BusinessHostUpdateSerializer, DeleteBusinessAccountSerializer, - DeletedAccountDataSerializer, DeleteModelsSerializer, NewBusinessAccountSerializer, NewBusinessHostSerializer, @@ -67,66 +67,46 @@ class BusinessHostService: self.user = user def create_account( - self, - email: str, - token_limit: Decimal | None = None, - account_privileges: Tuple[str, Any] | None = None, + self, email: str, host: BusinessUserHost, account_privileges: str ) -> BusinessAccountService: password = generate_token(15) user = CustomUserModel.objects.create_user(email=email, password=password) - user.save() PaymentPlanService(user).subscribe_user_to_plan(PaymentPlan.objects.get(price=0, is_corporate=False)) - account_service = BusinessAccountService.create( - user, - self.user.host_account, - account_privileges=account_privileges, - ) - if token_limit is not None: - account_service.update_limit(token_limit) - EmailService(self.user).send_corporate_greeting_email(account_service.account, password) + account_service = BusinessAccountService.create(user, host, account_privileges=account_privileges) + EmailService.send_corporate_greeting_email(account_service.account, password) return account_service - def create_existing( - self, - email: str, - token_limit: Decimal | None = None, - account_privileges: Tuple[str, Any] | None = None, - ) -> BusinessAccountService: + def create_existing(self, email: str, account_privileges: str, group: UUID | None = None) -> BusinessAccountService: + company = self.user.host or self.user.employee.parent_company + if self.user.account_type == 'business_admin' and account_privileges == 'admin': + raise AdminCreateForbidden try: user = UserSelector.get_by_email(email.lower()) - if AccountStatusSelector(user).is_business_host() and not user.is_deleted: - raise AlreadyHost(user) - if AccountStatusSelector(user).is_business_account() and not user.is_deleted: - raise AlreadyAccount(user) + if user.host: + raise AlreadyHost + if user.employee and user.employee.parent_company != company: + raise AlreadyAccount if PaymentPlanSelector(user).is_plan_paid(): - raise AlreadyHasPlan(user) + raise InviteeHasPlan except CustomUserModel.DoesNotExist: - return self.create_account( - email=email, - token_limit=token_limit, - account_privileges=account_privileges, - ) - try: - account_service = BusinessAccountService.create( - user, - self.user.host_account, - account_privileges=account_privileges, - ) - except IntegrityError: - account_service = BusinessAccountService.from_user(user) - if token_limit is not None: - account_service.update_limit(token_limit) + return self.create_account(email=email, host=company, account_privileges=account_privileges) + account_service = BusinessAccountService.create( + user, + company, + account_privileges=account_privileges, + ) + account_service.account.group = BusinessGroup.objects.filter(uid=group, parent_company=company).first() + account_service.account.save() if not user.is_deleted: - EmailService(self.user).send_corporate_invitation_email(account_service.account) + EmailService.send_corporate_invitation_email(account_service.account, company) else: password = generate_token(15) user.set_password(password) user.is_deleted = False user.save() - EmailService(self.user).send_corporate_greeting_email(account_service.account, password) - + EmailService.send_corporate_greeting_email(account_service.account, password) return account_service def create(self, request: Request) -> BusinessAccountDataSerializer: @@ -151,51 +131,29 @@ class BusinessHostService: logger.exception("Ошибка при создании учетной записи:") raise APIException(f"Произошла ошибка при создании учетной записи: {exc}") from exc - def update_token_limit( - self, - user: CustomUserModel, - amount: Decimal, - ): - BusinessAccountSelector.from_user(user, company=self.user.host_account).to_service().update_limit( - amount - ) - def update_user_invitation_status(self, user: CustomUserModel, new_status: Tuple[str, Any]): BusinessAccountSelector.from_user(user, company=self.user.host_account).to_service().update_status( new_status ) - def update_privileges(self, user: CustomUserModel, new_privileges: str): - BusinessAccountSelector.from_user( - user, company=self.user.host_account - ).to_service().update_privileges(new_privileges) + def update(self, email: str, token_limit: Decimal | None, privileges: str) -> BusinessAccount: + business_account = UserSelector.get_by_email(email).employee + if not business_account: + raise BusinessAccountNotFound + + if business_account.user.account_type == 'business_admin' == self.user.account_type: + raise AdminUpdateForbidden - def update(self, request: Request, **kwargs): - serializer = AccountDataUpdateSerializer(data=request.data) - serializer.is_valid(raise_exception=True) + if self.user.account_type == 'business_admin' and privileges == 'admin': + raise AdminPromoteForbidden - user_email = kwargs.get('user_email', None) - if user_email is None: - raise Exception(_('No user_email is provided')) - - user = UserSelector.get_by_email(user_email) - if not AccountStatusSelector(user).is_business_account(): - raise Exception("Business Account for this user doesn't exist") - if token_limit := serializer.validated_data.get('token_limit', None): - self.update_token_limit( - user, - token_limit, - ) - if status := serializer.validated_data.get('status', False): - self.update_user_invitation_status(user, status) - if privileges := serializer.validated_data.get('account_privileges', None): - self.update_privileges(user, privileges) - - account_type = UserSelector(user).check_account_type() - if status == InvitationStatus.CANCELLED: - return DeletedAccountDataSerializer(user, context={'account_type': 'regular'}) - - return BusinessAccountDataSerializer(user.business_account, context={'account_type': account_type}) + company = self.user.host or self.user.employee.parent_company + if business_account.parent_company != company: + raise AlreadyAccount + business_account.token_limit = token_limit + business_account.account_privileges = privileges + business_account.save() + return business_account def update_self(self, request: Request, serialize: bool = True) -> BusinessHostSerializer: serializer = BusinessHostUpdateSerializer(data=request.data) @@ -220,36 +178,29 @@ class BusinessHostService: return BusinessHostSerializer(company, context={'token_cap_enabled': company.token_cap_enabled}) return company - def delete(self, request: Request): - serializer = DeleteBusinessAccountSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - account = BusinessAccount.objects.filter( - parent_company=self.user.host_account, user__uid=serializer.validated_data['uid'] - ).first() + def delete(self, employee_user_uid: UUID): + company = self.user.host or self.user.employee.parent_company + account = BusinessAccount.objects.filter(parent_company=company, user__uid=employee_user_uid).first() if not account: raise BusinessAccountNotFound + if self.user.account_type == 'business_admin' == account.user.account_type: + raise AdminDeleteForbidden + account.delete() def create_host(self, request: Request) -> UserDataSerializer: serializer = NewBusinessHostSerializer(data=request.data) serializer.is_valid(raise_exception=True) - if PaymentPlanSelector(self.user).is_plan_paid(): - raise business_host_exceptions.AlreadyHasPlan(self.user) - if AccountStatusSelector(self.user).is_business_host(): - raise business_host_exceptions.AlreadyHost(self.user) + raise business_host_exceptions.AlreadyHost() if AccountStatusSelector(self.user).is_business_account(): - raise business_host_exceptions.AlreadyAccount(self.user) + raise business_host_exceptions.AlreadyAccount() host = BusinessUserHost.objects.create(user=self.user, **serializer.validated_data) - host.save() - PaymentPlanService(self.user).subscribe_user_to_plan( - PaymentPlan.objects.get(price=0, is_corporate=True) - ) EmailService(self.user).send_copr_purchase_email(host) account_type = UserSelector(self.user).check_account_type() @@ -282,12 +233,10 @@ class BusinessHostService: return BusinessHostSelector(self.user).get_allowed_models() def reinvite_business_account(self, business_account: BusinessAccount) -> None: - if ( - self.user.account_type == business_account.user.account_type - and self.user.account_type == 'business_admin' - ): - raise AccessDenied + if self.user.account_type == 'business_admin' == business_account.user.account_type: + raise AdminReinviteForbidden password = generate_token(15) business_account.user.set_password(password) + business_account.user.is_deleted = False business_account.user.save() EmailService(self.user).send_reinvited_email(business_account, password) @@ -1,11 +1,15 @@ import logging +from datetime import datetime +from decimal import Decimal +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,12 +17,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 core.minio_service import MinIOService -from reports.models.error_report import ErrorReport - -from django.core.mail import send_mail -from django.utils.html import strip_tags - +from reports.domain import Report logger = logging.getLogger(__name__) @@ -32,16 +31,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): @@ -50,27 +51,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): @@ -78,106 +81,101 @@ 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')) + @classmethod + def send_corporate_greeting_email(cls, account: BusinessAccount, password: str | None = None): 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 - self.send_email( - subject='Ваш аккаунт на платформе AIR', message=html_message, user_email=account.user.email + html_message = cls._render_letter_template( + template_name='authentication/corporate_greeting_email', + context={ + 'business_account': account, + 'invitation_url': settings.INVITATION_RESPONSE_URL, + 'token': token.key, + 'password': password, + }, + ) + cls.send_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 + token = EmailTokenService(account.user).generate_user_token() + 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_corporate_invitation_email(self, account: BusinessAccount): - if self.user.host_account is None: - raise Exception(_('Regular users cannot send invitation letters')) + @classmethod + def send_corporate_invitation_email(cls, account: BusinessAccount, host: BusinessUserHost) -> None: 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 = cls._render_letter_template( + template_name='authentication/corporate_invitation_email', + context={ + 'email': account.user.email, + 'host': host, + 'invite_url': settings.INVITATION_RESPONSE_URL, + 'token': token.key, + }, + ) + cls.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) + + @classmethod + def send_payment_email( + cls, product_title: str, next_payment_at: datetime | str, amount: Decimal, payer_email: str, status: str + ) -> None: + templates = { + 'succeeded': 'authentication/success_payment_notification', + 'canceled': 'authentication/canceled_payment_notification', + } + html_message = cls._render_letter_template( + template_name=templates[status], + context={'product_title': product_title, 'next_payment_at': next_payment_at, 'amount': amount}, + ) + cls.send_email('Детали платежа', html_message, (payer_email,)) + @@ -16,7 +16,7 @@ class EmailTokenService: return EmailToken.objects.create(user=self.user, key=generate_token(22)) @classmethod - def get_token(self, key: str) -> EmailToken: + def get_token(cls, key: str) -> EmailToken: try: return EmailToken.objects.get(key=key) except EmailToken.DoesNotExist: @@ -89,7 +89,6 @@ class UserService: ) except CustomUserModel.DoesNotExist: pass - return user def create_user_telegram(self, request: Request) -> TelegramUser: @@ -218,7 +217,7 @@ class UserService: token = EmailTokenSelector.get_email_token(key=key) if token is None: - raise Exception(_("No token like this in a database")) + raise EmailTokenNotFound serializer = ChangePasswordSerializer(data=request.data) serializer.is_valid(raise_exception=True) @@ -0,0 +1,16 @@ + + + + + Подтверждение аккаунта + + +

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

+
+

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

+

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

+

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

+

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

+

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

+ + \ No newline at end of file @@ -0,0 +1,22 @@ + + + + + Неуспешный платеж + + +

Здравствуйте!

+ +

К сожалению, платёж за восстановление баланса по плану на сумму {{ amount }} не был успешно + обработан.

+ +

Следующая попытка списания будет выполнена: {{ next_payment_at }}.

+ +

+ Пожалуйста, убедитесь, что на вашем счёте достаточно средств или + обновите платёжные данные, чтобы избежать прерывания доступа. +

+ +

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

+ + @@ -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 @@ -1,13 +0,0 @@ - - - - - Ваш аккаунт на платформе AIR: - - -

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

-

E-mail: {{ email }}

-

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

-

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

- - @@ -0,0 +1,16 @@ + + + + + Детали платежа + + +

Здравствуйте!

+ +

{{ product_title }} на нашем сайте.

+ +

Следующая дата пополнения: {{ next_payment_at }}.

+ +

Если у вас возникнут вопросы, пожалуйста, свяжитесь с нашей службой поддержки — мы всегда рады помочь.

+ + @@ -0,0 +1,94 @@ +from authentication.models import CustomUserModel, BusinessUserHost, BusinessAccount + +from core.tests import BaseAuthorizedAPITest +from payments.models import PaymentPlan + + +class MeAPITest(BaseAuthorizedAPITest): + ENDPOINT = '/api/v1/auth/me' + + @classmethod + def setup_host(cls) -> None: + cls.host_user = CustomUserModel.objects.create_user(email='test_2@test.test', password='test_2') + cls.host = BusinessUserHost.objects.create(user=cls.host_user) + cls.host_payment_plan = PaymentPlan.objects.create( + price=1000, tokens_per_plan=900, is_corporate=True + ) + cls.host_user.payment_plan.plan = cls.host_payment_plan + cls.host_user.payment_plan.save() + + @classmethod + def setup_test_data(cls) -> None: + cls.payment_plan, _ = PaymentPlan.objects.update_or_create( + price=0, tokens_per_plan=10, defaults={} + ) + cls.setup_host() + + def test_unauthorized_status_code(self) -> None: + response = self.client.get(self.ENDPOINT) + self.assertEqual(response.status_code, 401) + self.assertEqual(response.json(), {'detail': 'Unauthorized'}) + + def test_authorized_status_code(self) -> None: + response = self.get() + self.assertEqual(response.status_code, 200) + + def test_completeness_response(self) -> None: + response = self.get() + keys = list(response.json().keys()) + self.assertEqual( + keys, + [ + 'uid', + 'first_name', + 'last_name', + 'created_at', + 'email', + 'is_active', + 'is_superuser', + 'is_staff', + 'is_confirmed', + 'is_subscribed_to_emails', + 'show_balance', + 'profile_picture_link', + 'account_type', + 'token', + 'payment_plan', + 'referral_code', + 'is_social', + 'social_auth', + ], + ) + + def test_account_type(self) -> None: + def _check_account_type(necessary_type: str) -> None: + account_type = self.get().json()['account_type'] + self.assertEqual(account_type, necessary_type) + + _check_account_type('regular') + host = BusinessUserHost.objects.create(user=self.user) + _check_account_type('business_host') + host.delete() + business_account = BusinessAccount.objects.create(user=self.user, parent_company=self.host) + _check_account_type('business_account') + for privilege, acc_type in {'admin': 'business_admin', 'sec': 'business_security'}.items(): + business_account.account_privileges = privilege + business_account.save() + _check_account_type(acc_type) + + def test_show_balance(self) -> None: + business_account = BusinessAccount.objects.create(user=self.user, parent_company=self.host) + show_balance = self.get().json()['show_balance'] + self.assertTrue(show_balance) + business_account.show_balance = False + business_account.save() + show_balance = self.get().json()['show_balance'] + self.assertFalse(show_balance) + + def test_payment_plan(self) -> None: + payment_plan_uid = self.get().json()['payment_plan']['plan']['uid'] + self.assertEqual(str(payment_plan_uid), str(self.payment_plan.uid)) + BusinessAccount.objects.create(user=self.user, parent_company=self.host) + payment_plan_uid = self.get().json()['payment_plan']['plan']['uid'] + self.assertEqual(str(payment_plan_uid), str(self.host_payment_plan.uid)) + @@ -16,7 +16,6 @@ PATH_PREFETCH_MAP = { 'payment_plan__plan', ), 'prefetch': ( - Prefetch('payment_plan__plan__accessed_models', queryset=NeuronModel.objects.only('slug')), Prefetch( 'business_account__parent_company__user__payment_plan__plan', queryset=NeuronModel.objects.only('slug'), @@ -44,18 +43,14 @@ PATH_PREFETCH_MAP = { 'parent_company__user__uid', ), *_gen_only('payment_plan', 'uid', 'last_payment_at'), - *_gen_only( - 'payment_plan__plan', 'uid', 'title', 'price', 'tokens_per_plan', 'points' - ), + *_gen_only('payment_plan__plan', 'uid', 'price', 'tokens_per_plan'), *_gen_only( 'business_account__parent_company__user__payment_plan', 'uid', 'last_payment_at', 'plan__uid', - 'plan__title', 'plan__price', 'plan__tokens_per_plan', - 'plan__points', ), 'host_account', ), @@ -81,4 +76,20 @@ PATH_PREFETCH_MAP = { 'host_account', ), }, + '/api/v1/payments/plans': { + 'select': ( + 'host_account', + 'payment_plan', + 'business_account__parent_company__user__payment_plan__plan', + 'payment_plan__plan', + 'payment_method', + 'payment_method__recurring_payment', + ), + 'prefetch': ( + Prefetch( + 'business_account__parent_company__user__payment_plan__plan', + queryset=NeuronModel.objects.only('slug'), + ), + ), + }, } @@ -53,21 +53,11 @@ class IsVKMiniApp(BaseException): class HasBusinessAdminPermissions(BasePermission): def has_permission(self, request, view): - if request.user.is_anonymous: + user = request.user + if user.is_anonymous: return False - account_status = AccountStatusSelector(request.user) - if account_status.is_business_host(): + elif user.host: return True - if not account_status.is_business_account(): - return False - - account_type = request.user.business_account.account_privileges - - return account_type == AccountPrivileges.ADMIN - - -class ChangeEmployeePassPermission(BasePermission): - def has_permission(self, request, view): - if request.user.is_anonymous: - return False - return request.user.account_type in ('business_host', 'business_admin', 'business_security') + elif b_acc := user.employee: + return b_acc.account_privileges == AccountPrivileges.ADMIN + return False @@ -104,6 +104,7 @@ class SyncAuthBearer(HttpBearer): class AsyncAuthBearer(HttpBearer): async def authenticate(self, request: HttpRequest, token: str) -> Any | None: mapper = PATH_PREFETCH_MAP.get(request.path, {}) + logger.info('%s %s', token, request.path) try: user_payload = await TokenService.decode(token=token) request.provider = 'air' @@ -157,15 +157,10 @@ class UserDetailSerializer(serializers.Serializer): class NewBusinessAccountSerializer(serializers.Serializer): email = serializers.EmailField() - token_limit = serializers.DecimalField( - max_digits=50, - decimal_places=2, - required=False, - default=None, - ) account_privileges = serializers.ChoiceField( choices=AccountPrivileges.choices, default=AccountPrivileges.REGULAR ) + group = serializers.UUIDField(required=False, allow_null=True) class DeleteBusinessAccountSerializer(serializers.Serializer): @@ -191,16 +186,6 @@ class BusinessHostUpdateSerializer(serializers.Serializer): token_cap = serializers.DecimalField(max_digits=15, decimal_places=2, required=False) -class DeletedAccountDataSerializer(serializers.Serializer): - email = serializers.EmailField() - account_type = serializers.SerializerMethodField() - created_at = serializers.DateTimeField() - updated_at = serializers.DateTimeField() - - def get_account_type(self, obj): - return self.context.get('account_type') - - class AccountStatusSerializer(serializers.Serializer): status = serializers.SerializerMethodField() @@ -273,7 +258,6 @@ class ChangeInvitationStatusSerializer(serializers.Serializer): class AccountDataUpdateSerializer(serializers.Serializer): - status = serializers.ChoiceField(choices=InvitationStatus.choices, required=False) token_limit = serializers.DecimalField(max_digits=50, decimal_places=2, default=None) account_privileges = serializers.ChoiceField( choices=AccountPrivileges.choices, default=AccountPrivileges.REGULAR @@ -91,7 +91,7 @@ urlpatterns = [ path('business-host/logs/', views.LogsAPIView.as_view()), path( 'business-host/account/change-pass/', - views.ChangeHostPassAPIView.as_view(), + views.ChangeBusinessAccountPassAPIView.as_view(), name='change_host_pass', ), path( @@ -22,8 +22,21 @@ from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView -from authentication.exceptions import BaseAlready -from authentication.exceptions.business_host_exceptions.access_denied import AccessDenied +from authentication.exceptions.business_host_exceptions import ( + AlreadyAccount, + AlreadyHasPlan, + AlreadyHost, + InviteeHasPlan, +) +from authentication.exceptions.business_account import ( + AdminCreateForbidden, + AdminDeleteForbidden, + AdminPasswordChangeForbidden, + AdminReinviteForbidden, + AdminUpdateForbidden, + BusinessAccountNotFound, + UnconfirmedUserChangePass, +) from authentication.exceptions.business_host_exceptions.not_allowed_ip import ( NotAllowedIP, ) @@ -33,6 +46,7 @@ from authentication.exceptions.email_token import EmailTokenNotFound from authentication.exceptions.user import ( DomainNotFound, EmailNotConfirmed, + PasswordsDoNotMatch, UserAlreadyExists, WrongEmail, WrongPassword, @@ -42,7 +56,6 @@ from authentication.models.business_host import BusinessUserHost from authentication.models.choices import AccountPrivileges, InvitationStatus from authentication.models.whitelist import CompanyIPWhitelist from authentication.permissions import ( - ChangeEmployeePassPermission, HasBusinessAdminPermissions, IsAnonymous, IsBusinessSecurity, @@ -296,8 +309,12 @@ class BusinessHostAPIView(APIView): try: result = BusinessHostService(self.request.user).create(request) return Response(result.data, status=status.HTTP_201_CREATED) - except BaseAlready as err: - return Response(err.msg(), status=status.HTTP_400_BAD_REQUEST) + except (AlreadyAccount, AlreadyHost, AlreadyHasPlan, InviteeHasPlan) as err: + return Response({'detail': str(err)}, status=status.HTTP_400_BAD_REQUEST) + except AdminCreateForbidden as err: + return Response({'detail': str(err)}, status=status.HTTP_403_FORBIDDEN) + except DomainNotFound: + return Response({'detail': _('Email sending error: email not found')}, status=status.HTTP_400_BAD_REQUEST) except Exception as err: return Response({'detail': f'{err}'}, status=status.HTTP_400_BAD_REQUEST) @@ -316,8 +333,14 @@ class BusinessHostAPIView(APIView): def delete(self, request, *args, **kwargs): """Delete user company""" try: - BusinessHostService(self.request.user).delete(request) + serializer = DeleteBusinessAccountSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + BusinessHostService(self.request.user).delete(serializer.validated_data['uid']) return Response({'detail': _('Business account has been deleted')}, status=status.HTTP_200_OK) + except BusinessAccountNotFound as exc: + return Response({'detail': str(exc)}, status=status.HTTP_400_BAD_REQUEST) + except AdminDeleteForbidden as exc: + return Response({'detail': str(exc)}, status=status.HTTP_403_FORBIDDEN) except Exception as exc: logger.exception(exc) return Response( @@ -341,12 +364,14 @@ class ReinviteBusinessAccountAPIView(APIView): ) BusinessHostService(request.user).reinvite_business_account(business_account=business_account) return Response({'detail': _('Business account has been reinvited')}, status=status.HTTP_200_OK) + except DomainNotFound: + return Response({'detail': _('Email sending error: email not found')}, status=status.HTTP_400_BAD_REQUEST) except LetterNotFound as exc: return Response({'detail': f'{exc}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) except LetterUnknownException as exc: logger.exception(exc) return Response({'detail': f'{exc}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - except AccessDenied as exc: + except AdminReinviteForbidden as exc: return Response({'detail': f'{exc}'}, status=status.HTTP_403_FORBIDDEN) except Exception as exc: logger.exception(exc) @@ -386,8 +411,8 @@ class HostWorkersAPIView(APIView): return Response({'detail': f'{err}'}, status=status.HTTP_400_BAD_REQUEST) -class ChangeHostPassAPIView(APIView): - permission_classes = (ChangeEmployeePassPermission,) +class ChangeBusinessAccountPassAPIView(APIView): + permission_classes = (HasBusinessAdminPermissions,) @extend_schema( parameters=[ @@ -395,15 +420,24 @@ class ChangeHostPassAPIView(APIView): ], request=ChangePasswordSerializer, ) - def patch(self, request, *args, **kwargs): + def patch(self, request, email: str, *args, **kwargs): """Change business account password""" try: + serializer = ChangePasswordSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + data = serializer.validated_data business_account = BusinessAccountSelector.filter_by_email( - request.parser_context['kwargs'].get('email'), + email, BusinessAccountService.get_company_name(self.request.user), ) - BusinessAccountService(business_account).update_user_password(request) - return Response({'detail': 'Host password has been updated'}, status=status.HTTP_200_OK) + if not business_account: + return Response({'detail': _('Business account not found')}, status=status.HTTP_400_BAD_REQUEST) + BusinessAccountService(business_account).update_user_password( + request.user, data['password_1'], data['password_2'] + ) + return Response({'detail': _('Business account password has been updated')}, status=status.HTTP_200_OK) + except (UnconfirmedUserChangePass, PasswordsDoNotMatch, AdminPasswordChangeForbidden) as exc: + return Response({'detail': str(exc)}, status=status.HTTP_403_FORBIDDEN) except Exception as exc: return Response({'detail': f'{exc}'}, status=status.HTTP_400_BAD_REQUEST) @@ -455,7 +489,7 @@ class ChangePasswordAPIView(APIView): """Edit user password from email.""" try: UserService.change_password(request) - return Response({'detail': 'password_updated'}, status=status.HTTP_200_OK) + return Response(status=status.HTTP_200_OK) except Exception as err: return Response({'detail': f'{err}'}, status=status.HTTP_400_BAD_REQUEST) @@ -556,10 +590,10 @@ class StartHostRegistrationAPIView(APIView): try: response = BusinessHostService(self.request.user).create_host(request) return Response(response.data, status=status.HTTP_201_CREATED) - except BaseAlready as err: - return Response(err.msg(), status=status.HTTP_400_BAD_REQUEST) - except Exception as err: - return Response({'detail': str(err)}, status=status.HTTP_400_BAD_REQUEST) + except (AlreadyAccount, AlreadyHost, AlreadyHasPlan, InviteeHasPlan) as exc: + return Response({'detail': str(exc)}, status=status.HTTP_400_BAD_REQUEST) + except Exception as exc: + return Response({'detail': str(exc)}, status=status.HTTP_400_BAD_REQUEST) class AllowedHostModelsAPIView(APIView): @@ -600,13 +634,25 @@ class HostInvitationAPIView(APIView): request=AccountDataUpdateSerializer, responses={200: BusinessAccountDataSerializer}, ) - def put(self, request, *args, **kwargs): + def put(self, request, user_email : str, *args, **kwargs): """Update company sub-user.""" try: - result = BusinessHostService(self.request.user).update(request, **kwargs) + serializer = AccountDataUpdateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + data = serializer.validated_data + employee = BusinessHostService(self.request.user).update( + user_email, data['token_limit'], data['account_privileges'] + ) + result = BusinessAccountDataSerializer( + employee, context={'account_type': employee.user.account_type} + ) return Response(result.data, status=status.HTTP_200_OK) - except Exception as err: - return Response({'detail': f'{err}'}, status=status.HTTP_400_BAD_REQUEST) + except (AlreadyAccount, BusinessAccountNotFound) as exc: + return Response({'detail': str(exc)}, status=status.HTTP_400_BAD_REQUEST) + except AdminUpdateForbidden as exc: + return Response({'detail': str(exc)}, status=status.HTTP_403_FORBIDDEN) + except Exception as exc: + return Response({'detail': f'{exc}'}, status=status.HTTP_400_BAD_REQUEST) class AccountInvitationAPIView(APIView): @@ -1,6 +1,7 @@ import logging.config from pathlib import Path +from PIL import ImageFile from celery.schedules import crontab from environs import Env @@ -247,6 +248,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) @@ -330,10 +332,8 @@ FAL_API_KEY = env.str('FAL_API_KEY', 'defaultapikey') YANDEX_CLOUD_API_KEY = env.str('YANDEX_CLOUD_API_KEY', 'defaultapikey') YANDEX_CLOUD_ID = env.str('YANDEX_CLOUD_ID', 'defaultapikey') -MAX_UPLOAD_SIZE_PER_MODEL = { - 'raifgpt': 50, - 'default': 8, -} +# FILES +ImageFile.LOAD_TRUNCATED_IMAGES = True # Payments YOOKASSA_ACCOUNT_ID = env.str('YOOKASSA_ACCOUNT_ID', default='defaultapikey') @@ -439,7 +439,6 @@ LOGGING = { }, } - logging.config.dictConfig(LOGGING) if (SENTRY_URL := env.str('SENTRY_URL', '')) and RELEASE and ENVIRONMENT: @@ -464,7 +463,15 @@ if (SENTRY_URL := env.str('SENTRY_URL', '')) and RELEASE and ENVIRONMENT: cache_spans=False, ), ], - ignore_errors=['InsufficientBalance'] + ignore_errors=[ + 'InsufficientBalance', + 'RequestBlocked', + 'PredictionInterruptedError', + 'ImageContentNotFound', + 'PromptLengthExceeded', + 'InvalidParameterError', + 'UnsupportedSize' + ], ) CACHEOPS_REDIS = env.str('CACHEOPS_REDIS', CACHES['default']['LOCATION']) @@ -478,6 +485,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}, @@ -487,3 +495,4 @@ if CACHEOPS_REDIS: FEATURE_FLAG_API_URL = env.str('FEATURE_FLAG_API_URL') FEATURE_FLAG_APP_NAME = env.str('FEATURE_FLAG_APP_NAME', 'staging') FEATURE_FLAG_INSTANCE_ID = env.str('FEATURE_FLAG_INSTANCE_ID') +FEATURE_FLAG_WEBHOOK_SECRET_KEY = env.str('FEATURE_FLAG_WEBHOOK_SECRET_KEY', 'FEATURE_FLAG_WEBHOOK_SECRET_KEY') @@ -52,17 +52,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() @@ -20,6 +20,7 @@ class MinIOService: 'air-errors', 'air-welcome-pic', 'air-messages', + 'air-media-presets' ] def __init__(self): @@ -0,0 +1,107 @@ +from abc import abstractmethod +from typing import Any + +from cacheops import invalidate_all +from django.test import TestCase +from ninja.testing import TestClient +from rest_framework_simplejwt.tokens import RefreshToken +from backend.urls import compatibility_api + +from authentication.models import CustomUserModel + + +class BaseAPITest(TestCase): + ENDPOINT: str + + @classmethod + @abstractmethod + def setUpTestData(cls) -> None: ... + + @abstractmethod + def test_unauthorized_status_code(self) -> None: ... + + def setUp(self): + invalidate_all() + super().setUp() + + +class BaseAuthorizedAPITest(BaseAPITest): + TEST_USER_EMAIL = 'test@test.test' + TEST_USER_PASSWORD = 'test' + + user: CustomUserModel + access_token: str + client: Any + + @classmethod + def setUpTestData(cls) -> None: + cls.setup_client() + cls.setup_user() + cls.setup_test_data() + cls.setup_authentication() + + @classmethod + def setup_client(cls) -> None: + cls.client = TestClient(compatibility_api) + + @classmethod + def setup_user(cls) -> None: + cls.user = CustomUserModel.objects.create_user( + email=cls.TEST_USER_EMAIL, password=cls.TEST_USER_PASSWORD + ) + + @classmethod + def setup_test_data(cls) -> None: + pass + + @classmethod + def setup_authentication(cls) -> None: + cls.access_token = str(RefreshToken.for_user(cls.user).access_token) + + def auth_headers(self) -> dict[str, str]: + return {'Authorization': f'Bearer {self.access_token}'} + + def get(self, endpoint: str | None = None, headers: dict[str, str] | None = None) -> Any: + endpoint = endpoint or self.ENDPOINT + headers = headers or self.auth_headers() + return self.client.get(endpoint, headers=headers) + + def post( + self, + data: dict[str, Any] | None = None, + endpoint: str | None = None, + headers: dict[str, str] | None = None, + ) -> Any: + endpoint = endpoint or self.ENDPOINT + headers = headers or self.auth_headers() + return self.client.post(endpoint, data=data, headers=headers) + + def put( + self, + data: dict[str, Any] | None = None, + endpoint: str | None = None, + headers: dict[str, str] | None = None, + ) -> Any: + endpoint = endpoint or self.ENDPOINT + headers = headers or self.auth_headers() + return self.client.put(endpoint, data=data, headers=headers) + + def patch( + self, + data: dict[str, Any] | None = None, + endpoint: str | None = None, + headers: dict[str, str] | None = None, + ) -> Any: + endpoint = endpoint or self.ENDPOINT + headers = headers or self.auth_headers() + return self.client.patch(endpoint, data=data, headers=headers) + + def delete(self, endpoint: str | None = None, headers: dict[str, str] | None = None) -> Any: + endpoint = endpoint or self.ENDPOINT + headers = headers or self.auth_headers() + return self.client.delete(endpoint, headers=headers) + + @abstractmethod + def test_authorized_status_code(self) -> None: ... + + @@ -15,7 +15,8 @@ class UnleashFeatureFlagService(FeatureFlagService): url=settings.FEATURE_FLAG_API_URL, app_name=settings.FEATURE_FLAG_APP_NAME, instance_id=settings.FEATURE_FLAG_INSTANCE_ID, - cache=UnleashRedisCache() + cache=UnleashRedisCache(), + environment=settings.FEATURE_FLAG_APP_NAME ) def get_flag_state_by_emails(self, name: str, emails: List[Email]) -> Mapping[Email, State]: @@ -0,0 +1,19 @@ +from django.db.models import Model +from django.utils.translation import gettext as _ + + +class UnknownError(Exception): + def __str__(self) -> str: + return _('An unknown error has occurred. Please contact support') + + +class DuplicateError(Exception): + def __init__(self, model: type[Model], attrs: tuple[str, ...]) -> None: + self.model = model + self.attrs = attrs + + def __str__(self) -> str: + return _('A %(model)s with fields %(fields)s already exists') % { + 'model': self.model._meta.verbose_name, + 'fields': ', '.join(self.attrs), + } @@ -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-12-04 19:13+0300\n" +"POT-Creation-Date: 2026-02-09 15:27+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -20,25 +20,77 @@ msgstr "" "n%10<=4 && (n%100<12 || n%100>14) ? 1 : n%10==0 || (n%10>=5 && n%10<=9) || " "(n%100>=11 && n%100<=14)? 2 : 3);\n" -#: authentication/exceptions/business_account.py:6 +#: authentication/exceptions/business_account.py:6 authentication/views.py:434 msgid "Business account not found" msgstr "Сотрудник не найден" +#: authentication/exceptions/business_account.py:11 +msgid "You cannot reinvite other administrators" +msgstr "Вы не можете повторно приглашать администраторов" + +#: authentication/exceptions/business_account.py:16 +msgid "You cannot change the password of an unconfirmed e-mail user." +msgstr "Вы не можете изменить пароль неподтвержденного по e-mail пользователя." + +#: authentication/exceptions/business_account.py:21 +msgid "Admin staff cannot change the password of another admin staff member" +msgstr "Администраторы не могут изменять пароль другого администратора" + +#: authentication/exceptions/business_account.py:26 +msgid "Admin staff cannot update other admin staff members" +msgstr "Администраторы не могут обновлять других администраторов" + +#: authentication/exceptions/business_account.py:31 +msgid "Admin staff cannot create other admin staff members" +msgstr "Администраторы не могут создавать других администраторов" + +#: authentication/exceptions/business_account.py:36 +msgid "Admin staff cannot delete other admin staff members" +msgstr "Администраторы не могут удалять других администраторов" + +#: authentication/exceptions/business_account.py:41 +msgid "Admin staff cannot promote other staff members to admin" +msgstr "Администраторы не могут повышать других сотрудников до администраторов" + +#: authentication/exceptions/business_account.py:46 +msgid "" +"You can only change the password for regular employees or security staff " +"members" +msgstr "" +"Вы можете изменять пароль только обычным сотрудникам или сотрудникам " +"безопасности" + #: authentication/exceptions/business_host_exceptions/access_denied.py:6 -#: authentication/services/business_account_service.py:108 -#: authentication/services/business_account_service.py:111 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_account.py:6 +msgid "User is already a business account for another company" +msgstr "Пользователь уже является сотрудником другой компании" + +#: 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/already_host.py:6 +msgid "User already has a host account" +msgstr "Пользователь уже имеет корпоративный аккаунт" #: authentication/exceptions/business_host_exceptions/not_allowed_ip.py:6 msgid "Current IP not allowed in this context" @@ -57,10 +109,8 @@ msgid "Email token not found. Please contact support" msgstr "E-mail токен не найден. Пожалуйста, свяжитесь со службой поддержки" #: authentication/exceptions/email_token.py:6 -#, fuzzy -#| msgid "No email token provided" msgid "No email token found" -msgstr "Токен не получен" +msgstr "Email токен не найден" #: authentication/exceptions/user.py:6 msgid "Wrong email" @@ -71,23 +121,27 @@ msgid "Wrong password" msgstr "Неверный пароль" #: authentication/exceptions/user.py:16 +msgid "Passwords don't match" +msgstr "Пароли не совпадают" + +#: authentication/exceptions/user.py:21 msgid "User has not confirmed his email yet" msgstr "Пользователь пока не подтвердил свой email" -#: authentication/exceptions/user.py:21 +#: authentication/exceptions/user.py:26 msgid "The user cannot be registered without an email" msgstr "Пользователь не может быть зарегистрирован без email'а" -#: authentication/exceptions/user.py:26 +#: authentication/exceptions/user.py:31 msgid "User already exists" msgstr "Пользователь уже существует" -#: authentication/exceptions/user.py:31 +#: authentication/exceptions/user.py:36 msgid "Domain not found" msgstr "Домен не найден" #: authentication/models/business_account.py:16 payments/models/promocode.py:72 -#: tools/public_api/models.py:33 +#: tools/public_api/models.py:34 tools/public_api/services/api_key.py:24 msgid "Owner" msgstr "Владелец" @@ -110,7 +164,7 @@ msgid "Acceptance" msgstr "Подтверждение" #: authentication/models/business_account.py:44 -#: authentication/models/business_group.py:21 tools/public_api/models.py:43 +#: authentication/models/business_group.py:21 tools/public_api/models.py:44 msgid "Token limit" msgstr "Лимит токенов" @@ -135,8 +189,8 @@ msgid "Child Business Accounts" msgstr "Дочерние Бизнес Аккаунты" #: authentication/models/business_group.py:8 ml_model/models.py:26 -#: ml_model/models.py:185 ml_model/models.py:429 -#: payments/models/payment_plan.py:21 tools/chats/models.py:9 +#: ml_model/models.py:185 ml_model/models.py:429 tools/chats/models.py:9 +#: tools/media/models.py:41 msgid "Title" msgstr "Название" @@ -151,9 +205,9 @@ msgstr "Бизнес Группы" #: authentication/models/business_host.py:22 #: authentication/models/email_token.py:13 authentication/models/user.py:236 #: authentication/models/user.py:237 authentication/models/user_telegram.py:22 -#: authentication/models/user_vk.py:12 payments/admin.py:35 -#: payments/admin.py:87 payments/models/invoice.py:15 -#: payments/models/payment.py:26 payments/models/payment_plan.py:49 +#: authentication/models/user_vk.py:12 payments/admin.py:37 +#: payments/admin.py:95 payments/models/invoice.py:15 +#: payments/models/payment.py:26 payments/models/payment_plan.py:51 msgid "User" msgstr "Пользователь" @@ -199,13 +253,14 @@ msgid "PSRN" msgstr "ОГРН" #: authentication/models/business_host.py:69 ml_model/models.py:46 -#: ml_model/models.py:77 ml_model/models.py:288 tools/public_api/models.py:30 +#: ml_model/models.py:77 ml_model/models.py:288 tools/public_api/models.py:31 +#: tools/public_api/services/api_key.py:24 msgid "Name" msgstr "Наименование" #: authentication/models/business_host.py:72 msgid "Preffered name" -msgstr "Предпочтительное имя" +msgstr "" #: authentication/models/business_host.py:73 msgid "Corporate email" @@ -231,11 +286,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 "Бизнес Аккаунты" @@ -348,7 +403,7 @@ msgid "Picture name" msgstr "Имя аватара" #: authentication/models/user.py:153 tools/chats/models.py:13 -#: tools/public_api/models.py:45 +#: tools/public_api/models.py:46 msgid "Is deleted" msgstr "Удален" @@ -457,7 +512,7 @@ msgstr "Срок действия токена доступа истек" msgid "User not found" msgstr "Пользователь не найден" -#: authentication/security.py:99 authentication/security.py:127 +#: authentication/security.py:99 authentication/security.py:128 msgid "Access token expired or does not exist" msgstr "Токен доступа просрочен или не существует" @@ -475,82 +530,64 @@ msgstr "Пользователь бизнес-аккаунта не зареги msgid "No user with this uid found" msgstr "Не найден пользователь с данным ID" -#: authentication/services/business_account_service.py:60 +#: authentication/services/business_account_service.py:54 msgid "BusinessAccount for this user doesn't exist" msgstr "Бизнес-аккаунт для данного юзера не найден" -#: authentication/services/business_account_service.py:76 +#: authentication/services/business_account_service.py:68 msgid "Invited account can either accept or reject an invitation" msgstr "Приглашенный аккаунт может принять или отклонить приглашение" -#: authentication/services/business_account_service.py:102 -#, fuzzy -#| msgid "Passwords do not match" -msgid "Passwords don't match" -msgstr "Пароли не совпадают" - -#: authentication/services/business_account_service.py:114 -msgid "You cannot change the password of an unconfirmed e-mail user." -msgstr "Вы не можете изменить пароль неподтвержденного по e-mail пользователя." - -#: authentication/services/business_host_service.py:179 -msgid "No user_email is provided" -msgstr "" - -#: authentication/services/email_service.py:51 +#: authentication/services/email_service.py:52 msgid "Error occured when proceed email sending" msgstr "Случилась ошибка во время отправки email" -#: authentication/services/email_service.py:129 -msgid "Regular users cannot send introductory letters" -msgstr "Обычные пользователи не могут отсылать письма" - -#: authentication/services/email_service.py:164 -msgid "Regular users cannot send invitation letters" -msgstr "Обычные пользователи не могут отправлять письма для приглашений" - -#: authentication/services/user_services.py:176 +#: authentication/services/user_services.py:175 msgid "No user like this in a database" msgstr "Такой пользователь отсутствует" -#: authentication/services/user_services.py:193 +#: authentication/services/user_services.py:192 msgid "token is not provided" msgstr "" -#: authentication/services/user_services.py:217 +#: authentication/services/user_services.py:216 msgid "No email token provided" msgstr "Токен не получен" -#: authentication/services/user_services.py:221 -msgid "No token like this in a database" -msgstr "Не найдено такого токена" - -#: authentication/services/user_services.py:230 +#: authentication/services/user_services.py:229 msgid "Passwords do not match" msgstr "Пароли не совпадают" -#: authentication/services/user_services.py:268 +#: authentication/services/user_services.py:267 msgid "Current password is wrong" msgstr "Текущий пароль неверен" -#: authentication/views.py:119 authentication/views.py:228 -#: authentication/views.py:324 authentication/views.py:354 +#: authentication/views.py:132 authentication/views.py:241 +#: authentication/views.py:347 authentication/views.py:379 msgid "Server error occured" msgstr "Случилась серверная ошибка" -#: authentication/views.py:224 +#: authentication/views.py:237 msgid "Email not found" msgstr "Email не найден" -#: authentication/views.py:320 +#: authentication/views.py:317 authentication/views.py:368 +msgid "Email sending error: email not found" +msgstr "Ошибка отправки письма: email не найден" + +#: authentication/views.py:339 msgid "Business account has been deleted" msgstr "Сотрудник успешно удален" -#: authentication/views.py:343 +#: authentication/views.py:366 msgid "Business account has been reinvited" msgstr "Повторное приглашение сотруднику успешно отправлено" -#: authentication/views.py:442 +#: authentication/views.py:438 +msgid "Business account password has been updated" +msgstr "Пароль сотрудника успешно обновлен" + +#: authentication/views.py:476 msgid "Could not confirm email, please try again." msgstr "Невозможно подтвердить email, попробуйте позже" @@ -562,21 +599,29 @@ msgstr "" msgid "Token is invalid" msgstr "" -#: core/minio_service.py:35 core/minio_service.py:53 core/minio_service.py:61 -#: core/minio_service.py:70 +#: core/minio_service.py:36 core/minio_service.py:54 core/minio_service.py:62 +#: core/minio_service.py:71 msgid "Unknown bucket destination" msgstr "Неизвестный бакет для загрузки" -#: messages/serializers.py:50 +#: lib/exceptions.py:7 +msgid "An unknown error has occurred. Please contact support" +msgstr "" +"Произошла неизвестная ошибка. Пожалуйста, обратитесь в службу поддержки" + +#: lib/exceptions.py:16 +#, python-format +msgid "A %(model)s with fields %(fields)s already exists" +msgstr "Уже существует %(model)s с полями %(fields)s" + +#: 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:133 ml_model/models.py:41 ml_model/models.py:498 -#, fuzzy -#| msgid "Tags" msgid "Tag" -msgstr "Теги" +msgstr "Тег" #: ml_model/admin.py:134 ml_model/models.py:42 ml_model/models.py:300 msgid "Tags" @@ -595,31 +640,29 @@ msgstr "Инференсы" msgid "Neuron Models" msgstr "Нейронные Модели" -#: ml_model/exceptions.py:11 +#: ml_model/exceptions.py:18 msgid "Inference is currently disabled, retry later." -msgstr "Инференс в настоящее время выключен, повторите попытку позже." +msgstr "Модель в настоящее время неактивна. Пожалуйста, повторите попытку позже." -#: ml_model/exceptions.py:19 +#: ml_model/exceptions.py:26 #, python-format msgid "Parameter %(parameter_name)s not valid, please retry later" -msgstr "Параметр %(parameter_name)s некорректен, повторите попытку позже" +msgstr "" -#: ml_model/exceptions.py:26 -#, fuzzy -#| msgid "Payment Rule" +#: ml_model/exceptions.py:33 msgid "Payment Rule not implemented" -msgstr "Платежное правило" +msgstr "Платежное правило не реализовано" -#: ml_model/exceptions.py:31 -msgid "Your request was blocked by our moderation system" -msgstr "Ваш запрос был заблокирован нашей системой модерации" - -#: ml_model/exceptions.py:36 +#: ml_model/exceptions.py:38 msgid "The model is currently disabled. Please try again later." msgstr "" "Модель в настоящее время неактивна. Пожалуйста, повторите попытку позже." -#: ml_model/exceptions.py:47 +#: ml_model/exceptions.py:43 +msgid "Your request was blocked by our moderation system" +msgstr "Ваш запрос был заблокирован нашей системой модерации" + +#: ml_model/exceptions.py:53 #, python-format msgid "" "Image size %(cw)dx%(ch)d is not supported. Please rotate image to " @@ -628,29 +671,31 @@ msgstr "" "Размер изображения %(cw)dx%(ch)d не поддерживается. Пожалуйста, переверните " "до %(rw)dx%(rh)d" -#: ml_model/exceptions.py:51 +#: ml_model/exceptions.py:56 #, python-format msgid "Image size %(cw)sx%(ch)s is not supported. Required size: %(rw)sx%(rh)s" msgstr "" "Размер изображения %(cw)sx%(ch)s не поддерживается. Требуемый размер: " "%(rw)sx%(rh)s" -#: ml_model/exceptions.py:56 -#, fuzzy -#| msgid "The payer does not exist" +#: ml_model/exceptions.py:63 +msgid "The model is not responding" +msgstr "Модель не отвечает" + +#: ml_model/exceptions.py:68 msgid "Scraper does not exists" -msgstr "Плательщик не существует" +msgstr "Скрапер не существует" -#: ml_model/exceptions.py:64 +#: ml_model/exceptions.py:76 #, python-format msgid "Format is not supported. Supported formats: %(formats)s" -msgstr "Формат не поддерживается. Поддерживаемые форматы: %(formats)s" +msgstr "" -#: ml_model/exceptions.py:69 +#: ml_model/exceptions.py:81 msgid "Unknown file format" -msgstr "Неизвестный формат файла" +msgstr "" -#: ml_model/exceptions.py:78 +#: ml_model/exceptions.py:90 #, python-format msgid "" "The attached file format is not supported. Available formats: " @@ -659,12 +704,46 @@ msgstr "" "Формат вложенного файла не поддерживается. Доступные форматы: " "%(available_extensions)s." -#: ml_model/exceptions.py:84 +#: ml_model/exceptions.py:96 +msgid "The length of the context has been exceeded." +msgstr "Длина контекста превышена." + +#: ml_model/exceptions.py:101 +msgid "Jinja template not found" +msgstr "Jinja-шаблон не найден" + +#: ml_model/exceptions.py:106 +msgid "There was an unknown error while rendering a template" +msgstr "При рендеринге шаблона произошла неизвестная ошибка" + +#: ml_model/exceptions.py:111 msgid "The neuron model does not exist" msgstr "Нейронная модель не существует" +#: ml_model/exceptions.py:119 +#, python-format +msgid "The %(file_type)s is not attached" +msgstr "Файл (%(file_type)s) не прикреплен" + +#: ml_model/exceptions.py:124 +msgid "No image content found in response. Try a different request" +msgstr "В промпте отсутствует описание изображения. Попробуйте другой запрос" + +#: ml_model/exceptions.py:129 +msgid "Use style type AUTO or GENERAL when a style preset is selected" +msgstr "При выбранном стиле используйте тип стиля AUTO или GENERAL" + +#: ml_model/exceptions.py:134 +msgid "Prediction interrupted. Please retry again" +msgstr "Генерация прервана. Пожалуйста, повторите попытку еще раз" + +#: ml_model/exceptions.py:150 +#, python-format +msgid "Prompt is too long. Maximum length is %(max_length)s characters." +msgstr "Промпт слишком длинный. Максимальная длина — %(max_length)s символов." + #: ml_model/models.py:27 ml_model/models.py:47 ml_model/models.py:79 -#: ml_model/models.py:291 ml_model/models.py:438 +#: ml_model/models.py:291 ml_model/models.py:438 tools/media/models.py:43 msgid "Slug" msgstr "Ярлык" @@ -685,27 +764,25 @@ msgid "Keyword Arguments" msgstr "" #: ml_model/models.py:67 -#, fuzzy -#| msgid "Runner is missing" msgid "Scraper is missing" -msgstr "Раннер не найден" +msgstr "Скрапер отсутствует" #: ml_model/models.py:72 ml_model/models.py:139 ml_model/models.py:236 -#: ml_model/models.py:424 reports/models/error_report.py:10 +#: ml_model/models.py:424 msgid "Text" msgstr "Текст" -#: ml_model/models.py:73 ml_model/models.py:237 +#: ml_model/models.py:73 ml_model/models.py:237 tools/media/models.py:48 msgid "File" msgstr "Файл" #: ml_model/models.py:74 ml_model/models.py:238 msgid "Embeddings" -msgstr "Эмбеддинги" +msgstr "" #: ml_model/models.py:76 ml_model/models.py:287 msgid "ID" -msgstr "ID" +msgstr "" #: ml_model/models.py:78 ml_model/models.py:186 ml_model/models.py:290 #: ml_model/models.py:436 payments/models/payment.py:52 @@ -714,19 +791,19 @@ msgstr "Описание" #: ml_model/models.py:99 msgid "Runner" -msgstr "Раннер" +msgstr "" #: ml_model/models.py:102 msgid "Output Type" -msgstr "Тип исходящего контента" +msgstr "" #: ml_model/models.py:104 ml_model/models.py:303 msgid "Enabled" -msgstr "Включен" +msgstr "" #: ml_model/models.py:111 msgid "Runner is missing" -msgstr "Раннер не найден" +msgstr "Раннер отсутствует" #: ml_model/models.py:133 ml_model/models.py:159 ml_model/models.py:202 #: ml_model/models.py:270 ml_model/models.py:296 @@ -777,19 +854,15 @@ msgstr "Обязательный" #: ml_model/models.py:164 #, python-format msgid "%(input_type)s input of %(deployment_title)s" -msgstr "Входящий поток типа %(input_type)s деплоймента %(deployment_title)s" +msgstr "" #: ml_model/models.py:170 -#, fuzzy -#| msgid "Model Input" msgid "Input" -msgstr "Модель" +msgstr "Вход" #: ml_model/models.py:171 -#, fuzzy -#| msgid "Model Inputs" msgid "Inputs" -msgstr "Входящий поток модели" +msgstr "Входы" #: ml_model/models.py:177 msgid "Integer" @@ -804,10 +877,8 @@ msgid "String" msgstr "Строка" #: ml_model/models.py:180 -#, fuzzy -#| msgid "Invoices" msgid "Choices" -msgstr "Списания" +msgstr "Список" #: ml_model/models.py:181 msgid "Float range" @@ -841,10 +912,9 @@ msgid "Key \"default\" is required" msgstr "" #: ml_model/models.py:213 -#, fuzzy, python-format -#| msgid "Parameter of %(model_title)s" +#, python-format msgid "Parameter \"%(key)s\" of %(deployment_title)s" -msgstr "Параметр %(model_title)s" +msgstr "Параметр \"%(key)s\" деплоймента %(deployment_title)s" #: ml_model/models.py:219 ml_model/models.py:348 msgid "Parameter" @@ -887,10 +957,8 @@ msgid "Interaction Type" msgstr "Тип взаимодействия" #: ml_model/models.py:257 -#, fuzzy -#| msgid "Interaction Type" msgid "Content Type" -msgstr "Тип взаимодействия" +msgstr "Тип контента" #: ml_model/models.py:263 payments/models/invoice.py:19 msgid "Cost" @@ -906,8 +974,6 @@ msgid "" "Payment Rule \"%(strategy)s\"/\"%(interaction_type)s\" of " "%(deployment_title)s" msgstr "" -"Платежное правило \"%(strategy)s\"/\"%(interaction_type)s\" деплоймента " -"%(deployment_title)s" #: ml_model/models.py:282 msgid "Payment Rule" @@ -918,11 +984,8 @@ msgid "Payment Rules" msgstr "Платежные правила" #: ml_model/models.py:307 -#, fuzzy -#| msgid "Inference cannot be available when parent Deployment is disabled" msgid "Inference cannot be available when related Deployment is disabled" -msgstr "" -"Инференс не может быть доступен, когда родительский Деплоймент выключен" +msgstr "Инференс не может быть доступен, когда связанный деплоймент выключен" #: ml_model/models.py:350 msgid "Value" @@ -930,24 +993,23 @@ msgstr "Значение" #: ml_model/models.py:358 msgid "Parameter must be hidden cause parent is hidden" -msgstr "Параметр должен быть скрыт, потому что родительский также скрыт" +msgstr "" #: ml_model/models.py:360 msgid "Parameter must be required cause parent is required" msgstr "" -"Параметр должен быть обязательным, потому что родительский также обязателен" #: ml_model/models.py:363 msgid "Overriden Parameter" -msgstr "Переопределенный параметр" +msgstr "Переопределённый параметр" #: ml_model/models.py:364 msgid "Overriden Parameters" -msgstr "Переопределенные параметры" +msgstr "Переопределённые параметры" #: ml_model/models.py:370 msgid "Addition" -msgstr "Сложение" +msgstr "" #: ml_model/models.py:371 msgid "Multiplication" @@ -960,11 +1022,11 @@ msgstr "Коэффициент" #: ml_model/models.py:386 #, python-format msgid "Payment Bias of %(inference_title)s" -msgstr "Платежный сдвиг %(inference_title)s" +msgstr "Смещение оплаты инференса %(inference_title)s" #: ml_model/models.py:389 ml_model/models.py:390 msgid "Payment Bias" -msgstr "Платежные сдвиг" +msgstr "Смещение оплаты" #: ml_model/models.py:394 msgid "Generation time" @@ -972,7 +1034,7 @@ msgstr "Время генерации" #: ml_model/models.py:395 msgid "Tokens cost" -msgstr "Стоимость в токенах" +msgstr "Стоимость токенов" #: ml_model/models.py:406 #, python-format @@ -981,11 +1043,11 @@ msgstr "" #: ml_model/models.py:412 msgid "Tracking Record" -msgstr "Отслеживающая запись" +msgstr "" #: ml_model/models.py:413 msgid "Tracking Records" -msgstr "Отслеживающие записи" +msgstr "" #: ml_model/models.py:423 msgid "Chat-bots" @@ -993,7 +1055,7 @@ msgstr "Чат-боты" #: ml_model/models.py:426 msgid "Video" -msgstr "" +msgstr "Видео" #: ml_model/models.py:434 msgid "Alternative Titles" @@ -1008,20 +1070,16 @@ msgid "Avatar" msgstr "Аватар" #: ml_model/models.py:456 -#, fuzzy -#| msgid "Type" msgid "Types" -msgstr "Тип" +msgstr "Типы" -#: ml_model/models.py:485 +#: ml_model/models.py:485 payments/models/payment_plan_feature.py:18 msgid "Neuron Model" msgstr "Нейронная Модель" #: ml_model/runners/dummy.py:19 -#, fuzzy -#| msgid "Missing required parameter: 'messages'" msgid "Missing required parameter - Message (key=message,type=str)" -msgstr "Отсутствует обязательный параметр: 'messages'" +msgstr "Отсутствует обязательный параметр — Message (key=message,type=str)" #: ml_model/runners/dummy.py:24 msgid "" @@ -1041,10 +1099,8 @@ msgid "" msgstr "" #: ml_model/runners/dummy.py:68 -#, fuzzy -#| msgid "Missing required parameter: 'messages'" msgid "Missing required parameter - URL (key=url,type=string)" -msgstr "Отсутствует обязательный параметр: 'messages'" +msgstr "Отсутствует обязательный параметр — URL (key=url,type=string)" #: ml_model/runners/dummy.py:72 msgid "" @@ -1065,10 +1121,8 @@ msgstr "" #: ml_model/runners/falai.py:19 ml_model/runners/openai.py:34 #: ml_model/runners/replicate.py:28 -#, fuzzy -#| msgid "Missing required parameter: 'messages'" msgid "Missing required parameter - Model (key=model,type=string)" -msgstr "Отсутствует обязательный параметр: 'messages'" +msgstr "Отсутствует обязательный параметр — Model (key=model,type=string)" #: ml_model/runners/falai.py:24 ml_model/runners/replicate.py:33 msgid "Missing required parameter - Model Owner (key=model_owner,type=string)" @@ -1077,25 +1131,23 @@ msgstr "" #: ml_model/services/inference.py:113 #, python-format msgid "Payment rules are missing; Inference: %s" -msgstr "" +msgstr "Отсутствуют правила оплаты; Inference: %s" #: ml_model/services/inference.py:263 -#, fuzzy -#| msgid "No matching version found" msgid "No tracking records found" -msgstr "Соответствующая версия не найдена" +msgstr "Не найдено записей трекинга" -#: payments/admin.py:33 payments/admin.py:65 payments/admin.py:85 +#: payments/admin.py:35 payments/admin.py:67 payments/admin.py:93 msgid "You can search by user email, exacted company name" msgstr "" "Вы можете осуществлять поиск по e-mail пользователя, точному названию " "компании" -#: payments/admin.py:38 payments/admin.py:90 +#: payments/admin.py:40 payments/admin.py:98 msgid "Missing" msgstr "Отсутствующий" -#: payments/admin.py:93 +#: payments/admin.py:101 msgid "Model" msgstr "Модель" @@ -1103,6 +1155,10 @@ msgstr "Модель" msgid "Payments" msgstr "Платежи" +#: payments/exceptions/full_balance.py:5 +msgid "Your balance is already full" +msgstr "" + #: payments/exceptions/insufficient_balance.py:18 #, python-format msgid "" @@ -1144,42 +1200,75 @@ msgstr "Статус" msgid "Payment" msgstr "Платеж" -#: payments/models/payment_plan.py:13 +#: payments/models/payment_plan.py:14 msgid "Price" msgstr "Цена" -#: payments/models/payment_plan.py:17 +#: payments/models/payment_plan.py:18 msgid "Tokens per plan" msgstr "Токенов за план" -#: payments/models/payment_plan.py:20 +#: payments/models/payment_plan.py:21 msgid "Is corporate" msgstr "Корпоративный" #: payments/models/payment_plan.py:22 +msgid "Individual" +msgstr "Индивидуальный" + +#: payments/models/payment_plan.py:23 msgid "Is visible" msgstr "Видимый" -#: payments/models/payment_plan.py:40 payments/models/payment_plan.py:55 +#: payments/models/payment_plan.py:42 payments/models/payment_plan.py:57 +#: payments/models/payment_plan_feature.py:16 msgid "Payment Plan" msgstr "Платежный План" -#: payments/models/payment_plan.py:41 +#: payments/models/payment_plan.py:43 msgid "Payment Plans" msgstr "Платежные Планы" -#: payments/models/payment_plan.py:57 +#: payments/models/payment_plan.py:59 msgid "Last payment at" msgstr "Последнее время платежа" -#: payments/models/payment_plan.py:59 +#: payments/models/payment_plan.py:61 msgid "Current balance" msgstr "Текущий баланс" -#: payments/models/payment_plan.py:76 payments/models/payment_plan.py:77 +#: payments/models/payment_plan.py:85 payments/models/payment_plan.py:86 msgid "User Balance" msgstr "Баланс пользователя" +#: payments/models/payment_plan_feature.py:11 +msgid "Text Page" +msgstr "Страница текста" + +#: payments/models/payment_plan_feature.py:12 +msgid "File (pcs)" +msgstr "Файл (шт.)" + +#: payments/models/payment_plan_feature.py:13 +msgid "Time (mins)" +msgstr "Время (мин.)" + +#: payments/models/payment_plan_feature.py:19 +msgid "Quantity" +msgstr "Количество" + +#: payments/models/payment_plan_feature.py:24 +msgid "Measurement unit" +msgstr "Единица измерения" + +#: payments/models/payment_plan_feature.py:33 +msgid "Payment plan feature" +msgstr "Преимущество платежного плана" + +#: payments/models/payment_plan_feature.py:34 +msgid "Payment plan features" +msgstr "Преимущества платежного плана" + #: payments/models/promocode.py:48 msgid "Action Function" msgstr "Активирующаяся функция" @@ -1221,7 +1310,7 @@ msgid "Promocode Activations" msgstr "Активации Промокодов" #: payments/models/recurring_payment.py:11 -#: payments/models/user_payment_method.py:24 +#: payments/models/user_payment_method.py:23 msgid "Payment Method" msgstr "Платежный метод" @@ -1237,34 +1326,26 @@ msgstr "Автоплатеж" msgid "Recurring Payments" msgstr "Автоплатежи" -#: payments/models/user_payment_method.py:25 +#: payments/models/user_payment_method.py:24 msgid "Payment Methods" msgstr "Платежные методы" -#: payments/routes/v1.py:60 +#: payments/routes/v1.py:90 msgid "The recurring payment is successfully cancelled" msgstr "Автоплатежи успешно отключены" -#: payments/selectors/payment_plan_selector.py:31 -msgid "Business accounts are not allowed to make purchases" -msgstr "Сотрудники не могут производить покупки" +#: payments/routes/v1.py:143 +msgid "Expenses" +msgstr "Затраты" -#: payments/selectors/payment_plan_selector.py:53 -msgid "No plan by this uid" -msgstr "Не найдено подписки по этому ID" +#: payments/routes/v1.py:147 +msgid "Refills" +msgstr "Пополнения" #: payments/services/model_billing_service.py:31 msgid "Unknown account type" msgstr "Неизвестный тип аккаунта" -#: payments/services/payment_method_service.py:43 -msgid "No current active payment method is set" -msgstr "Ни одного активного метода не установлено" - -#: payments/services/payment_method_service.py:52 -msgid "No payment method by this id" -msgstr "Не найдено метода платежа по этому ID" - #: poller/models.py:11 msgid "Address" msgstr "Адрес" @@ -1281,22 +1362,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:8 msgid "Tools" msgstr "Инструменты" @@ -1322,6 +1387,10 @@ msgstr "Медиа" msgid "Enabled inference not found in model" msgstr "" +#: tools/chats/apis.py:211 +msgid "The message has already been deleted" +msgstr "Сообщение уже было удалено" + #: tools/chats/models.py:17 #, python-format msgid "Chat %(id)s" @@ -1335,6 +1404,14 @@ msgstr "Чат" msgid "New chat" 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-ключа" @@ -1343,15 +1420,15 @@ msgstr "Необходимо повысить лимит токенов у API- msgid "API Key not found" msgstr "API-ключ не найден" -#: tools/public_api/models.py:46 +#: tools/public_api/models.py:47 msgid "Expires at" msgstr "Когда заканчивается" -#: tools/public_api/models.py:50 +#: tools/public_api/models.py:57 msgid "API Key" msgstr "API Ключ" -#: tools/public_api/models.py:51 +#: tools/public_api/models.py:58 msgid "API Keys" msgstr "API Ключи" @@ -1365,35 +1442,13 @@ msgstr "" "Модель заблокирована, т.к закончила обновляться или временно заблокирована, " "попробуйте позже" -#~ msgid "Is recurrent" -#~ msgstr "Рекуррентный" - -#~ msgid "Duration" -#~ msgstr "Длительность" - -#~ msgid "Next payment at" -#~ msgstr "Следующее время платежа" +#~ msgid "Lyrics is too long" +#~ msgstr "Текст песни слишком длинный" -#~ msgid "Recurrent billing task" -#~ msgstr "Рекуррентная задача на платеж" - -#~ msgid "Account is already confirmed" -#~ msgstr "Аккаунт уже подтвержден" - -#~ msgid "Wrong username" -#~ msgstr "Неверное имя пользователя" - -#~ msgid "The model is not responding" -#~ msgstr "Модель не отвечает" - -#~ msgid "The length of the context has been exceeded." -#~ msgstr "Длина контекста превышена." - -#~ msgid "Jinja template not found" -#~ msgstr "Jinja-шаблон не найден" - -#~ msgid "There was an unknown error while rendering a template" -#~ msgstr "При рендеринге шаблона произошла неизвестная ошибка" +#, python-format +#~ msgid "Version %(version)s already has input with the same type: %(type)s" +#~ msgstr "" +#~ "Версия %(version)s уже имеет входные данные с таким же типом: %(type)s" #~ msgid "Category" #~ msgstr "Категория" @@ -1434,6 +1489,9 @@ msgstr "" #~ msgid "%(model_title)s | %(input_type)s" #~ msgstr "%(model_title)s | %(input_type)s" +#~ msgid "List" +#~ msgstr "Список" + #~ msgid "By all data" #~ msgstr "По всем данным" @@ -1459,50 +1517,20 @@ msgstr "" #~ msgid "no model by this id" #~ msgstr "Не найдено моделей по этому ID" -#~ msgid "" -#~ "Unable to recognize the image. (Supported formats are PNG, JPG, JPEG)" -#~ msgstr "" -#~ "Невозможно распознать изображение. (Поддерживаемые форматы: PNG, JPG, " -#~ "JPEG)" - -#~ msgid "No matching version found" -#~ msgstr "Соответствующая версия не найдена" - #~ msgid "No image given for improving" #~ msgstr "Нет изображения для улучшения" #~ msgid "Model data cannot be retrieved" #~ msgstr "Невозможно получить данные модели" -#~ msgid "Stories" -#~ msgstr "Истории" - -#~ msgid "Is published" -#~ msgstr "Опубликовано" - -#~ msgid "Story" -#~ msgstr "История" - -#~ msgid "Page" -#~ msgstr "Страница" +#~ msgid "Images" +#~ msgstr "Изображения" -#~ msgid "Pages" -#~ msgstr "Страницы" +#~ msgid "No current active payment method is set" +#~ msgstr "Ни одного активного метода не установлено" -#~ msgid "Label" -#~ msgstr "Метка" - -#~ msgid "Redirect URL" -#~ msgstr "URL перехода" - -#~ msgid "Widget" -#~ msgstr "Виджет" - -#~ msgid "Widgets" -#~ msgstr "Виджеты" - -#~ msgid "Feed" -#~ msgstr "Шейр пользователей" +#~ msgid "No payment method by this id" +#~ msgstr "Не найдено метода платежа по этому ID" #~ msgid "" #~ "Error occured when create generation. It may cause NSFW-content not " @@ -1511,12 +1539,6 @@ msgstr "" #~ "Случилась ошибка во время генерации. Она может возникать из-за того, что " #~ "NSFW-контент запрещен. Попробуйте снова" -#~ msgid "List" -#~ msgstr "Список" - -#~ msgid "Token prefix is missing" -#~ msgstr "Отсутствует префикс токена" - #~ msgid "The request must not be empty" #~ msgstr "Запрос не должен быть пустым" @@ -1526,6 +1548,42 @@ msgstr "" #~ msgid "Model not found" #~ msgstr "Модель не найдена" +#~ msgid "Regular users cannot send introductory letters" +#~ msgstr "Обычные пользователи не могут отсылать письма" + +#~ msgid "Regular users cannot send invitation letters" +#~ msgstr "Обычные пользователи не могут отправлять письма для приглашений" + +#~ msgid "No token like this in a database" +#~ msgstr "Не найдено такого токена" + +#~ msgid "Business accounts are not allowed to make purchases" +#~ msgstr "Сотрудники не могут производить покупки" + +#~ msgid "No plan by this uid" +#~ msgstr "Не найдено подписки по этому ID" + +#~ 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 "Достижение" @@ -1542,6 +1600,39 @@ msgstr "" #~ msgid "Issued achievement" #~ msgstr "Выданное достижение" +#~ msgid "Account is already confirmed" +#~ msgstr "Аккаунт уже подтвержден" + +#~ msgid "Stories" +#~ msgstr "Истории" + +#~ msgid "Is published" +#~ msgstr "Опубликовано" + +#~ msgid "Story" +#~ msgstr "История" + +#~ msgid "Page" +#~ msgstr "Страница" + +#~ msgid "Pages" +#~ msgstr "Страницы" + +#~ msgid "Label" +#~ msgstr "Метка" + +#~ msgid "Redirect URL" +#~ msgstr "URL перехода" + +#~ msgid "Widget" +#~ msgstr "Виджет" + +#~ msgid "Widgets" +#~ msgstr "Виджеты" + +#~ msgid "Feed" +#~ msgstr "Шейр пользователей" + #~ msgid "Points" #~ msgstr "Поинты" @@ -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('inference', '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): @@ -7,6 +7,7 @@ class Migration(migrations.Migration): dependencies = [ ('ml_model', '0056_alter_deployment_runner_import_path'), + ('payments', '0023_reordering_payment_plan_features'), ] operations = [ @@ -0,0 +1,130 @@ +import httpx +import numpy as np +import redis + +from concurrent.futures import ThreadPoolExecutor, as_completed +from typing import Tuple, List + +from django.conf import settings +from langchain_text_splitters import RecursiveCharacterTextSplitter +from pydantic.v1 import UUID4 +from redis.commands.search.document import Document +from redis.commands.search.query import Query + +from ml_model.tasks import drop_redis_vectors + + +class EmbeddingService: + @classmethod + def split_text_to_chunks(cls, raw_text: str, chunk_size: int = 4000, overlap: int = 200) -> list[str]: + text_splitter = RecursiveCharacterTextSplitter( + chunk_size=chunk_size, + chunk_overlap=overlap, + length_function=len, + separators=['\n\n', '\n', '.', ' ', ''], + ) + return text_splitter.split_text(raw_text) + + @classmethod + def process_chunk( + cls, + client: httpx.Client, + chunk: str, + redis_client: redis.Redis, + message_uid: str, + chunk_id: int, + ) -> int: + embedding, e_total_tokens = cls._get_embedding(client=client, content=chunk) + cls._save_embeddings( + redis_client=redis_client, + message_uid=message_uid, + chunk_id=chunk_id, + text=chunk, + embeddings=embedding, + ) + return e_total_tokens + + @classmethod + def _get_embedding(cls, client: httpx.Client, content: str) -> Tuple[List[float], int]: + response = client.post(url='embeddings', json={'model': 'text-embedding-3-large', 'input': content}) + response.raise_for_status() + data = response.json() + return data['data'][0]['embedding'], data['usage']['total_tokens'] + + @classmethod + def _save_embeddings( + cls, redis_client: redis.Redis, message_uid: str, chunk_id: int, text: str, embeddings: List[float] + ) -> None: + embeddings_bytes = np.array(embeddings).astype(dtype=np.float32).tobytes() + redis_client.hset( + f'ml_model:messages:{message_uid}:vectors:{chunk_id}', + mapping={ + 'message_uid': message_uid, + 'section_text': text, + 'section_embeddings': embeddings_bytes, + }, + ) + + @classmethod + def search_via_embeddings( + cls, + redis_client: redis.Redis, + message_uid: str, + user_query_embeddings: List[float], + top_k: int = 10, + ) -> List[Document]: + base_query = ( + f'@message_uid:{{{message_uid}}}=>[KNN {top_k} @section_embeddings $vector AS vector_score]' + ) + query = ( + Query(base_query) + .return_fields('section_text') + .sort_by('vector_score') + .paging(0, top_k) + .dialect(2) + ) + params_dict = {'vector': np.array(user_query_embeddings).astype(dtype=np.float32).tobytes()} + results = redis_client.ft('ml_model-index').search(query, params_dict) + return results.docs + + @classmethod + def make_embeddings_prompt(cls, document_name: str, section_texts: List[str], question: str) -> str: + return f"""Ты — аналитик данных. Отвечай только на основе предоставленного контекста. + Название файла: {document_name} + Фрагменты: + {'\n'.join(section_texts)} + Вопрос: {question} + """ + + @classmethod + def get_large_file_data(cls, msg_uid: UUID4, chunks, proxy, user_content): + redis_client = redis.Redis(host=settings.REDIS_HOST, port=settings.REDIS_PORT, db=0) + embedding_tokens = 0 + message_uid = str(msg_uid).replace('-', '_') + with httpx.Client( + base_url='https://api.openai.com/v1/', + proxy=f'{proxy.protocol}://{proxy.address}', + headers={'Authorization': f'Bearer {settings.OPENAI_API_KEY}'}, + timeout=600, + ) as client: + threads = [] + with ThreadPoolExecutor(max_workers=settings.MAX_THREADS) as executor: + for chunk_id, chunk in enumerate(chunks): + threads.append( + executor.submit( + cls.process_chunk, client, chunk, redis_client, message_uid, chunk_id + ) + ) + for thread in as_completed(threads): + embedding_tokens += thread.result() + query_embedding, e_total_tokens = cls._get_embedding(client=client, content=user_content) + embedding_tokens += e_total_tokens + result = [ + s['section_text'] + for s in cls.search_via_embeddings( + redis_client=redis_client, message_uid=message_uid, user_query_embeddings=query_embedding + ) + ] + drop_redis_vectors.delay(message_uid) + redis_client.close() + return embedding_tokens, result \ No newline at end of file @@ -0,0 +1,85 @@ +import re +import subprocess +import zipfile +import docx2txt +import fitz +import openpyxl + +from io import BytesIO + + +class FileProcessingService: + @classmethod + def get_file_extension(cls, 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 + + @classmethod + def get_file_data(cls, file_extension: str, file_bytes: bytes) -> str: + is_word = file_extension in ('doc', 'docx') + method_name = 'word' if is_word else file_extension + operation = getattr(cls, 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 text + + @classmethod + def get_pdf_data(cls, pdf_data: bytes) -> str: + try: + doc = fitz.open(stream=pdf_data, filetype='pdf') + raw_text = '' + for page_number, page in enumerate(doc, start=1): + content = page.get_text('text') + if content: + raw_text += content + doc.close() + fitz.TOOLS.store_shrink(100) + except Exception: + return f'Ошибка: Файл поврежден или не может быть прочитан.' + return f'Содержимое файла: {raw_text.strip()}' + + @classmethod + def get_xlsx_data(cls, xlsx_data: bytes) -> str: + try: + xlsx_content = BytesIO(xlsx_data) + workbook = openpyxl.load_workbook(xlsx_content) + raw_text = '' + for sheet_name in workbook.sheetnames: + sheet = workbook[sheet_name] + for row in sheet.iter_rows(values_only=True): + raw_text += f'Данные ряда: {row}\n' + except Exception: + raw_text = 'Произошла ошибка во время чтения файла' + return f'Содержимое файла: {raw_text}' + + @classmethod + def get_word_data(cls, extension: str, word_data: bytes) -> str: + try: + 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=word_data) + text = text.decode('utf-8') + else: + text = '' + except Exception: + text = 'Файл поврежден или не может быть прочитан.' + if text.strip(): + return f'Это текст, извлечённый из загруженного WORD-файла:\n{text}' + else: + return 'Файл пуст или содержит изображения, из которых невозможно извлечь текст.' + @@ -1,11 +1,18 @@ from django.utils.translation import gettext as _ + class GenerationException(Exception): def __str__(self): return 'Случилась ошибка во время генерации у этой модели, пожалуйста повторите попытку позже' +class NSFWDetectedException(Exception): ... + + +class LargeResourceConsumptionException(Exception): ... + + class InferenceDisabled(Exception): def __str__(self): return _('Inference is currently disabled, retry later.') @@ -26,14 +33,14 @@ class PaymentRuleNotImplemented(Exception): return _('Payment Rule not implemented') -class RequestBlocked(Exception): +class DeploymentDisabled(Exception): def __str__(self): - return _('Your request was blocked by our moderation system') + return _('The model is currently disabled. Please try again later.') -class DeploymentDisabled(Exception): +class RequestBlocked(Exception): def __str__(self): - return _('The model is currently disabled. Please try again later.') + return _('Your request was blocked by our moderation system') class UnsupportedSize(Exception): @@ -43,13 +50,18 @@ class UnsupportedSize(Exception): def __str__(self): if tuple(self.current_size.values()) == tuple(reversed(self.required_size.values())): - return _( - 'Image size %(cw)dx%(ch)d is not supported. ' - 'Please rotate image to %(rw)dx%(rh)d' - ) % (self.current_size | self.required_size) - return _( - 'Image size %(cw)sx%(ch)s is not supported. Required size: %(rw)sx%(rh)s' - ) % (self.current_size | self.required_size) + return _('Image size %(cw)dx%(ch)d is not supported. Please rotate image to %(rw)dx%(rh)d') % ( + self.current_size | self.required_size + ) + return _('Image size %(cw)sx%(ch)s is not supported. Required size: %(rw)sx%(rh)s') % ( + self.current_size | self.required_size + ) + + +class ModelTimeoutError(Exception): + def __str__(self): + return _('The model is not responding') + class ScraperDoesNotExists(Exception): def __str__(self): @@ -79,6 +91,62 @@ class FileExtensionNotSupported(Exception): ) % {'available_extensions': ', '.join(self.extensions)} +class ExceededContextLengthError(Exception): + def __str__(self) -> str: + return _('The length of the context has been exceeded.') + + +class TemplateNotFound(Exception): + def __str__(self): + return _('Jinja template not found') + + +class TemplateUnknownException(Exception): + def __str__(self): + return _('There was an unknown error while rendering a template') + + 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') + + +class PredictionInterruptedError(Exception): + def __str__(self): + return _('Prediction interrupted. Please retry again') + + +class InvalidParameterError(Exception): + def __init__(self, error_text: str): + self.error_text = error_text + + def __str__(self): + return self.error_text + + +class PromptLengthExceeded(Exception): + def __init__(self, max_length: int = 3000) -> None: + self.max_length = max_length + + def __str__(self) -> str: + return _('Prompt is too long. Maximum length is %(max_length)s characters.') % { + 'max_length': self.max_length + } @@ -0,0 +1,5 @@ +from django.utils.translation import gettext as _ + +class FullBalanceException(Exception): + def __str__(self) -> str: + return _('Your balance is already full') \ No newline at end of file @@ -33,3 +33,4 @@ class Migration(migrations.Migration): }, ), ] + @@ -15,3 +15,4 @@ class Migration(migrations.Migration): name='duration', ), ] + @@ -21,3 +21,4 @@ class Migration(migrations.Migration): new_name='pay_at', ), ] + @@ -15,3 +15,4 @@ class Migration(migrations.Migration): name='is_recurrent', ), ] + @@ -0,0 +1,53 @@ +# Generated by Django 5.0.11 on 2025-12-19 15:34 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ml_model', '0023_alter_modelcategory_slug_and_more'), + ('payments', '0021_remove_paymentplan_is_recurrent'), + ] + + operations = [ + migrations.CreateModel( + name='PaymentPlanFeature', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('order', models.PositiveIntegerField(db_index=True, editable=False, verbose_name='order')), + ('name', models.CharField(max_length=30, verbose_name='Name')), + ('quantity', models.PositiveSmallIntegerField(default=1, verbose_name='Quantity')), + ('measurement_unit', models.CharField( + choices=[ + ('text_page', 'Страница текста'), + ('file', 'Файл (шт.)'), + ('time', 'Время (мин.)'), + ], + default='text_page', + max_length=15, + verbose_name='Measurement unit', + )), + ('category', models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='payment_plan_feature', + to='ml_model.modelcategory', + verbose_name='Category', + )), + ('plan', models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='features', + to='payments.paymentplan', + verbose_name='Payment Plan', + )), + ], + options={ + 'verbose_name': 'Payment plan feature', + 'verbose_name_plural': 'Payment plan features', + 'ordering': ('order',), + 'abstract': False, + }, + ), + ] + @@ -0,0 +1,53 @@ +# Generated by Django 5.0.11 on 2025-12-19 15:35 +import re + +from django.db import migrations + + +def move_points_to_features(apps, schema_editor): + PaymentPlan = apps.get_model('payments', 'PaymentPlan') + PaymentPlanFeature = apps.get_model('payments', 'PaymentPlanFeature') + ModelCategory = apps.get_model('ml_model', 'ModelCategory') + + measurement_units = {'images': 'file', 'chat-bots': 'text_page'} + try: + categories = { + 'images': ModelCategory.objects.get(slug='images'), + 'chat-bots': ModelCategory.objects.get(slug='chat-bots'), + } + except Exception: + return + + for plan in PaymentPlan.objects.all(): + order_counter = 1 + payment_plan_features_data = [] + for point in plan.points: + cleaned = re.sub(r'\b(в|во|или)\b', '', point) + cleaned = re.sub(r'(?<=\d)\s+(?=\d)', '', cleaned) + cleaned = re.sub(r'\bтекста\b', '', cleaned) + cleaned = re.sub(r'\bкартинок\b', 'images', cleaned) + cleaned = re.sub(r'\bстраниц\b', 'chat-bots', cleaned) + cleaned = re.sub(r'\s+', ' ', cleaned).strip() + data = cleaned.split() + payment_plan_feature = PaymentPlanFeature( + plan=plan, + name=' '.join(data[2:]), + quantity=int(data[0]), + category=categories[data[1]], + measurement_unit=measurement_units[data[1]], + order=order_counter, + ) + payment_plan_features_data.append(payment_plan_feature) + order_counter += 1 + PaymentPlanFeature.objects.bulk_create(payment_plan_features_data) + + +class Migration(migrations.Migration): + dependencies = [ + ('payments', '0022_paymentplanfeature'), + ] + + operations = [ + migrations.RunPython(move_points_to_features, migrations.RunPython.noop) + ] + @@ -0,0 +1,33 @@ +# Generated by Django 5.0.11 on 2025-12-25 16:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('payments', '0023_move_points_to_features'), + ] + + operations = [ + migrations.AlterField( + model_name='paymentplanfeature', + name='measurement_unit', + field=models.CharField( + choices=[ + ('text_page', 'Text Page'), + ('file', 'File (pcs)'), + ('time', 'Time (mins)'), + ], + default='text_page', + max_length=15, + verbose_name='Measurement unit', + ), + ), + migrations.AlterField( + model_name='paymentplanfeature', + name='quantity', + field=models.PositiveIntegerField(default=1, verbose_name='Quantity'), + ), + ] + @@ -0,0 +1,102 @@ +# Generated by Django 5.0.11 on 2025-12-25 15:32 + +import math +from decimal import Decimal + +from django.db import migrations + + +def update_plans_features_quantity(apps, schema_editor): + PaymentPlan = apps.get_model('payments', 'PaymentPlan') + PaymentPlanFeature = apps.get_model('payments', 'PaymentPlanFeature') + NeuronModel = apps.get_model('ml_model', 'NeuronModel') + + model_configs = { + 'sora': (Decimal('1080'), 'file'), + 'kling': (Decimal('270'), 'file'), + 'runway': (Decimal('150'), 'file'), + 'speedance': (Decimal('216'), 'file'), + 'veo': (Decimal('640'), 'file'), + 'hailuo': (Decimal('147'), 'file'), + 'minimaxvideo': (Decimal('150'), 'file'), + 'ray': (Decimal('135'), 'file'), + 'hunyuan': (Decimal('370'), 'file'), + 'lyria': (Decimal('36'), 'time'), + 'suno': (Decimal('17.5'), 'time'), + 'minimaxmusic': (Decimal('10.5'), 'time'), + 'stablemusic': (Decimal('80') / (Decimal('3') + Decimal('10') / Decimal('60')), 'time'), + 'chatgpt': (Decimal('3.28'), 'text_page'), + 'chatgpt_5': (Decimal('0.9'), 'text_page'), + 'claude': (Decimal('5.4'), 'text_page'), + 'gemini': (Decimal('7.92'), 'text_page'), + 'grok': (Decimal('5.76'), 'text_page'), + 'perplexity': (Decimal('8.6'), 'text_page'), + 'qwen': (Decimal('0.076'), 'text_page'), + 'qwen_235B': (Decimal('0.23'), 'text_page'), + 'mistral': (Decimal('0.07'), 'text_page'), + 'deepseek': (Decimal('2.8'), 'text_page'), + 'llama': (Decimal('0.23'), 'text_page'), + 'dalle': (Decimal('2'), 'file'), + 'midjourney': (Decimal('2'), 'file'), + 'stablediffusion': (Decimal('32.5'), 'file'), + 'flux': (Decimal('3'), 'file'), + 'flux_2': (Decimal('72'), 'file'), + 'fluxproultra': (Decimal('18'), 'file'), + 'flux-krea': (Decimal('7.5'), 'file'), + 'ideogram': (Decimal('9'), 'file'), + 'leonardo': (Decimal('0.0015'), 'file'), + 'seedream': (Decimal('9'), 'file'), + 'reve': (Decimal('7.5'), 'file'), + 'recraft': (Decimal('24'), 'file'), + 'geminiimage': (Decimal('19.5'), 'file'), + 'nanobanana': (Decimal('90'), 'file'), + 'wan': (Decimal('50'), 'file'), + 'gptimage': (Decimal('125'), 'file'), + } + + plans = PaymentPlan.objects.prefetch_related('accessed_models').all() + models = NeuronModel.objects.select_related('category').all() + + features_to_create = [] + + for plan in plans: + order_counter = 1 + accessed_model_ids = set(plan.accessed_models.values_list('pk', flat=True)) + + for model in models: + if model.pk not in accessed_model_ids: + continue + + model_config = model_configs.get(model.slug) + if model_config is None: + continue + + max_price, measurement_unit = model_config + quantity = math.floor(plan.tokens_per_plan / max_price) + + feature = PaymentPlanFeature( + plan=plan, + name=model.title or model.slug, + quantity=quantity, + category=model.category, + measurement_unit=measurement_unit, + order=order_counter, + ) + features_to_create.append(feature) + order_counter += 1 + + PaymentPlanFeature.objects.all().delete() + PaymentPlanFeature.objects.bulk_create(features_to_create) + + +class Migration(migrations.Migration): + + dependencies = [ + ('ml_model', '0039_alter_neuronmodel_slug'), + ('payments', '0024_alter_paymentplanfeature_measurement_unit_and_more'), + ] + + operations = [ + migrations.RunPython(update_plans_features_quantity, migrations.RunPython.noop) + ] + @@ -0,0 +1,74 @@ +# Generated by Django 5.0.11 on 2026-01-19 09:11 + +import django.db.models.deletion +from django.db import migrations, models +from django.db.models import Q + + +def convert_features_title_to_model(apps, schema_editor): + PaymentPlanFeature = apps.get_model('payments', 'PaymentPlanFeature') + NeuronModel = apps.get_model('ml_model', 'NeuronModel') + payment_plan_features = [] + + for payment_plan_feature in PaymentPlanFeature.objects.all(): + try: + payment_plan_feature.model = NeuronModel.objects.get( + Q(title=payment_plan_feature.name) | Q(slug=payment_plan_feature.name) + ) + except NeuronModel.DoesNotExist: + continue + payment_plan_features.append(payment_plan_feature) + + if payment_plan_features: + PaymentPlanFeature.objects.bulk_update(payment_plan_features, ['model']) + + +class Migration(migrations.Migration): + + dependencies = [ + ('ml_model', '0039_alter_neuronmodel_slug'), + ('payments', '0025_update_plans_features_quantity'), + ] + + operations = [ + migrations.AddField( + model_name='paymentplanfeature', + name='model', + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='feature', + to='ml_model.neuronmodel', + verbose_name='Neuron Model', + null=True, + blank=True, + ), + ), + migrations.RunPython(convert_features_title_to_model, migrations.RunPython.noop), + migrations.AlterField( + model_name='paymentplanfeature', + name='model', + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='feature', + to='ml_model.neuronmodel', + verbose_name='Neuron Model', + ), + ), + migrations.AlterUniqueTogether( + name='paymentplanfeature', + unique_together={('plan', 'model')}, + ), + migrations.RemoveField( + model_name='paymentplanfeature', + name='name', + ), + migrations.RemoveField( + model_name='paymentplan', + name='accessed_models', + ), + migrations.RemoveField( + model_name='paymentplanfeature', + name='category', + ), + ] + @@ -0,0 +1,44 @@ +# Generated by Django 5.0.11 on 2026-01-19 11:42 + +from collections import defaultdict + +from django.db import migrations + + +def reordering_payment_plan_features(apps, schema_editor): + NeuronModel = apps.get_model('ml_model', 'NeuronModel') + PaymentPlan = apps.get_model('payments', 'PaymentPlan') + PaymentPlanFeature = apps.get_model('payments', 'PaymentPlanFeature') + + category_model_order = { + category: {model.slug: model.order for model in NeuronModel.objects.filter(category__slug=category)} + for category in {'chat-bots', 'images', 'videos', 'audio'} + } + + features_to_update = [] + + for plan in PaymentPlan.objects.prefetch_related('features__model__category', 'features__model').all(): + by_category = defaultdict(list) + for feature in plan.features.all(): + by_category[feature.model.category.slug].append(feature) + + for category_slug, category_features in by_category.items(): + category_features.sort(key=lambda f: category_model_order[category_slug][f.model.slug]) + for index, feature in enumerate(category_features, start=1): + feature.order = index + features_to_update.append(feature) + + if features_to_update: + PaymentPlanFeature.objects.bulk_update(features_to_update, ['order']) + + +class Migration(migrations.Migration): + + dependencies = [ + ('payments', '0026_remove_paymentplan_accessed_models_and_more'), + ] + + operations = [ + migrations.RunPython(reordering_payment_plan_features, migrations.RunPython.noop) + ] + @@ -0,0 +1,19 @@ +# Generated by Django 5.0.11 on 2026-01-21 10:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('payments', '0027_reordering_payment_plan_features'), + ] + + operations = [ + migrations.AddField( + model_name='paymentplan', + name='individual', + field=models.BooleanField(default=False, verbose_name='Individual'), + ), + ] + @@ -0,0 +1,42 @@ +import math +from decimal import Decimal + +from django.db import migrations + + +def update_videos_quantity(apps, schema_editor): + VIDEO_MODELS = { + 'sora': Decimal('120'), + 'hailuo': Decimal('57'), + 'veo': Decimal('240'), + 'kling': Decimal('75'), + 'ray': Decimal('135'), + 'runway': Decimal('75'), + 'minimaxvideo': Decimal('150'), + 'hunyuan': Decimal('370'), + 'wan': Decimal('25'), + 'speedance': Decimal('9'), + } + + PaymentPlanFeature = apps.get_model('payments', 'PaymentPlanFeature') + payment_features = [] + + for feature in PaymentPlanFeature.objects.select_related('model', 'plan').filter(model__slug__in=VIDEO_MODELS.keys()): + feature.quantity = math.floor(feature.plan.tokens_per_plan / VIDEO_MODELS[feature.model.slug]) + payment_features.append(feature) + + if payment_features: + PaymentPlanFeature.objects.bulk_update(payment_features, ['quantity']) + + +class Migration(migrations.Migration): + + dependencies = [ + ('payments', '0028_paymentplan_individual'), + ] + + operations = [ + migrations.RunPython(update_videos_quantity, migrations.RunPython.noop) + ] + + @@ -0,0 +1,18 @@ +# Generated by Django 5.0.11 on 2026-02-07 12:11 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('payments', '0029_update_videos_quantity'), + ] + + operations = [ + migrations.RemoveField( + model_name='paymentplan', + name='title', + ), + ] + @@ -0,0 +1,25 @@ +# Generated by Django 5.0.11 on 2026-02-03 10:11 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('payments', '0030_remove_paymentplan_title'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.RemoveField( + model_name='userpaymentmethod', + name='currently_active', + ), + migrations.AlterField( + model_name='userpaymentmethod', + name='user', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='payment_method', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь'), + ), + ] @@ -4,6 +4,7 @@ from payments.models.user_payment_method import UserPaymentMethod from payments.models.invoice import Invoice from payments.models.promocode import PromoCode, PromoCodeActivation from payments.models.recurring_payment import RecurringPayment +from payments.models.payment_plan_feature import PaymentPlanFeature __all__ = ( 'Payment', @@ -13,5 +14,6 @@ __all__ = ( 'Invoice', 'PromoCode', 'PromoCodeActivation', - 'RecurringPayment' + 'RecurringPayment', + 'PaymentPlanFeature' ) @@ -2,6 +2,7 @@ from datetime import datetime from django.contrib.auth import get_user_model from django.contrib.postgres.fields import ArrayField +from django.core.exceptions import ObjectDoesNotExist from django.db import models from django.utils.translation import gettext_lazy as _ @@ -18,7 +19,7 @@ class PaymentPlan(BaseModel): default=10, ) is_corporate = models.BooleanField(default=False, verbose_name=_('Is corporate')) - title = models.CharField(verbose_name=_('Title'), max_length=120, null=True, blank=True) + individual = models.BooleanField(default=False, verbose_name=_('Individual')) is_visible = models.BooleanField(default=True, verbose_name=_('Is visible')) points = ArrayField( default=list, @@ -27,13 +28,14 @@ class PaymentPlan(BaseModel): verbose_name='Поинты', help_text='Перечислять через запятую', ) - accessed_models = models.ManyToManyField( - NeuronModel, - verbose_name='Доступные модели', - ) + + @property + def accessed_models(self): + ids = self.features.values_list('model__pk', flat=True) + return NeuronModel.objects.filter(pk__in=ids) def __str__(self) -> str: - return f'{self.title or "Ошибка"}' + return f'{self.uid}' class Meta: ordering = ['price'] @@ -69,6 +71,13 @@ class PaymentPlanUserInfo(BaseModel): self.last_payment_at = datetime.now().date() return super().save(force_insert, force_update, using, update_fields) + @property + def is_recurring(self): + try: + return self.user.payment_method.recurring_payment + except ObjectDoesNotExist: + return None + def __str__(self) -> str: return f'{self.user.email or "Ошибка"}' @@ -0,0 +1,35 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ +from ordered_model.models import OrderedModel + +from ml_model.models import NeuronModel +from payments.models import PaymentPlan + + +class PaymentPlanFeature(OrderedModel): + class MeasurementUnitChoices(models.TextChoices): + TEXT_PAGE = ('text_page', _('Text Page')) + FILE = ('file', _('File (pcs)')) + TIME = ('time', _('Time (mins)')) + + plan = models.ForeignKey( + PaymentPlan, on_delete=models.CASCADE, related_name='features', verbose_name=_('Payment Plan') + ) + model = models.ForeignKey(NeuronModel, on_delete=models.CASCADE, related_name='feature', verbose_name=_('Neuron Model')) + quantity = models.PositiveIntegerField(default=1, verbose_name=_('Quantity')) + measurement_unit = models.CharField( + max_length=15, + choices=MeasurementUnitChoices.choices, + default=MeasurementUnitChoices.TEXT_PAGE, + verbose_name=_('Measurement unit'), + ) + + order_with_respect_to = 'plan' + + def __str__(self) -> str: + return f'{self.plan.uid} ({self.model.title})' + + class Meta(OrderedModel.Meta): + verbose_name = _('Payment plan feature') + verbose_name_plural = _('Payment plan features') + unique_together = ('plan', 'model') @@ -6,13 +6,12 @@ from core.models import BaseModel class UserPaymentMethod(BaseModel): - user = models.ForeignKey( + user = models.OneToOneField( get_user_model(), on_delete=models.CASCADE, - related_name='payment_methods', + related_name='payment_method', verbose_name='Пользователь', ) - currently_active = models.BooleanField(default=False, verbose_name='Способ платежа активен') payment_method_id = models.UUIDField(unique=True, verbose_name='UID платёжного метода') card_type = models.CharField(max_length=15, verbose_name='Тип карты') last_four = models.CharField(max_length=4, verbose_name='Последние 4 цифры карты') @@ -1,19 +1,38 @@ -import logging import orjson +import calendar +import logging +from collections import defaultdict +from datetime import date, timedelta from decimal import Decimal + from django.utils.translation import gettext_lazy as _ -from ninja import Router +from dateutil.relativedelta import relativedelta +from django.db.models import CharField, F, Func, Sum, Value, Prefetch +from django.db.models.functions import Round, TruncDay, TruncMonth, TruncYear +from django.utils.translation import gettext as _ +from ninja import Query, Router from ninja.errors import HttpError -from authentication.security import SyncAuthBearer +from authentication.models import CustomUserModel +from authentication.security import AsyncAuthBearer, SyncAuthBearer +from payments.exceptions.payer_not_found import PayerNotFound +from payments.models import Invoice, Payment, PaymentPlan, PaymentPlanFeature, UserPaymentMethod +from authentication.services.email_service import EmailService from payments.models import RecurringPayment from payments.schema import UserBalance +from payments.schemas import ( + ExpensesParamsSchema, + ExpensesSchema, + NewSubscriptionSchema, + PaymentLinkSchema, + PaymentPlanSchema, +) from payments.selectors.payment_plan_selector import PaymentPlanSelector +from payments.typing import IntervalStrategyEnum, SourceStrategyEnum from payments.services.payment_plan_service import PaymentPlanService from payments.services.payment_service import PaymentService -from payments.services.referral_account import ReferralAccountService router = Router(auth=SyncAuthBearer(), tags=['payments']) @@ -34,27 +53,188 @@ def get_user_balance(request): @router.post('payment-result', tags=['payments/payment-result'], auth=None) -def handle_yookassa_webhook(request): - try: - data = orjson.loads(request.body) - payment = PaymentService.handle_payment(data['object']['id']) - payment_instance = PaymentService.save_payment(payment) - if payment.status == 'waiting_for_capture': - PaymentService.handle_captured_payment(payment.id) - elif payment.status == 'succeeded': - PaymentService.handle_succeeded_payment(payment, payment_instance.user, payment_instance.plan) - PaymentPlanService(payment_instance.user).subscribe_user_to_plan(payment_instance.plan) - if ref_acc := payment_instance.user.referer_account: - ReferralAccountService.apply_accrual(referer_account=ref_acc, payment=payment_instance) - elif payment.status == 'canceled' and payment.metadata.get('recurring'): - PaymentService.handle_canceled_payment(payment, payment_instance.user) - return 200 +async def handle_yookassa_webhook(request): + data = orjson.loads(request.body) + payment = await PaymentService.handle_payment(data['object']['id']) + try: + payer = await CustomUserModel.objects.prefetch_related('payment_plan', 'payment_plan__plan').aget( + uid=payment.description + ) + payer_current_plan = payer.payment_plan.plan + payment_instance = await PaymentService(payer).do_payment(payment) + except CustomUserModel.DoesNotExist: + raise HttpError(400, str(PayerNotFound)) except Exception as exc: logger.exception(exc) raise HttpError(400, f'{exc}') + try: + if payment.status in ('succeeded', 'canceled'): + recurring = await RecurringPayment.objects.filter(method__user=payment_instance.user).afirst() + next_payment_at = recurring.pay_at if recurring else 'Автоплатежи отключены' + new_plan = payment_instance.plan + EmailService.send_payment_email( + f'Вы {"приобрели план" if payer_current_plan.price != new_plan.price else "восстановили баланс по плану"} {new_plan.tokens_per_plan} токенов', + next_payment_at, + payment_instance.amount.quantize(Decimal('0.01'), rounding='ROUND_UP'), + payment_instance.user.email, + payment.status, + ) + except Exception as exc: + logger.exception(exc) + return 200 @router.post('revoke-recurring-payment', tags=['payments/revoke-recurring-payment']) def revoke_recurring_payment(request): - RecurringPayment.objects.filter(method__user=request.auth).delete() + UserPaymentMethod.objects.filter(user=request.auth).delete() return 200, {'detail': _('The recurring payment is successfully cancelled')} + + +@router.get('expenses', tags=['payments/expenses'], response=list[ExpensesSchema]) +def list_expenses(request, data: ExpensesParamsSchema = Query(...)): + try: + 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.select_related('model') + grouped = defaultdict(Decimal) + for inv in qs: + label = (inv.model.types or ['other'])[0] if inv.model else 'other' + grouped[label] = grouped[label] + (inv.cost or Decimal('0')) + return [ExpensesSchema(source=label, amount=amount) for label, amount in grouped.items()] + 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('yyyy.MM.dd'), function='to_char', output_field=CharField() + ), + amount=Round(Sum('cost')), + ) + case SourceStrategyEnum.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}') + + +@router.get('plans', tags=['payments/plans'], auth=AsyncAuthBearer(), response=list[PaymentPlanSchema]) +async def list_payment_plans(request): + try: + logger.info(request.auth) + if request.auth.account_type not in {'regular', 'business_host'}: + raise HttpError(401, 'Unauthorized') + is_corporate = request.auth.account_type == 'business_host' + plans = PaymentPlan.objects.filter( + price__gt=0, is_corporate=is_corporate, is_visible=True + ).prefetch_related( + Prefetch( + 'features', + queryset=PaymentPlanFeature.objects.filter( + model__inferences__enabled=True + ).distinct().select_related('model').prefetch_related('model__inferences'), + to_attr='active_features', + ) + ) + result = [] + GROUPED_FEATURES_ORDER = ['chat-bots', 'images', 'videos', 'audio'] + async for plan in plans: + raw_grouped = defaultdict(list) + for feature in plan.active_features: + cat_label = (feature.model.types or ['other'])[0] if feature.model else 'other' + raw_grouped[cat_label].append( + { + 'name': feature.model.title, + 'quantity': feature.quantity, + 'measurement_unit': feature.measurement_unit, + } + ) + grouped = dict( + sorted(raw_grouped.items(), key=lambda items: GROUPED_FEATURES_ORDER.index(items[0])) + ) + result.append( + PaymentPlanSchema( + uid=plan.uid, + price=plan.price, + tokens_per_plan=plan.tokens_per_plan, + points=plan.points, + grouped_features=[{'name': cat, 'features': feats} for cat, feats in grouped.items()], + individual=plan.individual, + ) + ) + return result + except Exception as exc: + logger.exception(exc, exc_info=True) + raise HttpError(400, f'{exc}') + + +@router.post('plans', tags=['payments/plans'], auth=AsyncAuthBearer(), response=PaymentLinkSchema) +async def create_payment_link(request, body: NewSubscriptionSchema): + try: + if request.auth.account_type not in {'regular', 'business_host'}: + raise HttpError(401, 'Unauthorized') + payment_plan = await PaymentPlan.objects.aget(uid=body.uid) + payment_url = await PaymentService(request.auth).create_payment_link(payment_plan) + return PaymentLinkSchema(payment_url=payment_url) + except Exception as exc: + raise HttpError(400, f'{exc}') + + +@router.delete('plans', tags=['payments/plans'], auth=AsyncAuthBearer(), response={200: None, 400: str}) +async def cancel_plan_subscription(request): + try: + if request.auth.account_type not in {'regular', 'business_host'}: + raise HttpError(401, 'Unauthorized') + await PaymentPlanService(request.auth).cancel_payment_plan() + except Exception as exc: + raise HttpError(400, f'{exc}') + + +@router.post('gitlab-webhook', tags=['payments/gitlab-webhook'], auth=None) +async def handle_gitlab_webhook(request): + try: + data = orjson.loads(request.body)['object_attributes'] + if data['name'] == 'recurring_payments' and not data['active']: + await UserPaymentMethod.objects.all().adelete() + except Exception as exc: + logger.error(exc) + return 200 @@ -1,31 +0,0 @@ -from uuid import UUID - -from authentication.models.user import CustomUserModel -from payments.models.user_payment_method import UserPaymentMethod -from payments.serializers import PaymentMethodSerializer - - -class PaymentMethodSelector: - def __init__(self, user: CustomUserModel): - self.user = user - - def list(self, serialize: bool = False): - methods = self.user.payment_methods.all() - if serialize: - return PaymentMethodSerializer(methods, many=True) - return methods - - def get_payment_method_by_uuid(self, method_id: UUID) -> UserPaymentMethod | None: - payment = UserPaymentMethod.objects.filter(user=self.user, payment_method_id=method_id) - - if not payment.exists(): - return None - - return payment.first() - - def get_current_active_method(self) -> UserPaymentMethod: - method = UserPaymentMethod.objects.filter(user=self.user, currently_active=True) - if not method.exists(): - raise Exception(f'No active payment method for user {self.user}') - - return method.first() @@ -1,19 +1,11 @@ import logging from decimal import Decimal -from typing import Literal -from uuid import UUID - -from django.db.models import Q -from django.utils.translation import gettext_lazy as _ 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 ( - PaymentPlanSerializer, - UserPaymentPlanSerializer, -) +from payments.serializers import UserPaymentPlanSerializer logger = logging.getLogger(__name__) @@ -22,38 +14,6 @@ class PaymentPlanSelector: def __init__(self, user: CustomUserModel): self.user = user - def get_payment_plans( - self, - serialize: bool = True, - ): - account_type = UserSelector(self.user).check_account_type() - if account_type == 'business_account': - raise Exception(_('Business accounts are not allowed to make purchases')) - - is_corporate = False if account_type == 'regular' else True - - plans = PaymentPlan.objects.filter( - ~Q(price=Decimal('0')) & Q(is_corporate=is_corporate) & Q(is_visible=True) - ).order_by('price') - - if serialize: - return PaymentPlanSerializer(plans, many=True) - - return plans - - def get_payment_plan_by_id(self, plan_id: UUID) -> PaymentPlan: - logger.info('START %s' % self.get_payment_plan_by_id.__name__) - plan = PaymentPlan.objects.get(uid=plan_id) - logger.info('PLAN: %s' % plan) - return plan - - def get_plan_detail_by_id(self, **kwargs) -> PaymentPlanSerializer: - plan = self.get_payment_plan_by_id(UUID(kwargs.get('id'))) - if plan is None: - raise Exception(_('No plan by this uid')) - - return PaymentPlanSerializer(plan) - def get_current_balance(self) -> Decimal: if ( self.user.account_type in ('business_account', 'business_admin', 'business_security') @@ -1,14 +1,9 @@ import logging from uuid import UUID -from django.db.transaction import atomic -from django.utils.translation import gettext_lazy as _ -from rest_framework.request import Request - from authentication.models.user import CustomUserModel from payments.models.user_payment_method import UserPaymentMethod -from payments.selectors.payment_method_selector import PaymentMethodSelector -from payments.serializers import ResetPaymentMethodSerializer + logger = logging.getLogger(__name__) @@ -21,46 +16,10 @@ class PaymentMethodService: logger.info('START %s', self.add_payment_method.__name__) payment_method, _ = UserPaymentMethod.objects.update_or_create( user=self.user, - last_four=last_four, - card_type=card_type, - defaults={ - 'payment_method_id': method_id, - 'currently_active': True - }, + defaults={'payment_method_id': method_id, 'last_four': last_four, 'card_type': card_type}, ) logger.info('NEW PAYMENT METHOD: %s', payment_method) return payment_method - def delete_payment_method(self, method_id: UUID): - existing = PaymentMethodSelector(self.user).get_payment_method_by_uuid(method_id) - if existing is not None: - existing.delete() - - def unset_payment_method(self): - current_active = PaymentMethodSelector(self.user).get_current_active_method() - - if current_active is None: - raise Exception(_('No current active payment method is set')) - - current_active.is_active = False - current_active.save() - - def set_new_payment_method(self, id: UUID): - method = PaymentMethodSelector(self.user).get_payment_method_by_uuid(id) - - if method is None: - raise Exception(_('No payment method by this id')) - - method.is_active = True - method.save() - - @atomic - def change_methods(self, id: UUID): - self.unset_payment_method() - self.set_new_payment_method(id) - - def update(self, request: Request): - serializer = ResetPaymentMethodSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - - self.change_methods(serializer.validated_data['uid']) + def delete_payment_method(self): + UserPaymentMethod.objects.filter(user=self.user).delete() @@ -1,10 +1,11 @@ import logging from decimal import Decimal +from asgiref.sync import sync_to_async + from authentication.models import CustomUserModel -from payments.models import Invoice, PaymentPlan, PaymentPlanUserInfo +from payments.models import Invoice, PaymentPlan, PaymentPlanUserInfo, RecurringPayment from payments.selectors.payment_plan_selector import PaymentPlanSelector -from payments.serializers import PaymentLinkSerializer from payments.services.model_billing_service import ModelBillingService from payments.services.payment_service import PaymentService @@ -20,31 +21,27 @@ class PaymentPlanService: self.user.payment_plan.current_token_balance += amount self.user.payment_plan.save() - def create_payment_plan_invoice(self, payment_plan_uid: str): - """""" - payment_plan = PaymentPlanSelector(self.user).get_payment_plan_by_id(payment_plan_uid) - resulting_link = self.payment_service.create_payment_link(plan=payment_plan) - result = PaymentLinkSerializer(data={'payment_url': resulting_link}) - result.is_valid(raise_exception=True) - return result - - def subscribe_user_to_plan(self, plan: PaymentPlan): - plan_info, created = PaymentPlanUserInfo.objects.get_or_create( + def subscribe_user_to_plan(self, plan: PaymentPlan, mode: str = 'set', amount: Decimal = Decimal('0')): + try: + current_balance = self.user.payment_plan.current_token_balance + except PaymentPlanUserInfo.DoesNotExist: + current_balance = Decimal('0') + token_balance = amount or plan.tokens_per_plan + PaymentPlanUserInfo.objects.update_or_create( user=self.user, defaults={ 'plan': plan, - 'current_token_balance': plan.tokens_per_plan, + 'current_token_balance': token_balance if mode == 'set' else current_balance + token_balance, }, ) - if not created: - plan_info.plan = plan - plan_info.current_token_balance += plan.tokens_per_plan - plan_info.save() - def cancel_payment_plan(self): + async def cancel_payment_plan(self): plan_info: PaymentPlanUserInfo = self.user.payment_plan - plan_info.plan = PaymentPlanSelector(self.user).get_free_plan(corporate=self.user.is_corporate()) - plan_info.save() + plan_info.plan = await sync_to_async(PaymentPlanSelector(self.user).get_free_plan)( + corporate=self.user.is_corporate() + ) + await plan_info.asave() + await RecurringPayment.objects.filter(method__user=self.user).adelete() def update_per_token_plan_details(self, payment_amount: Decimal, model=None): ModelBillingService(self.user).charge(payment_amount) @@ -1,9 +1,12 @@ -from datetime import datetime, timedelta +from datetime import timedelta import logging +from decimal import Decimal from uuid import UUID, uuid4 +from asgiref.sync import sync_to_async from dateutil.relativedelta import relativedelta from django.conf import settings +from django.db import transaction from django.utils import timezone from yookassa import Configuration from yookassa import Payment as YookassaPayment @@ -14,11 +17,12 @@ from yookassa.domain.response import PaymentResponse as YookassaPaymentResponse from authentication.models import CustomUserModel from lib.unleash.client import web_client -from payments.exceptions.payer_not_found import PayerNotFound +from payments.exceptions.full_balance import FullBalanceException from payments.models import RecurringPayment from payments.models.payment import Payment as PaymentModel from payments.models.payment_plan import PaymentPlan from payments.services.payment_method_service import PaymentMethodService +from payments.services.referral_account import ReferralAccountService logger = logging.getLogger(__name__) @@ -30,21 +34,43 @@ class PaymentService: def __init__(self, user: CustomUserModel): self.user = user - def create_payment_link(self, plan: PaymentPlan) -> str: + async def create_payment_link(self, plan: PaymentPlan) -> str: + current_plan = self.user.payment_plan.plan + current_balance = self.user.payment_plan.current_token_balance + plan_price = plan.price + plan_tokens = plan.tokens_per_plan + tokens_mode = 'set' + if current_balance >= plan_tokens and plan == current_plan: + raise FullBalanceException + if 0 < current_plan.price <= plan.price and self.user.payment_plan.is_recurring: + plans_price_diff = plan.price - current_plan.price + token_price = current_plan.price / current_plan.tokens_per_plan + tokens_diff = current_plan.tokens_per_plan - current_balance + additional_tokens = 0 if tokens_diff < 0 else tokens_diff + plan_price = Decimal(plans_price_diff + (token_price * additional_tokens)).quantize( + Decimal('0.01'), rounding='ROUND_UP' + ) + balance_sufficient = (plan_tokens - current_balance) <= 0 + plan_tokens = plan_tokens if balance_sufficient else (plan_tokens - current_balance) + tokens_mode = 'set' if balance_sufficient else 'add' + tokens_update = {'mode': tokens_mode, 'amount': str(plan_tokens)} + product_title = f'Вы {"купили план" if current_plan.price != plan.price else "восстановили баланс по плану"} {plan.tokens_per_plan} токенов' receipt_data = { 'customer': {'email': self.user.email}, 'items': [ { - 'description': f'План {plan.tokens_per_plan} токенов за {plan.price} р.', - 'amount': {'value': f'{plan.price}', 'currency': 'RUB'}, + 'description': product_title, + 'amount': {'value': f'{plan_price}', 'currency': 'RUB'}, 'vat_code': 1, 'quantity': '1', } ], } - is_recurring = web_client.get_flag_state('recurring_payments', self.user.email) + is_recurring = ( + web_client.get_flag_state('recurring_payments', self.user.email) and not plan.individual + ) payment_data = { - 'amount': {'value': f'{plan.price}', 'currency': 'RUB'}, + 'amount': {'value': f'{plan_price}', 'currency': 'RUB'}, 'payment_method_data': {'type': 'bank_card'}, 'receipt': receipt_data, 'confirmation': { @@ -53,68 +79,81 @@ class PaymentService: }, 'description': str(self.user.uid), 'capture': True, - 'save_payment_method': is_recurring + 'save_payment_method': is_recurring, + 'metadata': {'plan_uid': str(plan.uid), **tokens_update}, } payment = YookassaPayment.create(payment_data, uuid4()) return payment.confirmation.confirmation_url - @classmethod - def handle_payment(cls, payment_id: UUID) -> YookassaPaymentResponse: - return YookassaPayment.find_one(payment_id) + @sync_to_async + def do_payment(self, payment: YookassaPaymentResponse) -> PaymentModel: + from payments.services.payment_plan_service import PaymentPlanService + + with transaction.atomic(): + payment_instance = self.save_payment(payment) + if payment.status == 'waiting_for_capture': + self.handle_captured_payment(payment.id) + elif payment.status == 'succeeded': + self.handle_succeeded_payment(payment, payment_instance.plan) + PaymentPlanService(self.user).subscribe_user_to_plan( + payment_instance.plan, payment.metadata['mode'], Decimal(payment.metadata['amount']) + ) + if ref_acc := self.user.referer_account: + ReferralAccountService.apply_accrual(referer_account=ref_acc, payment=payment_instance) + elif payment.status == 'canceled' and payment.metadata.get('recurring'): + self.handle_canceled_payment(payment) + + return payment_instance @classmethod - def handle_captured_payment(cls, payment_id: UUID) -> None: + async def handle_payment(cls, payment_id: UUID) -> YookassaPaymentResponse: + return await sync_to_async(YookassaPayment.find_one)(payment_id) + + def handle_captured_payment(self, payment_id: UUID) -> None: YookassaPayment.capture(str(payment_id)) - @classmethod - def handle_succeeded_payment(cls, payment: YookassaPaymentResponse, user: CustomUserModel, plan: PaymentPlan) -> None: + def handle_succeeded_payment(self, payment: YookassaPaymentResponse, plan: PaymentPlan) -> None: if ( payment.payment_method.saved and not payment.authorization_details.three_d_secure.applied - and web_client.get_flag_state('recurring_payments', user.email) + and web_client.get_flag_state('recurring_payments', self.user.email) + and not plan.individual ): - payment_method = cls.save_payment_method(user, payment.payment_method) + payment_method = self.save_payment_method(payment.payment_method) RecurringPayment.objects.update_or_create( - method__user=user, + method__user=self.user, defaults={ 'method': payment_method, 'pay_at': timezone.now() + relativedelta(months=1), - 'plan': plan - } + 'plan': plan, + }, ) else: - RecurringPayment.objects.filter(method__user=user).delete() + PaymentMethodService(self.user).delete_payment_method() - @classmethod - def handle_canceled_payment(cls, payment: YookassaPaymentResponse, user: CustomUserModel) -> None: + def handle_canceled_payment(self, payment: YookassaPaymentResponse) -> None: logger.error(f'Recurrent payment error: {payment.cancellation_details.reason}') if payment.cancellation_details.reason == 'permission_revoked': - RecurringPayment.objects.filter(method__user=user).delete() + RecurringPayment.objects.filter(method__user=self.user).delete() else: - recurring = RecurringPayment.objects.filter(method__user=user).first() + recurring = RecurringPayment.objects.filter(method__user=self.user).first() if recurring: - recurring.pay_at = datetime.now() + timedelta(days=2) + recurring.pay_at = timezone.now() + timedelta(days=2) recurring.save() - @classmethod - def save_payment_method(cls, user: CustomUserModel, method_data: PaymentDataBankCard): + def save_payment_method(self, method_data: PaymentDataBankCard): card = method_data.card - return PaymentMethodService(user).add_payment_method( + return PaymentMethodService(self.user).add_payment_method( method_id=UUID(method_data.id), card_type=card.card_type, last_four=card.last4 ) - @classmethod - def save_payment(cls, payment: YookassaPaymentResponse) -> PaymentModel: - try: - payer = CustomUserModel.objects.get(uid=payment.description) - except CustomUserModel.DoesNotExist: - raise PayerNotFound - plan = PaymentPlan.objects.get_or_none(price=payment.amount.value) + def save_payment(self, payment: YookassaPaymentResponse) -> PaymentModel: + plan = PaymentPlan.objects.get_or_none(uid=payment.metadata.get('plan_uid')) payment_instance, _ = PaymentModel.objects.update_or_create( uid=payment.id, defaults=dict( - user=payer, + user=self.user, amount=payment.amount.value, plan=plan, status=payment.status, @@ -15,17 +15,19 @@ class ReferralAccountService: return ReferralAccount.objects.create(owner=user) @classmethod - def create_invite(cls, referer_account: CustomUserModel, invitee: CustomUserModel): + def create_invite(cls, referer_account: ReferralAccount, invitee: CustomUserModel): return ReferralInvite.objects.create(referer_account=referer_account, invitee=invitee) @classmethod def apply_accrual(cls, referer_account: ReferralAccount, payment: Payment): - accrual_amount = (payment.plan.tokens_per_plan * Decimal(0.2)).quantize(Decimal('1')) - accrual = ReferralAccrual.objects.create( - referer_account=referer_account, - payment=payment, - amount=accrual_amount, - ) - referer_account.owner.payment_plan.current_token_balance += accrual_amount - referer_account.owner.payment_plan.save() - return accrual + if not ReferralAccrual.objects.filter( + referer_account=referer_account, payment__user=payment.user + ).first(): + accrual_amount = (payment.plan.tokens_per_plan * Decimal(0.2)).quantize(Decimal('1')) + ReferralAccrual.objects.create( + referer_account=referer_account, + payment=payment, + amount=accrual_amount, + ) + referer_account.owner.payment_plan.current_token_balance += accrual_amount + referer_account.owner.payment_plan.save() @@ -0,0 +1,209 @@ +from decimal import Decimal + +from authentication.models import BusinessUserHost, BusinessAccount + +from core.tests import BaseAuthorizedAPITest +from ml_model.models import Deployment, Inference, NeuronModel, NeuronModelInferenceLnk +from payments.models import PaymentPlan, PaymentPlanFeature + + +class PlansAPITest(BaseAuthorizedAPITest): + ENDPOINT = '/api/v1/payments/plans' + + @classmethod + def setup_test_data(cls) -> None: + cls.free_plan, created = PaymentPlan.objects.update_or_create( + price=0, tokens_per_plan=10, defaults={} + ) + + deployment = Deployment.objects.create( + name='Test Deployment', + slug='test-deployment-plans', + runner_import_path='ml_model.runners:DummyTextRunner', + output_type='text', + enabled=True, + ) + inference = Inference.objects.create( + deployment=deployment, + slug='test-inference-plans', + enabled=True, + ) + + cls.model1 = NeuronModel.objects.create( + title='Model 1', slug='model-1', types=['chat-bots'] + ) + cls.model2 = NeuronModel.objects.create( + title='Model 2', slug='model-2', types=['chat-bots'] + ) + cls.model3 = NeuronModel.objects.create( + title='Model 3', slug='model-3', types=['images'] + ) + NeuronModelInferenceLnk.objects.create(neuron_model=cls.model1, inference=inference, order=1) + NeuronModelInferenceLnk.objects.create(neuron_model=cls.model3, inference=inference, order=2) + + cls.regular_plan1 = PaymentPlan.objects.create( + price=1000, + tokens_per_plan=100, + is_corporate=False, + is_visible=True, + ) + + cls.regular_plan2 = PaymentPlan.objects.create( + price=2000, + tokens_per_plan=200, + is_corporate=False, + is_visible=True, + ) + + PaymentPlanFeature.objects.create( + plan=cls.regular_plan1, + model=cls.model1, + quantity=10, + measurement_unit='text_page', + ) + PaymentPlanFeature.objects.create( + plan=cls.regular_plan1, + model=cls.model2, + quantity=20, + measurement_unit='file', + ) + PaymentPlanFeature.objects.create( + plan=cls.regular_plan1, + model=cls.model3, + quantity=30, + measurement_unit='time', + ) + + cls.corporate_plan1 = PaymentPlan.objects.create( + price=10000, + tokens_per_plan=1000, + is_corporate=True, + is_visible=True, + ) + cls.corporate_plan2 = PaymentPlan.objects.create( + price=20000, + tokens_per_plan=2000, + is_corporate=True, + is_visible=True, + ) + + cls.hidden_plan = PaymentPlan.objects.create( + price=500, tokens_per_plan=50, is_corporate=False, is_visible=False + ) + + def test_unauthorized_status_code(self) -> None: + response = self.client.get(self.ENDPOINT) + self.assertEqual(response.status_code, 401) + self.assertEqual(response.json(), {'detail': 'Unauthorized'}) + + def test_unauthorized_by_permission(self) -> None: + from authentication.models import CustomUserModel + host_user = CustomUserModel.objects.create_user(email='test_2@test.test', password='test_2') + host = BusinessUserHost.objects.create(user=host_user) + BusinessAccount.objects.create(user=self.user, parent_company=host) + response = self.client.get(self.ENDPOINT) + self.assertEqual(response.status_code, 401) + self.assertEqual(response.json(), {'detail': 'Unauthorized'}) + + def test_authorized_status_code(self) -> None: + response = self.get() + self.assertEqual(response.status_code, 200) + + def test_completeness_response(self) -> None: + plans = self.get().json() + self.assertGreater(len(plans), 0) + plan = plans[0] + keys = list(plan.keys()) + self.assertEqual( + keys, + [ + 'uid', + 'price', + 'tokens_per_plan', + 'points', + 'grouped_features', + 'accessed_models', + 'individual' + ], + ) + + def test_regular_plans(self) -> None: + plans = self.get().json() + plan_uids = [str(plan['uid']) for plan in plans] + self.assertIn(str(self.regular_plan1.uid), plan_uids) + self.assertIn(str(self.regular_plan2.uid), plan_uids) + + def test_hidden_plans(self) -> None: + plans = self.get().json() + plan_uids = [str(plan['uid']) for plan in plans] + self.assertNotIn(str(self.hidden_plan.uid), plan_uids) + + def test_zero_price_plans(self) -> None: + plans = self.get().json() + plan_uids = [str(plan['uid']) for plan in plans] + self.assertNotIn(str(self.free_plan.uid), plan_uids) + + def test_corporate_plans(self) -> None: + BusinessUserHost.objects.create(user=self.user) + plans = self.get().json() + plan_uids = [str(plan['uid']) for plan in plans] + self.assertIn(str(self.corporate_plan1.uid), plan_uids) + self.assertIn(str(self.corporate_plan2.uid), plan_uids) + + def test_grouped_features_structure(self) -> None: + plans = self.get().json() + plan_with_features = [plan for plan in plans if plan['grouped_features']][0] + + grouped_features = plan_with_features['grouped_features'] + self.assertIsInstance(grouped_features, list) + self.assertGreater(len(grouped_features), 0) + + for group in grouped_features: + self.assertIn('name', group) + self.assertIn('features', group) + self.assertIsInstance(group['name'], str) + self.assertIsInstance(group['features'], list) + + for feature in group['features']: + self.assertIn('name', feature) + self.assertIn('quantity', feature) + self.assertIn('measurement_unit', feature) + self.assertIsInstance(feature['name'], str) + self.assertIsInstance(feature['quantity'], int) + self.assertIsInstance(feature['measurement_unit'], str) + + def test_features_grouped_by_category(self) -> None: + plans = self.get().json() + plan_with_features = [plan for plan in plans if plan['grouped_features']][0] + + grouped_features = plan_with_features['grouped_features'] + category_names = [group['name'] for group in grouped_features] + self.assertIn('chat-bots', category_names) + self.assertIn('images', category_names) + + category1_group = [g for g in grouped_features if g['name'] == 'chat-bots'][0] + self.assertEqual(len(category1_group['features']), 1) + + category2_group = [g for g in grouped_features if g['name'] == 'images'][0] + self.assertEqual(len(category2_group['features']), 1) + + def test_feature_values(self) -> None: + plans = self.get().json() + plan_with_features = [plan for plan in plans if plan['grouped_features']][0] + self.assertIsNotNone(plan_with_features) + + all_features = [] + for group in plan_with_features['grouped_features']: + all_features.extend(group['features']) + + feature_names = {f['name'] for f in all_features} + self.assertSetEqual({'Model 1', 'Model 3'}, feature_names) + + feature1 = [f for f in all_features if f['name'] == 'Model 1'][0] + self.assertEqual(feature1['quantity'], 10) + self.assertEqual(feature1['measurement_unit'], 'text_page') + + def test_tokens_per_plan(self) -> None: + plans = self.get().json() + for plan in plans: + self.assertGreater(Decimal(str(plan['tokens_per_plan'])), 0) \ No newline at end of file @@ -0,0 +1,66 @@ +from decimal import Decimal + +from authentication.models import CustomUserModel, BusinessUserHost, BusinessAccount, BusinessGroup + +from core.tests import BaseAuthorizedAPITest +from payments.models import PaymentPlan + + +class BalanceAPITest(BaseAuthorizedAPITest): + ENDPOINT = '/api/v1/payments/user-balance' + + @classmethod + def setup_host(cls) -> None: + cls.host_user = CustomUserModel.objects.create_user(email='test_2@test.test', password='test_2') + cls.host = BusinessUserHost.objects.create(user=cls.host_user) + cls.host_payment_plan = PaymentPlan.objects.create( + price=1000, tokens_per_plan=900, is_corporate=True + ) + cls.host_user.payment_plan.plan = cls.host_payment_plan + cls.host_user.payment_plan.save() + + @classmethod + def setup_test_data(cls) -> None: + PaymentPlan.objects.update_or_create(price=0, tokens_per_plan=10, defaults={}) + cls.setup_host() + + def test_unauthorized_status_code(self) -> None: + response = self.client.get(self.ENDPOINT) + self.assertEqual(response.status_code, 401) + self.assertEqual(response.json(), {'detail': 'Unauthorized'}) + + def test_authorized_status_code(self) -> None: + response = self.get() + self.assertEqual(response.status_code, 200) + + def test_completeness_response(self) -> None: + response = self.get() + keys = list(response.json().keys()) + self.assertEqual(keys, ['current_token_balance']) + + def test_correct_balance(self) -> None: + balance = self.get().json()['current_token_balance'] + self.assertEqual(Decimal(balance), Decimal('10.00')) + self.user.payment_plan.current_token_balance -= 2 + self.user.payment_plan.save() + balance = self.get().json()['current_token_balance'] + self.assertEqual(Decimal(balance), Decimal('8.00')) + + def test_displaying_balance(self) -> None: + balance = self.get().json()['current_token_balance'] + self.assertEqual(Decimal(balance), Decimal('10.00')) + business_account = BusinessAccount.objects.create( + user=self.user, parent_company=self.host, acceptance_status='accepted', token_limit=Decimal('50') + ) + balance = self.get().json()['current_token_balance'] + self.assertEqual(Decimal(balance), business_account.token_limit) + business_account.token_limit = None + business_account.save() + balance = self.get().json()['current_token_balance'] + self.assertEqual(Decimal(balance), self.host_user.balance) + business_account.group = BusinessGroup.objects.create( + title='Test group 1', token_limit=100, parent_company=self.host + ) + business_account.save() + balance = self.get().json()['current_token_balance'] + self.assertEqual(Decimal(balance), business_account.group.token_limit) \ No newline at end of file @@ -5,12 +5,14 @@ from django.db import models from django.db.models import Count, Sum from django.db.models.functions import Coalesce from django.utils.translation import gettext_lazy as _ +from ordered_model.admin import OrderedInlineModelAdminMixin, OrderedModelAdmin from authentication.admin import CustomUserModelAdmin from payments.models import ( Invoice, Payment, PaymentPlan, + PaymentPlanFeature, PaymentPlanUserInfo, PromoCode, PromoCodeActivation, @@ -40,16 +42,16 @@ class PaymentAdmin(admin.ModelAdmin): @admin.register(PaymentPlan) -class PaymentPlanAdmin(admin.ModelAdmin): +class PaymentPlanAdmin(OrderedInlineModelAdminMixin, admin.ModelAdmin): list_display = [ - 'title', + 'uid', 'price', 'tokens_per_plan', 'is_corporate', + 'individual', 'is_visible', ] search_fields = ['price', 'tokens_per_plan'] - filter_horizontal = ('accessed_models',) @admin.register(PaymentPlanUserInfo) @@ -65,6 +67,12 @@ class PaymentPlanUserInfoAdmin(admin.ModelAdmin): search_help_text = _('You can search by user email, exacted company name') +@admin.register(PaymentPlanFeature) +class PaymentPlanFeatureAdmin(OrderedModelAdmin): + list_display = ('plan', 'model', 'move_up_down_links') + list_filter = ('plan',) + + @admin.register(UserPaymentMethod) class UserPaymentMethodAdmin(admin.ModelAdmin): raw_id_fields = ['user'] @@ -11,9 +11,9 @@ class PaymentsConfig(AppConfig): verbose_name = _('Payments') def ready(self): - from .signals import init_referral_account + import payments.signals web_client.client.initialize_client() - setting_changed.connect(init_referral_account) + setting_changed.connect(payments.signals.init_referral_account) return super().ready() @@ -1,25 +1,45 @@ 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 condecimal, field_serializer +from payments.typing import IntervalStrategyEnum, SourceStrategyEnum from payments.models import PromoCode +class PaymentPlanFeatureSchema(Schema): + name: str + quantity: int + measurement_unit: str + + +class GroupedPlanFeatureSchema(Schema): + name: str + features: List[PaymentPlanFeatureSchema] + + class PaymentPlanSchema(Schema): uid: UUID - title: str price: condecimal(max_digits=10, decimal_places=2) tokens_per_plan: condecimal(max_digits=10, decimal_places=2) - points: List - accessed_models: List[str] + points: list[str] + grouped_features: list[GroupedPlanFeatureSchema] = [] + accessed_models: list[str] = [] + individual: bool - @field_validator('accessed_models', mode='before') - @classmethod - def get_slugs(cls, value: str) -> List[str]: - return [obj.slug for obj in value] + @staticmethod + def resolve_accessed_models(obj): + return list(obj.accessed_models.values_list('slug', flat=True)) + + +class NewSubscriptionSchema(Schema): + uid: UUID + + +class PaymentLinkSchema(Schema): + payment_url: str class UserPlanDetailSchema(Schema): @@ -33,6 +53,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) \ No newline at end of file @@ -1,20 +1,19 @@ from django.contrib.auth import get_user_model from rest_framework import serializers -from ml_model.models import NeuronModel from payments.models import Invoice, Payment, PromoCode from payments.models.referral_account import ReferralAccount class PaymentPlanSerializer(serializers.Serializer): uid = serializers.UUIDField() - title = serializers.CharField() price = serializers.DecimalField(max_digits=10, decimal_places=2) tokens_per_plan = serializers.DecimalField(max_digits=50, decimal_places=2) - points = serializers.ListField(read_only=True) - accessed_models = serializers.SlugRelatedField( - slug_field='slug', queryset=NeuronModel.objects.all(), many=True - ) + accessed_models = serializers.SerializerMethodField() + individual = serializers.BooleanField() + + def get_accessed_models(self, obj): + return [model.slug for model in obj.accessed_models] class ReferralSerializer(serializers.ModelSerializer): @@ -22,7 +21,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): @@ -51,31 +50,6 @@ class UserPlanDetailSerializer(serializers.Serializer): current_token_balance = serializers.IntegerField() -class NewSubsriptionSerializer(serializers.Serializer): - uid = serializers.UUIDField() - is_test = serializers.IntegerField() - - -class PaymentLinkSerializer(serializers.Serializer): - payment_url = serializers.URLField() - - -class PaymentMethodSerializer(serializers.Serializer): - uid = serializers.UUIDField() - currently_active = serializers.BooleanField() - payment_method_id = serializers.UUIDField() - card_type = serializers.CharField() - last_four = serializers.CharField() - - -class ResetPaymentMethodSerializer(serializers.Serializer): - uid = serializers.UUIDField() - - -class DeletePaymentMethodSerializer(serializers.Serializer): - uid = serializers.UUIDField() - - class UserPaymentPlanSerializer(serializers.Serializer): current_token_balance = serializers.DecimalField(max_digits=50, decimal_places=2) @@ -88,11 +62,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 @@ -4,6 +4,7 @@ from django.db.models.signals import post_save from django.dispatch import receiver from authentication.models.user import CustomUserModel +from payments.models import PaymentPlan, UserPaymentMethod from payments.services.referral_account import ReferralAccountService @@ -16,3 +17,11 @@ def init_referral_account( ): if created: ReferralAccountService.create_account(user=instance) + + +@receiver(post_save, sender=PaymentPlan) +def delete_recurrent_for_individual_plans( + sender: Type[PaymentPlan], instance: PaymentPlan, created: bool, **kwargs +): + if instance.individual: + UserPaymentMethod.objects.filter(user__payment_plan__plan=instance).delete() \ No newline at end of file @@ -25,12 +25,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 @@ -41,16 +36,17 @@ def withdraw(user_id: UUID, amount: Decimal): @shared_task def execute_recurring_payments() -> None: - overdue_payments = RecurringPayment.objects.filter(pay_at__lte=timezone.now()) + overdue_payments = RecurringPayment.objects.filter(pay_at__lte=timezone.now(), plan__isnull=False, plan__individual=False) for overdue_payment in overdue_payments: if celery_client.get_flag_state('recurring_payments', overdue_payment.method.user.email): customer = overdue_payment.method.user plan = overdue_payment.plan + product_title = f'Вы восстановили баланс по плану {plan.tokens_per_plan} токенов' receipt_data = { 'customer': {'email': customer.email}, 'items': [ { - 'description': f'План {plan.tokens_per_plan} токенов за {plan.price} р.', + 'description': product_title, 'amount': {'value': f'{plan.price}', 'currency': 'RUB'}, 'vat_code': 1, 'quantity': '1', @@ -63,6 +59,13 @@ def execute_recurring_payments() -> None: 'receipt': receipt_data, 'description': str(customer.uid), 'capture': True, - 'metadata': {'recurring': True} + 'metadata': { + 'recurring': True, + 'plan_uid': str(plan.uid), + 'mode': 'set', + 'amount': str(plan.tokens_per_plan), + }, } - YookassaPayment.create(payment_data, uuid4()) \ No newline at end of file + YookassaPayment.create(payment_data, uuid4()) + else: + overdue_payment.method.delete() \ No newline at end of file @@ -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,19 +4,12 @@ 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(), name='referral-account', ), path('invoices', views.InvoicesAPIView.as_view(), name='invoices'), - path('plans', views.PaymentPlanAPIView.as_view(), name='payment-plans'), - path( - 'methods', - views.PaymentMethodsAPIView.as_view(), - name='payment-methods', - ), path('promocode', views.PromoCodeAPIView.as_view(), name='promocode'), path( 'telegram-sub', @@ -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, Func 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, @@ -20,23 +17,13 @@ from rest_framework.views import APIView from authentication.permissions import IsTelegramAirBot from authentication.selectors.user_selector import UserSelector -from payments.exceptions.PlanIsFree import PlanIsFree from payments.models import Invoice -from payments.models.payment import Payment 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, - PaymentPlanSerializer, ReferralAccountSerializer, ) -from payments.services.payment_method_service import PaymentMethodService from payments.services.payment_plan_service import PaymentPlanService from payments.services.promocode_service import PromoCodeService @@ -57,109 +44,6 @@ class PaymentAPIView(APIView): return Response({'detail': f'{err}'}, status=status.HTTP_400_BAD_REQUEST) -class PaymentPlanAPIView(APIView): - permission_classes = (IsAuthenticated, IsAllowedToPay) - - @extend_schema( - responses={200: PaymentPlanSerializer}, - ) - def get(self, request: Request, *args, **kwargs): - """List available payment plans""" - try: - result = PaymentPlanSelector(self.request.user).get_payment_plans() - return Response(result.data, status=status.HTTP_200_OK) - except Exception as err: - logger.exception(err) - return Response({'detail': f'{err}'}, status=status.HTTP_400_BAD_REQUEST) - - @extend_schema( - request=NewSubsriptionSerializer, - responses={200: PaymentLinkSerializer}, - ) - def post(self, request, *args, **kwargs): - """Create new payment link for chosen Payment Plan""" - try: - payment_plan = NewSubsriptionSerializer(data=request.data) - payment_plan.is_valid() - result = PaymentPlanService(self.request.user).create_payment_plan_invoice( - payment_plan_uid=payment_plan.validated_data['uid'] - ) - return Response(result.validated_data, status=status.HTTP_200_OK) - except Exception as err: - logger.exception(err) - return Response({'detail': f'{err}'}, status=status.HTTP_400_BAD_REQUEST) - - def delete(self, request, *args, **kwargs): - """Cancel subscription (Payment Plan)""" - try: - PaymentPlanService(self.request.user).cancel_payment_plan() - return Response(status=status.HTTP_200_OK) - except PlanIsFree as err: - logger.exception(err) - return Response( - {'detail': 'Free plan cannot be cancelled'}, - status=status.HTTP_400_BAD_REQUEST, - ) - except Exception as err: - logger.exception(err) - return Response({'detail': f'{err}'}, status=status.HTTP_400_BAD_REQUEST) - - -class PaymentMethodsAPIView(APIView): - permission_classes = (IsAuthenticated, IsAllowedToPay) - - def get(self, request, *args, **kwargs): - """List user's saved payment methods (cards)""" - try: - result = PaymentMethodSelector(self.request.user).list(serialize=True) - return Response(result.data, status=status.HTTP_200_OK) - except Exception as err: - logger.exception(err) - return Response({'detail': f'{err}'}, status=status.HTTP_400_BAD_REQUEST) - - def put(self, request, *args, **kwargs): - """Update payment method (card) data.""" - try: - PaymentMethodService(self.request.user).update(request) - return Response({'ok': True}, status=status.HTTP_200_OK) - except Exception as err: - logger.exception(err) - return Response({'detail': f'{err}'}, status=status.HTTP_400_BAD_REQUEST) - - def delete(self, request, *args, **kwargs): - """Delete user's payment method (card)""" - try: - serializer = DeletePaymentMethodSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - PaymentMethodService(self.request.user).delete_payment_method(**serializer.validated_data) - return Response({'ok': True}, status=status.HTTP_200_OK) - except Exception as err: - logger.exception(err) - return Response({'detail': f'{err}'}, status=status.HTTP_400_BAD_REQUEST) - - -class UserPlanAPIView(APIView): - permission_classes = (IsAuthenticated,) - - def get(self, request, *args, **kwargs): - """Get user balance""" - try: - result = PaymentPlanSelector(self.request.user).get_current_balance() - return Response({'current_token_balance': result}, status=status.HTTP_200_OK) - except Exception as err: - logger.exception(err) - return Response({'detail': f'{err}'}, status=status.HTTP_400_BAD_REQUEST) - - def delete(self, request, *args, **kwargs): - """Cancel user paid Payment Plan (drop to free subscription)""" - try: - PaymentPlanService(request.user).cancel_payment_plan() - return Response({'ok': True}, status=status.HTTP_200_OK) - except Exception as err: - logger.exception(err) - return Response({'detail': f'{err}'}, status=status.HTTP_400_BAD_REQUEST) - - class InvoicesAPIView(ListAPIView): permission_classes = [ IsAuthenticated, @@ -170,122 +54,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__types').annotate( - source=Func(F('model__types'), function='unnest'), - 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'] @@ -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 core.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) @@ -0,0 +1,24 @@ +from rest_framework.test import APIClient + +from core.tests import BaseAuthorizedAPITest + + +class SupportAPITest(BaseAuthorizedAPITest): + ENDPOINT = '/api/v1/reports/' + + @classmethod + def setup_client(cls) -> None: + cls.client = APIClient() + + def test_unauthorized_status_code(self) -> None: + response = self.client.post(self.ENDPOINT, data={'report_text': 'test', 'images': []}) + self.assertEqual(response.status_code, 401) + self.assertEqual(response.json(), {'detail': 'Учетные данные не были предоставлены.'}) + + def test_authorized_status_code(self) -> None: + response = self.post(data={'report_text': 'test', 'images': []}) + self.assertEqual(response.status_code, 201) + + def test_completeness_response(self) -> None: + response = self.post(data={'report_text': 'test', 'images': []}) + self.assertEqual(response.json(), {'detail': 'error report sent'}) @@ -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) @@ -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): @@ -206,7 +206,9 @@ class MessageAPIView(APIView): Hide message """ chat = Chat.objects.get(pk=chat_uid) - message = Message.objects.get(pk=message_uid, chats_chats_messages=chat, is_deleted=False) + message = Message.objects.filter(pk=message_uid, chats_chats_messages=chat, is_deleted=False).first() + if not message: + return Response({'detail': _('The message has already been deleted')}) message.is_deleted = True message.save() return Response(status=204) @@ -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') @@ -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') @@ -0,0 +1,28 @@ +# Generated by Django 5.0.11 on 2025-12-24 15:07 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('public_api', '0010_apistore_model_alter_apistore_user'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='apikey', + unique_together=set(), + ), + migrations.AddConstraint( + model_name='apikey', + constraint=models.UniqueConstraint( + condition=models.Q(('is_deleted', False)), + fields=('user', 'name'), + name='unique_active_apikey_per_user', + ), + ), + ] + @@ -3,11 +3,11 @@ from decimal import Decimal from django.contrib.admin.models import ADDITION, DELETION, LogEntry from django.contrib.contenttypes.models import ContentType +from django.db import IntegrityError +from django.utils.translation import gettext as _ -from authentication.selectors.account_status_selector import ( - AccountStatusSelector, -) from core.service import BaseService +from lib.exceptions import DuplicateError, UnknownError from tools.public_api.models import APIKey from tools.public_api.selectors.api_key import APIKeySelector from tools.public_api.serializers import APIKeyResultSerializer @@ -15,11 +15,14 @@ from tools.public_api.serializers import APIKeyResultSerializer class APIKeyService(BaseService): def create(self, payload: dict, serialize: bool = False) -> APIKey | APIKeyResultSerializer: - if not ( - self.user.account_type in ('business_host', 'regular', 'business_admin') - ): + if not (self.user.account_type in ('business_host', 'regular', 'business_admin')): raise Exception('Can not create API key from business sub-account.') - api_key = APIKey.objects.create(user=self.user, **payload) + try: + api_key = APIKey.objects.create(user=self.user, **payload) + except IntegrityError as exc: + if 'unique' in str(exc): + raise DuplicateError(model=APIKey, attrs=(_('Name'), _('Owner'))) + raise UnknownError from exc LogEntry.objects.log_action( self.user.pk, ContentType.objects.get_for_model(api_key).pk, @@ -0,0 +1,46 @@ +from datetime import date + +from rest_framework.test import APIClient + +from core.tests import BaseAuthorizedAPITest +from tools.public_api.models import APIKey + + +class APIKeyAPITest(BaseAuthorizedAPITest): + ENDPOINT = '/api/v1/public/api-key' + + @classmethod + def setup_client(cls) -> None: + cls.client = APIClient() + + def test_unauthorized_status_code(self) -> None: + response = self.client.get(self.ENDPOINT) + self.assertEqual(response.status_code, 401) + self.assertEqual(response.json(), {'detail': 'Учетные данные не были предоставлены.'}) + + def test_authorized_status_code_on_create(self) -> None: + response = self.post(data={'name': 'Test key'}) + self.assertEqual(response.status_code, 201) + + def test_create_response_structure(self) -> None: + response = self.post(data={'name': 'Test key'}) + keys = list(response.json().keys()) + self.assertEqual(keys, ['created_at', 'name', 'key', 'expires_at', 'user', 'token_limit']) + + def test_list_keys_structure(self) -> None: + APIKey.objects.create(user=self.user, name='Key 1') + APIKey.objects.create(user=self.user, name='Key 2') + + response = self.get() + self.assertEqual(response.status_code, 200) + + data = response.json() + keys = list(data[0].keys()) + self.assertEqual(keys, ['created_at', 'name', 'key', 'expires_at', 'user', 'token_limit']) + + def test_create_with_expires_at_and_verify_in_db(self) -> None: + expires = date(2030, 1, 1) + self.post(data={'name': 'Key with TTL', 'expires_at': expires.isoformat()}) + api_key = APIKey.objects.get(user=self.user, name='Key with TTL', is_deleted=False) + self.assertEqual(api_key.name, 'Key with TTL') + self.assertEqual(api_key.expires_at, expires) @@ -4,6 +4,7 @@ from datetime import date, timedelta from django.contrib.auth import get_user_model from django.db import models +from django.db.models import UniqueConstraint, Q from django.utils.translation import gettext_lazy as _ from core.models import BaseModel @@ -46,7 +47,13 @@ class APIKey(BaseModel): expires_at = models.DateField(verbose_name=_('Expires at'), null=True, blank=True) class Meta: - unique_together = ['user', 'name', 'is_deleted'] + constraints = [ + UniqueConstraint( + fields=['user', 'name'], + condition=Q(is_deleted=False), + name='unique_active_apikey_per_user', + ) + ] verbose_name = _('API Key') verbose_name_plural = _('API Keys') @@ -1,3 +0,0 @@ -# from django.test import TestCase # Flake8 angery >:( - -# Create your tests here. @@ -6,6 +6,7 @@ __pycache__/ # works files .env venv/ +.venv/ virtualenv/ air_reports/ .python-version