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`. - `tasks.py` - задачи dramatiq: запускают сценарий и присылают результат в чат. Воркер: `make dj_worker`.
- `bot/interface.py` - интерфейс бота `MyPointInterface` (выбирается у бота в админке). - `bot/interface.py` - интерфейс бота `MyPointInterface` (выбирается у бота в админке).
- `bot/texts.py` - кнопки и тексты. - `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 Telegram.interfaces import Interface, command, register, state, text
from .. import services, tasks from .. import services, tasks
from ..models import Point, Server from ..models import Credentials, Point, Server
from . import texts from . import texts
# состояния диалога # состояния диалога
@@ -26,14 +26,14 @@ PROXY_SCHEMES = ('http', 'https', 'socks5')
HTML = 'HTML' HTML = 'HTML'
def parse_credentials(value: str) -> services.Credentials | None: def parse_credentials(value: str) -> Credentials | None:
"""'ip:password' -> Credentials. Пароль может содержать двоеточия.""" """'ip:password' -> Credentials. Пароль может содержать двоеточия."""
address, _, password = value.strip().partition(':') address, _, password = value.strip().partition(':')
try: try:
address = str(ipaddress.ip_address(address)) address = str(ipaddress.ip_address(address))
except ValueError: except ValueError:
return None 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: def is_domain(value: str) -> bool:
@@ -58,7 +58,7 @@ class MyPointInterface(Interface):
@text(texts.MAIN_MENU, texts.CANCEL) @text(texts.MAIN_MENU, texts.CANCEL)
def main_menu(self, ctx): def main_menu(self, ctx):
ctx.reset() ctx.reset()
points = list(ctx.user.points.order_by('created_at')) points = list(Point.objects.owned_by(ctx.user))
if points: if points:
ctx.reply(texts.points_list(points), keyboard=texts.MAIN_KEYBOARD_WITH_POINTS, parse_mode=HTML) ctx.reply(texts.points_list(points), keyboard=texts.MAIN_KEYBOARD_WITH_POINTS, parse_mode=HTML)
else: else:
@@ -136,7 +136,7 @@ class MyPointInterface(Interface):
other = draft.get('mask' if key == 'server' else 'server', {}) other = draft.get('mask' if key == 'server' else 'server', {})
if other.get('address') == credentials.address: if other.get('address') == credentials.address:
return ctx.reply(texts.ADDRESS_DUPLICATE) 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)) return ctx.reply(texts.ADDRESS_TAKEN.format(address=credentials.address))
draft[key] = {'address': credentials.address, 'password': credentials.password} draft[key] = {'address': credentials.address, 'password': credentials.password}
self.show_draft(ctx) self.show_draft(ctx)
@@ -195,10 +195,10 @@ class MyPointInterface(Interface):
return ctx.reply(texts.MASK_WITHOUT_DOMAIN) return ctx.reply(texts.MASK_WITHOUT_DOMAIN)
if draft.get('cloudflare') and not draft.get('domain'): if draft.get('cloudflare') and not draft.get('domain'):
return ctx.reply(texts.CLOUDFLARE_WITHOUT_DOMAIN) return ctx.reply(texts.CLOUDFLARE_WITHOUT_DOMAIN)
point = services.create_point( point = Point.objects.create_point(
ctx.user, ctx.user,
panel=services.Credentials(**draft['server']), panel=Credentials(**draft['server']),
mask=services.Credentials(**draft['mask']) if draft.get('mask') else None, mask=Credentials(**draft['mask']) if draft.get('mask') else None,
domain=draft.get('domain', ''), domain=draft.get('domain', ''),
cloudflare=draft.get('cloudflare', False), cloudflare=draft.get('cloudflare', False),
outbound_proxy=draft.get('proxy', ''), outbound_proxy=draft.get('proxy', ''),
@@ -212,12 +212,12 @@ class MyPointInterface(Interface):
@text(texts.MANAGE) @text(texts.MANAGE)
def ask_point(self, ctx): def ask_point(self, ctx):
ctx.reset() 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)}') self.ask(ctx, SELECT, f'{texts.SELECT_PROMPT}\n{texts.points_list(points)}')
@state(SELECT) @state(SELECT)
def select_point(self, ctx): 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: if point is None:
return ctx.reply(texts.POINT_NOT_FOUND) return ctx.reply(texts.POINT_NOT_FOUND)
self.show_point(ctx, point) self.show_point(ctx, point)
@@ -225,14 +225,14 @@ class MyPointInterface(Interface):
@text(texts.SERVER_MENU) @text(texts.SERVER_MENU)
def server_menu(self, ctx): 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: if point is None:
return self.main_menu(ctx) return self.main_menu(ctx)
self.show_point(ctx, point) self.show_point(ctx, point)
def current_point(self, ctx) -> Point | None: def current_point(self, ctx) -> Point | None:
point_id = ctx.data.get('point') 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): def show_point(self, ctx, point: Point):
ctx.reset() ctx.reset()
@@ -260,8 +260,7 @@ class MyPointInterface(Interface):
point = self.idle_point(ctx) point = self.idle_point(ctx)
if point is None: if point is None:
return return
point.status = Point.Status.RELEASING point.set_status(Point.Status.RELEASING)
point.save(update_fields=['status'])
tasks.release_point.send(point.pk, ctx.chat.pk) tasks.release_point.send(point.pk, ctx.chat.pk)
ctx.reset() ctx.reset()
ctx.reply(texts.RELEASE_STARTED, keyboard=texts.MAIN_KEYBOARD_WITH_POINTS) ctx.reply(texts.RELEASE_STARTED, keyboard=texts.MAIN_KEYBOARD_WITH_POINTS)
@@ -280,7 +279,7 @@ class MyPointInterface(Interface):
point = self.idle_point(ctx) point = self.idle_point(ctx)
if point is None: if point is None:
return 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)) return ctx.reply(texts.REINSTALL_UNKNOWN_ADDRESS.format(address=credentials.address))
self.start_reinstall(ctx, point, credentials.address, credentials.password) self.start_reinstall(ctx, point, credentials.address, credentials.password)
@@ -291,8 +290,7 @@ class MyPointInterface(Interface):
self.start_reinstall(ctx, point) self.start_reinstall(ctx, point)
def start_reinstall(self, ctx, point: Point, address: str | None = None, password: str | None = None): def start_reinstall(self, ctx, point: Point, address: str | None = None, password: str | None = None):
point.status = Point.Status.INSTALLING point.set_status(Point.Status.INSTALLING)
point.save(update_fields=['status'])
tasks.reinstall_point.send(point.pk, ctx.chat.pk, address, password) tasks.reinstall_point.send(point.pk, ctx.chat.pk, address, password)
ctx.reset() ctx.reset()
ctx.reply(texts.REINSTALL_STARTED, keyboard=texts.MAIN_KEYBOARD_WITH_POINTS) 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.base_user import BaseUserManager
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.db import models from django.db import models, transaction
from serverus import Host from serverus import Host, generate_password
PANEL_PORT_RANGE = (10000, 20000)
"""Не пересекается с диапазоном ssh портов serverus."""
class UserManager(BaseUserManager): class UserManager(BaseUserManager):
@@ -50,10 +56,76 @@ class User(AbstractUser):
raise NotImplementedError('User has no email') 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): class Point(models.Model):
"""Точка доступа: сервер с панелью 3x-ui и, по желанию, сервер маскировки перед ним. """Точка доступа: сервер с панелью 3x-ui и, по желанию, сервер маскировки перед ним.
С маскировкой клиенты подключаются к серверу маскировки, он перенаправляет трафик на панель и скрывает ее адрес. С маскировкой клиенты подключаются к серверу маскировки, он перенаправляет трафик на панель и скрывает ее адрес.
Создание: Point.objects.create_point. Поиск: методы PointQuerySet. Удаление: point.delete() удаляет и серверы.
""" """
CF_WARP = 'cf_warp' CF_WARP = 'cf_warp'
@@ -83,6 +155,8 @@ class Point(models.Model):
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
objects = PointManager()
class Meta: class Meta:
verbose_name = 'точка доступа' verbose_name = 'точка доступа'
verbose_name_plural = 'точки доступа' verbose_name_plural = 'точки доступа'
@@ -94,16 +168,27 @@ class Point(models.Model):
def is_busy(self) -> bool: def is_busy(self) -> bool:
return self.status in (self.Status.INSTALLING, self.Status.RELEASING) 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() return self.servers.filter(role=role).first()
@property @property
def panel(self) -> 'Server': def panel(self) -> 'Server':
"""Сервер панели. Есть у каждой точки: create_point создает его вместе с точкой."""
return self.server(Server.Role.PANEL) return self.server(Server.Role.PANEL)
@property @property
def mask(self) -> 'Server | None': def mask(self) -> 'Server | None':
return self.server(Server.Role.MASK) """Сервер маскировки, если он есть."""
return self.find_server(Server.Role.MASK)
@property @property
def entry(self) -> 'Server': def entry(self) -> 'Server':
@@ -111,8 +196,32 @@ class Point(models.Model):
return self.mask or self.panel 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): 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): class Role(models.TextChoices):
PANEL = 'panel', 'панель' PANEL = 'panel', 'панель'
@@ -133,6 +242,8 @@ class Server(models.Model):
known_hosts = models.TextField(blank=True, help_text='Отпечаток ssh сервера. Сбрасывается после переустановки ОС') known_hosts = models.TextField(blank=True, help_text='Отпечаток ssh сервера. Сбрасывается после переустановки ОС')
uname = models.CharField(max_length=512, blank=True) uname = models.CharField(max_length=512, blank=True)
objects = ServerManager()
class Meta: class Meta:
verbose_name = 'сервер' verbose_name = 'сервер'
verbose_name_plural = 'серверы' verbose_name_plural = 'серверы'
+5 -58
View File
@@ -1,56 +1,15 @@
"""Сценарии точки доступа поверх serverus: установка, переустановка и возврат серверов.""" """Сценарии точки доступа поверх serverus: установка, переустановка и возврат серверов."""
import secrets
from contextlib import contextmanager from contextlib import contextmanager
from dataclasses import dataclass
import serverus import serverus
from django.db import transaction
from .models import Point, Server from .models import Point, Server
PANEL_PORT_RANGE = (10000, 20000)
"""Не пересекается с диапазоном ssh портов serverus."""
class ProvisionError(Exception): class ProvisionError(Exception):
pass 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): def install_point(point: Point):
"""Настраивает серверы точки. Порядок не важен: маскировке нужен только адрес панели.""" """Настраивает серверы точки. Порядок не важен: маскировке нужен только адрес панели."""
with _installing(point): with _installing(point):
@@ -64,7 +23,7 @@ def reinstall(point: Point, address: str | None = None, password: str | None = N
address - переустановленный сервер точки, без него переустанавливаются все. address - переустановленный сервер точки, без него переустанавливаются все.
password - новый пароль root от провайдера, без него используется пароль, присланный при добавлении сервера. 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): with _installing(point):
for server in servers: for server in servers:
server.known_hosts = '' server.known_hosts = ''
@@ -90,7 +49,7 @@ def release_point(point: Point) -> tuple[dict[str, str], dict[str, str]]:
else: else:
errors[server.address] = result.error errors[server.address] = result.error
if errors: 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: else:
point.delete() point.delete()
return passwords, errors return passwords, errors
@@ -150,23 +109,11 @@ def _check(server: Server, result: serverus.Result) -> serverus.Result:
@contextmanager @contextmanager
def _installing(point: Point): def _installing(point: Point):
"""Статус INSTALLING на время блока, затем READY или ERROR с текстом ошибки.""" """Статус INSTALLING на время блока, затем READY или ERROR с текстом ошибки."""
_set_status(point, Point.Status.INSTALLING) point.set_status(Point.Status.INSTALLING)
try: try:
yield yield
except Exception as error: except Exception as error:
_set_status(point, Point.Status.ERROR, str(error)) point.set_status(Point.Status.ERROR, str(error))
raise 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() super().setUp()
self.say('/start') self.say('/start')
self.point = create_point(TelegramUser.objects.get()) self.point = create_point(TelegramUser.objects.get())
self.point.status = Point.Status.READY self.point.set_status(Point.Status.READY)
self.point.save()
def open_point(self): def open_point(self):
self.say(texts.MANAGE) self.say(texts.MANAGE)
@@ -175,8 +174,7 @@ class PointMenuTests(BotTestCase):
@mock.patch.object(tasks.release_point, 'send') @mock.patch.object(tasks.release_point, 'send')
def test_busy_point(self, send): def test_busy_point(self, send):
self.point.status = Point.Status.INSTALLING self.point.set_status(Point.Status.INSTALLING)
self.point.save()
self.open_point() self.open_point()
self.assertEqual(self.say(texts.RELEASE), texts.POINT_BUSY.format(status='устанавливается')) self.assertEqual(self.say(texts.RELEASE), texts.POINT_BUSY.format(status='устанавливается'))
send.assert_not_called() 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 Telegram.models import User as TelegramUser
from . import services from . import services
from .models import Point, Server from .models import PANEL_PORT_RANGE, Credentials, Point, Server
class FakeRunner: class FakeRunner:
@@ -34,12 +34,12 @@ def patch_runner(runner):
return mock.patch('serverus.runner.run_playbook', 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): def create_point(owner=None, mask=MASK, **kwargs):
owner = owner or TelegramUser.objects.create(tg_id=1) 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): class CreatePointTests(TestCase):
@@ -48,7 +48,7 @@ class CreatePointTests(TestCase):
self.assertEqual(point.status, Point.Status.INSTALLING) 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.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')) 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(low <= point.panel_port < high)
self.assertTrue(point.panel_username and point.panel_password and point.panel_path) self.assertTrue(point.panel_username and point.panel_password and point.panel_path)
self.assertEqual(point.panel.default_root_password, 'panel-pw') 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(list(point.servers.all()), [point.panel])
self.assertEqual(point.entry, 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): class InstallTests(TestCase):
def test_install_point(self): def test_install_point(self):