chore: улучшен интерфейс работы с моделями
This commit is contained in:
1 parent
6cfb00a847
commit
adab286884
6 files changed
+175
-89
No files matched your search
+116
-5
@@ -1,7 +1,13 @@
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
|
||||
from django.contrib.auth.base_user import BaseUserManager
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.db import models
|
||||
from serverus import Host
|
||||
from django.db import models, transaction
|
||||
from serverus import Host, generate_password
|
||||
|
||||
PANEL_PORT_RANGE = (10000, 20000)
|
||||
"""Не пересекается с диапазоном ssh портов serverus."""
|
||||
|
||||
|
||||
class UserManager(BaseUserManager):
|
||||
@@ -50,10 +56,76 @@ class User(AbstractUser):
|
||||
raise NotImplementedError('User has no email')
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Credentials:
|
||||
"""Доступ к серверу, который прислал пользователь: адрес и пароль root от провайдера."""
|
||||
address: str
|
||||
password: str
|
||||
|
||||
|
||||
# Соглашение для поиска: get_* и server() бросают DoesNotExist, если записи нет,
|
||||
# find_* возвращают None, и это отражено в типе. Вызывающая сторона видит по имени и типу, нужна ли проверка на None.
|
||||
|
||||
|
||||
class PointQuerySet(models.QuerySet):
|
||||
def owned_by(self, owner) -> 'PointQuerySet':
|
||||
"""Точки владельца от старых к новым."""
|
||||
return self.filter(owner=owner).order_by('created_at')
|
||||
|
||||
def find_by_pk(self, pk) -> 'Point | None':
|
||||
return self.filter(pk=pk).first()
|
||||
|
||||
def find_by_name(self, name: str) -> 'Point | None':
|
||||
return self.filter(name=name).first()
|
||||
|
||||
def find_last(self) -> 'Point | None':
|
||||
"""Последняя созданная точка."""
|
||||
return self.order_by('created_at').last()
|
||||
|
||||
def is_name_taken(self, name: str) -> bool:
|
||||
return self.filter(name=name).exists()
|
||||
|
||||
|
||||
class PointManager(models.Manager.from_queryset(PointQuerySet)):
|
||||
def create_point(
|
||||
self, owner, panel: Credentials, mask: Credentials | None = None, *, domain: str = '',
|
||||
cloudflare: bool = False, outbound_proxy: str = '',
|
||||
) -> 'Point':
|
||||
"""Создает точку вместе с ее серверами. Вызывать через Point.objects.
|
||||
|
||||
Точка без сервера панели не существует, поэтому панель обязательна. Имя точки и доступ к панели генерируются.
|
||||
"""
|
||||
low, high = PANEL_PORT_RANGE
|
||||
with transaction.atomic():
|
||||
point = self.create(
|
||||
owner=owner,
|
||||
name=self._unique_name(),
|
||||
domain=domain,
|
||||
cloudflare=cloudflare,
|
||||
outbound_proxy=outbound_proxy,
|
||||
panel_username=generate_password(12),
|
||||
panel_password=generate_password(),
|
||||
panel_port=low + secrets.randbelow(high - low),
|
||||
panel_path=generate_password(18),
|
||||
)
|
||||
Server.objects.create_server(point, Server.Role.PANEL, panel)
|
||||
if mask:
|
||||
Server.objects.create_server(point, Server.Role.MASK, mask)
|
||||
return point
|
||||
|
||||
def _unique_name(self) -> str:
|
||||
while True:
|
||||
name = f'point-{secrets.token_hex(3)}'
|
||||
if not self.is_name_taken(name):
|
||||
return name
|
||||
|
||||
|
||||
class Point(models.Model):
|
||||
"""Точка доступа: сервер с панелью 3x-ui и, по желанию, сервер маскировки перед ним.
|
||||
|
||||
С маскировкой клиенты подключаются к серверу маскировки, он перенаправляет трафик на панель и скрывает ее адрес.
|
||||
|
||||
Создание: Point.objects.create_point. Поиск: методы PointQuerySet. Удаление: point.delete() удаляет и серверы.
|
||||
"""
|
||||
|
||||
CF_WARP = 'cf_warp'
|
||||
@@ -83,6 +155,8 @@ class Point(models.Model):
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
objects = PointManager()
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'точка доступа'
|
||||
verbose_name_plural = 'точки доступа'
|
||||
@@ -94,16 +168,27 @@ class Point(models.Model):
|
||||
def is_busy(self) -> bool:
|
||||
return self.status in (self.Status.INSTALLING, self.Status.RELEASING)
|
||||
|
||||
def server(self, role: str) -> 'Server | None':
|
||||
def set_status(self, status: str, error: str = ''):
|
||||
self.status = status
|
||||
self.error = error
|
||||
self.save(update_fields=['status', 'error'])
|
||||
|
||||
def server(self, role: str) -> 'Server':
|
||||
"""Сервер с ролью role. Бросает Server.DoesNotExist, если его нет."""
|
||||
return self.servers.get(role=role)
|
||||
|
||||
def find_server(self, role: str) -> 'Server | None':
|
||||
return self.servers.filter(role=role).first()
|
||||
|
||||
@property
|
||||
def panel(self) -> 'Server':
|
||||
"""Сервер панели. Есть у каждой точки: create_point создает его вместе с точкой."""
|
||||
return self.server(Server.Role.PANEL)
|
||||
|
||||
@property
|
||||
def mask(self) -> 'Server | None':
|
||||
return self.server(Server.Role.MASK)
|
||||
"""Сервер маскировки, если он есть."""
|
||||
return self.find_server(Server.Role.MASK)
|
||||
|
||||
@property
|
||||
def entry(self) -> 'Server':
|
||||
@@ -111,8 +196,32 @@ class Point(models.Model):
|
||||
return self.mask or self.panel
|
||||
|
||||
|
||||
class ServerQuerySet(models.QuerySet):
|
||||
def find_by_address(self, address: str) -> 'Server | None':
|
||||
return self.filter(address=address).first()
|
||||
|
||||
def is_address_taken(self, address: str) -> bool:
|
||||
return self.filter(address=address).exists()
|
||||
|
||||
|
||||
class ServerManager(models.Manager.from_queryset(ServerQuerySet)):
|
||||
def create_server(self, point: Point, role: str, credentials: Credentials) -> 'Server':
|
||||
"""Добавляет сервер в точку. Вызывать через Server.objects. Присланный пароль запоминается как пароль по умолчанию."""
|
||||
return self.create(
|
||||
point=point,
|
||||
role=role,
|
||||
address=credentials.address,
|
||||
root_password=credentials.password,
|
||||
default_root_password=credentials.password,
|
||||
)
|
||||
|
||||
|
||||
class Server(models.Model):
|
||||
"""Сервер под управлением системы. Поля ssh отражают текущее состояние сервера."""
|
||||
"""Сервер под управлением системы. Поля ssh отражают текущее состояние сервера.
|
||||
|
||||
Создание: Server.objects.create_server, обычно в составе Point.objects.create_point. Поиск: методы ServerQuerySet,
|
||||
в том числе в пределах точки: point.servers.find_by_address(address).
|
||||
"""
|
||||
|
||||
class Role(models.TextChoices):
|
||||
PANEL = 'panel', 'панель'
|
||||
@@ -133,6 +242,8 @@ class Server(models.Model):
|
||||
known_hosts = models.TextField(blank=True, help_text='Отпечаток ssh сервера. Сбрасывается после переустановки ОС')
|
||||
uname = models.CharField(max_length=512, blank=True)
|
||||
|
||||
objects = ServerManager()
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'сервер'
|
||||
verbose_name_plural = 'серверы'
|
||||
|
||||
Reference in new issue
Block a user