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

+10 -1
View File
@@ -16,9 +16,18 @@ Django приложение связывающее все части сайта
С маскировкой клиенты подключаются к серверу маскировки, он перенаправляет трафик на панель и скрывает ее адрес. Маскировка требует домен.
Работа с записями идет через менеджеры и методы моделей, а не через `filter`/`create` напрямую:
- создание: `Point.objects.create_point(owner, panel, mask=None, *, domain, cloudflare, outbound_proxy)` создает точку вместе с серверами, имя и доступ к панели генерирует сама. `Server.objects.create_server(point, role, credentials)`. `Credentials` - адрес и пароль root, которые прислал пользователь
- поиск: `Point.objects.owned_by(owner)`, дальше `find_by_name`, `find_by_pk`, `find_last`. У серверов `find_by_address` и `is_address_taken`, в том числе в пределах точки через `point.servers`
- изменение: `point.set_status(status, error)`, `server.reset_fingerprint()`
- удаление: `point.delete()`, серверы удаляются вместе с точкой
Соглашение для поиска: `get_*` и `point.server(role)` бросают `DoesNotExist`, если записи нет, а `find_*` возвращают `None`, и это видно по типу. `point.panel` всегда есть, потому что панель создается вместе с точкой, а `point.mask` может быть `None`.
## Модули
- `services.py` - сценарии поверх serverus: `create_point`, `install_point`, `reinstall`, `release_point`.
- `services.py` - сценарии поверх serverus: `install_point`, `reinstall`, `release_point`.
- `tasks.py` - задачи dramatiq: запускают сценарий и присылают результат в чат. Воркер: `make dj_worker`.
- `bot/interface.py` - интерфейс бота `MyPointInterface` (выбирается у бота в админке).
- `bot/texts.py` - кнопки и тексты.
+15 -17
View File
@@ -5,7 +5,7 @@ from urllib.parse import urlsplit
from Telegram.interfaces import Interface, command, register, state, text
from .. import services, tasks
from ..models import Point, Server
from ..models import Credentials, Point, Server
from . import texts
# состояния диалога
@@ -26,14 +26,14 @@ PROXY_SCHEMES = ('http', 'https', 'socks5')
HTML = 'HTML'
def parse_credentials(value: str) -> services.Credentials | None:
def parse_credentials(value: str) -> Credentials | None:
"""'ip:password' -> Credentials. Пароль может содержать двоеточия."""
address, _, password = value.strip().partition(':')
try:
address = str(ipaddress.ip_address(address))
except ValueError:
return None
return services.Credentials(address, password) if password else None
return Credentials(address, password) if password else None
def is_domain(value: str) -> bool:
@@ -58,7 +58,7 @@ class MyPointInterface(Interface):
@text(texts.MAIN_MENU, texts.CANCEL)
def main_menu(self, ctx):
ctx.reset()
points = list(ctx.user.points.order_by('created_at'))
points = list(Point.objects.owned_by(ctx.user))
if points:
ctx.reply(texts.points_list(points), keyboard=texts.MAIN_KEYBOARD_WITH_POINTS, parse_mode=HTML)
else:
@@ -136,7 +136,7 @@ class MyPointInterface(Interface):
other = draft.get('mask' if key == 'server' else 'server', {})
if other.get('address') == credentials.address:
return ctx.reply(texts.ADDRESS_DUPLICATE)
if Server.objects.filter(address=credentials.address).exists():
if Server.objects.is_address_taken(credentials.address):
return ctx.reply(texts.ADDRESS_TAKEN.format(address=credentials.address))
draft[key] = {'address': credentials.address, 'password': credentials.password}
self.show_draft(ctx)
@@ -195,10 +195,10 @@ class MyPointInterface(Interface):
return ctx.reply(texts.MASK_WITHOUT_DOMAIN)
if draft.get('cloudflare') and not draft.get('domain'):
return ctx.reply(texts.CLOUDFLARE_WITHOUT_DOMAIN)
point = services.create_point(
point = Point.objects.create_point(
ctx.user,
panel=services.Credentials(**draft['server']),
mask=services.Credentials(**draft['mask']) if draft.get('mask') else None,
panel=Credentials(**draft['server']),
mask=Credentials(**draft['mask']) if draft.get('mask') else None,
domain=draft.get('domain', ''),
cloudflare=draft.get('cloudflare', False),
outbound_proxy=draft.get('proxy', ''),
@@ -212,12 +212,12 @@ class MyPointInterface(Interface):
@text(texts.MANAGE)
def ask_point(self, ctx):
ctx.reset()
points = ctx.user.points.order_by('created_at')
points = Point.objects.owned_by(ctx.user)
self.ask(ctx, SELECT, f'{texts.SELECT_PROMPT}\n{texts.points_list(points)}')
@state(SELECT)
def select_point(self, ctx):
point = ctx.user.points.filter(name=ctx.text.strip()).first()
point = Point.objects.owned_by(ctx.user).find_by_name(ctx.text.strip())
if point is None:
return ctx.reply(texts.POINT_NOT_FOUND)
self.show_point(ctx, point)
@@ -225,14 +225,14 @@ class MyPointInterface(Interface):
@text(texts.SERVER_MENU)
def server_menu(self, ctx):
"""Кнопка из уведомления: последняя открытая точка или последняя созданная."""
point = self.current_point(ctx) or ctx.user.points.order_by('-created_at').first()
point = self.current_point(ctx) or Point.objects.owned_by(ctx.user).find_last()
if point is None:
return self.main_menu(ctx)
self.show_point(ctx, point)
def current_point(self, ctx) -> Point | None:
point_id = ctx.data.get('point')
return ctx.user.points.filter(pk=point_id).first() if point_id else None
return Point.objects.owned_by(ctx.user).find_by_pk(point_id) if point_id else None
def show_point(self, ctx, point: Point):
ctx.reset()
@@ -260,8 +260,7 @@ class MyPointInterface(Interface):
point = self.idle_point(ctx)
if point is None:
return
point.status = Point.Status.RELEASING
point.save(update_fields=['status'])
point.set_status(Point.Status.RELEASING)
tasks.release_point.send(point.pk, ctx.chat.pk)
ctx.reset()
ctx.reply(texts.RELEASE_STARTED, keyboard=texts.MAIN_KEYBOARD_WITH_POINTS)
@@ -280,7 +279,7 @@ class MyPointInterface(Interface):
point = self.idle_point(ctx)
if point is None:
return
if not point.servers.filter(address=credentials.address).exists():
if point.servers.find_by_address(credentials.address) is None:
return ctx.reply(texts.REINSTALL_UNKNOWN_ADDRESS.format(address=credentials.address))
self.start_reinstall(ctx, point, credentials.address, credentials.password)
@@ -291,8 +290,7 @@ class MyPointInterface(Interface):
self.start_reinstall(ctx, point)
def start_reinstall(self, ctx, point: Point, address: str | None = None, password: str | None = None):
point.status = Point.Status.INSTALLING
point.save(update_fields=['status'])
point.set_status(Point.Status.INSTALLING)
tasks.reinstall_point.send(point.pk, ctx.chat.pk, address, password)
ctx.reset()
ctx.reply(texts.REINSTALL_STARTED, keyboard=texts.MAIN_KEYBOARD_WITH_POINTS)
+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 = 'серверы'
+5 -58
View File
@@ -1,56 +1,15 @@
"""Сценарии точки доступа поверх serverus: установка, переустановка и возврат серверов."""
import secrets
from contextlib import contextmanager
from dataclasses import dataclass
import serverus
from django.db import transaction
from .models import Point, Server
PANEL_PORT_RANGE = (10000, 20000)
"""Не пересекается с диапазоном ssh портов serverus."""
class ProvisionError(Exception):
pass
@dataclass(frozen=True)
class Credentials:
address: str
password: str
def create_point(
owner, panel: Credentials, mask: Credentials | None = None, domain: str = '', cloudflare: bool = False,
outbound_proxy: str = '',
) -> Point:
"""Создает точку. mask - необязательный сервер маскировки."""
low, high = PANEL_PORT_RANGE
with transaction.atomic():
point = Point.objects.create(
owner=owner,
name=_unique_name(),
domain=domain,
cloudflare=cloudflare,
outbound_proxy=outbound_proxy,
panel_username=serverus.generate_password(12),
panel_password=serverus.generate_password(),
panel_port=low + secrets.randbelow(high - low),
panel_path=serverus.generate_password(18),
)
for role, credentials in ((Server.Role.PANEL, panel), (Server.Role.MASK, mask)):
if credentials:
point.servers.create(
role=role,
address=credentials.address,
root_password=credentials.password,
default_root_password=credentials.password,
)
return point
def install_point(point: Point):
"""Настраивает серверы точки. Порядок не важен: маскировке нужен только адрес панели."""
with _installing(point):
@@ -64,7 +23,7 @@ def reinstall(point: Point, address: str | None = None, password: str | None = N
address - переустановленный сервер точки, без него переустанавливаются все.
password - новый пароль root от провайдера, без него используется пароль, присланный при добавлении сервера.
"""
servers = point.servers.filter(address=address) if address else point.servers.all()
servers = [point.servers.get(address=address)] if address else point.servers.all()
with _installing(point):
for server in servers:
server.known_hosts = ''
@@ -90,7 +49,7 @@ def release_point(point: Point) -> tuple[dict[str, str], dict[str, str]]:
else:
errors[server.address] = result.error
if errors:
_set_status(point, Point.Status.ERROR, '\n'.join(f'{address}: {error}' for address, error in errors.items()))
point.set_status(Point.Status.ERROR, '\n'.join(f'{address}: {error}' for address, error in errors.items()))
else:
point.delete()
return passwords, errors
@@ -150,23 +109,11 @@ def _check(server: Server, result: serverus.Result) -> serverus.Result:
@contextmanager
def _installing(point: Point):
"""Статус INSTALLING на время блока, затем READY или ERROR с текстом ошибки."""
_set_status(point, Point.Status.INSTALLING)
point.set_status(Point.Status.INSTALLING)
try:
yield
except Exception as error:
_set_status(point, Point.Status.ERROR, str(error))
point.set_status(Point.Status.ERROR, str(error))
raise
_set_status(point, Point.Status.READY)
point.set_status(Point.Status.READY)
def _set_status(point: Point, status: str, error: str = ''):
point.status = status
point.error = error
point.save(update_fields=['status', 'error'])
def _unique_name() -> str:
while True:
name = f'point-{secrets.token_hex(3)}'
if not Point.objects.filter(name=name).exists():
return name
+2 -4
View File
@@ -143,8 +143,7 @@ class PointMenuTests(BotTestCase):
super().setUp()
self.say('/start')
self.point = create_point(TelegramUser.objects.get())
self.point.status = Point.Status.READY
self.point.save()
self.point.set_status(Point.Status.READY)
def open_point(self):
self.say(texts.MANAGE)
@@ -175,8 +174,7 @@ class PointMenuTests(BotTestCase):
@mock.patch.object(tasks.release_point, 'send')
def test_busy_point(self, send):
self.point.status = Point.Status.INSTALLING
self.point.save()
self.point.set_status(Point.Status.INSTALLING)
self.open_point()
self.assertEqual(self.say(texts.RELEASE), texts.POINT_BUSY.format(status='устанавливается'))
send.assert_not_called()
+27 -4
View File
@@ -5,7 +5,7 @@ from django.test import TestCase
from Telegram.models import User as TelegramUser
from . import services
from .models import Point, Server
from .models import PANEL_PORT_RANGE, Credentials, Point, Server
class FakeRunner:
@@ -34,12 +34,12 @@ def patch_runner(runner):
return mock.patch('serverus.runner.run_playbook', runner)
MASK = services.Credentials('10.0.0.2', 'mask-pw')
MASK = Credentials('10.0.0.2', 'mask-pw')
def create_point(owner=None, mask=MASK, **kwargs):
owner = owner or TelegramUser.objects.create(tg_id=1)
return services.create_point(owner, services.Credentials('10.0.0.1', 'panel-pw'), mask, **kwargs)
return Point.objects.create_point(owner, Credentials('10.0.0.1', 'panel-pw'), mask, **kwargs)
class CreatePointTests(TestCase):
@@ -48,7 +48,7 @@ class CreatePointTests(TestCase):
self.assertEqual(point.status, Point.Status.INSTALLING)
self.assertEqual((point.panel.address, point.panel.root_password), ('10.0.0.1', 'panel-pw'))
self.assertEqual((point.mask.address, point.mask.root_password), ('10.0.0.2', 'mask-pw'))
low, high = services.PANEL_PORT_RANGE
low, high = PANEL_PORT_RANGE
self.assertTrue(low <= point.panel_port < high)
self.assertTrue(point.panel_username and point.panel_password and point.panel_path)
self.assertEqual(point.panel.default_root_password, 'panel-pw')
@@ -60,6 +60,29 @@ class CreatePointTests(TestCase):
self.assertEqual(list(point.servers.all()), [point.panel])
self.assertEqual(point.entry, point.panel)
def test_panel_is_required(self):
point = create_point(mask=None)
point.panel.delete()
with self.assertRaises(Server.DoesNotExist):
point.panel
self.assertIsNone(point.find_server(Server.Role.PANEL))
def test_search(self):
point = create_point()
owner = point.owner
stranger = TelegramUser.objects.create(tg_id=2)
self.assertEqual(Point.objects.owned_by(owner).find_by_name(point.name), point)
self.assertIsNone(Point.objects.owned_by(stranger).find_by_name(point.name))
self.assertIsNone(Point.objects.owned_by(stranger).find_by_pk(point.pk))
self.assertEqual(Point.objects.owned_by(owner).find_last(), point)
self.assertTrue(Server.objects.is_address_taken('10.0.0.2'))
self.assertEqual(point.servers.find_by_address('10.0.0.2'), point.mask)
self.assertIsNone(point.servers.find_by_address('10.0.0.3'))
def test_delete_removes_servers(self):
create_point().delete()
self.assertFalse(Server.objects.exists())
class InstallTests(TestCase):
def test_install_point(self):