chore: улучшен интерфейс работы с моделями

This commit is contained in:
protokey committed 2026-09-25 16:24:03 +04:00
1 parent 6cfb00a847
commit adab286884
6 files changed
+175 -89

No files matched your search

+116 -5
View File
@@ -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 = 'серверы'