@@ -0,0 +1,6 @@ +from django.utils.translation import gettext as _ + + +class AlreadyConfirmed(Exception): + def __str__(self): + return _('User is already confirmed') @@ -8,6 +8,7 @@ from django.db.transaction import atomic from django.utils.translation import gettext_lazy as _ from rest_framework.request import Request +from authentication.exceptions.business_host_exceptions.already_confirmed import AlreadyConfirmed from authentication.exceptions.business_host_exceptions.not_allowed_ip import ( NotAllowedIP, ) @@ -185,9 +186,14 @@ class UserService: raise EmailTokenNotFound user = token.user - if not user.is_confirmed: - user.is_confirmed = True - user.save() + try: + if user.is_confirmed: + raise AlreadyConfirmed + else: + user.is_confirmed = True + user.save() + finally: + token.delete() @classmethod def request_password_change(cls, request: Request): @@ -37,6 +37,7 @@ from authentication.exceptions.business_account import ( BusinessAccountNotFound, UnconfirmedUserChangePass, ) +from authentication.exceptions.business_host_exceptions.already_confirmed import AlreadyConfirmed from authentication.exceptions.business_host_exceptions.not_allowed_ip import ( NotAllowedIP, ) @@ -481,7 +482,7 @@ class ConfirmUserAPIView(APIView): try: UserService.confirm_user(request) return Response({'detail': 'user confirmed'}, status=status.HTTP_200_OK) - except EmailTokenNotFound as exc: + except (EmailTokenNotFound, AlreadyConfirmed) as exc: return Response({'detail': f'{exc}'}, status=status.HTTP_400_BAD_REQUEST) except Exception as exc: logger.exception(exc) @@ -2,13 +2,13 @@ # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. -# +# #, fuzzy msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-30 14:11+0300\n" +"POT-Creation-Date: 2026-05-08 12:06+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -68,6 +68,10 @@ msgstr "У вас недостаточно прав для выполнения msgid "User is already a business account for another company" msgstr "Пользователь уже является сотрудником другой компании" +#: authentication/exceptions/business_host_exceptions/already_confirmed.py:6 +msgid "User is already confirmed" +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 " @@ -557,23 +561,23 @@ msgstr "Приглашенный аккаунт может принять или msgid "Error occured when proceed email sending" msgstr "Случилась ошибка во время отправки email" -#: authentication/services/user_services.py:164 +#: authentication/services/user_services.py:165 msgid "No user like this in a database" msgstr "Такой пользователь отсутствует" -#: authentication/services/user_services.py:181 +#: authentication/services/user_services.py:182 msgid "token is not provided" msgstr "" -#: authentication/services/user_services.py:205 +#: authentication/services/user_services.py:211 msgid "No email token provided" msgstr "Токен не получен" -#: authentication/services/user_services.py:215 +#: authentication/services/user_services.py:221 msgid "Passwords do not match" msgstr "Пароли не совпадают" -#: authentication/services/user_services.py:253 +#: authentication/services/user_services.py:259 msgid "Current password is wrong" msgstr "Текущий пароль неверен" @@ -1047,7 +1051,7 @@ msgstr "Соответствующая версия не найдена" msgid "Image is ready" msgstr "Изображение готово" -#: ml_model/services/elevenlabs_music.py:46 +#: ml_model/services/elevenlabs_music.py:45 msgid "Duration cannot be less than 5 seconds" msgstr "Длительность не может быть меньше 5 секунд" @@ -1065,11 +1069,6 @@ msgstr "" "Режим «Плавное движение» доступен только для 5-секундных видео в качестве " "540p и 720p" -#: ml_model/services/minimaxmusic.py:58 -#: ml_model/services/minimaxmusic_lite.py:62 -msgid "Lyrics is too long" -msgstr "Текст песни слишком длинный" - #: ml_model/services/minio_service.py:37 ml_model/services/minio_service.py:55 #: ml_model/services/minio_service.py:63 ml_model/services/minio_service.py:72 msgid "Unknown bucket destination" @@ -1079,6 +1078,10 @@ msgstr "Неизвестный бакет для загрузки" msgid "No image given for improving" msgstr "Нет изображения для улучшения" +#: ml_model/tasks.py:136 +msgid "Lyrics is too long" +msgstr "Текст песни слишком длинный" + #: ml_model/views.py:65 msgid "Model data cannot be retrieved" msgstr "Невозможно получить данные модели" @@ -1179,7 +1182,7 @@ msgstr "Последнее время платежа" msgid "Next payment at" msgstr "Следующее время платежа" -#: payments/models/payment_plan.py:63 payments/models/user_payment_method.py:17 +#: payments/models/payment_plan.py:63 payments/models/user_payment_method.py:25 msgid "Payment Method" msgstr "Платежный метод" @@ -1263,23 +1266,47 @@ msgstr "Активация Промокода" msgid "Promocode Activations" msgstr "Активации Промокодов" -#: payments/models/user_payment_method.py:8 -msgid "Payment method UID" -msgstr "UID платёжного метода" - #: payments/models/user_payment_method.py:9 -msgid "Card type" -msgstr "Тип карты" +msgid "Bank Card" +msgstr "Банковская карта" #: payments/models/user_payment_method.py:10 -msgid "Last four card digits" -msgstr "Последние 4 цифры карты" +msgid "Mir Pay" +msgstr "Mir Pay" #: payments/models/user_payment_method.py:11 +msgid "Sberbank" +msgstr "Сбербанк" + +#: payments/models/user_payment_method.py:12 +msgid "YooMoney" +msgstr "ЮMoney" + +#: payments/models/user_payment_method.py:13 +msgid "T-bank" +msgstr "Т-банк" + +#: payments/models/user_payment_method.py:14 +msgid "SBP" +msgstr "СБП" + +#: payments/models/user_payment_method.py:16 +msgid "Gateway" +msgstr "Шлюз" + +#: payments/models/user_payment_method.py:17 +msgid "Payment method UID" +msgstr "UID платёжного метода" + +#: payments/models/user_payment_method.py:18 tools/media/models.py:78 +msgid "Meta" +msgstr "Метаданные" + +#: payments/models/user_payment_method.py:19 msgid "Attempts" msgstr "Попытки" -#: payments/models/user_payment_method.py:18 +#: payments/models/user_payment_method.py:26 msgid "Payment Methods" msgstr "Платежные методы" @@ -1353,7 +1380,7 @@ msgstr "Публичный API" msgid "Media" msgstr "Медиа" -#: tools/chats/apis.py:201 tools/media/apis.py:205 +#: tools/chats/apis.py:201 tools/media/apis.py:207 #: tools/public_api/views/base.py:100 msgid "" "An unexpected generation error has occurred. Please try again later or use a " @@ -1375,12 +1402,12 @@ msgstr "Чат %(id)s" msgid "Chat" msgstr "Чат" -#: tools/media/apis.py:174 +#: tools/media/apis.py:175 msgid "" "Temporary issues with the service, we are already working on a solution." msgstr "Временные неполадки с сервисом, мы уже работаем над их решением." -#: tools/media/apis.py:252 tools/public_api/views/ml_service.py:63 +#: tools/media/apis.py:254 tools/public_api/views/ml_service.py:63 #: tools/public_api/views/ml_service.py:107 msgid "Voice not found." msgstr "Голос не найден." @@ -1409,10 +1436,6 @@ msgstr "Тип" msgid "File" msgstr "Файл" -#: tools/media/models.py:78 -msgid "Meta" -msgstr "Метаданные" - #: tools/media/models.py:84 msgid "Preset" msgstr "Пресет" @@ -1513,6 +1536,12 @@ msgstr "Название голоса успешно обновлено" msgid "Preset voices are shared and cannot be deleted. Use your own voice id." msgstr "Пресеты общие для всех — их нельзя удалить. Укажите id своего голоса." +#~ msgid "Card type" +#~ msgstr "Тип карты" + +#~ msgid "Last four card digits" +#~ msgstr "Последние 4 цифры карты" + #~ msgid "" #~ "Error occured when create generation. It may cause NSFW-content not " #~ "allowed, retry again" @@ -1590,9 +1619,6 @@ msgstr "Пресеты общие для всех — их нельзя удал #~ msgid "Issued achievement" #~ msgstr "Выданное достижение" -#~ msgid "Account is already confirmed" -#~ msgstr "Аккаунт уже подтвержден" - #~ msgid "Stories" #~ msgstr "Истории" @@ -0,0 +1,18 @@ +# Generated by Django 5.0.11 on 2026-05-06 13:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ml_model', '0053_alter_modelcategory_options_modelcategory_order'), + ] + + operations = [ + migrations.AlterField( + model_name='modelinput', + name='type', + field=models.CharField(choices=[('text', 'Text'), ('image', 'Image'), ('pdf', 'PDF'), ('docx', 'DOCX'), ('doc', 'DOC'), ('txt', 'Text File (Notebook)'), ('zip', 'ZIP Archive'), ('audio', 'Audio'), ('video', 'Video')], default='text', max_length=32, verbose_name='Type'), + ), + ] @@ -0,0 +1,44 @@ +# Generated by Django 5.0.11 on 2026-05-06 13:35 + +from django.db import migrations, models + +def fill_metadata(apps, schema_editor): + PaymentMethod = apps.get_model('payments', 'PaymentMethod') + payment_methods = [] + + for payment_method in PaymentMethod.objects.all(): + payment_method.metadata = {'card_type': payment_method.card_type, 'last4': payment_method.last_four} + payment_methods.append(payment_method) + + PaymentMethod.objects.bulk_update(payment_methods, fields=['metadata']) + + +class Migration(migrations.Migration): + + dependencies = [ + ('payments', '0028_paymentplanuserinfo_referral_balance'), + ] + + operations = [ + migrations.AddField( + model_name='paymentmethod', + name='gateway', + field=models.CharField(choices=[('bank_card', 'Bank Card'), ('mir_pay', 'Mir Pay'), ('sberbank', 'Sberbank'), ('yoo_money', 'YooMoney'), ('tinkoff_bank', 'T-bank'), ('sbp', 'SBP')], default='bank_card', max_length=20, verbose_name='Gateway'), + preserve_default=False, + ), + migrations.AddField( + model_name='paymentmethod', + name='metadata', + field=models.JSONField(default=dict, verbose_name='Meta'), + preserve_default=False, + ), + migrations.RunPython(fill_metadata, migrations.RunPython.noop), + migrations.RemoveField( + model_name='paymentmethod', + name='card_type', + ), + migrations.RemoveField( + model_name='paymentmethod', + name='last_four', + ), + ] @@ -5,13 +5,21 @@ from core.models import BaseModel class PaymentMethod(BaseModel): + class GatewayChoices(models.TextChoices): + BANK_CARD = ('bank_card', _('Bank Card')) + MIR_PAY = ('mir_pay', _('Mir Pay')) + SBERBANK = ('sberbank', _('Sberbank')) + YOOMONEY = ('yoo_money', _('YooMoney')) + T_BANK = ('tinkoff_bank', _('T-bank')) + SBP = ('sbp', _('SBP')) + + gateway = models.CharField(max_length=20, choices=GatewayChoices.choices, verbose_name=_('Gateway')) payment_method_id = models.UUIDField(unique=True, verbose_name=_('Payment method UID')) - card_type = models.CharField(max_length=15, verbose_name=_('Card type')) - last_four = models.CharField(max_length=4, verbose_name=_('Last four card digits')) + metadata = models.JSONField(verbose_name=_('Meta')) attempts = models.PositiveSmallIntegerField(default=0, verbose_name=_('Attempts')) def __str__(self) -> str: - return f'Card {self.card_type} ****{self.last_four}' + return f'{self.gateway} ({self.payment_method_id})' class Meta: verbose_name = _('Payment Method') @@ -62,8 +62,8 @@ def get_user_balance(request): async def handle_yookassa_webhook(request): data = orjson.loads(request.body) logger.info('YooKassa webhook received: payment_id=%s', data['object']['id']) - payment = await PaymentService.handle_payment(data['object']['id']) try: + payment = await PaymentService.handle_payment(data['object']['id']) payer = await CustomUserModel.objects.prefetch_related('payment_plan', 'payment_plan__plan', 'payment_plan__method').aget( uid=payment.description ) @@ -239,7 +239,7 @@ async def handle_gitlab_webhook(request): updated_count = await PaymentPlanUserInfo.objects.filter( plan__price__gt=0, plan__individual=False, - ).aupdate(next_payment_at=None if not is_active else (timezone.now() + relativedelta(months=1))) + ).aupdate(next_payment_at=None if not is_active else (timezone.now() + timedelta(days=30))) logger.info( 'Recurring feature flag synced: active=%s updated_subscriptions=%s', is_active, @@ -1,5 +1,4 @@ import logging -from uuid import UUID from authentication.models import CustomUserModel from payments.models.user_payment_method import PaymentMethod @@ -12,16 +11,35 @@ class PaymentMethodService: def __init__(self, user: CustomUserModel) -> None: self.user = user - def add_payment_method(self, method_id: UUID, card_type: str, last_four: str): + def add_payment_method(self, yookassa_payment_method): + gateway = yookassa_payment_method.type + if yookassa_payment_method.type in ('bank_card', 'sberbank', 'tinkoff_bank'): + metadata = { + 'card_type': yookassa_payment_method.card.card_type, + 'last4': yookassa_payment_method.card.last4, + } + if source := getattr(yookassa_payment_method.card, 'source', None): + gateway = source + elif yookassa_payment_method.type == 'yoo_money': + metadata = {'account_number': yookassa_payment_method.account_number} + elif yookassa_payment_method.type == 'sbp': + metadata = {'sbp_operation_id': yookassa_payment_method.sbp_operation_id} + else: + metadata = {} payment_method, created = PaymentMethod.objects.update_or_create( user_plan_info__user=self.user, - defaults={'payment_method_id': method_id, 'last_four': last_four, 'card_type': card_type, 'attempts': 0}, + defaults={ + 'gateway': gateway, + 'payment_method_id': yookassa_payment_method.id, + 'metadata': metadata, + 'attempts': 0, + }, ) logger.info( 'Payment method saved: email=%s method_uid=%s payment_method_id=%s created=%s', self.user.email, payment_method.uid, - method_id, + yookassa_payment_method.id, created, ) return payment_method @@ -1,10 +1,10 @@ import logging +from datetime import timedelta 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 @@ -14,7 +14,6 @@ from yookassa.domain.response import PaymentResponse as YookassaPaymentResponse from authentication.models import CustomUserModel from lib.unleash.client import web_client -from payments.exceptions.full_balance import FullBalanceException from payments.models.payment import Payment as PaymentModel from payments.models.payment_plan import PaymentPlan, PaymentPlanUserInfo from payments.services.payment_method_service import PaymentMethodService @@ -49,7 +48,6 @@ class PaymentService: ) payment_data = { 'amount': {'value': f'{plan.price}', 'currency': 'RUB'}, - 'payment_method_data': {'type': 'bank_card'}, 'receipt': receipt_data, 'confirmation': { 'type': 'redirect', @@ -118,12 +116,9 @@ class PaymentService: and web_client.get_flag_state('recurring_payments', self.user.email) and not plan.individual ): - card = payment.payment_method.card - payment_method = PaymentMethodService(self.user).add_payment_method( - payment.payment_method.id, card.card_type, card.last4 - ) + payment_method = PaymentMethodService(self.user).add_payment_method(payment.payment_method) PaymentPlanUserInfo.objects.filter(user=self.user).update( - method=payment_method, next_payment_at=timezone.now() + relativedelta(months=1) + method=payment_method, next_payment_at=timezone.now() + timedelta(days=30) ) logger.info( 'Recurring payment method saved: email=%s method_uid=%s next_payment_at_set=true', @@ -133,7 +128,7 @@ class PaymentService: else: if web_client.get_flag_state('recurring_payments', self.user.email) and not plan.individual: PaymentPlanUserInfo.objects.filter(user=self.user).update( - next_payment_at=timezone.now() + relativedelta(months=1) + next_payment_at=timezone.now() + timedelta(days=30) ) logger.info( 'Recurring schedule updated without saved method: email=%s next_payment_at_set=true', @@ -75,7 +75,7 @@ class PaymentPlanFeatureAdmin(OrderedModelAdmin): @admin.register(PaymentMethod) class PaymentMethodAdmin(admin.ModelAdmin): - list_display = ['payment_method_id', 'card_type', 'last_four', 'attempts'] + list_display = ['gateway', 'payment_method_id', 'attempts'] @admin.register(Invoice) @@ -5851,13 +5851,13 @@ files = [ [[package]] name = "yookassa" -version = "2.5.0" +version = "3.10.1" description = "YooKassa API SDK Python Library" optional = false python-versions = "*" groups = ["main"] files = [ - {file = "yookassa-2.5.0.tar.gz", hash = "sha256:5ddb279d6e867c74b66549e3096196606b5f04bf4927bde2513072b7a08ee3ff"}, + {file = "yookassa-3.10.1.tar.gz", hash = "sha256:6eddff428be5eb86c001c79c334ce8ad265b52b315baba70dc5f19bec0f25955"}, ] [package.dependencies] @@ -5945,4 +5945,4 @@ testing = ["coverage[toml]", "zope.event", "zope.testing"] [metadata] lock-version = "2.1" python-versions = "^3.12" -content-hash = "062dda7a1043076481446c18a95fa53ce5e21da80cdbe045621204ecf8a72811" +content-hash = "6d8d326ffa25ad4bc05f2319238735e45ac25671682a40765e79d603685635d4" @@ -13,7 +13,7 @@ django = "5.0.*" django-cors-headers = "^4.2.0" djangorestframework-simplejwt = "^5.2.2" djangorestframework = "^3.14.0" -yookassa = "^2.4.0" +yookassa = "^3.10.1" environs = "^9.5.0" dj-rest-auth = "^4.0.1" django-celery-beat = "^2.5.0"