From 741db3a7927fed286c95769d0f917bd5d82f0be8 Mon Sep 17 00:00:00 2001 From: Andrey Smakotin Date: Thu, 8 Jan 2026 22:17:22 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=BF=D0=B0=D0=BF=D0=BA=D0=B8=20platform?= =?UTF-8?q?=5Fadmin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- myproject/platform_admin/__init__.py | 0 myproject/platform_admin/admin.py | 64 +++++++++ myproject/platform_admin/apps.py | 7 + myproject/platform_admin/backends.py | 81 +++++++++++ myproject/platform_admin/forms.py | 71 ++++++++++ .../platform_admin/migrations/0001_initial.py | 36 +++++ .../platform_admin/migrations/__init__.py | 0 myproject/platform_admin/models.py | 105 ++++++++++++++ .../templates/platform_admin/dashboard.html | 132 ++++++++++++++++++ .../templates/platform_admin/login.html | 111 +++++++++++++++ myproject/platform_admin/tests.py | 3 + myproject/platform_admin/urls.py | 15 ++ myproject/platform_admin/views.py | 75 ++++++++++ 13 files changed, 700 insertions(+) create mode 100644 myproject/platform_admin/__init__.py create mode 100644 myproject/platform_admin/admin.py create mode 100644 myproject/platform_admin/apps.py create mode 100644 myproject/platform_admin/backends.py create mode 100644 myproject/platform_admin/forms.py create mode 100644 myproject/platform_admin/migrations/0001_initial.py create mode 100644 myproject/platform_admin/migrations/__init__.py create mode 100644 myproject/platform_admin/models.py create mode 100644 myproject/platform_admin/templates/platform_admin/dashboard.html create mode 100644 myproject/platform_admin/templates/platform_admin/login.html create mode 100644 myproject/platform_admin/tests.py create mode 100644 myproject/platform_admin/urls.py create mode 100644 myproject/platform_admin/views.py diff --git a/myproject/platform_admin/__init__.py b/myproject/platform_admin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/myproject/platform_admin/admin.py b/myproject/platform_admin/admin.py new file mode 100644 index 0000000..d4ac04e --- /dev/null +++ b/myproject/platform_admin/admin.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.db import connection + +from .models import PlatformAdmin + + +@admin.register(PlatformAdmin) +class PlatformAdminAdmin(UserAdmin): + """ + Админка для управления администраторами платформы. + + Доступна только в public schema. + """ + list_display = ('email', 'name', 'is_active', 'is_superuser', 'date_joined', 'last_login') + list_filter = ('is_active', 'is_superuser', 'date_joined') + search_fields = ('email', 'name') + ordering = ('-date_joined',) + + fieldsets = ( + (None, {'fields': ('email', 'password')}), + ('Персональные данные', {'fields': ('name',)}), + ('Права доступа', { + 'fields': ('is_active', 'is_staff', 'is_superuser'), + 'description': 'Суперадмин может входить в /admin/ на tenant доменах для поддержки.' + }), + ('Важные даты', {'fields': ('last_login', 'date_joined')}), + ) + + add_fieldsets = ( + (None, { + 'classes': ('wide',), + 'fields': ('email', 'name', 'password1', 'password2', 'is_superuser'), + }), + ) + + readonly_fields = ('date_joined', 'last_login') + + def has_module_permission(self, request): + """Показывать только в public schema.""" + if hasattr(connection, 'schema_name') and connection.schema_name != 'public': + return False + return super().has_module_permission(request) + + def has_view_permission(self, request, obj=None): + if hasattr(connection, 'schema_name') and connection.schema_name != 'public': + return False + return super().has_view_permission(request, obj) + + def has_add_permission(self, request): + if hasattr(connection, 'schema_name') and connection.schema_name != 'public': + return False + return super().has_add_permission(request) + + def has_change_permission(self, request, obj=None): + if hasattr(connection, 'schema_name') and connection.schema_name != 'public': + return False + return super().has_change_permission(request, obj) + + def has_delete_permission(self, request, obj=None): + if hasattr(connection, 'schema_name') and connection.schema_name != 'public': + return False + return super().has_delete_permission(request, obj) diff --git a/myproject/platform_admin/apps.py b/myproject/platform_admin/apps.py new file mode 100644 index 0000000..d845034 --- /dev/null +++ b/myproject/platform_admin/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class PlatformAdminConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'platform_admin' + verbose_name = 'Администраторы платформы' diff --git a/myproject/platform_admin/backends.py b/myproject/platform_admin/backends.py new file mode 100644 index 0000000..e940080 --- /dev/null +++ b/myproject/platform_admin/backends.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- +""" +Authentication backend для PlatformAdmin. + +Этот backend используется для аутентификации администраторов платформы. +Работает на public домене, а также на tenant доменах для суперадминов. +""" +from django.contrib.auth.backends import ModelBackend +from django.db import connection + + +class PlatformAdminBackend(ModelBackend): + """ + Backend аутентификации для PlatformAdmin. + + Особенности: + - На public домене: аутентифицирует любого PlatformAdmin + - На tenant домене: аутентифицирует только PlatformAdmin с is_superuser=True + (для поддержки и отладки клиентов) + + Обычные PlatformAdmin (без is_superuser) не могут логиниться на tenant доменах. + """ + + def authenticate(self, request, username=None, password=None, **kwargs): + """ + Аутентификация PlatformAdmin по email и паролю. + + Args: + request: HTTP запрос + username: Email администратора + password: Пароль + + Returns: + PlatformAdmin если аутентификация успешна, иначе None + """ + from platform_admin.models import PlatformAdmin + + if username is None or password is None: + return None + + try: + # Всегда читаем из default (public schema) + user = PlatformAdmin.objects.using('default').get(email=username) + except PlatformAdmin.DoesNotExist: + # Run the default password hasher once to reduce the timing + # difference between an existing and a non-existing user + PlatformAdmin().set_password(password) + return None + + if not user.check_password(password): + return None + + if not self.user_can_authenticate(user): + return None + + # На tenant домене — только суперадмин может логиниться + schema_name = getattr(connection, 'schema_name', 'public') + if schema_name != 'public' and not user.is_superuser: + return None + + return user + + def get_user(self, user_id): + """ + Получение PlatformAdmin по ID. + + Всегда читает из public schema. + """ + from platform_admin.models import PlatformAdmin + + try: + return PlatformAdmin.objects.using('default').get(pk=user_id) + except PlatformAdmin.DoesNotExist: + return None + + def user_can_authenticate(self, user): + """ + Проверка что пользователь активен. + """ + is_active = getattr(user, 'is_active', None) + return is_active or is_active is None diff --git a/myproject/platform_admin/forms.py b/myproject/platform_admin/forms.py new file mode 100644 index 0000000..56f882c --- /dev/null +++ b/myproject/platform_admin/forms.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +""" +Формы для аутентификации администраторов платформы. +""" +from django import forms +from django.contrib.auth import authenticate + + +class PlatformAdminLoginForm(forms.Form): + """ + Форма входа для администраторов платформы. + """ + email = forms.EmailField( + label="Email", + widget=forms.EmailInput(attrs={ + 'class': 'form-control', + 'placeholder': 'admin@platform.com', + 'autofocus': True, + }) + ) + password = forms.CharField( + label="Пароль", + strip=False, + widget=forms.PasswordInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Пароль', + }) + ) + + error_messages = { + 'invalid_login': "Неверный email или пароль.", + 'inactive': "Этот аккаунт деактивирован.", + } + + def __init__(self, request=None, *args, **kwargs): + self.request = request + self.user_cache = None + super().__init__(*args, **kwargs) + + def clean(self): + email = self.cleaned_data.get('email') + password = self.cleaned_data.get('password') + + if email is not None and password: + self.user_cache = authenticate( + self.request, + username=email, + password=password + ) + if self.user_cache is None: + raise forms.ValidationError( + self.error_messages['invalid_login'], + code='invalid_login', + ) + else: + self.confirm_login_allowed(self.user_cache) + + return self.cleaned_data + + def confirm_login_allowed(self, user): + """ + Проверка что пользователь может войти. + """ + if not user.is_active: + raise forms.ValidationError( + self.error_messages['inactive'], + code='inactive', + ) + + def get_user(self): + return self.user_cache diff --git a/myproject/platform_admin/migrations/0001_initial.py b/myproject/platform_admin/migrations/0001_initial.py new file mode 100644 index 0000000..fd3f6f4 --- /dev/null +++ b/myproject/platform_admin/migrations/0001_initial.py @@ -0,0 +1,36 @@ +# Generated by Django 5.0.10 on 2026-01-08 15:56 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='PlatformAdmin', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('email', models.EmailField(max_length=254, unique=True, verbose_name='Email')), + ('name', models.CharField(max_length=200, verbose_name='Имя')), + ('is_staff', models.BooleanField(default=True, help_text='Определяет, может ли пользователь входить в Django Admin', verbose_name='Доступ к админке')), + ('is_active', models.BooleanField(default=True, verbose_name='Активен')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Дата регистрации')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='Последний вход')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'Администратор платформы', + 'verbose_name_plural': 'Администраторы платформы', + }, + ), + ] diff --git a/myproject/platform_admin/migrations/__init__.py b/myproject/platform_admin/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/myproject/platform_admin/models.py b/myproject/platform_admin/models.py new file mode 100644 index 0000000..044c907 --- /dev/null +++ b/myproject/platform_admin/models.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- +""" +Модели для администраторов платформы. + +PlatformAdmin - отдельная сущность для управления тенантами, +живёт только в public schema и не связана с CustomUser. +""" +from django.db import models +from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin +from django.utils import timezone + + +class PlatformAdminManager(BaseUserManager): + """Менеджер для модели PlatformAdmin.""" + + def create_user(self, email, name, password=None, **extra_fields): + """ + Создаёт обычного администратора платформы. + """ + if not email: + raise ValueError('Email обязателен') + email = self.normalize_email(email) + extra_fields.setdefault('is_staff', True) + extra_fields.setdefault('is_active', True) + user = self.model(email=email, name=name, **extra_fields) + user.set_password(password) + user.save(using=self._db) + return user + + def create_superuser(self, email, name, password=None, **extra_fields): + """ + Создаёт суперадминистратора платформы. + + Суперадмин может: + - Управлять всеми тенантами + - Входить в /admin/ на tenant доменах для поддержки + """ + extra_fields['is_superuser'] = True + extra_fields['is_staff'] = True + return self.create_user(email, name, password, **extra_fields) + + +class PlatformAdmin(AbstractBaseUser, PermissionsMixin): + """ + Администратор платформы для управления тенантами. + + Живёт ТОЛЬКО в public schema. + НЕ имеет доступа к бизнес-данным тенантов (только управление). + НЕ связан с UserRole и ролевой системой тенантов. + + Используется для: + - Управления тенантами (Client) + - Одобрения заявок на регистрацию (TenantRegistration) + - Управления подписками (Subscription) + - Управления доменами (Domain) + + Суперадмин (is_superuser=True) дополнительно может: + - Входить в /admin/ на tenant доменах для поддержки клиентов + """ + email = models.EmailField( + unique=True, + verbose_name="Email" + ) + name = models.CharField( + max_length=200, + verbose_name="Имя" + ) + + is_staff = models.BooleanField( + default=True, + verbose_name="Доступ к админке", + help_text="Определяет, может ли пользователь входить в Django Admin" + ) + is_active = models.BooleanField( + default=True, + verbose_name="Активен" + ) + + date_joined = models.DateTimeField( + default=timezone.now, + verbose_name="Дата регистрации" + ) + last_login = models.DateTimeField( + null=True, + blank=True, + verbose_name="Последний вход" + ) + + objects = PlatformAdminManager() + + USERNAME_FIELD = 'email' + REQUIRED_FIELDS = ['name'] + + class Meta: + verbose_name = "Администратор платформы" + verbose_name_plural = "Администраторы платформы" + + def __str__(self): + return f"{self.name} ({self.email})" + + def get_full_name(self): + return self.name + + def get_short_name(self): + return self.name.split()[0] if self.name else self.email diff --git a/myproject/platform_admin/templates/platform_admin/dashboard.html b/myproject/platform_admin/templates/platform_admin/dashboard.html new file mode 100644 index 0000000..f031605 --- /dev/null +++ b/myproject/platform_admin/templates/platform_admin/dashboard.html @@ -0,0 +1,132 @@ + + + + + + Панель администратора платформы + + + + + + +
+ {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} + +

Обзор платформы

+ +
+
+
+
+
{{ total_tenants }}
+
Всего тенантов
+
+
+
+
+
+
+
{{ active_tenants }}
+
Активных тенантов
+
+
+
+
+
+
+
{{ pending_registrations }}
+
Ожидают одобрения
+
+
+
+
+
+
+
{{ active_subscriptions }}
+
Активных подписок
+
+
+
+
+ +
+ +
+
+
+
Информация
+
+
+

Вы вошли как: {{ request.user.email }}

+

Права: + {% if request.user.is_superuser %} + Суперадминистратор (доступ к админке тенантов) + {% else %} + Администратор платформы + {% endif %} +

+
+
+
+
+
+ + + + diff --git a/myproject/platform_admin/templates/platform_admin/login.html b/myproject/platform_admin/templates/platform_admin/login.html new file mode 100644 index 0000000..5864397 --- /dev/null +++ b/myproject/platform_admin/templates/platform_admin/login.html @@ -0,0 +1,111 @@ + + + + + + Вход - Администратор платформы + + + + +
+
+
+

Администратор платформы

+ Управление тенантами и подписками +
+
+ {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} + +
+ {% csrf_token %} + + {% if form.non_field_errors %} +
+ {% for error in form.non_field_errors %} + {{ error }} + {% endfor %} +
+ {% endif %} + +
+ + {{ form.email }} + {% if form.email.errors %} +
+ {% for error in form.email.errors %}{{ error }}{% endfor %} +
+ {% endif %} +
+ +
+ + {{ form.password }} + {% if form.password.errors %} +
+ {% for error in form.password.errors %}{{ error }}{% endfor %} +
+ {% endif %} +
+ +
+ +
+
+
+
+ +
+ Только для администраторов платформы +
+
+ + + + diff --git a/myproject/platform_admin/tests.py b/myproject/platform_admin/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/myproject/platform_admin/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/myproject/platform_admin/urls.py b/myproject/platform_admin/urls.py new file mode 100644 index 0000000..a3ae422 --- /dev/null +++ b/myproject/platform_admin/urls.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +""" +URL маршрутизация для администраторов платформы. +""" +from django.urls import path + +from . import views + +app_name = 'platform_admin' + +urlpatterns = [ + path('login/', views.platform_login_view, name='login'), + path('logout/', views.platform_logout_view, name='logout'), + path('dashboard/', views.dashboard_view, name='dashboard'), +] diff --git a/myproject/platform_admin/views.py b/myproject/platform_admin/views.py new file mode 100644 index 0000000..780682d --- /dev/null +++ b/myproject/platform_admin/views.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +""" +Views для аутентификации администраторов платформы. +""" +from django.shortcuts import render, redirect +from django.contrib.auth import login, logout +from django.contrib import messages +from django.db import connection + +from .forms import PlatformAdminLoginForm +from .models import PlatformAdmin + + +def platform_login_view(request): + """ + Страница входа для администраторов платформы. + + Доступна только на public домене. + """ + # Проверяем что мы на public домене + schema_name = getattr(connection, 'schema_name', 'public') + if schema_name != 'public': + messages.error(request, 'Вход для администраторов платформы доступен только на главном домене.') + return redirect('/') + + # Если пользователь уже залогинен как PlatformAdmin + if request.user.is_authenticated and isinstance(request.user, PlatformAdmin): + return redirect('platform_admin:dashboard') + + if request.method == 'POST': + form = PlatformAdminLoginForm(request, data=request.POST) + if form.is_valid(): + user = form.get_user() + login(request, user, backend='platform_admin.backends.PlatformAdminBackend') + messages.success(request, f'Добро пожаловать, {user.name}!') + + next_url = request.GET.get('next') + if next_url: + return redirect(next_url) + return redirect('platform_admin:dashboard') + else: + form = PlatformAdminLoginForm(request) + + return render(request, 'platform_admin/login.html', {'form': form}) + + +def platform_logout_view(request): + """ + Выход администратора платформы. + """ + logout(request) + messages.info(request, 'Вы вышли из системы.') + return redirect('platform_admin:login') + + +def dashboard_view(request): + """ + Главная страница панели администратора платформы. + + Показывает статистику по тенантам, заявкам и подпискам. + """ + # Проверяем что пользователь - PlatformAdmin + if not request.user.is_authenticated or not isinstance(request.user, PlatformAdmin): + return redirect('platform_admin:login') + + from tenants.models import Client, TenantRegistration, Subscription + + context = { + 'total_tenants': Client.objects.exclude(schema_name='public').count(), + 'active_tenants': Client.objects.filter(is_active=True).exclude(schema_name='public').count(), + 'pending_registrations': TenantRegistration.objects.filter(status='pending').count(), + 'active_subscriptions': Subscription.objects.filter(is_active=True).count(), + } + + return render(request, 'platform_admin/dashboard.html', context)