mirror of
https://github.com/netbox-community/netbox.git
synced 2025-12-14 12:29:35 -06:00
Initial work on FR #20788 (cable profiles)
This commit is contained in:
parent
cee2a5e0ed
commit
0901694b2b
23
netbox/circuits/migrations/0054_cable_position.py
Normal file
23
netbox/circuits/migrations/0054_cable_position.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import django.core.validators
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
('circuits', '0053_owner'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='circuittermination',
|
||||||
|
name='cable_position',
|
||||||
|
field=models.PositiveIntegerField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
validators=[
|
||||||
|
django.core.validators.MinValueValidator(1),
|
||||||
|
django.core.validators.MaxValueValidator(1024),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -25,15 +25,16 @@ class CableSerializer(PrimaryModelSerializer):
|
|||||||
a_terminations = GenericObjectSerializer(many=True, required=False)
|
a_terminations = GenericObjectSerializer(many=True, required=False)
|
||||||
b_terminations = GenericObjectSerializer(many=True, required=False)
|
b_terminations = GenericObjectSerializer(many=True, required=False)
|
||||||
status = ChoiceField(choices=LinkStatusChoices, required=False)
|
status = ChoiceField(choices=LinkStatusChoices, required=False)
|
||||||
|
profile = ChoiceField(choices=CableProfileChoices, required=False)
|
||||||
tenant = TenantSerializer(nested=True, required=False, allow_null=True)
|
tenant = TenantSerializer(nested=True, required=False, allow_null=True)
|
||||||
length_unit = ChoiceField(choices=CableLengthUnitChoices, allow_blank=True, required=False, allow_null=True)
|
length_unit = ChoiceField(choices=CableLengthUnitChoices, allow_blank=True, required=False, allow_null=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Cable
|
model = Cable
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'url', 'display_url', 'display', 'type', 'a_terminations', 'b_terminations', 'status', 'tenant',
|
'id', 'url', 'display_url', 'display', 'type', 'a_terminations', 'b_terminations', 'status', 'profile',
|
||||||
'label', 'color', 'length', 'length_unit', 'description', 'owner', 'comments', 'tags', 'custom_fields',
|
'tenant', 'label', 'color', 'length', 'length_unit', 'description', 'owner', 'comments', 'tags',
|
||||||
'created', 'last_updated',
|
'custom_fields', 'created', 'last_updated',
|
||||||
]
|
]
|
||||||
brief_fields = ('id', 'url', 'display', 'label', 'description')
|
brief_fields = ('id', 'url', 'display', 'label', 'description')
|
||||||
|
|
||||||
@ -60,10 +61,12 @@ class CableTerminationSerializer(NetBoxModelSerializer):
|
|||||||
model = CableTermination
|
model = CableTermination
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'url', 'display', 'cable', 'cable_end', 'termination_type', 'termination_id',
|
'id', 'url', 'display', 'cable', 'cable_end', 'termination_type', 'termination_id',
|
||||||
'termination', 'created', 'last_updated',
|
'termination', 'position', 'created', 'last_updated',
|
||||||
]
|
]
|
||||||
read_only_fields = fields
|
read_only_fields = fields
|
||||||
brief_fields = ('id', 'url', 'display', 'cable', 'cable_end', 'termination_type', 'termination_id')
|
brief_fields = (
|
||||||
|
'id', 'url', 'display', 'cable', 'cable_end', 'termination_type', 'termination_id', 'position',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CablePathSerializer(serializers.ModelSerializer):
|
class CablePathSerializer(serializers.ModelSerializer):
|
||||||
|
|||||||
118
netbox/dcim/cable_profiles.py
Normal file
118
netbox/dcim/cable_profiles.py
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from dcim.models import CableTermination
|
||||||
|
|
||||||
|
|
||||||
|
class BaseCableProfile:
|
||||||
|
# Maximum number of terminations allowed per side
|
||||||
|
a_max_connections = None
|
||||||
|
b_max_connections = None
|
||||||
|
|
||||||
|
# Number of A & B terminations must match
|
||||||
|
symmetrical = True
|
||||||
|
|
||||||
|
# Whether to pop the position stack when tracing a cable from this end
|
||||||
|
pop_stack_a_side = True
|
||||||
|
pop_stack_b_side = True
|
||||||
|
|
||||||
|
def clean(self, cable):
|
||||||
|
if self.a_max_connections and len(cable.a_terminations) > self.a_max_connections:
|
||||||
|
raise ValidationError({
|
||||||
|
'a_terminations': _(
|
||||||
|
'Maximum A side connections for profile {profile}: {max}'
|
||||||
|
).format(
|
||||||
|
profile=cable.get_profile_display(),
|
||||||
|
max=self.a_max_connections,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
if self.b_max_connections and len(cable.b_terminations) > self.b_max_connections:
|
||||||
|
raise ValidationError({
|
||||||
|
'b_terminations': _(
|
||||||
|
'Maximum B side connections for profile {profile}: {max}'
|
||||||
|
).format(
|
||||||
|
profile=cable.get_profile_display(),
|
||||||
|
max=self.a_max_connections,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
if self.symmetrical and len(cable.a_terminations) != len(cable.b_terminations):
|
||||||
|
raise ValidationError({
|
||||||
|
'b_terminations': _(
|
||||||
|
'Number of A and B terminations must be equal for profile {profile}'
|
||||||
|
).format(
|
||||||
|
profile=cable.get_profile_display(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
def get_mapped_position(self, position):
|
||||||
|
return position
|
||||||
|
|
||||||
|
def get_peer_terminations(self, terminations, position_stack):
|
||||||
|
local_end = terminations[0].cable_end
|
||||||
|
position = None
|
||||||
|
|
||||||
|
# Pop the position stack if necessary
|
||||||
|
if (local_end == 'A' and self.pop_stack_a_side) or (local_end == 'B' and self.pop_stack_b_side):
|
||||||
|
position = position_stack.pop()[0]
|
||||||
|
|
||||||
|
qs = CableTermination.objects.filter(
|
||||||
|
cable=terminations[0].cable,
|
||||||
|
cable_end=terminations[0].opposite_cable_end
|
||||||
|
)
|
||||||
|
if position is not None:
|
||||||
|
qs = qs.filter(position=self.get_mapped_position(position))
|
||||||
|
return qs
|
||||||
|
|
||||||
|
|
||||||
|
class StraightSingleCableProfile(BaseCableProfile):
|
||||||
|
a_max_connections = 1
|
||||||
|
b_max_connections = 1
|
||||||
|
|
||||||
|
|
||||||
|
class StraightMultiCableProfile(BaseCableProfile):
|
||||||
|
a_max_connections = None
|
||||||
|
b_max_connections = None
|
||||||
|
|
||||||
|
|
||||||
|
class AToManyCableProfile(BaseCableProfile):
|
||||||
|
a_max_connections = 1
|
||||||
|
b_max_connections = None
|
||||||
|
symmetrical = False
|
||||||
|
pop_stack_a_side = False
|
||||||
|
|
||||||
|
|
||||||
|
class BToManyCableProfile(BaseCableProfile):
|
||||||
|
a_max_connections = None
|
||||||
|
b_max_connections = 1
|
||||||
|
symmetrical = False
|
||||||
|
pop_stack_b_side = False
|
||||||
|
|
||||||
|
|
||||||
|
class Shuffle4x4CableProfile(BaseCableProfile):
|
||||||
|
a_max_connections = 4
|
||||||
|
b_max_connections = 4
|
||||||
|
|
||||||
|
def get_mapped_position(self, position):
|
||||||
|
return {
|
||||||
|
1: 1,
|
||||||
|
2: 3,
|
||||||
|
3: 2,
|
||||||
|
4: 4,
|
||||||
|
}.get(position)
|
||||||
|
|
||||||
|
|
||||||
|
class Shuffle8x8CableProfile(BaseCableProfile):
|
||||||
|
a_max_connections = 8
|
||||||
|
b_max_connections = 8
|
||||||
|
|
||||||
|
def get_mapped_position(self, position):
|
||||||
|
return {
|
||||||
|
1: 1,
|
||||||
|
2: 2,
|
||||||
|
3: 5,
|
||||||
|
4: 6,
|
||||||
|
5: 3,
|
||||||
|
6: 4,
|
||||||
|
7: 7,
|
||||||
|
8: 8,
|
||||||
|
}.get(position)
|
||||||
@ -1717,6 +1717,40 @@ class PortTypeChoices(ChoiceSet):
|
|||||||
# Cables/links
|
# Cables/links
|
||||||
#
|
#
|
||||||
|
|
||||||
|
class CableProfileChoices(ChoiceSet):
|
||||||
|
STRAIGHT_SINGLE = 'straight-single'
|
||||||
|
STRAIGHT_MULTI = 'straight-multi'
|
||||||
|
A_TO_MANY = 'a-to-many'
|
||||||
|
B_TO_MANY = 'b-to-many'
|
||||||
|
SHUFFLE_4X4 = 'shuffle-4x4'
|
||||||
|
SHUFFLE_8X8 = 'shuffle-8x8'
|
||||||
|
|
||||||
|
CHOICES = (
|
||||||
|
(STRAIGHT_SINGLE, _('Straight (single position)')),
|
||||||
|
(STRAIGHT_MULTI, _('Straight (multi-position)')),
|
||||||
|
# TODO: Better names for many-to-one profiles?
|
||||||
|
(A_TO_MANY, _('A to many')),
|
||||||
|
(B_TO_MANY, _('B to many')),
|
||||||
|
(SHUFFLE_4X4, _('Shuffle (4x4)')),
|
||||||
|
(SHUFFLE_8X8, _('Shuffle (8x8)')),
|
||||||
|
)
|
||||||
|
|
||||||
|
# TODO: Move these designations into the profiles
|
||||||
|
A_SIDE_NUMBERED = (
|
||||||
|
STRAIGHT_SINGLE,
|
||||||
|
STRAIGHT_MULTI,
|
||||||
|
B_TO_MANY,
|
||||||
|
SHUFFLE_4X4,
|
||||||
|
SHUFFLE_8X8,
|
||||||
|
)
|
||||||
|
B_SIDE_NUMBERED = (
|
||||||
|
STRAIGHT_SINGLE,
|
||||||
|
STRAIGHT_MULTI,
|
||||||
|
A_TO_MANY,
|
||||||
|
SHUFFLE_4X4,
|
||||||
|
SHUFFLE_8X8,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CableTypeChoices(ChoiceSet):
|
class CableTypeChoices(ChoiceSet):
|
||||||
# Copper - Twisted Pair (UTP/STP)
|
# Copper - Twisted Pair (UTP/STP)
|
||||||
|
|||||||
@ -20,6 +20,14 @@ RACK_ELEVATION_DEFAULT_MARGIN_WIDTH = 15
|
|||||||
RACK_STARTING_UNIT_DEFAULT = 1
|
RACK_STARTING_UNIT_DEFAULT = 1
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Cables
|
||||||
|
#
|
||||||
|
|
||||||
|
CABLETERMINATION_POSITION_MIN = 1
|
||||||
|
CABLETERMINATION_POSITION_MAX = 1024
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# RearPorts
|
# RearPorts
|
||||||
#
|
#
|
||||||
|
|||||||
@ -2316,6 +2316,9 @@ class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet):
|
|||||||
status = django_filters.MultipleChoiceFilter(
|
status = django_filters.MultipleChoiceFilter(
|
||||||
choices=LinkStatusChoices
|
choices=LinkStatusChoices
|
||||||
)
|
)
|
||||||
|
profile = django_filters.MultipleChoiceFilter(
|
||||||
|
choices=CableProfileChoices
|
||||||
|
)
|
||||||
color = django_filters.MultipleChoiceFilter(
|
color = django_filters.MultipleChoiceFilter(
|
||||||
choices=ColorChoices
|
choices=ColorChoices
|
||||||
)
|
)
|
||||||
|
|||||||
@ -780,6 +780,12 @@ class CableBulkEditForm(PrimaryModelBulkEditForm):
|
|||||||
required=False,
|
required=False,
|
||||||
initial=''
|
initial=''
|
||||||
)
|
)
|
||||||
|
profile = forms.ChoiceField(
|
||||||
|
label=_('Profile'),
|
||||||
|
choices=add_blank_choice(CableProfileChoices),
|
||||||
|
required=False,
|
||||||
|
initial=''
|
||||||
|
)
|
||||||
tenant = DynamicModelChoiceField(
|
tenant = DynamicModelChoiceField(
|
||||||
label=_('Tenant'),
|
label=_('Tenant'),
|
||||||
queryset=Tenant.objects.all(),
|
queryset=Tenant.objects.all(),
|
||||||
@ -808,11 +814,11 @@ class CableBulkEditForm(PrimaryModelBulkEditForm):
|
|||||||
|
|
||||||
model = Cable
|
model = Cable
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
FieldSet('type', 'status', 'tenant', 'label', 'description'),
|
FieldSet('type', 'status', 'profile', 'tenant', 'label', 'description'),
|
||||||
FieldSet('color', 'length', 'length_unit', name=_('Attributes')),
|
FieldSet('color', 'length', 'length_unit', name=_('Attributes')),
|
||||||
)
|
)
|
||||||
nullable_fields = (
|
nullable_fields = (
|
||||||
'type', 'status', 'tenant', 'label', 'color', 'length', 'description', 'comments',
|
'type', 'status', 'profile', 'tenant', 'label', 'color', 'length', 'description', 'comments',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1461,6 +1461,12 @@ class CableImportForm(PrimaryModelImportForm):
|
|||||||
required=False,
|
required=False,
|
||||||
help_text=_('Connection status')
|
help_text=_('Connection status')
|
||||||
)
|
)
|
||||||
|
profile = CSVChoiceField(
|
||||||
|
label=_('Profile'),
|
||||||
|
choices=CableProfileChoices,
|
||||||
|
required=False,
|
||||||
|
help_text=_('Cable connection profile')
|
||||||
|
)
|
||||||
type = CSVChoiceField(
|
type = CSVChoiceField(
|
||||||
label=_('Type'),
|
label=_('Type'),
|
||||||
choices=CableTypeChoices,
|
choices=CableTypeChoices,
|
||||||
@ -1491,8 +1497,8 @@ class CableImportForm(PrimaryModelImportForm):
|
|||||||
model = Cable
|
model = Cable
|
||||||
fields = [
|
fields = [
|
||||||
'side_a_site', 'side_a_device', 'side_a_type', 'side_a_name', 'side_b_site', 'side_b_device', 'side_b_type',
|
'side_a_site', 'side_a_device', 'side_a_type', 'side_a_name', 'side_b_site', 'side_b_device', 'side_b_type',
|
||||||
'side_b_name', 'type', 'status', 'tenant', 'label', 'color', 'length', 'length_unit', 'description',
|
'side_b_name', 'type', 'status', 'profile', 'tenant', 'label', 'color', 'length', 'length_unit',
|
||||||
'owner', 'comments', 'tags',
|
'description', 'owner', 'comments', 'tags',
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(self, data=None, *args, **kwargs):
|
def __init__(self, data=None, *args, **kwargs):
|
||||||
|
|||||||
@ -1119,7 +1119,7 @@ class CableFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
|
|||||||
fieldsets = (
|
fieldsets = (
|
||||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||||
FieldSet('site_id', 'location_id', 'rack_id', 'device_id', name=_('Location')),
|
FieldSet('site_id', 'location_id', 'rack_id', 'device_id', name=_('Location')),
|
||||||
FieldSet('type', 'status', 'color', 'length', 'length_unit', 'unterminated', name=_('Attributes')),
|
FieldSet('type', 'status', 'profile', 'color', 'length', 'length_unit', 'unterminated', name=_('Attributes')),
|
||||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||||
)
|
)
|
||||||
region_id = DynamicModelMultipleChoiceField(
|
region_id = DynamicModelMultipleChoiceField(
|
||||||
@ -1175,6 +1175,11 @@ class CableFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
|
|||||||
required=False,
|
required=False,
|
||||||
choices=add_blank_choice(LinkStatusChoices)
|
choices=add_blank_choice(LinkStatusChoices)
|
||||||
)
|
)
|
||||||
|
profile = forms.MultipleChoiceField(
|
||||||
|
label=_('Profile'),
|
||||||
|
required=False,
|
||||||
|
choices=add_blank_choice(CableProfileChoices)
|
||||||
|
)
|
||||||
color = ColorField(
|
color = ColorField(
|
||||||
label=_('Color'),
|
label=_('Color'),
|
||||||
required=False
|
required=False
|
||||||
|
|||||||
@ -807,8 +807,8 @@ class CableForm(TenancyForm, PrimaryModelForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Cable
|
model = Cable
|
||||||
fields = [
|
fields = [
|
||||||
'a_terminations_type', 'b_terminations_type', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color',
|
'a_terminations_type', 'b_terminations_type', 'type', 'status', 'profile', 'tenant_group', 'tenant',
|
||||||
'length', 'length_unit', 'description', 'owner', 'comments', 'tags',
|
'label', 'color', 'length', 'length_unit', 'description', 'owner', 'comments', 'tags',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,7 @@ from django.db import connection
|
|||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
|
||||||
from dcim.models import CablePath, ConsolePort, ConsoleServerPort, Interface, PowerFeed, PowerOutlet, PowerPort
|
from dcim.models import CablePath, ConsolePort, ConsoleServerPort, Interface, PowerFeed, PowerOutlet, PowerPort
|
||||||
from dcim.signals import create_cablepath
|
from dcim.signals import create_cablepaths
|
||||||
|
|
||||||
ENDPOINT_MODELS = (
|
ENDPOINT_MODELS = (
|
||||||
ConsolePort,
|
ConsolePort,
|
||||||
@ -81,7 +81,7 @@ class Command(BaseCommand):
|
|||||||
self.stdout.write(f'Retracing {origins_count} cabled {model._meta.verbose_name_plural}...')
|
self.stdout.write(f'Retracing {origins_count} cabled {model._meta.verbose_name_plural}...')
|
||||||
i = 0
|
i = 0
|
||||||
for i, obj in enumerate(origins, start=1):
|
for i, obj in enumerate(origins, start=1):
|
||||||
create_cablepath([obj])
|
create_cablepaths([obj])
|
||||||
if not i % 100:
|
if not i % 100:
|
||||||
self.draw_progress_bar(i * 100 / origins_count)
|
self.draw_progress_bar(i * 100 / origins_count)
|
||||||
self.draw_progress_bar(100)
|
self.draw_progress_bar(100)
|
||||||
|
|||||||
40
netbox/dcim/migrations/0218_cable_positions.py
Normal file
40
netbox/dcim/migrations/0218_cable_positions.py
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import django.core.validators
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('dcim', '0217_owner'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='cable',
|
||||||
|
name='profile',
|
||||||
|
field=models.CharField(blank=True, max_length=50),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='cabletermination',
|
||||||
|
name='position',
|
||||||
|
field=models.PositiveIntegerField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
validators=[
|
||||||
|
django.core.validators.MinValueValidator(1),
|
||||||
|
django.core.validators.MaxValueValidator(1024),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='cabletermination',
|
||||||
|
options={'ordering': ('cable', 'cable_end', 'position', 'pk')},
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='cabletermination',
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
fields=('cable', 'cable_end', 'position'),
|
||||||
|
name='dcim_cabletermination_unique_position'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
107
netbox/dcim/migrations/0219_cable_position.py
Normal file
107
netbox/dcim/migrations/0219_cable_position.py
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import django.core.validators
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
('dcim', '0218_cable_positions'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='consoleport',
|
||||||
|
name='cable_position',
|
||||||
|
field=models.PositiveIntegerField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
validators=[
|
||||||
|
django.core.validators.MinValueValidator(1),
|
||||||
|
django.core.validators.MaxValueValidator(1024),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='consoleserverport',
|
||||||
|
name='cable_position',
|
||||||
|
field=models.PositiveIntegerField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
validators=[
|
||||||
|
django.core.validators.MinValueValidator(1),
|
||||||
|
django.core.validators.MaxValueValidator(1024),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='frontport',
|
||||||
|
name='cable_position',
|
||||||
|
field=models.PositiveIntegerField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
validators=[
|
||||||
|
django.core.validators.MinValueValidator(1),
|
||||||
|
django.core.validators.MaxValueValidator(1024),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='interface',
|
||||||
|
name='cable_position',
|
||||||
|
field=models.PositiveIntegerField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
validators=[
|
||||||
|
django.core.validators.MinValueValidator(1),
|
||||||
|
django.core.validators.MaxValueValidator(1024),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='powerfeed',
|
||||||
|
name='cable_position',
|
||||||
|
field=models.PositiveIntegerField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
validators=[
|
||||||
|
django.core.validators.MinValueValidator(1),
|
||||||
|
django.core.validators.MaxValueValidator(1024),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='poweroutlet',
|
||||||
|
name='cable_position',
|
||||||
|
field=models.PositiveIntegerField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
validators=[
|
||||||
|
django.core.validators.MinValueValidator(1),
|
||||||
|
django.core.validators.MaxValueValidator(1024),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='powerport',
|
||||||
|
name='cable_position',
|
||||||
|
field=models.PositiveIntegerField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
validators=[
|
||||||
|
django.core.validators.MinValueValidator(1),
|
||||||
|
django.core.validators.MaxValueValidator(1024),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='rearport',
|
||||||
|
name='cable_position',
|
||||||
|
field=models.PositiveIntegerField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
validators=[
|
||||||
|
django.core.validators.MinValueValidator(1),
|
||||||
|
django.core.validators.MaxValueValidator(1024),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -3,6 +3,7 @@ import itertools
|
|||||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.dispatch import Signal
|
from django.dispatch import Signal
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
@ -54,6 +55,12 @@ class Cable(PrimaryModel):
|
|||||||
choices=LinkStatusChoices,
|
choices=LinkStatusChoices,
|
||||||
default=LinkStatusChoices.STATUS_CONNECTED
|
default=LinkStatusChoices.STATUS_CONNECTED
|
||||||
)
|
)
|
||||||
|
profile = models.CharField(
|
||||||
|
verbose_name=_('profile'),
|
||||||
|
max_length=50,
|
||||||
|
choices=CableProfileChoices,
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
tenant = models.ForeignKey(
|
tenant = models.ForeignKey(
|
||||||
to='tenancy.Tenant',
|
to='tenancy.Tenant',
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
@ -92,7 +99,7 @@ class Cable(PrimaryModel):
|
|||||||
null=True
|
null=True
|
||||||
)
|
)
|
||||||
|
|
||||||
clone_fields = ('tenant', 'type',)
|
clone_fields = ('tenant', 'type', 'profile')
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ('pk',)
|
ordering = ('pk',)
|
||||||
@ -123,6 +130,18 @@ class Cable(PrimaryModel):
|
|||||||
def get_status_color(self):
|
def get_status_color(self):
|
||||||
return LinkStatusChoices.colors.get(self.status)
|
return LinkStatusChoices.colors.get(self.status)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def profile_class(self):
|
||||||
|
from dcim import cable_profiles
|
||||||
|
return {
|
||||||
|
CableProfileChoices.STRAIGHT_SINGLE: cable_profiles.StraightSingleCableProfile,
|
||||||
|
CableProfileChoices.STRAIGHT_MULTI: cable_profiles.StraightMultiCableProfile,
|
||||||
|
CableProfileChoices.A_TO_MANY: cable_profiles.AToManyCableProfile,
|
||||||
|
CableProfileChoices.B_TO_MANY: cable_profiles.BToManyCableProfile,
|
||||||
|
CableProfileChoices.SHUFFLE_4X4: cable_profiles.Shuffle4x4CableProfile,
|
||||||
|
CableProfileChoices.SHUFFLE_8X8: cable_profiles.Shuffle8x8CableProfile,
|
||||||
|
}.get(self.profile)
|
||||||
|
|
||||||
def _get_x_terminations(self, side):
|
def _get_x_terminations(self, side):
|
||||||
"""
|
"""
|
||||||
Return the terminating objects for the given cable end (A or B).
|
Return the terminating objects for the given cable end (A or B).
|
||||||
@ -195,6 +214,10 @@ class Cable(PrimaryModel):
|
|||||||
if self._state.adding and self.pk is None and (not self.a_terminations or not self.b_terminations):
|
if self._state.adding and self.pk is None and (not self.a_terminations or not self.b_terminations):
|
||||||
raise ValidationError(_("Must define A and B terminations when creating a new cable."))
|
raise ValidationError(_("Must define A and B terminations when creating a new cable."))
|
||||||
|
|
||||||
|
# Validate terminations against the assigned cable profile (if any)
|
||||||
|
if self.profile:
|
||||||
|
self.profile_class().clean(self)
|
||||||
|
|
||||||
if self._terminations_modified:
|
if self._terminations_modified:
|
||||||
|
|
||||||
# Check that all termination objects for either end are of the same type
|
# Check that all termination objects for either end are of the same type
|
||||||
@ -315,12 +338,14 @@ class Cable(PrimaryModel):
|
|||||||
ct.delete()
|
ct.delete()
|
||||||
|
|
||||||
# Save any new CableTerminations
|
# Save any new CableTerminations
|
||||||
for termination in self.a_terminations:
|
for i, termination in enumerate(self.a_terminations, start=1):
|
||||||
if not termination.pk or termination not in a_terminations:
|
if not termination.pk or termination not in a_terminations:
|
||||||
CableTermination(cable=self, cable_end='A', termination=termination).save()
|
position = i if self.profile in CableProfileChoices.A_SIDE_NUMBERED else None
|
||||||
for termination in self.b_terminations:
|
CableTermination(cable=self, cable_end='A', position=position, termination=termination).save()
|
||||||
|
for i, termination in enumerate(self.b_terminations, start=1):
|
||||||
if not termination.pk or termination not in b_terminations:
|
if not termination.pk or termination not in b_terminations:
|
||||||
CableTermination(cable=self, cable_end='B', termination=termination).save()
|
position = i if self.profile in CableProfileChoices.B_SIDE_NUMBERED else None
|
||||||
|
CableTermination(cable=self, cable_end='B', position=position, termination=termination).save()
|
||||||
|
|
||||||
|
|
||||||
class CableTermination(ChangeLoggedModel):
|
class CableTermination(ChangeLoggedModel):
|
||||||
@ -347,6 +372,14 @@ class CableTermination(ChangeLoggedModel):
|
|||||||
ct_field='termination_type',
|
ct_field='termination_type',
|
||||||
fk_field='termination_id'
|
fk_field='termination_id'
|
||||||
)
|
)
|
||||||
|
position = models.PositiveIntegerField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
validators=(
|
||||||
|
MinValueValidator(CABLETERMINATION_POSITION_MIN),
|
||||||
|
MaxValueValidator(CABLETERMINATION_POSITION_MAX)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# Cached associations to enable efficient filtering
|
# Cached associations to enable efficient filtering
|
||||||
_device = models.ForeignKey(
|
_device = models.ForeignKey(
|
||||||
@ -377,12 +410,16 @@ class CableTermination(ChangeLoggedModel):
|
|||||||
objects = RestrictedQuerySet.as_manager()
|
objects = RestrictedQuerySet.as_manager()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ('cable', 'cable_end', 'pk')
|
ordering = ('cable', 'cable_end', 'position', 'pk')
|
||||||
constraints = (
|
constraints = (
|
||||||
models.UniqueConstraint(
|
models.UniqueConstraint(
|
||||||
fields=('termination_type', 'termination_id'),
|
fields=('termination_type', 'termination_id'),
|
||||||
name='%(app_label)s_%(class)s_unique_termination'
|
name='%(app_label)s_%(class)s_unique_termination'
|
||||||
),
|
),
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=('cable', 'cable_end', 'position'),
|
||||||
|
name='%(app_label)s_%(class)s_unique_position'
|
||||||
|
),
|
||||||
)
|
)
|
||||||
verbose_name = _('cable termination')
|
verbose_name = _('cable termination')
|
||||||
verbose_name_plural = _('cable terminations')
|
verbose_name_plural = _('cable terminations')
|
||||||
@ -446,6 +483,7 @@ class CableTermination(ChangeLoggedModel):
|
|||||||
termination.snapshot()
|
termination.snapshot()
|
||||||
termination.cable = self.cable
|
termination.cable = self.cable
|
||||||
termination.cable_end = self.cable_end
|
termination.cable_end = self.cable_end
|
||||||
|
termination.cable_position = self.position
|
||||||
termination.save()
|
termination.save()
|
||||||
|
|
||||||
def delete(self, *args, **kwargs):
|
def delete(self, *args, **kwargs):
|
||||||
@ -455,6 +493,7 @@ class CableTermination(ChangeLoggedModel):
|
|||||||
termination.snapshot()
|
termination.snapshot()
|
||||||
termination.cable = None
|
termination.cable = None
|
||||||
termination.cable_end = None
|
termination.cable_end = None
|
||||||
|
termination.cable_position = None
|
||||||
termination.save()
|
termination.save()
|
||||||
|
|
||||||
super().delete(*args, **kwargs)
|
super().delete(*args, **kwargs)
|
||||||
@ -653,6 +692,9 @@ class CablePath(models.Model):
|
|||||||
path.append([
|
path.append([
|
||||||
object_to_path_node(t) for t in terminations
|
object_to_path_node(t) for t in terminations
|
||||||
])
|
])
|
||||||
|
# If not null, push cable_position onto the stack
|
||||||
|
if terminations[0].cable_position is not None:
|
||||||
|
position_stack.append([terminations[0].cable_position])
|
||||||
|
|
||||||
# Step 2: Determine the attached links (Cable or WirelessLink), if any
|
# Step 2: Determine the attached links (Cable or WirelessLink), if any
|
||||||
links = [termination.link for termination in terminations if termination.link is not None]
|
links = [termination.link for termination in terminations if termination.link is not None]
|
||||||
@ -687,23 +729,31 @@ class CablePath(models.Model):
|
|||||||
|
|
||||||
# Step 6: Determine the far-end terminations
|
# Step 6: Determine the far-end terminations
|
||||||
if isinstance(links[0], Cable):
|
if isinstance(links[0], Cable):
|
||||||
termination_type = ObjectType.objects.get_for_model(terminations[0])
|
# Profile-based tracing
|
||||||
local_cable_terminations = CableTermination.objects.filter(
|
if links[0].profile:
|
||||||
termination_type=termination_type,
|
cable_profile = links[0].profile_class()
|
||||||
termination_id__in=[t.pk for t in terminations]
|
peer_cable_terminations = cable_profile.get_peer_terminations(terminations, position_stack)
|
||||||
)
|
remote_terminations = [ct.termination for ct in peer_cable_terminations]
|
||||||
|
|
||||||
q_filter = Q()
|
# Legacy (positionless) behavior
|
||||||
for lct in local_cable_terminations:
|
else:
|
||||||
cable_end = 'A' if lct.cable_end == 'B' else 'B'
|
termination_type = ObjectType.objects.get_for_model(terminations[0])
|
||||||
q_filter |= Q(cable=lct.cable, cable_end=cable_end)
|
local_cable_terminations = CableTermination.objects.filter(
|
||||||
|
termination_type=termination_type,
|
||||||
|
termination_id__in=[t.pk for t in terminations]
|
||||||
|
)
|
||||||
|
|
||||||
# Make sure this filter has been populated; if not, we have probably been given invalid data
|
q_filter = Q()
|
||||||
if not q_filter:
|
for lct in local_cable_terminations:
|
||||||
break
|
cable_end = 'A' if lct.cable_end == 'B' else 'B'
|
||||||
|
q_filter |= Q(cable=lct.cable, cable_end=cable_end)
|
||||||
|
|
||||||
remote_cable_terminations = CableTermination.objects.filter(q_filter)
|
# Make sure this filter has been populated; if not, we have probably been given invalid data
|
||||||
remote_terminations = [ct.termination for ct in remote_cable_terminations]
|
if not q_filter:
|
||||||
|
break
|
||||||
|
|
||||||
|
remote_cable_terminations = CableTermination.objects.filter(q_filter)
|
||||||
|
remote_terminations = [ct.termination for ct in remote_cable_terminations]
|
||||||
else:
|
else:
|
||||||
# WirelessLink
|
# WirelessLink
|
||||||
remote_terminations = [
|
remote_terminations = [
|
||||||
|
|||||||
@ -175,6 +175,15 @@ class CabledObjectModel(models.Model):
|
|||||||
blank=True,
|
blank=True,
|
||||||
null=True
|
null=True
|
||||||
)
|
)
|
||||||
|
cable_position = models.PositiveIntegerField(
|
||||||
|
verbose_name=_('cable position'),
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
validators=(
|
||||||
|
MinValueValidator(CABLETERMINATION_POSITION_MIN),
|
||||||
|
MaxValueValidator(CABLETERMINATION_POSITION_MAX)
|
||||||
|
),
|
||||||
|
)
|
||||||
mark_connected = models.BooleanField(
|
mark_connected = models.BooleanField(
|
||||||
verbose_name=_('mark connected'),
|
verbose_name=_('mark connected'),
|
||||||
default=False,
|
default=False,
|
||||||
@ -194,14 +203,23 @@ class CabledObjectModel(models.Model):
|
|||||||
def clean(self):
|
def clean(self):
|
||||||
super().clean()
|
super().clean()
|
||||||
|
|
||||||
if self.cable and not self.cable_end:
|
if self.cable:
|
||||||
raise ValidationError({
|
if not self.cable_end:
|
||||||
"cable_end": _("Must specify cable end (A or B) when attaching a cable.")
|
raise ValidationError({
|
||||||
})
|
"cable_end": _("Must specify cable end (A or B) when attaching a cable.")
|
||||||
|
})
|
||||||
|
if not self.cable_position:
|
||||||
|
raise ValidationError({
|
||||||
|
"cable_position": _("Must specify cable termination position when attaching a cable.")
|
||||||
|
})
|
||||||
if self.cable_end and not self.cable:
|
if self.cable_end and not self.cable:
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
"cable_end": _("Cable end must not be set without a cable.")
|
"cable_end": _("Cable end must not be set without a cable.")
|
||||||
})
|
})
|
||||||
|
if self.cable_position and not self.cable:
|
||||||
|
raise ValidationError({
|
||||||
|
"cable_position": _("Cable termination position must not be set without a cable.")
|
||||||
|
})
|
||||||
if self.mark_connected and self.cable:
|
if self.mark_connected and self.cable:
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
"mark_connected": _("Cannot mark as connected with a cable attached.")
|
"mark_connected": _("Cannot mark as connected with a cable attached.")
|
||||||
|
|||||||
@ -11,7 +11,7 @@ from .models import (
|
|||||||
VirtualChassis,
|
VirtualChassis,
|
||||||
)
|
)
|
||||||
from .models.cables import trace_paths
|
from .models.cables import trace_paths
|
||||||
from .utils import create_cablepath, rebuild_paths
|
from .utils import create_cablepaths, rebuild_paths
|
||||||
|
|
||||||
COMPONENT_MODELS = (
|
COMPONENT_MODELS = (
|
||||||
ConsolePort,
|
ConsolePort,
|
||||||
@ -114,7 +114,7 @@ def update_connected_endpoints(instance, created, raw=False, **kwargs):
|
|||||||
if not nodes:
|
if not nodes:
|
||||||
continue
|
continue
|
||||||
if isinstance(nodes[0], PathEndpoint):
|
if isinstance(nodes[0], PathEndpoint):
|
||||||
create_cablepath(nodes)
|
create_cablepaths(nodes)
|
||||||
else:
|
else:
|
||||||
rebuild_paths(nodes)
|
rebuild_paths(nodes)
|
||||||
|
|
||||||
|
|||||||
@ -108,6 +108,7 @@ class CableTable(TenancyColumnsMixin, PrimaryModelTable):
|
|||||||
verbose_name=_('Site B')
|
verbose_name=_('Site B')
|
||||||
)
|
)
|
||||||
status = columns.ChoiceFieldColumn()
|
status = columns.ChoiceFieldColumn()
|
||||||
|
profile = columns.ChoiceFieldColumn()
|
||||||
length = columns.TemplateColumn(
|
length = columns.TemplateColumn(
|
||||||
template_code=CABLE_LENGTH,
|
template_code=CABLE_LENGTH,
|
||||||
order_by=('_abs_length')
|
order_by=('_abs_length')
|
||||||
@ -125,8 +126,8 @@ class CableTable(TenancyColumnsMixin, PrimaryModelTable):
|
|||||||
model = Cable
|
model = Cable
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'device_a', 'device_b', 'rack_a', 'rack_b',
|
'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'device_a', 'device_b', 'rack_a', 'rack_b',
|
||||||
'location_a', 'location_b', 'site_a', 'site_b', 'status', 'type', 'tenant', 'tenant_group', 'color',
|
'location_a', 'location_b', 'site_a', 'site_b', 'status', 'profile', 'type', 'tenant', 'tenant_group',
|
||||||
'color_name', 'length', 'description', 'comments', 'tags', 'created', 'last_updated',
|
'color', 'color_name', 'length', 'description', 'comments', 'tags', 'created', 'last_updated',
|
||||||
)
|
)
|
||||||
default_columns = (
|
default_columns = (
|
||||||
'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'status', 'type',
|
'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'status', 'type',
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.db import router, transaction
|
from django.db import router, transaction
|
||||||
@ -31,17 +33,22 @@ def path_node_to_object(repr):
|
|||||||
return ct.model_class().objects.filter(pk=object_id).first()
|
return ct.model_class().objects.filter(pk=object_id).first()
|
||||||
|
|
||||||
|
|
||||||
def create_cablepath(terminations):
|
def create_cablepaths(objects):
|
||||||
"""
|
"""
|
||||||
Create CablePaths for all paths originating from the specified set of nodes.
|
Create CablePaths for all paths originating from the specified set of nodes.
|
||||||
|
|
||||||
:param terminations: Iterable of CableTermination objects
|
:param objects: Iterable of cabled objects (e.g. Interfaces)
|
||||||
"""
|
"""
|
||||||
from dcim.models import CablePath
|
from dcim.models import CablePath
|
||||||
|
|
||||||
cp = CablePath.from_origin(terminations)
|
# Arrange objects by cable position. All objects with a null position are grouped together.
|
||||||
if cp:
|
origins = defaultdict(list)
|
||||||
cp.save()
|
for obj in objects:
|
||||||
|
origins[obj.cable_position].append(obj)
|
||||||
|
|
||||||
|
for position, objects in origins.items():
|
||||||
|
if cp := CablePath.from_origin(objects):
|
||||||
|
cp.save()
|
||||||
|
|
||||||
|
|
||||||
def rebuild_paths(terminations):
|
def rebuild_paths(terminations):
|
||||||
@ -56,7 +63,7 @@ def rebuild_paths(terminations):
|
|||||||
with transaction.atomic(using=router.db_for_write(CablePath)):
|
with transaction.atomic(using=router.db_for_write(CablePath)):
|
||||||
for cp in cable_paths:
|
for cp in cable_paths:
|
||||||
cp.delete()
|
cp.delete()
|
||||||
create_cablepath(cp.origins)
|
create_cablepaths(cp.origins)
|
||||||
|
|
||||||
|
|
||||||
def update_interface_bridges(device, interface_templates, module=None):
|
def update_interface_bridges(device, interface_templates, module=None):
|
||||||
|
|||||||
@ -19,6 +19,10 @@
|
|||||||
<th scope="row">{% trans "Status" %}</th>
|
<th scope="row">{% trans "Status" %}</th>
|
||||||
<td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
|
<td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Profile" %}</th>
|
||||||
|
<td>{% badge object.get_profile_display %}</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">{% trans "Tenant" %}</th>
|
<th scope="row">{% trans "Tenant" %}</th>
|
||||||
<td>
|
<td>
|
||||||
|
|||||||
@ -53,6 +53,7 @@
|
|||||||
<h2 class="col-9 offset-3">{% trans "Cable" %}</h2>
|
<h2 class="col-9 offset-3">{% trans "Cable" %}</h2>
|
||||||
</div>
|
</div>
|
||||||
{% render_field form.status %}
|
{% render_field form.status %}
|
||||||
|
{% render_field form.profile %}
|
||||||
{% render_field form.type %}
|
{% render_field form.type %}
|
||||||
{% render_field form.label %}
|
{% render_field form.label %}
|
||||||
{% render_field form.description %}
|
{% render_field form.description %}
|
||||||
|
|||||||
@ -5,7 +5,7 @@ from django.dispatch import receiver
|
|||||||
|
|
||||||
from dcim.exceptions import UnsupportedCablePath
|
from dcim.exceptions import UnsupportedCablePath
|
||||||
from dcim.models import CablePath, Interface
|
from dcim.models import CablePath, Interface
|
||||||
from dcim.utils import create_cablepath
|
from dcim.utils import create_cablepaths
|
||||||
from utilities.exceptions import AbortRequest
|
from utilities.exceptions import AbortRequest
|
||||||
from .models import WirelessLink
|
from .models import WirelessLink
|
||||||
|
|
||||||
@ -37,7 +37,7 @@ def update_connected_interfaces(instance, created, raw=False, **kwargs):
|
|||||||
if created:
|
if created:
|
||||||
for interface in (instance.interface_a, instance.interface_b):
|
for interface in (instance.interface_a, instance.interface_b):
|
||||||
try:
|
try:
|
||||||
create_cablepath([interface])
|
create_cablepaths([interface])
|
||||||
except UnsupportedCablePath as e:
|
except UnsupportedCablePath as e:
|
||||||
raise AbortRequest(e)
|
raise AbortRequest(e)
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user