Добавление папки platform_admin

This commit is contained in:
2026-01-08 22:17:22 +03:00
parent 969e49f4b5
commit 741db3a792
13 changed files with 700 additions and 0 deletions

View File

View File

@@ -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)

View File

@@ -0,0 +1,7 @@
from django.apps import AppConfig
class PlatformAdminConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'platform_admin'
verbose_name = 'Администраторы платформы'

View File

@@ -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

View File

@@ -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

View File

@@ -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': 'Администраторы платформы',
},
),
]

View File

@@ -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

View File

@@ -0,0 +1,132 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Панель администратора платформы</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
.navbar {
background-color: #343a40;
}
.stat-card {
border: none;
border-radius: 10px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.stat-card .card-body {
padding: 1.5rem;
}
.stat-number {
font-size: 2.5rem;
font-weight: bold;
}
.stat-label {
color: #6c757d;
font-size: 0.9rem;
}
</style>
</head>
<body>
<nav class="navbar navbar-dark mb-4">
<div class="container">
<span class="navbar-brand">Администратор платформы</span>
<div class="d-flex align-items-center">
<span class="text-light me-3">{{ request.user.name }}</span>
<a href="{% url 'platform_admin:logout' %}" class="btn btn-outline-light btn-sm">Выйти</a>
</div>
</div>
</nav>
<div class="container">
{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
<h2 class="mb-4">Обзор платформы</h2>
<div class="row g-4 mb-4">
<div class="col-md-3">
<div class="card stat-card">
<div class="card-body text-center">
<div class="stat-number text-primary">{{ total_tenants }}</div>
<div class="stat-label">Всего тенантов</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card">
<div class="card-body text-center">
<div class="stat-number text-success">{{ active_tenants }}</div>
<div class="stat-label">Активных тенантов</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card">
<div class="card-body text-center">
<div class="stat-number text-warning">{{ pending_registrations }}</div>
<div class="stat-label">Ожидают одобрения</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card">
<div class="card-body text-center">
<div class="stat-number text-info">{{ active_subscriptions }}</div>
<div class="stat-label">Активных подписок</div>
</div>
</div>
</div>
</div>
<div class="row g-4">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Быстрые действия</h5>
</div>
<div class="card-body">
<a href="/admin/" class="btn btn-primary me-2 mb-2">
Django Admin
</a>
<a href="/admin/tenants/client/" class="btn btn-outline-primary me-2 mb-2">
Управление тенантами
</a>
<a href="/admin/tenants/tenantregistration/" class="btn btn-outline-primary me-2 mb-2">
Заявки на регистрацию
</a>
<a href="/admin/tenants/subscription/" class="btn btn-outline-primary mb-2">
Подписки
</a>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Информация</h5>
</div>
<div class="card-body">
<p><strong>Вы вошли как:</strong> {{ request.user.email }}</p>
<p><strong>Права:</strong>
{% if request.user.is_superuser %}
Суперадминистратор (доступ к админке тенантов)
{% else %}
Администратор платформы
{% endif %}
</p>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@@ -0,0 +1,111 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Вход - Администратор платформы</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body {
background-color: #f5f5f5;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
}
.login-container {
width: 100%;
max-width: 400px;
padding: 15px;
}
.card {
border: none;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.card-header {
background-color: #343a40;
color: white;
border-radius: 10px 10px 0 0 !important;
text-align: center;
padding: 20px;
}
.card-header h4 {
margin: 0;
}
.card-header small {
opacity: 0.8;
}
.btn-primary {
background-color: #343a40;
border-color: #343a40;
}
.btn-primary:hover {
background-color: #23272b;
border-color: #23272b;
}
</style>
</head>
<body>
<div class="login-container">
<div class="card">
<div class="card-header">
<h4>Администратор платформы</h4>
<small>Управление тенантами и подписками</small>
</div>
<div class="card-body p-4">
{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
<form method="post">
{% csrf_token %}
{% if form.non_field_errors %}
<div class="alert alert-danger">
{% for error in form.non_field_errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
<div class="mb-3">
<label for="id_email" class="form-label">Email</label>
{{ form.email }}
{% if form.email.errors %}
<div class="invalid-feedback d-block">
{% for error in form.email.errors %}{{ error }}{% endfor %}
</div>
{% endif %}
</div>
<div class="mb-3">
<label for="id_password" class="form-label">Пароль</label>
{{ form.password }}
{% if form.password.errors %}
<div class="invalid-feedback d-block">
{% for error in form.password.errors %}{{ error }}{% endfor %}
</div>
{% endif %}
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary btn-lg">Войти</button>
</div>
</form>
</div>
</div>
<div class="text-center mt-3 text-muted">
<small>Только для администраторов платформы</small>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View File

@@ -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'),
]

View File

@@ -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)