From fb4bcf37ec7d5bcd3848d52b96d6e981f7189e81 Mon Sep 17 00:00:00 2001 From: Andrey Smakotin Date: Mon, 1 Dec 2025 22:08:18 +0300 Subject: [PATCH] feat: implement password setup link for tenant registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When admin approves tenant registration: - Owner account created in tenant schema (in addition to admin@localhost) - Owner assigned 'owner' role with full permissions - Password setup email sent with secure 7-day token link - Owner sets password via link and auto-logs into their shop Key changes: - Added password_setup_token fields to TenantRegistration model - Created tenants/services.py with formatted email service - Modified _approve_registration to create owner account - Added password_setup_confirm view with token validation - Created password setup template and URL route - Added admin action to resend password setup emails Security: - Token expires after 7 days - Password not transmitted in email (secure setup link) - Owner account inactive until password set - Admin@localhost preserved for system administrator access 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../accounts/password_setup_confirm.html | 56 +++++++++++ myproject/accounts/urls.py | 1 + myproject/accounts/views.py | 89 ++++++++++++++++- myproject/tenants/admin.py | 56 ++++++++++- ...registration_owner_notified_at_and_more.py | 28 ++++++ myproject/tenants/models.py | 23 +++++ myproject/tenants/services.py | 97 +++++++++++++++++++ 7 files changed, 348 insertions(+), 2 deletions(-) create mode 100644 myproject/accounts/templates/accounts/password_setup_confirm.html create mode 100644 myproject/tenants/migrations/0002_tenantregistration_owner_notified_at_and_more.py create mode 100644 myproject/tenants/services.py diff --git a/myproject/accounts/templates/accounts/password_setup_confirm.html b/myproject/accounts/templates/accounts/password_setup_confirm.html new file mode 100644 index 0000000..894ea4b --- /dev/null +++ b/myproject/accounts/templates/accounts/password_setup_confirm.html @@ -0,0 +1,56 @@ +{% extends 'base.html' %} + +{% block title %}Установка пароля{% endblock %} + +{% block content %} +
+
+

Установка пароля

+ + +
+ Добро пожаловать! Ваш магазин {{ tenant.name }} активирован. +
Установите пароль для входа в систему. +
+ +
+
+
+ {% csrf_token %} + {% include 'accounts/password_input.html' with field_name='password1' field_label='Пароль' required=True %} + {% include 'accounts/password_input.html' with field_name='password2' field_label='Подтверждение пароля' required=True %} + +
+ + +
+ + После установки пароля вы автоматически войдете в свой магазин. + +
+
+
+
+
+ + +{% endblock %} diff --git a/myproject/accounts/urls.py b/myproject/accounts/urls.py index 73cc46b..13acaa7 100644 --- a/myproject/accounts/urls.py +++ b/myproject/accounts/urls.py @@ -12,4 +12,5 @@ urlpatterns = [ path('confirm//', views.confirm_email, name='confirm_email'), path('password-reset/', views.password_reset_request, name='password_reset'), path('password-reset//', views.password_reset_confirm, name='password_reset_confirm'), + path('setup-password//', views.password_setup_confirm, name='password_setup'), ] \ No newline at end of file diff --git a/myproject/accounts/views.py b/myproject/accounts/views.py index 9e3a3f8..34cd6a5 100644 --- a/myproject/accounts/views.py +++ b/myproject/accounts/views.py @@ -198,4 +198,91 @@ def password_reset_confirm(request, token): messages.error(request, 'Пароли не совпадают.') # Отображаем форму смены пароля - return render(request, 'accounts/password_reset_confirm.html', {'user': user}) \ No newline at end of file + return render(request, 'accounts/password_reset_confirm.html', {'user': user}) + + +def password_setup_confirm(request, token): + """ + Позволить владельцу тенанта установить начальный пароль после одобрения регистрации. + Похоже на сброс пароля, но для новых аккаунтов. + """ + from tenants.models import TenantRegistration + from datetime import timedelta + from django.utils import timezone + + # Найти регистрацию по токену + try: + registration = TenantRegistration.objects.get( + password_setup_token=token, + status=TenantRegistration.STATUS_APPROVED + ) + except TenantRegistration.DoesNotExist: + messages.error(request, 'Ссылка для настройки пароля недействительна.') + return redirect('index') + + # Проверить истечение токена (7 дней) + if registration.password_setup_token_created_at: + expires_at = registration.password_setup_token_created_at + timedelta(days=7) + if timezone.now() > expires_at: + messages.error( + request, + 'Ссылка для настройки пароля истекла. Пожалуйста, свяжитесь с поддержкой.' + ) + return redirect('index') + + # Получить тенант и пользователя-владельца + from django.db import connection + tenant = registration.tenant + if not tenant: + messages.error(request, 'Тенант не найден.') + return redirect('index') + + # Переключиться на схему тенанта чтобы найти владельца + connection.set_tenant(tenant) + try: + User = get_user_model() + owner = User.objects.get(email=registration.owner_email) + except User.DoesNotExist: + connection.set_schema_to_public() + messages.error(request, 'Пользователь не найден.') + return redirect('index') + + # Обработать POST - установить пароль + if request.method == 'POST': + password1 = request.POST.get('password1') + password2 = request.POST.get('password2') + + if password1 and password2 and password1 == password2: + # Установить пароль и активировать аккаунт + owner.set_password(password1) + owner.is_active = True + owner.save() + + # Очистить токен + connection.set_schema_to_public() + registration.password_setup_token = None + registration.password_setup_token_created_at = None + registration.save() + + # Автоматический вход + connection.set_tenant(tenant) + login(request, owner, backend='django.contrib.auth.backends.ModelBackend') + + messages.success( + request, + f'Пароль успешно установлен! Добро пожаловать в {tenant.name}!' + ) + + # Перенаправить на домен тенанта + tenant_url = f'http://{tenant.schema_name}.localhost:8000/' + return redirect(tenant_url) + else: + messages.error(request, 'Пароли не совпадают.') + + connection.set_schema_to_public() + + # Отрисовать форму установки пароля + return render(request, 'accounts/password_setup_confirm.html', { + 'registration': registration, + 'tenant': tenant + }) \ No newline at end of file diff --git a/myproject/tenants/admin.py b/myproject/tenants/admin.py index 2f925a6..00b862d 100644 --- a/myproject/tenants/admin.py +++ b/myproject/tenants/admin.py @@ -137,7 +137,7 @@ class TenantRegistrationAdmin(admin.ModelAdmin): }), ) - actions = ['approve_registrations', 'reject_registrations'] + actions = ['approve_registrations', 'reject_registrations', 'resend_password_setup_email'] def actions_column(self, obj): """Кнопки действий для каждой заявки""" @@ -187,6 +187,23 @@ class TenantRegistrationAdmin(admin.ModelAdmin): reject_registrations.short_description = "✗ Отклонить выбранные заявки" + def resend_password_setup_email(self, request, queryset): + """Повторно отправить письмо с ссылкой для установки пароля""" + from tenants.services import send_password_setup_email + + sent_count = 0 + for registration in queryset.filter(status=TenantRegistration.STATUS_APPROVED): + try: + send_password_setup_email(registration) + sent_count += 1 + except Exception as e: + messages.error(request, f"Не удалось отправить письмо для {registration.shop_name}: {e}") + + if sent_count > 0: + messages.success(request, f"Письма повторно отправлены: {sent_count}") + + resend_password_setup_email.short_description = "📧 Отправить письмо повторно" + def changeform_view(self, request, object_id=None, form_url='', extra_context=None): """ Обработка действий активации/отклонения через GET параметры @@ -285,6 +302,32 @@ class TenantRegistrationAdmin(admin.ModelAdmin): else: logger.warning(f"Пользователь с email {settings.TENANT_ADMIN_EMAIL} уже существует в тенанте") + # Создаем аккаунт владельца тенанта + logger.info(f"Создание аккаунта владельца для тенанта: {client.id}") + if not User.objects.filter(email=registration.owner_email).exists(): + owner = User.objects.create_user( + email=registration.owner_email, + name=registration.owner_name, + password=None # Пароль будет установлен через ссылку + ) + # Помечаем email как подтвержденный, так как владелец регистрировался с ним + owner.is_email_confirmed = True + owner.email_confirmed_at = timezone.now() + owner.is_active = False # Неактивен до установки пароля + owner.save() + logger.info(f"Аккаунт владельца создан: {owner.id} ({owner.email})") + + # Назначаем роль owner владельцу + try: + from user_roles.models import Role + RoleService.assign_role_to_user(owner, Role.OWNER, created_by=None) + logger.info(f"Роль owner назначена владельцу {owner.email}") + except Exception as e: + logger.error(f"Ошибка при назначении роли owner: {e}", exc_info=True) + # Не прерываем процесс + else: + logger.warning(f"Пользователь с email {registration.owner_email} уже существует в тенанте") + # Создаем системного клиента для анонимных продаж logger.info(f"Создание системного клиента для тенанта: {client.id}") from customers.models import Customer @@ -337,6 +380,17 @@ class TenantRegistrationAdmin(admin.ModelAdmin): # Возвращаемся в public схему connection.set_schema_to_public() + # Отправляем письмо владельцу с ссылкой для установки пароля + logger.info(f"Отправка письма владельцу с ссылкой установки пароля: {registration.owner_email}") + try: + from tenants.services import send_password_setup_email + send_password_setup_email(registration) + logger.info(f"Письмо успешно отправлено владельцу {registration.owner_email}") + except Exception as e: + logger.error(f"Ошибка при отправке письма владельцу: {e}", exc_info=True) + # Не прерываем процесс одобрения, если письмо не отправилось + # Админ может повторно отправить письмо через действие в админке + # Обновляем статус заявки registration.status = TenantRegistration.STATUS_APPROVED registration.processed_at = timezone.now() diff --git a/myproject/tenants/migrations/0002_tenantregistration_owner_notified_at_and_more.py b/myproject/tenants/migrations/0002_tenantregistration_owner_notified_at_and_more.py new file mode 100644 index 0000000..08d585c --- /dev/null +++ b/myproject/tenants/migrations/0002_tenantregistration_owner_notified_at_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 5.0.10 on 2025-12-01 18:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenants', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='tenantregistration', + name='owner_notified_at', + field=models.DateTimeField(blank=True, help_text='Когда было отправлено письмо владельцу с ссылкой установки пароля', null=True, verbose_name='Дата уведомления владельца'), + ), + migrations.AddField( + model_name='tenantregistration', + name='password_setup_token', + field=models.UUIDField(blank=True, help_text='UUID токен для ссылки установки пароля владельцем', null=True, unique=True, verbose_name='Токен установки пароля'), + ), + migrations.AddField( + model_name='tenantregistration', + name='password_setup_token_created_at', + field=models.DateTimeField(blank=True, help_text='Когда был создан токен установки пароля (действителен 7 дней)', null=True, verbose_name='Дата создания токена'), + ), + ] diff --git a/myproject/tenants/models.py b/myproject/tenants/models.py index eb2460e..c8c6369 100644 --- a/myproject/tenants/models.py +++ b/myproject/tenants/models.py @@ -182,6 +182,29 @@ class TenantRegistration(models.Model): verbose_name="Созданный тенант" ) + # Поля для установки пароля владельцем + password_setup_token = models.UUIDField( + null=True, + blank=True, + unique=True, + verbose_name="Токен установки пароля", + help_text="UUID токен для ссылки установки пароля владельцем" + ) + + password_setup_token_created_at = models.DateTimeField( + null=True, + blank=True, + verbose_name="Дата создания токена", + help_text="Когда был создан токен установки пароля (действителен 7 дней)" + ) + + owner_notified_at = models.DateTimeField( + null=True, + blank=True, + verbose_name="Дата уведомления владельца", + help_text="Когда было отправлено письмо владельцу с ссылкой установки пароля" + ) + class Meta: verbose_name = "Заявка на регистрацию" verbose_name_plural = "Заявки на регистрацию" diff --git a/myproject/tenants/services.py b/myproject/tenants/services.py new file mode 100644 index 0000000..6371952 --- /dev/null +++ b/myproject/tenants/services.py @@ -0,0 +1,97 @@ +# -*- coding: utf-8 -*- +""" +Сервисы для работы с тенантами +""" +import uuid +from django.utils import timezone +from django.conf import settings + + +def send_password_setup_email(registration): + """ + Отправить email с ссылкой для установки пароля одобренному владельцу тенанта. + + Args: + registration: экземпляр TenantRegistration + + Returns: + bool: True если письмо успешно "отправлено" (выведено в консоль) + """ + # Генерировать токен + registration.password_setup_token = uuid.uuid4() + registration.password_setup_token_created_at = timezone.now() + + # Построить URL + # В продакшене это будет полный URL с доменом + setup_url = f"http://localhost:8000/accounts/setup-password/{registration.password_setup_token}/" + tenant_url = f"http://{registration.schema_name}.localhost:8000/" + + # Составить письмо + subject = f"Ваш магазин {registration.shop_name} активирован!" + + message = f""" +╔══════════════════════════════════════════════════════════════════╗ +║ ВАША ЗАЯВКА НА РЕГИСТРАЦИЮ МАГАЗИНА ОДОБРЕНА! ║ +╚══════════════════════════════════════════════════════════════════╝ + +Здравствуйте, {registration.owner_name}! + +Отличные новости! Ваша заявка на регистрацию магазина "{registration.shop_name}" +была одобрена администратором. + +╔══════════════════════════════════════════════════════════════════╗ +║ ДАННЫЕ ДЛЯ ВХОДА ║ +╚══════════════════════════════════════════════════════════════════╝ + +📧 Email для входа: {registration.owner_email} +🏪 Ваш магазин доступен по адресу: {tenant_url} + +╔══════════════════════════════════════════════════════════════════╗ +║ УСТАНОВИТЕ ПАРОЛЬ (действительна 7 дней) ║ +╚══════════════════════════════════════════════════════════════════╝ + +Для завершения настройки аккаунта, пожалуйста, установите пароль, +перейдя по следующей ссылке: + +{setup_url} + +⏰ Ссылка действительна в течение 7 дней. + +╔══════════════════════════════════════════════════════════════════╗ +║ ЧТО ДЕЛАТЬ ДАЛЬШЕ? ║ +╚══════════════════════════════════════════════════════════════════╝ + +1. Нажмите на ссылку выше +2. Придумайте надежный пароль +3. Войдите в свой магазин и начните работу! + +Если у вас возникли вопросы, свяжитесь с нами: +📧 support@inventory.by +📞 +375 29 123-45-67 + +С уважением, +Команда Inventory System + +--- +Если вы не подавали заявку на регистрацию, проигнорируйте это письмо. + """ + + from_email = settings.DEFAULT_FROM_EMAIL + recipient_list = [registration.owner_email] + + # Вывести в консоль с красивым форматированием + print("\n" + "="*70) + print("📧 ОТПРАВКА EMAIL (Console Backend)") + print("="*70) + print(f"Тема: {subject}") + print(f"От: {from_email}") + print(f"Кому: {recipient_list[0]}") + print("-"*70) + print(message) + print("="*70 + "\n") + + # Обновить registration + registration.owner_notified_at = timezone.now() + registration.save() + + return True