Files
octopus/myproject/tenants/models.py
Andrey Smakotin fb4bcf37ec feat: implement password setup link for tenant registration
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 <noreply@anthropic.com>
2025-12-01 22:08:18 +03:00

312 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
from django.db import models
from django.conf import settings
from django.core.validators import RegexValidator
from django.utils import timezone
from django_tenants.models import TenantMixin, DomainMixin
from phonenumber_field.modelfields import PhoneNumberField
from datetime import timedelta
# Зарезервированные имена схем, которые нельзя использовать
RESERVED_SCHEMA_NAMES = [
'public', 'admin', 'api', 'www', 'mail', 'ftp', 'smtp',
'static', 'media', 'assets', 'cdn', 'app', 'web',
'billing', 'register', 'login', 'logout', 'dashboard',
'test', 'dev', 'staging', 'production', 'demo'
]
class Client(TenantMixin):
"""
Модель тенанта (владельца магазина).
Каждый тенант = отдельная схема в PostgreSQL с изолированными данными.
"""
name = models.CharField(
max_length=200,
db_index=True,
verbose_name="Название магазина"
)
owner_email = models.EmailField(
verbose_name="Email владельца",
help_text="Контактный email владельца магазина. Один email может использоваться для нескольких магазинов (для супер-админа)"
)
owner_name = models.CharField(
max_length=200,
blank=True,
null=True,
verbose_name="Имя владельца"
)
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name="Дата создания"
)
is_active = models.BooleanField(
default=True,
verbose_name="Активен",
help_text="Активна ли учетная запись магазина (ручная блокировка админом)"
)
# Дополнительные поля для будущего расширения
phone = PhoneNumberField(
blank=True,
null=True,
verbose_name="Телефон"
)
notes = models.TextField(
blank=True,
null=True,
verbose_name="Заметки",
help_text="Внутренние заметки администратора"
)
class Meta:
verbose_name = "Тенант (Магазин)"
verbose_name_plural = "Тенанты (Магазины)"
ordering = ['-created_at']
def __str__(self):
return f"{self.name} ({self.schema_name})"
# auto_create_schema наследуется от TenantMixin
# auto_drop_schema наследуется от TenantMixin
auto_create_schema = True # Автоматически создавать схему при создании тенанта
class Domain(DomainMixin):
"""
Модель домена для тенанта.
Связывает поддомен (например shop1.inventory.by) с тенантом.
"""
class Meta:
verbose_name = "Домен"
verbose_name_plural = "Домены"
def __str__(self):
return self.domain
class TenantRegistration(models.Model):
"""
Модель заявки на регистрацию нового тенанта.
Заявки сначала создаются со статусом 'pending', затем админ их активирует.
"""
STATUS_PENDING = 'pending'
STATUS_APPROVED = 'approved'
STATUS_REJECTED = 'rejected'
STATUS_CHOICES = [
(STATUS_PENDING, 'Ожидает проверки'),
(STATUS_APPROVED, 'Одобрено'),
(STATUS_REJECTED, 'Отклонено'),
]
# Данные от пользователя
shop_name = models.CharField(
max_length=200,
verbose_name="Название магазина"
)
schema_name = models.CharField(
max_length=63,
unique=True,
validators=[
RegexValidator(
regex=r'^[a-z0-9](?:[a-z0-9\-]{0,61}[a-z0-9])?$',
message='Поддомен должен содержать только латинские буквы в нижнем регистре, цифры и дефис. '
'Длина от 3 до 63 символов. Не может начинаться или заканчиваться дефисом.'
)
],
verbose_name="Желаемый поддомен",
help_text="Например: myshop (будет доступен как myshop.inventory.by)"
)
owner_email = models.EmailField(
verbose_name="Email владельца"
)
owner_name = models.CharField(
max_length=200,
verbose_name="Имя владельца"
)
phone = PhoneNumberField(
verbose_name="Телефон"
)
# Служебные поля
status = models.CharField(
max_length=20,
choices=STATUS_CHOICES,
default=STATUS_PENDING,
db_index=True,
verbose_name="Статус"
)
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name="Дата подачи заявки"
)
processed_at = models.DateTimeField(
null=True,
blank=True,
verbose_name="Дата обработки"
)
processed_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
verbose_name="Обработал",
help_text="Администратор, который обработал заявку"
)
rejection_reason = models.TextField(
blank=True,
verbose_name="Причина отклонения"
)
# Ссылка на созданный тенант (заполняется после активации)
tenant = models.OneToOneField(
Client,
null=True,
blank=True,
on_delete=models.SET_NULL,
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 = "Заявки на регистрацию"
ordering = ['-created_at']
def __str__(self):
return f"{self.shop_name} ({self.schema_name}) - {self.get_status_display()}"
class Subscription(models.Model):
"""
Модель подписки тенанта.
Определяет план подписки и срок действия.
"""
PLAN_TRIAL = 'trial'
PLAN_MONTHLY = 'monthly'
PLAN_QUARTERLY = 'quarterly'
PLAN_YEARLY = 'yearly'
PLAN_CHOICES = [
(PLAN_TRIAL, 'Триальный (90 дней)'),
(PLAN_MONTHLY, 'Месячный'),
(PLAN_QUARTERLY, 'Квартальный (3 месяца)'),
(PLAN_YEARLY, 'Годовой'),
]
client = models.OneToOneField(
Client,
on_delete=models.CASCADE,
related_name='subscription',
verbose_name="Тенант"
)
plan = models.CharField(
max_length=20,
choices=PLAN_CHOICES,
default=PLAN_TRIAL,
verbose_name="План подписки"
)
started_at = models.DateTimeField(
verbose_name="Дата начала"
)
expires_at = models.DateTimeField(
verbose_name="Дата окончания"
)
is_active = models.BooleanField(
default=True,
verbose_name="Активна",
help_text="Активна ли подписка (может быть отключена вручную)"
)
auto_renew = models.BooleanField(
default=False,
verbose_name="Автопродление",
help_text="Автоматически продлевать подписку"
)
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name="Дата создания"
)
updated_at = models.DateTimeField(
auto_now=True,
verbose_name="Дата обновления"
)
class Meta:
verbose_name = "Подписка"
verbose_name_plural = "Подписки"
ordering = ['-expires_at']
def __str__(self):
return f"{self.client.name} - {self.get_plan_display()} (до {self.expires_at.date()})"
def is_expired(self):
"""Проверка истечения подписки"""
if not self.expires_at:
return False
return timezone.now() > self.expires_at
def days_left(self):
"""Количество дней до окончания подписки"""
if not self.expires_at:
return 0
if self.is_expired():
return 0
delta = self.expires_at - timezone.now()
return max(delta.days, 0)
@staticmethod
def create_trial(client):
"""Создать триальную подписку на 90 дней"""
now = timezone.now()
return Subscription.objects.create(
client=client,
plan=Subscription.PLAN_TRIAL,
started_at=now,
expires_at=now + timedelta(days=90),
is_active=True
)