@@ -0,0 +1,18 @@ +# Generated by Django 5.0.11 on 2025-04-21 22:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0015_usersetting'), + ] + + operations = [ + migrations.AlterField( + model_name='customusermodel', + name='is_confirmed', + field=models.BooleanField(default=False, verbose_name='Is email confirmed'), + ), + ] @@ -53,6 +53,7 @@ class CustomUserModelManager(BaseUserManager): user.is_staff = True user.is_superuser = True + user.is_confirmed = True user.save(using=self._db) return user @@ -152,7 +153,7 @@ class CustomUserModel(AbstractBaseUser, PermissionsMixin, BaseModel): is_staff = models.BooleanField(default=False, verbose_name=_('Is staff')) is_superuser = models.BooleanField(default=False, verbose_name=_('Is superuser')) - is_confirmed = models.BooleanField(default=True, verbose_name=_('Is email confirmed')) + is_confirmed = models.BooleanField(default=False, verbose_name=_('Is email confirmed')) is_subscribed_to_emails = models.BooleanField(default=True, verbose_name=_('Is subscribed')) profile_picture_name = models.CharField( max_length=255, @@ -19,6 +19,15 @@ class BusinessAccountSelector: return cls(account.first()) + @classmethod + def filter_by_email(cls, email: str, company: BusinessUserHost | None = None): + account = BusinessAccount.objects.filter(user__email=email) + if company is not None: + account = account.filter(parent_company=company) + if not account.exists(): + return None + return account.first() + def account_type(self) -> str: return self.account.account_privileges @@ -2,6 +2,7 @@ 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.models import ( BusinessAccount, @@ -9,6 +10,7 @@ from authentication.models import ( CustomUserModel, ) from authentication.models.choices import InvitationStatus +from authentication.serializers import ChangePasswordSerializer class BusinessAccountService: @@ -59,6 +61,12 @@ class BusinessAccountService: return cls(account.first()) + @classmethod + def get_company_name(cls, user: CustomUserModel): + if user.account_type == 'business_host': + return user.host_account + return user.business_account.parent_company + def update(self, data: OrderedDict) -> BusinessAccount: if data['status'] == InvitationStatus.ACCEPTED: self.accept() @@ -84,6 +92,22 @@ class BusinessAccountService: 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")) + + if self.account.user.account_type not in ('business_account', 'business_security'): + raise Exception(_("You do not have sufficient rights to perform this action")) + + self.account.user.set_password(serializer.validated_data['password_1']) + self.account.user.save() + def update_status(self, status: Tuple[str, Any]): if status not in self.STATUS_STATE_MACHINE[self.account.acceptance_status]: raise Exception('Update to this state is impossible') @@ -55,10 +55,7 @@ class UserService: raise UserAlreadyExists user = CustomUserModel.objects.create_user( - username=username, - email=user_data['email'], - password=user_data['password'], - is_confirmed=False, + username=username, email=user_data['email'], password=user_data['password'] ) UTMService(user).create_utm( @@ -304,3 +301,9 @@ def update_profile_picture_social(*args, **kwargs): f'https://avatars.yandex.net/get-yapic/{response["default_avatar_id"]}/islands-retina-50' ) air_user.save() + + +def confirm_oauth_email(user: Any | None = None, *args, **kwargs): + if user: + user.is_confirmed = True + user.save() @@ -64,3 +64,10 @@ class HasBusinessAdminPermissions(BasePermission): 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') @@ -85,6 +85,11 @@ urlpatterns = [ name='allowed-models', ), path('business-host/logs/', views.LogsAPIView.as_view()), + path( + 'business-host/account/change-pass/', + views.ChangeHostPassAPIView.as_view(), + name='change_host_pass', + ), path( 'business-host/accounts', views.HostWorkersAPIView.as_view(), @@ -36,7 +36,9 @@ from authentication.permissions import ( IsBusinessSecurity, IsTelegramAirBot, IsVKMiniApp, + ChangeEmployeePassPermission, ) +from authentication.selectors.business_account_selector import BusinessAccountSelector from authentication.selectors.business_host_selector import ( BusinessHostSelector, ) @@ -71,6 +73,7 @@ from authentication.serializers import ( UpdateProfilePictureSerializer, UpdateUserDataSerializer, UserDataSerializer, + ChangePasswordSerializer, ) from authentication.services.business_account_service import ( BusinessAccountService, @@ -337,6 +340,27 @@ class HostWorkersAPIView(APIView): return Response({'detail': f'{err}'}, status=status.HTTP_400_BAD_REQUEST) +class ChangeHostPassAPIView(APIView): + permission_classes = (ChangeEmployeePassPermission,) + @extend_schema( + parameters=[ + OpenApiParameter('email', str, 'path', required=True), + ], + request=ChangePasswordSerializer, + ) + def patch(self, request, *args, **kwargs): + """Change business account password""" + try: + business_account = BusinessAccountSelector.filter_by_email( + request.parser_context['kwargs'].get('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) + except Exception as err: + return Response({'detail': f'{err}'}, status=status.HTTP_400_BAD_REQUEST) + + class HostModelStatisticsAPIView(APIView): permission_classes = (HasBusinessAdminPermissions,) @@ -32,6 +32,7 @@ MIDDLEWARE = [ 'authentication.middleware.CompanyIPMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'social_django.middleware.SocialAuthExceptionMiddleware', ] CORE_APP = 'core.apps.CoreConfig' @@ -118,7 +119,6 @@ SIMPLE_JWT = { 'REFRESH_TOKEN_LIFETIME': env.timedelta('JWT_REFRESH_TOKEN_LIFETIME', 60 * 60 * 24), 'ROTATE_REFRESH_TOKENS': True, 'BLACKLIST_AFTER_ROTATION': True, - 'UPDATE_LAST_LOGIN': True, 'USER_ID_FIELD': 'uid', 'USER_ID_CLAIM': 'uid', } @@ -132,12 +132,17 @@ SOCIAL_AUTH_PIPELINE = ( 'social_core.pipeline.social_auth.associate_by_email', 'social_core.pipeline.user.create_user', 'social_core.pipeline.social_auth.associate_user', + 'authentication.services.user_services.confirm_oauth_email', 'social_core.pipeline.social_auth.load_extra_data', 'social_core.pipeline.user.user_details', 'authentication.services.user_services.update_profile_picture_social', ) + SOCIALACCOUNT_EMAIL_VERIFICATION = 'none' -SOCIALACCOUNT_EMAIL_REQUIRED = False +SOCIALACCOUNT_EMAIL_REQUIRED = True +SOCIALACCOUNT_USERNAME_REQUIRED = False +SOCIAL_AUTH_YANDEX_OAUTH2_SCOPE = ['login:email'] + AUTH_PASSWORD_VALIDATORS = [ { 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', @@ -16,6 +16,7 @@ from deepl.translator import TextResult from requests import Response from backend import settings +from ml_model.utils import count_openrouter_tokens from poller.models import Proxy logger = logging.getLogger(__name__) @@ -143,7 +144,20 @@ def openrouter_run(version: str, messages: list, callback_data: dict, model_name answer = f'**Рассуждение:**\n\n{reasoning}\n\n**Основная мысль:**\n\n{content}' elif content: answer = content - return re.sub(r'\\+["n*]', '', answer), data['usage']['prompt_tokens'], data['usage']['completion_tokens'] + if data.get('choices')[0].get('error') is not None: + error = json.loads( + data['choices'][0]['error']['metadata'].replace("'", '"') + ).get('raw', {}).get('type') + if error in ('overloaded_error',): + input_tokens, output_tokens = count_openrouter_tokens(model_name, messages, content + reasoning) + else: + input_tokens = data['usage']['prompt_tokens'] + output_tokens = data['usage']['completion_tokens'] + return ( + re.sub(r'\\+["n*]', '', answer), + input_tokens, + output_tokens + ) logger.error(f'Error occured via model {model_name}. Data: {resp.content}') raise Exception(f'No answer from {model_name}, please retry later') @@ -1,5 +1,7 @@ +import tiktoken + from random import randint -from typing import Literal +from typing import Literal, List, Dict, Any, Tuple from authentication.models import CustomUserModel from authentication.selectors.account_status_selector import ( @@ -12,12 +14,12 @@ from authentication.selectors.business_account_selector import ( def random_with_N_digits(n): range_start = 10 ** (n - 1) - range_end = (10**n) - 1 + range_end = (10 ** n) - 1 return randint(range_start, range_end) def check_account_type( - user: CustomUserModel, + user: CustomUserModel, ) -> Literal['business_host'] | Literal['business_account'] | Literal['regular'] | Literal['business_admin']: status = AccountStatusSelector(user) if status.is_business_host(): @@ -30,3 +32,26 @@ def check_account_type( return 'business_admin' return 'business_account' + + +def count_openrouter_tokens(model_name: str, messages: List[Dict[str, Any]], output: str) -> Tuple[int, int]: + """A function for count tokens for OpenRouter Neuron Models""" + encodings = { + 'Qwen': 'cl100k_base', + 'Deepseek': 'cl100k_base', + 'Claude': 'r50k_base', + 'Perplexity': 'cl100k_base', + 'Mistral': 'r50k_base', + 'LLaMA': 'cl100k_base', + 'Grok': 'r50k_base', + 'Gemini': 'cl100k_base', + } + encoding = tiktoken.get_encoding(encodings[model_name]) + input_tokens = 100 if model_name == 'LLaMA' else 0 + for message in messages: + if isinstance(message['content'], list): + input_tokens += len(encoding.encode(message['content'][0]['text'])) + else: + input_tokens += len(encoding.encode(message['content'])) + output_tokens = len(encoding.encode(output)) + return (input_tokens, output_tokens)