@@ -0,0 +1,19 @@ +# Generated by Django 5.0.11 on 2025-09-28 10:18 + +import django.db.models.functions.text +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ('authentication', '0020_alter_customusermodel_username'), + ] + + operations = [ + migrations.AddIndex( + model_name='customusermodel', + index=models.Index(django.db.models.functions.text.Upper('username'), name='idx_users_email_upper'), + ), + ] @@ -0,0 +1,17 @@ +# Generated by Django 5.0.11 on 2025-10-01 11:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0021_create_upper_index'), + ] + + operations = [ + migrations.AddIndex( + model_name='emailtoken', + index=models.Index(fields=['key'], name='idx_email_token_key'), + ), + ] @@ -1,5 +1,6 @@ from django.contrib.auth import get_user_model from django.db import models +from django.db.models import Index from django.utils.translation import gettext_lazy as _ from core.models import BaseModel @@ -15,5 +16,8 @@ class EmailToken(BaseModel): key = models.CharField(max_length=30, verbose_name=_('Key')) class Meta: + indexes = [ + Index(fields=['key'], name='idx_email_token_key') + ] verbose_name = _('Email Token') verbose_name_plural = _('Email Tokens') @@ -10,6 +10,8 @@ from django.contrib.auth.models import ( ) from django.core.exceptions import ObjectDoesNotExist from django.db import models +from django.db.models import Index +from django.db.models.functions import Upper from django.db.models.signals import post_save from django.dispatch import receiver from django.utils.translation import gettext_lazy as _ @@ -179,22 +181,13 @@ class CustomUserModel(AbstractBaseUser, PermissionsMixin, BaseModel): @property def account_type(self): - from authentication.selectors.account_status_selector import ( - AccountStatusSelector, - ) - from authentication.selectors.business_account_selector import ( - BusinessAccountSelector, - ) - - status = AccountStatusSelector(self) - if status.is_business_host(): + if self.host: return 'business_host' - elif not status.is_business_account(): + elif not self.employee: return 'regular' - business_account = BusinessAccountSelector.from_user(self) - if business_account.account_type() == 'admin': + if self.business_account.account_privileges == 'admin': return 'business_admin' - elif business_account.account_type() == 'sec': + elif self.business_account.account_privileges == 'sec': return 'business_security' return 'business_account' @@ -236,6 +229,13 @@ class CustomUserModel(AbstractBaseUser, PermissionsMixin, BaseModel): except ObjectDoesNotExist: return None + @property + def employee(self): + try: + return self.business_account + except ObjectDoesNotExist: + return None + def is_corporate(self): return self.account_type == 'business_host' @@ -249,6 +249,9 @@ class CustomUserModel(AbstractBaseUser, PermissionsMixin, BaseModel): class Meta: ordering = ['-created_at'] + indexes = [ + Index(Upper('username'), name='idx_users_email_upper'), + ] verbose_name = _('User') verbose_name_plural = _('User') @@ -1,30 +1,17 @@ -from typing import List -from uuid import UUID +from ninja import Router +from ninja.errors import HttpError -from ninja import Query, Router - -from authentication.schemas import ( - CreateUserSettingSchema, - UpdateUserSettingSchema, - UserSettingFilterSchema, - UserSettingSchema, -) +from authentication.schemas import UserSchema from authentication.security import SyncAuthBearer -from authentication.services.user_services import UserService - -router = Router(auth=SyncAuthBearer(), tags=['users']) - - -@router.get('settings/', tags=['users/settings'], response=List[UserSettingSchema]) -def get_user_settings(request, filters: UserSettingFilterSchema = Query(...)): - return UserService.list_settings(filters=filters.get_filter_expression()) - +from authentication.selectors.user_selector import UserSelector -@router.post('settings/', tags=['users/settings'], response=UserSettingSchema) -def add_setting(request, data: CreateUserSettingSchema): - return UserService.add_setting(user_id=request.auth.uid, **data.model_dump()) +router = Router(auth=SyncAuthBearer(), tags=['auth']) -@router.put('settings/{id}', tags=['users/settings'], response={204: None}) -def update_setting(request, id: UUID, data: UpdateUserSettingSchema): - UserService.update_setting(setting_id=id, **data.model_dump()) +@router.get('me', tags=['auth/me'], response=UserSchema) +def get_user_data(request): + """Get user details.""" + try: + return UserSelector.detail(user=request.auth, provider=request.provider) + except Exception as exc: + raise HttpError(400, f'{exc}') @@ -2,7 +2,10 @@ from datetime import date, timedelta from uuid import UUID from django.db.models import Prefetch +from django.utils import timezone from django.utils.translation import gettext_lazy as _ +from rest_framework_simplejwt.token_blacklist.models import OutstandingToken +from rest_framework_simplejwt.tokens import RefreshToken from social_django.models import UserSocialAuth from authentication.models import CustomUserModel @@ -39,13 +42,7 @@ class UserSelector: return users @classmethod - def detail(cls, serialize: bool = True, **kwargs) -> UserDetailSerializer | CustomUserModel: - user_id = kwargs.get('id') - user = ( - CustomUserModel.objects.filter(uid=user_id) - .prefetch_related('payment_plan', 'payment_plan__plan') - .get() - ) + def detail(cls, user: CustomUserModel, provider: str) -> UserDetailSerializer | CustomUserModel: # TODO: refactor this hook if user.account_type in ( 'business_account', @@ -53,11 +50,14 @@ class UserSelector: 'business_admin', ): user.show_balance = user.business_account.show_balance - if not user.show_balance: - user.payment_plan.current_token_balance = 0 user.payment_plan.plan = user.business_account.parent_company.user.payment_plan.plan - if serialize: - return UserDetailSerializer(user) + refresh_token = OutstandingToken.objects.filter(user=user).order_by('-created_at').first() + if not refresh_token or refresh_token.expires_at < timezone.now() and provider == 'yandex': + refresh = str(RefreshToken.for_user(user)) + else: + refresh = refresh_token.token + access_token = RefreshToken(token=refresh).access_token + user.token = {'access': str(access_token), 'refresh': str(refresh)} return user def list_social_accounts(self, serialize: bool = False): @@ -283,12 +283,12 @@ class UserService: return UserSetting.objects.filter(filters) @classmethod - def add_setting(cls, user_id: UUID, device: str, type: str, value: Any) -> UserSetting: - return UserSetting.objects.create(user_id=user_id, device=device, type=type, value=value) + async def add_setting(cls, user_id: UUID, device: str, type: str, value: Any) -> UserSetting: + return await UserSetting.objects.acreate(user_id=user_id, device=device, type=type, value=value) @classmethod - def update_setting(cls, setting_id: UUID, value: Any) -> None: - UserSetting.objects.filter(id=setting_id).update(value=value) + async def update_setting(cls, setting_id: UUID, value: Any) -> None: + return await UserSetting.objects.filter(id=setting_id).aupdate(value=value) # For Social Auth pipeline @@ -9,3 +9,6 @@ class AuthenticationConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'authentication' verbose_name = 'Пользователи' + + def ready(self): + from .signals import invalidate_user_cache @@ -0,0 +1,54 @@ +# Authentication mapper +from django.db.models import Prefetch + +from ml_model.models import NeuronModel + + +def _gen_only(chain: str, *fields: str): + return [(f"{chain}__{f}") for f in fields] + + +PATH_PREFETCH_MAP = { + '/api/v1/auth/me': { + 'select': ( + 'host_account', + 'business_account__parent_company__user__payment_plan__plan', + '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'), + ), + 'social_auth' + ), + 'only': ( + 'uid', 'first_name', 'last_name', 'username', 'created_at', 'email', 'active', + 'is_superuser', 'is_staff', 'is_confirmed', 'is_subscribed_to_emails', 'profile_picture_name', + *_gen_only('business_account', 'uid', 'parent_company__uid', 'show_balance', + 'account_privileges', 'parent_company__user__uid'), + *_gen_only('payment_plan', 'uid', 'last_payment_at', 'next_payment_at'), + *_gen_only('payment_plan__plan', 'uid', 'title', 'price', 'tokens_per_plan', 'duration', 'points'), + *_gen_only('business_account__parent_company__user__payment_plan', 'uid', 'last_payment_at', + 'next_payment_at', 'plan__uid', 'plan__title', 'plan__price', 'plan__tokens_per_plan', + 'plan__duration', 'plan__points') + ) + }, + '/api/v1/payments/user-balance': { + 'select': ( + 'host_account', + 'business_account__group', + 'payment_plan', + 'business_account__parent_company__user__payment_plan', + ), + 'prefetch': (), + 'only': ( + 'uid', 'is_staff', 'is_superuser', + *_gen_only('business_account', 'account_privileges', 'acceptance_status', 'token_limit'), + *_gen_only('business_account__group', 'uid', 'token_limit'), + *_gen_only('payment_plan', 'uid', 'current_token_balance'), + *_gen_only('business_account__parent_company__user__payment_plan', 'uid', 'current_token_balance') + ) + } +} @@ -1,36 +0,0 @@ -import logging -from typing import Any - -from django.contrib.auth import logout -from django.core.exceptions import PermissionDenied - -from authentication.models.business_host import BusinessUserHost -from authentication.selectors.user_selector import UserSelector -from authentication.utils import get_client_ip - -logger = logging.getLogger(__name__) - - -class CompanyIPMiddleware: - def __init__(self, get_response) -> None: - self.get_response = get_response - - def __call__(self, request) -> Any: - if request.user.is_authenticated: - acc_type = UserSelector(request.user).check_account_type() - if acc_type == 'regular' or request.user.is_staff or request.user.is_superuser: - pass - else: - company: BusinessUserHost = ( - request.user.host_account - if acc_type == 'business_host' - else request.user.business_account.parent_company - ) - if ( - company.ip_whitelist.is_enabled - and not company.ip_whitelist.ips.filter(ip=get_client_ip(request)).exists() - ): - logout(request) - raise PermissionDenied - response = self.get_response(request) - return response @@ -1,28 +1,46 @@ -from typing import List, Optional +from datetime import datetime +from typing import Optional, Dict, List -from ninja import Field, FilterSchema, ModelSchema +from ninja import ModelSchema, Schema +from pydantic import UUID4 +from social_django.models import UserSocialAuth -from authentication.models.user import UserSetting +from authentication.models import CustomUserModel +from payments.schemas import UserPlanDetailSchema, PromoCodeSchema -class UserSettingFilterSchema(FilterSchema): - device: Optional[str] = None - types: List[str] = Field(None, q='type__in') - - -class UserSettingSchema(ModelSchema): +class SocialAccountSchema(ModelSchema): class Meta: - model = UserSetting - exclude = ('user',) + model = UserSocialAuth + fields = '__all__' + + +class UserSchema(Schema): + uid: UUID4 + first_name: str + last_name: str + username: str + created_at: datetime + email: str + is_active: bool + is_superuser: bool + is_staff: bool + is_confirmed: bool + is_subscribed_to_emails: bool + show_balance: bool = True + profile_picture_link: Optional[str] + account_type: str + token: Dict[str, str] + payment_plan: UserPlanDetailSchema + referral_code: Optional[PromoCodeSchema] = None + is_social: bool + social_auth: List[SocialAccountSchema] + + @staticmethod + def resolve_profile_picture_link(obj: CustomUserModel): + return obj.profile_picture_link + + @staticmethod + def resolve_account_type(obj: CustomUserModel): + return obj.account_type - -class CreateUserSettingSchema(ModelSchema): - class Meta: - model = UserSetting - fields = ('device', 'type', 'value') - - -class UpdateUserSettingSchema(ModelSchema): - class Meta: - model = UserSetting - fields = ('value',) @@ -2,9 +2,8 @@ import jwt import logging from typing import Any from urllib.parse import parse_qs -from uuid import UUID -from asgiref.sync import async_to_sync +from asgiref.sync import async_to_sync, sync_to_async from channels.routing import URLRouter from channels.security.websocket import WebsocketDenier @@ -12,70 +11,131 @@ from django.conf import settings from django.http import HttpRequest from django.utils.translation import gettext as _ from ninja.errors import HttpError +from drf_spectacular.contrib.rest_framework_simplejwt import ( + SimpleJWTScheme as BaseSimpleJWTScheme, +) from ninja.security import HttpBearer -from oauth2_provider.models import AccessToken -from rest_framework import exceptions from rest_framework.authentication import BaseAuthentication +from rest_framework.exceptions import AuthenticationFailed from authentication.exceptions import InvalidToken -from authentication.models import CustomUserModel +from authentication.mapper import PATH_PREFETCH_MAP +from authentication.models import CustomUserModel, BusinessUserHost from authentication.services.token import TokenService +from authentication.utils import get_client_ip logger = logging.getLogger(__name__) +def _check_ip_client(user: CustomUserModel, request: HttpRequest): + if (acc_type := user.account_type) != 'regular' and not user.is_staff and not user.is_superuser: + company: BusinessUserHost = ( + user.host_account + if acc_type == 'business_host' + else user.business_account.parent_company + ) + if ( + company.ip_whitelist.is_enabled + and not company.ip_whitelist.ips.filter(ip=get_client_ip(request)).exists() + ): + raise AuthenticationFailed(_('Forbidden'), code='ip_address_not_allowed') + + class JWTAuthentication(BaseAuthentication): - def authenticate(self, request): - authorization_header = request.headers.get('Authorization') - if not authorization_header or not authorization_header.startswith('Bearer'): - return None + def authenticate(self, request=None): + if request: + authorization_header = request.headers.get('Authorization') + if not authorization_header or not authorization_header.startswith('Bearer'): + return None + try: + access_token = authorization_header.split(' ')[1] + payload = jwt.decode(access_token, settings.SECRET_KEY, algorithms=['HS256']) + except jwt.ExpiredSignatureError: + raise AuthenticationFailed(_('Access token is expired')) + try: + user = CustomUserModel.objects.select_related( + 'payment_plan', + 'payment_plan__plan', + 'business_account', + 'business_account__group', + 'business_account__parent_company__user__payment_plan', + 'business_account__parent_company__user__payment_plan__plan', + 'host_account' + ).get(uid=payload['uid']) + _check_ip_client(user, request) + except CustomUserModel.DoesNotExist as exc: + raise AuthenticationFailed(_('User not found'), code='user_not_found') from exc + return user, None + return None, None - try: - access_token = authorization_header.split(' ')[1] - payload = jwt.decode(access_token, settings.SECRET_KEY, algorithms=['HS256']) - except jwt.ExpiredSignatureError: - raise exceptions.AuthenticationFailed(_('Access token is expired')) - user = CustomUserModel.objects.select_related( - 'payment_plan', - 'business_account', - 'business_account__group', - 'business_account__parent_company__user__payment_plan', - 'host_account', - ).get(uid=UUID(payload['uid'])) +class SimpleJWTScheme(BaseSimpleJWTScheme): + target_class = JWTAuthentication - return (user, None) + def __init__(self, target=None): + self.authenticate = JWTAuthentication.authenticate class SyncAuthBearer(HttpBearer): def authenticate(self, request: HttpRequest, token: str) -> Any | None: + mapper = PATH_PREFETCH_MAP.get(request.path, {}) try: user_payload = async_to_sync(TokenService.decode)(token=token) - return CustomUserModel.objects.get( + request.provider = 'air' + user = CustomUserModel.objects.select_related( + *mapper.get('select', []) + ).prefetch_related( + *mapper.get('prefetch', []) + ).only( + *mapper.get('only', []) + ).get( **{key: user_payload[f'{key}'] for key in settings.JWT_SETTINGS['encode_attributes']} ) except InvalidToken: try: - access = AccessToken.objects.prefetch_related('user').get(token=token) - return access.user - except AccessToken.DoesNotExist: + user = CustomUserModel.objects.select_related( + *mapper.get('select', []) + ).prefetch_related( + *mapper.get('prefetch', []) + ).only( + *mapper.get('only', []) + ).filter(oauth2_provider_accesstoken__token=token).latest('oauth2_provider_accesstoken__created') + request.provider = 'yandex' + except: raise HttpError(401, _('Access token expired or does not exist')) + _check_ip_client(user, request) + return user -class AuthBearer(HttpBearer): +class AsyncAuthBearer(HttpBearer): async def authenticate(self, request: HttpRequest, token: str) -> Any | None: + mapper = PATH_PREFETCH_MAP.get(request.path, {}) try: user_payload = await TokenService.decode(token=token) - return await CustomUserModel.objects.aget( + request.provider = 'air' + user = await CustomUserModel.objects.select_related( + *mapper.get('select', []) + ).prefetch_related( + *mapper.get('prefetch', []) + ).only( + *mapper.get('only', []) + ).aget( **{key: user_payload[f'{key}'] for key in settings.JWT_SETTINGS['encode_attributes']} ) except InvalidToken: try: - access = await AccessToken.objects.prefetch_related('user').aget(token=token) - return access.user - except AccessToken.DoesNotExist: + user = await CustomUserModel.objects.select_related( + *mapper.get('select', []) + ).prefetch_related( + *mapper.get('prefetch', []) + ).only( + *mapper.get('only', []) + ).filter(oauth2_provider_accesstoken__token=token).alatest('oauth2_provider_accesstoken__created') + request.provider = 'yandex' + except Exception: raise HttpError(401, _('Access token expired or does not exist')) - + await sync_to_async(_check_ip_client)(user, request) + return user class WebsocketGlobalAuth: def __init__(self, app: URLRouter): @@ -0,0 +1,20 @@ +from cacheops import cache +from cacheops.getset import dnfs_to_conj_keys + +from authentication.models import BusinessAccount, CustomUserModel + +from django.db.models.signals import post_save, post_delete +from django.dispatch import receiver + + +@receiver([post_save, post_delete], sender=BusinessAccount) +def invalidate_user_cache(sender, instance, signal, **kwargs): + cache_keys = cache.conn.smembers(dnfs_to_conj_keys( + '', + {'authentication_customusermodel': [{'uid': instance.user_id}]} + )[0]) + for key in cache_keys: + data = cache.get(key.decode()) + if isinstance(data, list) and isinstance((user := data[0]), CustomUserModel): + user.business_account = instance if signal == post_save else None + cache.set(key.decode(), [user]) \ No newline at end of file @@ -15,7 +15,6 @@ urlpatterns = [ view=views.MailWhitelist.as_view(), name='mail-whitelist', ), - path('me', views.UserAPIView.as_view(), name='me'), path( 'register-telegram', views.UserTelegramAPIView.as_view(), @@ -199,15 +199,15 @@ class UserAPIView(APIView): and anonymous users to create a new user account. """ - def get(self, request, *args, **kwargs): - """Get user details.""" - self.permission_classes = (IsAuthenticated,) - - try: - response = UserSelector.detail(id=request.user.uid) - return Response(response.data, status=status.HTTP_200_OK) - except Exception as err: - return Response({'detail': f'{err}'}, status=status.HTTP_400_BAD_REQUEST) + # def get(self, request, *args, **kwargs): + # """Get user details.""" + # self.permission_classes = (IsAuthenticated,) + # + # try: + # response = UserSelector.detail(user=request.user) + # return Response(response.data, status=status.HTTP_200_OK) + # except Exception as err: + # return Response({'detail': f'{err}'}, status=status.HTTP_400_BAD_REQUEST) @extend_schema( request=NewUserSerializer, @@ -429,8 +429,9 @@ class DeleteUserAPIView(APIView): try: UserService(self.request.user).delete_user(self.request) return Response(status=status.HTTP_200_OK) - except Exception as err: - return Response({'detail': f'{err}'}, status=status.HTTP_400_BAD_REQUEST) + except Exception as exc: + logger.exception(exc) + return Response({'detail': f'{exc}'}, status=status.HTTP_400_BAD_REQUEST) class ConfirmUserAPIView(APIView): @@ -27,7 +27,6 @@ MIDDLEWARE = [ 'core.middleware.PyroscopeWrapper', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'authentication.middleware.CompanyIPMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', 'social_django.middleware.SocialAuthExceptionMiddleware', @@ -50,6 +49,7 @@ EXTERNAL_APPS = [ 'rest_framework', 'rest_framework.authtoken', 'rest_framework_simplejwt', + 'rest_framework_simplejwt.token_blacklist', 'oauth2_provider', 'social_django', 'drf_social_oauth2', @@ -74,6 +74,7 @@ INTERNAL_APPS = [ 'stories.apps.StoriesConfig', 'poller.apps.PollerConfig', 'tools.apps.ToolsConfig', + 'users.apps.UsersConfig', ] TOOLS = [ @@ -271,6 +272,7 @@ SPECTACULAR_SETTINGS = { 'DESCRIPTION': 'AIR Project API conf', 'VERSION': '1.0.0', 'SERVE_INCLUDE_SCHEMA': False, + 'SERVE_AUTHENTICATION': ['authentication.security.SimpleJWTScheme'], 'COMPONENT_SPLIT_REQUEST': True, 'SWAGGER_UI_DIST': 'SIDECAR', 'SWAGGER_UI_FAVICON_HREF': 'SIDECAR', @@ -503,11 +505,13 @@ CACHEOPS_DEGRADE_ON_FAILURE = True if CACHEOPS_REDIS: CACHEOPS = { - 'authentication.*': {'ops': 'all', 'timeout': 60 * 60}, + # 'authentication.*': {'ops': 'all', 'timeout': 60 * 60}, + 'authentication.companyipwhitelist': {'ops': 'all', 'timeout': 60 * 60}, 'ml_model.*': {'ops': 'all', 'timeout': 60 * 60}, 'tools.chats.*': {'ops': 'all', 'timeout': 60 * 60}, 'tools.media.*': {'ops': 'all', 'timeout': 60 * 60}, - 'payments.*': {'ops': 'all', 'timeout': 60 * 60}, + 'payments.paymentplan': {'ops': 'all', 'timeout': 60 * 60}, 'messages.*': {'ops': 'all', 'timeout': 60 * 60}, 'reports.*': {'ops': 'all', 'timeout': 60 * 60}, + 'token_blacklist.outstandingtoken': {'ops': 'get', 'timeout': 60 * 60 * 24}, } @@ -18,10 +18,13 @@ from authentication.exceptions import ( from backend.public import urlpatterns as public_urlpatterns api = NinjaAPI(title='AIR', version='3.0.0') +api_debug = NinjaAPI(title='AIR API DEBUG', version='0.0.1', docs_url=None) api.add_router('ai/', 'ml_model.routes.v1.router') -api.add_router('users/', 'authentication.routes.v1.router') +api.add_router('users/', 'users.routes.v1.router') api.add_router('chats/', 'tools.chats.routes.v3.router') api.add_router('media/', 'tools.media.routes.v3.router') +api_debug.add_router('auth/', 'authentication.routes.v1.router') +api_debug.add_router('payments/', 'payments.routes.v1.router') logger = logging.getLogger(__name__) @@ -51,9 +54,9 @@ def healthz_status(request): urlpatterns = [ - path('healthz/', healthz_status), - path('api/v1/stories/', include('stories.urls')), + path('api/v1/healthz/', healthz_status), path('admin/', admin.site.urls), + path('api/v1/stories/', include('stories.urls')), path('api/v1/auth/', include('authentication.urls')), path('api/v1/payments/', include('payments.urls')), path('api/v1/reports/', include('reports.urls')), @@ -70,6 +73,7 @@ urlpatterns = [ SpectacularSwaggerView.as_view(url_name='schema-public'), ), path('api/v1/api/', api.urls), + path('api/v1/', api_debug.urls), ] urlpatterns += public_urlpatterns @@ -1389,3 +1389,15 @@ msgstr "Срок действия токена доступа истек" msgid "Token prefix is missing" msgstr "Отсутствует префикс токена" + +msgid "Access token expired or does not exist" +msgstr "Токен доступа просрочен или не существует" + +msgid "Model data cannot be retrieved" +msgstr "Невозможно получить данные модели" + +msgid "The payer does not exist" +msgstr "Плательщик не существует" + +msgid "The request must not be empty" +msgstr "Запрос не должен быть пустым" @@ -0,0 +1,29 @@ +# Generated by Django 5.0.11 on 2025-09-22 10:36 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('msgs', '0005_alter_message_content_type_alter_message_object_id_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='MessageError', + fields=[ + ('uid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создан')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Изменён')), + ('detail', models.TextField(verbose_name='Detail')), + ('message', models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, related_name='message', to='msgs.message', verbose_name='Message')), + ], + options={ + 'verbose_name': 'Message error', + 'verbose_name_plural': 'Message errors', + }, + ), + ] @@ -0,0 +1,18 @@ +# Generated by Django 5.0.11 on 2025-09-23 10:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('msgs', '0006_messageerror'), + ] + + operations = [ + migrations.AddField( + model_name='messageerror', + name='stacktrace', + field=models.TextField(blank=True, null=True, verbose_name='Stack Trace'), + ), + ] @@ -0,0 +1,19 @@ +# Generated by Django 5.0.11 on 2025-09-23 10:18 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('msgs', '0007_messageerror_stacktrace'), + ] + + operations = [ + migrations.AlterField( + model_name='messageerror', + name='message', + field=models.OneToOneField(on_delete=django.db.models.deletion.DO_NOTHING, related_name='message_error', to='msgs.message', verbose_name='Message'), + ), + ] @@ -0,0 +1,16 @@ +# Generated by Django 5.0.11 on 2025-10-02 14:42 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('msgs', '0008_alter_messageerror_message'), + ] + + operations = [ + migrations.DeleteModel( + name='MessageError', + ), + ] @@ -64,7 +64,7 @@ class Message(models.Model): ) def __str__(self) -> str: - return f'Сообщение {self.pk} от {self.content_object.user}' + return f'Сообщение {self.pk}' class Meta: verbose_name = 'Сообщение' @@ -8,9 +8,9 @@ from django.core.cache import cache from django.http import StreamingHttpResponse from ninja import Router -from authentication.security import AuthBearer +from authentication.security import AsyncAuthBearer -router = Router(auth=AuthBearer(), tags=['messages']) +router = Router(auth=AsyncAuthBearer(), tags=['messages']) logger = logging.getLogger(__name__) @@ -0,0 +1,29 @@ +from django.contrib import admin +from django.urls import reverse +from django.utils.html import format_html + +from messages.models import Message + + +@admin.register(Message) +class MessageAdmin(admin.ModelAdmin): + list_per_page = 20 + readonly_fields = ('uid', 'created_at', 'content_object_link') + fields = ( + 'uid', + 'created_at', + 'content_object_link', + 'content', + 'file', + 'from_public_api', + 'is_sent', + 'info' + ) + raw_id_fields = ('content_type',) + + @admin.display(description="Связанный объект") + def content_object_link(self, obj): + if c_obj := obj.content_object: + url = reverse(f'admin:{c_obj._meta.app_label}_{c_obj._meta.model_name}_change', args=[c_obj.pk]) + return format_html('{}', url, str(c_obj)) + return '-' @@ -0,0 +1,6 @@ +from django.utils.translation import gettext as _ + + +class PayerNotFound(Exception): + def __str__(self) -> str: + return _('The payer does not exist') @@ -0,0 +1,23 @@ +from decimal import Decimal + +from ninja import Router +from ninja.errors import HttpError + +from authentication.security import SyncAuthBearer +from payments.schema import UserBalance +from payments.selectors.payment_plan_selector import PaymentPlanSelector + +router = Router(auth=SyncAuthBearer(), tags=['payments']) + + +@router.get('user-balance', tags=['payments/user-balance'], response=UserBalance) +def get_user_balance(request): + """Get user balance.""" + try: + balance = PaymentPlanSelector(request.auth).get_current_balance() + current_balance = Decimal( + f'{balance:.2f}' if balance == balance.to_integral() else balance.normalize().to_eng_string() + ) + return UserBalance(current_token_balance=current_balance) + except Exception as exc: + raise HttpError(401, f'{exc}') @@ -59,11 +59,8 @@ class PaymentPlanSelector: return PaymentPlanSerializer(plan) def get_current_balance(self) -> Decimal: - user_type = UserSelector(self.user).check_account_type() if ( - user_type == 'business_account' - or user_type == 'business_admin' - or user_type == 'business_security' + self.user.account_type in ('business_account', 'business_admin', 'business_security') ) and self.user.business_account.acceptance_status == InvitationStatus.ACCEPTED: balance = ( self.user.business_account.group.token_limit @@ -9,6 +9,7 @@ from yookassa.domain.models.payment_data.response.payment_data_bank_card import ) from authentication.models import CustomUserModel +from payments.exceptions.payer_not_found import PayerNotFound from payments.models.payment import Payment as PaymentModel from payments.models.payment_plan import PaymentPlan from payments.selectors.payment_method_selector import PaymentMethodSelector @@ -68,7 +69,10 @@ class PaymentService: @classmethod def confirm_payment(cls, payment_id: UUID): payment = YookassaPayment.find_one(str(payment_id)) - user = CustomUserModel.objects.get(uid=payment.description) + try: + user = CustomUserModel.objects.get(uid=payment.description) + except CustomUserModel.DoesNotExist: + raise PayerNotFound if payment.status == 'waiting_for_capture': YookassaPayment.capture(str(payment_id)) elif payment.status == 'succeeded': @@ -0,0 +1,7 @@ +from decimal import Decimal + +from ninja import Schema + + +class UserBalance(Schema): + current_token_balance: Decimal @@ -0,0 +1,40 @@ +from datetime import date +from typing import List +from uuid import UUID + +from ninja import Schema, ModelSchema +from pydantic import field_validator, condecimal + +from payments.models import PromoCode + + +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) + duration: str + points: List + accessed_models: List[str] + + @field_validator('accessed_models', mode='before') + @classmethod + def get_slugs(cls, value: str) -> List[str]: + return [obj.slug for obj in value] + + +class UserPlanDetailSchema(Schema): + uid: UUID + plan: PaymentPlanSchema + last_payment_at: date + next_payment_at: date + + +class PromoCodeSchema(ModelSchema): + class Meta: + model = PromoCode + exclude = ('activated_by',) + + @field_validator('code', check_fields=False) + def check_code(cls, value: str): + return value.strip() @@ -17,7 +17,6 @@ urlpatterns = [ views.PaymentMethodsAPIView.as_view(), name='payment-methods', ), - path('user-balance', views.UserPlanAPIView.as_view(), name='user-balance'), path( 'payment-result', views.PaymentConfirmationAPIView.as_view(), @@ -21,6 +21,7 @@ from rest_framework.views import APIView from authentication.permissions import IsAnonymous, IsTelegramAirBot from authentication.selectors.user_selector import UserSelector from payments.exceptions.PlanIsFree import PlanIsFree +from payments.exceptions.payer_not_found import PayerNotFound from payments.models import Invoice from payments.models.payment import Payment, PaymentPlan from payments.permissions import IsAllowedToPay @@ -166,10 +167,12 @@ class PaymentConfirmationAPIView(APIView): permission_classes = (IsAnonymous,) def post(self, request, *args, **kwargs): - """Endpoint for payment service (Yookassa) WebHook. Hook happens after sucessful payment.""" + """Endpoint for payment service (Yookassa) WebHook. Hook happens after successful payment.""" try: PaymentPlanService.handle_success_payment(self.request) return Response(status=status.HTTP_200_OK) + except PayerNotFound as err: + return Response({'detail': str(err)}, status=status.HTTP_400_BAD_REQUEST) except Exception as err: logger.exception(err) return Response({'detail': {str(err)}}, status=status.HTTP_400_BAD_REQUEST) @@ -2,7 +2,7 @@ from typing import List from ninja import Router -from authentication.security import AuthBearer, SyncAuthBearer +from authentication.security import AsyncAuthBearer, SyncAuthBearer from messages.routes.v3 import get_message_stream from ml_model.schemas import NeuronModelLinkSchema, NeuronModelsSchema from ml_model.services.neuron_model import NeuronModelService @@ -20,6 +20,6 @@ def list_models(request): return NeuronModelService.list_all(types=['chat-bots']) -router.get('{object_id}/messages/stream', auth=AuthBearer(), response={200: str, 204: None})( +router.get('{object_id}/messages/stream', auth=AsyncAuthBearer(), response={200: str, 204: None})( get_message_stream ) @@ -2,7 +2,7 @@ from typing import List from ninja import Router -from authentication.security import AuthBearer, SyncAuthBearer +from authentication.security import AsyncAuthBearer, SyncAuthBearer from messages.routes.v3 import get_message_stream from ml_model.schemas import NeuronModelLinkSchema, NeuronModelsSchema from ml_model.services.neuron_model import NeuronModelService @@ -14,7 +14,7 @@ router.get( 'images/{object_id}/messages/stream', tags=['media/images'], operation_id='images_routes_v3_get_message_stream', - auth=AuthBearer(), + auth=AsyncAuthBearer(), response={200: str, 204: None}, )(get_message_stream) @@ -0,0 +1,30 @@ +from typing import List +from uuid import UUID + +from ninja import Query, Router + +from users.schemas import ( + CreateUserSettingSchema, + UpdateUserSettingSchema, + UserSettingFilterSchema, + UserSettingSchema, +) +from authentication.security import AsyncAuthBearer +from authentication.services.user_services import UserService + +router = Router(auth=AsyncAuthBearer(), tags=['users']) + + +@router.get('settings/', tags=['users/settings'], response=List[UserSettingSchema]) +async def get_user_settings(request, filters: UserSettingFilterSchema = Query(...)): + return [setting async for setting in UserService.list_settings(filters=filters.get_filter_expression())] + + +@router.post('settings/', tags=['users/settings'], response=UserSettingSchema) +async def add_setting(request, data: CreateUserSettingSchema): + return await UserService.add_setting(user_id=request.auth.uid, **data.model_dump()) + + +@router.put('settings/{id}', tags=['users/settings'], response={204: None}) +async def update_setting(request, id: UUID, data: UpdateUserSettingSchema): + await UserService.update_setting(setting_id=id, **data.model_dump()) @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class UsersConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'users' @@ -0,0 +1,28 @@ +from typing import List, Optional + +from ninja import Field, FilterSchema, ModelSchema + +from authentication.models.user import UserSetting + + +class UserSettingFilterSchema(FilterSchema): + device: Optional[str] = None + types: List[str] = Field(None, q='type__in') + + +class UserSettingSchema(ModelSchema): + class Meta: + model = UserSetting + exclude = ('user',) + + +class CreateUserSettingSchema(ModelSchema): + class Meta: + model = UserSetting + fields = ('device', 'type', 'value') + + +class UpdateUserSettingSchema(ModelSchema): + class Meta: + model = UserSetting + fields = ('value',) @@ -5,8 +5,8 @@ TRACE_ID_HEADER=X-Trace-ID SESSION_ID_HEADER=X-Session-ID # NEURON MODELS -OPENAI_API_KEY=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 -REPLICATE_API_KEY=r8_4IjhLLMyKyq3nm8qauTndtdOMixxmep3uRQMu +OPENAI_API_KEY=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 +REPLICATE_API_KEY=r8_9xLXwWopiEbvrOdP63PetcNyYAiSPoV1nFtY8 OPENROUTER_API_KEY=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 GOOGLE_API_KEY=AIzaSyBf9el4d_CY610zjCcesKxKL70BLfl57OM FAL_API_KEY=617f0fe4-c627-4119-9681-11af2c3e416a:3d618ccd0ee11ed82543801d7da96d1d @@ -21,7 +21,7 @@ JWT_SECRET_KEY=testtest JWT_ACCESS_TOKEN_LIFETIME=604800 JWT_REFRESH_TOKEN_LIFETIME=604800 ALLOWED_HOSTS=localhost -CSRF_TRUSTED_ORIGINS=http://localhost:8000 +CSRF_TRUSTED_ORIGINS=http://localhost CORS_ALLOWED_ORIGINS=http://localhost:3000 TELEGRAM_BOT_TOKEN=None