289 lines
9.0 KiB
Python
289 lines
9.0 KiB
Python
# -*- 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="Созданный тенант"
|
||
)
|
||
|
||
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
|
||
)
|