chore: улучшен интерфейс работы с моделями
This commit is contained in:
1 parent
6cfb00a847
commit
adab286884
6 files changed
+175
-89
No files matched your search
+10
-1
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in new issue
Block a user