Merge branch 'feature-ui' into 16913-admin-nav-item-hidden

This commit is contained in:
Jeremy Stretch 2024-07-25 16:47:41 -04:00
commit 093c2b03d4
85 changed files with 2149 additions and 379 deletions

View File

@ -0,0 +1,13 @@
# Circuit Groups
[Circuits](./circuit.md) can be arranged into administrative groups for organization. The assignment of a circuit to a group is optional.
## Fields
### Name
A unique human-friendly name.
### Slug
A unique URL-friendly identifier. (This value can be used for filtering.)

View File

@ -0,0 +1,25 @@
# Circuit Group Assignments
Circuits can be assigned to [circuit groups](./circuitgroup.md) for correlation purposes. For instance, three circuits, each belonging to a different provider, may each be assigned to the same circuit group. Each assignment may optionally include a priority designation.
## Fields
### Group
The [circuit group](./circuitgroup.md) being assigned.
### Circuit
The [circuit](./circuit.md) that is being assigned to the group.
### Priority
The circuit's operation priority relative to its peers within the group. The assignment of a priority is optional. Choices include:
* Primary
* Secondary
* Tertiary
* Inactive
!!! tip
Additional priority choices may be defined by setting `CircuitGroupAssignment.priority` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.

View File

@ -39,3 +39,7 @@ An alternative part number to uniquely identify the module type.
### Weight
The numeric weight of the module, including a unit designation (e.g. 3 kilograms or 1 pound).
### Airflow
The direction in which air circulates through the device chassis for cooling.

View File

@ -54,4 +54,7 @@ The maximum total weight capacity for all installed devices, inclusive of the ra
If selected, the rack's elevation will display unit 1 at the top of the rack. (Most racks use ascending numbering, with unit 1 assigned to the bottommost position.)
### Airflow
The direction in which air circulates through the rack for cooling.

View File

@ -0,0 +1,16 @@
# Events
Plugins can register their own custom event types for use with NetBox [event rules](../../models/extras/eventrule.md). This is accomplished by calling the `register()` method on an instance of the `Event` class. This can be done anywhere within the plugin. An example is provided below.
```python
from django.utils.translation import gettext_lazy as _
from netbox.events import Event, EVENT_TYPE_SUCCESS
Event(
name='ticket_opened',
text=_('Ticket opened'),
type=EVENT_TYPE_SUCCESS
).register()
```
::: netbox.events.Event

View File

@ -142,6 +142,7 @@ nav:
- Forms: 'plugins/development/forms.md'
- Filters & Filter Sets: 'plugins/development/filtersets.md'
- Search: 'plugins/development/search.md'
- Events: 'plugins/development/events.md'
- Data Backends: 'plugins/development/data-backends.md'
- REST API: 'plugins/development/rest-api.md'
- GraphQL API: 'plugins/development/graphql-api.md'
@ -163,6 +164,8 @@ nav:
- Data Model:
- Circuits:
- Circuit: 'models/circuits/circuit.md'
- CircuitGroup: 'models/circuits/circuitgroup.md'
- CircuitGroupAssignment: 'models/circuits/circuitgroupassignment.md'
- Circuit Termination: 'models/circuits/circuittermination.md'
- Circuit Type: 'models/circuits/circuittype.md'
- Provider: 'models/circuits/provider.md'

View File

@ -1,7 +1,7 @@
from rest_framework import serializers
from circuits.choices import CircuitStatusChoices
from circuits.models import Circuit, CircuitTermination, CircuitType
from circuits.choices import CircuitPriorityChoices, CircuitStatusChoices
from circuits.models import Circuit, CircuitGroup, CircuitGroupAssignment, CircuitTermination, CircuitType
from dcim.api.serializers_.cables import CabledObjectSerializer
from dcim.api.serializers_.sites import SiteSerializer
from netbox.api.fields import ChoiceField, RelatedObjectCountField
@ -12,6 +12,8 @@ from .providers import ProviderAccountSerializer, ProviderNetworkSerializer, Pro
__all__ = (
'CircuitSerializer',
'CircuitGroupAssignmentSerializer',
'CircuitGroupSerializer',
'CircuitTerminationSerializer',
'CircuitTypeSerializer',
)
@ -43,6 +45,34 @@ class CircuitCircuitTerminationSerializer(WritableNestedSerializer):
]
class CircuitGroupSerializer(NetBoxModelSerializer):
tenant = TenantSerializer(nested=True, required=False, allow_null=True)
circuit_count = RelatedObjectCountField('assignments')
class Meta:
model = CircuitGroup
fields = [
'id', 'url', 'display_url', 'display', 'name', 'slug', 'description', 'tenant',
'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count'
]
brief_fields = ('id', 'url', 'display', 'name')
class CircuitGroupAssignmentSerializer_(NetBoxModelSerializer):
"""
Base serializer for group assignments under CircuitSerializer.
"""
group = CircuitGroupSerializer(nested=True)
priority = ChoiceField(choices=CircuitPriorityChoices, allow_blank=True, required=False)
class Meta:
model = CircuitGroupAssignment
fields = [
'id', 'url', 'display_url', 'display', 'group', 'priority', 'tags', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'group', 'priority')
class CircuitSerializer(NetBoxModelSerializer):
provider = ProviderSerializer(nested=True)
provider_account = ProviderAccountSerializer(nested=True, required=False, allow_null=True, default=None)
@ -51,13 +81,14 @@ class CircuitSerializer(NetBoxModelSerializer):
tenant = TenantSerializer(nested=True, required=False, allow_null=True)
termination_a = CircuitCircuitTerminationSerializer(read_only=True, allow_null=True)
termination_z = CircuitCircuitTerminationSerializer(read_only=True, allow_null=True)
assignments = CircuitGroupAssignmentSerializer_(nested=True, many=True, required=False)
class Meta:
model = Circuit
fields = [
'id', 'url', 'display_url', 'display', 'cid', 'provider', 'provider_account', 'type', 'status', 'tenant',
'install_date', 'termination_date', 'commit_rate', 'description', 'termination_a', 'termination_z',
'comments', 'tags', 'custom_fields', 'created', 'last_updated',
'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'assignments',
]
brief_fields = ('id', 'url', 'display', 'cid', 'description')
@ -75,3 +106,14 @@ class CircuitTerminationSerializer(NetBoxModelSerializer, CabledObjectSerializer
'link_peers', 'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
]
brief_fields = ('id', 'url', 'display', 'circuit', 'term_side', 'description', 'cable', '_occupied')
class CircuitGroupAssignmentSerializer(CircuitGroupAssignmentSerializer_):
circuit = CircuitSerializer(nested=True)
class Meta:
model = CircuitGroupAssignment
fields = [
'id', 'url', 'display_url', 'display', 'group', 'circuit', 'priority', 'tags', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'group', 'circuit', 'priority')

View File

@ -14,6 +14,8 @@ router.register('provider-networks', views.ProviderNetworkViewSet)
router.register('circuit-types', views.CircuitTypeViewSet)
router.register('circuits', views.CircuitViewSet)
router.register('circuit-terminations', views.CircuitTerminationViewSet)
router.register('circuit-groups', views.CircuitGroupViewSet)
router.register('circuit-group-assignments', views.CircuitGroupAssignmentViewSet)
app_name = 'circuits-api'
urlpatterns = router.urls

View File

@ -55,6 +55,26 @@ class CircuitTerminationViewSet(PassThroughPortMixin, NetBoxModelViewSet):
filterset_class = filtersets.CircuitTerminationFilterSet
#
# Circuit Groups
#
class CircuitGroupViewSet(NetBoxModelViewSet):
queryset = CircuitGroup.objects.all()
serializer_class = serializers.CircuitGroupSerializer
filterset_class = filtersets.CircuitGroupFilterSet
#
# Circuit Group Assignments
#
class CircuitGroupAssignmentViewSet(NetBoxModelViewSet):
queryset = CircuitGroupAssignment.objects.all()
serializer_class = serializers.CircuitGroupAssignmentSerializer
filterset_class = filtersets.CircuitGroupAssignmentFilterSet
#
# Provider accounts
#

View File

@ -76,3 +76,19 @@ class CircuitTerminationPortSpeedChoices(ChoiceSet):
(1544, 'T1 (1.544 Mbps)'),
(2048, 'E1 (2.048 Mbps)'),
]
class CircuitPriorityChoices(ChoiceSet):
key = 'CircuitGroupAssignment.priority'
PRIORITY_PRIMARY = 'primary'
PRIORITY_SECONDARY = 'secondary'
PRIORITY_TERTIARY = 'tertiary'
PRIORITY_INACTIVE = 'inactive'
CHOICES = [
(PRIORITY_PRIMARY, _('Primary')),
(PRIORITY_SECONDARY, _('Secondary')),
(PRIORITY_TERTIARY, _('Tertiary')),
(PRIORITY_INACTIVE, _('Inactive')),
]

View File

@ -13,6 +13,8 @@ from .models import *
__all__ = (
'CircuitFilterSet',
'CircuitGroupAssignmentFilterSet',
'CircuitGroupFilterSet',
'CircuitTerminationFilterSet',
'CircuitTypeFilterSet',
'ProviderNetworkFilterSet',
@ -303,3 +305,43 @@ class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet):
Q(pp_info__icontains=value) |
Q(description__icontains=value)
).distinct()
class CircuitGroupFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
class Meta:
model = CircuitGroup
fields = ('id', 'name', 'slug', 'description')
class CircuitGroupAssignmentFilterSet(NetBoxModelFilterSet):
q = django_filters.CharFilter(
method='search',
label=_('Search'),
)
circuit_id = django_filters.ModelMultipleChoiceFilter(
queryset=Circuit.objects.all(),
label=_('Circuit'),
)
group_id = django_filters.ModelMultipleChoiceFilter(
queryset=CircuitGroup.objects.all(),
label=_('Circuit group (ID)'),
)
group = django_filters.ModelMultipleChoiceFilter(
field_name='group__slug',
queryset=CircuitGroup.objects.all(),
to_field_name='slug',
label=_('Circuit group (slug)'),
)
class Meta:
model = CircuitGroupAssignment
fields = ('id', 'circuit', 'group', 'priority')
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(circuit__cid__icontains=value) |
Q(group__name__icontains=value)
)

View File

@ -1,7 +1,7 @@
from django import forms
from django.utils.translation import gettext_lazy as _
from circuits.choices import CircuitCommitRateChoices, CircuitStatusChoices
from circuits.choices import CircuitCommitRateChoices, CircuitPriorityChoices, CircuitStatusChoices
from circuits.models import *
from dcim.models import Site
from ipam.models import ASN
@ -14,6 +14,8 @@ from utilities.forms.widgets import BulkEditNullBooleanSelect, DatePicker, Numbe
__all__ = (
'CircuitBulkEditForm',
'CircuitGroupAssignmentBulkEditForm',
'CircuitGroupBulkEditForm',
'CircuitTerminationBulkEditForm',
'CircuitTypeBulkEditForm',
'ProviderBulkEditForm',
@ -219,3 +221,40 @@ class CircuitTerminationBulkEditForm(NetBoxModelBulkEditForm):
FieldSet('port_speed', 'upstream_speed', name=_('Termination Details')),
)
nullable_fields = ('description')
class CircuitGroupBulkEditForm(NetBoxModelBulkEditForm):
description = forms.CharField(
label=_('Description'),
max_length=200,
required=False
)
tenant = DynamicModelChoiceField(
label=_('Tenant'),
queryset=Tenant.objects.all(),
required=False
)
model = CircuitGroup
nullable_fields = (
'description', 'tenant',
)
class CircuitGroupAssignmentBulkEditForm(NetBoxModelBulkEditForm):
circuit = DynamicModelChoiceField(
label=_('Circuit'),
queryset=Circuit.objects.all(),
required=False
)
priority = forms.ChoiceField(
label=_('Priority'),
choices=add_blank_choice(CircuitPriorityChoices),
required=False
)
model = CircuitGroupAssignment
fieldsets = (
FieldSet('circuit', 'priority'),
)
nullable_fields = ('priority',)

View File

@ -11,6 +11,8 @@ from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, SlugFiel
__all__ = (
'CircuitImportForm',
'CircuitGroupAssignmentImportForm',
'CircuitGroupImportForm',
'CircuitTerminationImportForm',
'CircuitTerminationImportRelatedForm',
'CircuitTypeImportForm',
@ -150,3 +152,24 @@ class CircuitTerminationImportForm(NetBoxModelImportForm, BaseCircuitTermination
'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id',
'pp_info', 'description', 'tags'
]
class CircuitGroupImportForm(NetBoxModelImportForm):
tenant = CSVModelChoiceField(
label=_('Tenant'),
queryset=Tenant.objects.all(),
required=False,
to_field_name='name',
help_text=_('Assigned tenant')
)
class Meta:
model = CircuitGroup
fields = ('name', 'slug', 'description', 'tenant', 'tags')
class CircuitGroupAssignmentImportForm(NetBoxModelImportForm):
class Meta:
model = CircuitGroupAssignment
fields = ('circuit', 'group', 'priority')

View File

@ -1,7 +1,7 @@
from django import forms
from django.utils.translation import gettext as _
from circuits.choices import CircuitCommitRateChoices, CircuitStatusChoices, CircuitTerminationSideChoices
from circuits.choices import CircuitCommitRateChoices, CircuitPriorityChoices, CircuitStatusChoices, CircuitTerminationSideChoices
from circuits.models import *
from dcim.models import Region, Site, SiteGroup
from ipam.models import ASN
@ -13,6 +13,8 @@ from utilities.forms.widgets import DatePicker, NumberWithOptions
__all__ = (
'CircuitFilterForm',
'CircuitGroupAssignmentFilterForm',
'CircuitGroupFilterForm',
'CircuitTerminationFilterForm',
'CircuitTypeFilterForm',
'ProviderFilterForm',
@ -230,3 +232,36 @@ class CircuitTerminationFilterForm(NetBoxModelFilterSetForm):
label=_('Provider')
)
tag = TagFilterField(model)
class CircuitGroupFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
model = CircuitGroup
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
)
tag = TagFilterField(model)
class CircuitGroupAssignmentFilterForm(NetBoxModelFilterSetForm):
model = CircuitGroupAssignment
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('circuit_id', 'group_id', 'priority', name=_('Assignment')),
)
circuit_id = DynamicModelMultipleChoiceField(
queryset=Circuit.objects.all(),
required=False,
label=_('Circuit')
)
group_id = DynamicModelMultipleChoiceField(
queryset=CircuitGroup.objects.all(),
required=False,
label=_('Group')
)
priority = forms.MultipleChoiceField(
label=_('Priority'),
choices=CircuitPriorityChoices,
required=False
)
tag = TagFilterField(model)

View File

@ -12,6 +12,8 @@ from utilities.forms.widgets import DatePicker, NumberWithOptions
__all__ = (
'CircuitForm',
'CircuitGroupAssignmentForm',
'CircuitGroupForm',
'CircuitTerminationForm',
'CircuitTypeForm',
'ProviderForm',
@ -171,3 +173,35 @@ class CircuitTerminationForm(NetBoxModelForm):
options=CircuitTerminationPortSpeedChoices
),
}
class CircuitGroupForm(TenancyForm, NetBoxModelForm):
slug = SlugField()
fieldsets = (
FieldSet('name', 'slug', 'description', 'tags', name=_('Circuit Group')),
FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
)
class Meta:
model = CircuitGroup
fields = [
'name', 'slug', 'description', 'tenant_group', 'tenant', 'tags',
]
class CircuitGroupAssignmentForm(NetBoxModelForm):
group = DynamicModelChoiceField(
label=_('Group'),
queryset=CircuitGroup.objects.all(),
)
circuit = DynamicModelChoiceField(
label=_('Circuit'),
queryset=Circuit.objects.all(),
)
class Meta:
model = CircuitGroupAssignment
fields = [
'group', 'circuit', 'priority', 'tags',
]

View File

@ -7,6 +7,8 @@ from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin
__all__ = (
'CircuitTerminationFilter',
'CircuitFilter',
'CircuitGroupAssignmentFilter',
'CircuitGroupFilter',
'CircuitTypeFilter',
'ProviderFilter',
'ProviderAccountFilter',
@ -32,6 +34,18 @@ class CircuitTypeFilter(BaseFilterMixin):
pass
@strawberry_django.filter(models.CircuitGroup, lookups=True)
@autotype_decorator(filtersets.CircuitGroupFilterSet)
class CircuitGroupFilter(BaseFilterMixin):
pass
@strawberry_django.filter(models.CircuitGroupAssignment, lookups=True)
@autotype_decorator(filtersets.CircuitGroupAssignmentFilterSet)
class CircuitGroupAssignmentFilter(BaseFilterMixin):
pass
@strawberry_django.filter(models.Provider, lookups=True)
@autotype_decorator(filtersets.ProviderFilterSet)
class ProviderFilter(BaseFilterMixin):

View File

@ -24,6 +24,16 @@ class CircuitsQuery:
return models.CircuitType.objects.get(pk=id)
circuit_type_list: List[CircuitTypeType] = strawberry_django.field()
@strawberry.field
def circuit_group(self, id: int) -> CircuitGroupType:
return models.CircuitGroup.objects.get(pk=id)
circuit_group_list: List[CircuitGroupType] = strawberry_django.field()
@strawberry.field
def circuit_group_assignment(self, id: int) -> CircuitGroupAssignmentType:
return models.CircuitGroupAssignment.objects.get(pk=id)
circuit_group_assignment_list: List[CircuitGroupAssignmentType] = strawberry_django.field()
@strawberry.field
def provider(self, id: int) -> ProviderType:
return models.Provider.objects.get(pk=id)

View File

@ -6,13 +6,15 @@ import strawberry_django
from circuits import models
from dcim.graphql.mixins import CabledObjectMixin
from extras.graphql.mixins import ContactsMixin, CustomFieldsMixin, TagsMixin
from netbox.graphql.types import NetBoxObjectType, ObjectType, OrganizationalObjectType
from netbox.graphql.types import BaseObjectType, NetBoxObjectType, ObjectType, OrganizationalObjectType
from tenancy.graphql.types import TenantType
from .filters import *
__all__ = (
'CircuitTerminationType',
'CircuitType',
'CircuitGroupAssignmentType',
'CircuitGroupType',
'CircuitTypeType',
'ProviderType',
'ProviderAccountType',
@ -91,3 +93,22 @@ class CircuitType(NetBoxObjectType, ContactsMixin):
tenant: TenantType | None
terminations: List[CircuitTerminationType]
@strawberry_django.type(
models.CircuitGroup,
fields='__all__',
filters=CircuitGroupFilter
)
class CircuitGroupType(OrganizationalObjectType):
tenant: TenantType | None
@strawberry_django.type(
models.CircuitGroupAssignment,
fields='__all__',
filters=CircuitGroupAssignmentFilter
)
class CircuitGroupAssignmentType(TagsMixin, BaseObjectType):
group: Annotated["CircuitGroupType", strawberry.lazy('circuits.graphql.types')]
circuit: Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')]

View File

@ -0,0 +1,90 @@
# Generated by Django 5.0.7 on 2024-07-22 06:27
import django.db.models.deletion
import taggit.managers
import utilities.json
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('circuits', '0043_circuittype_color'),
('extras', '0118_notifications'),
('tenancy', '0015_contactassignment_rename_content_type'),
]
operations = [
migrations.CreateModel(
name='CircuitGroup',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('created', models.DateTimeField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)),
(
'custom_field_data',
models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder),
),
('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)),
('description', models.CharField(blank=True, max_length=200)),
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
(
'tenant',
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name='circuit_groups',
to='tenancy.tenant',
),
),
],
options={
'verbose_name': 'Circuit group',
'verbose_name_plural': 'Circuit group',
'ordering': ('name',),
},
),
migrations.CreateModel(
name='CircuitGroupAssignment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('created', models.DateTimeField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)),
(
'custom_field_data',
models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder),
),
('priority', models.CharField(blank=True, max_length=50)),
(
'circuit',
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name='assignments',
to='circuits.circuit',
),
),
(
'group',
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name='assignments',
to='circuits.circuitgroup',
),
),
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
],
options={
'verbose_name': 'Circuit group assignment',
'verbose_name_plural': 'Circuit group assignments',
'ordering': ('circuit', 'priority', 'pk'),
},
),
migrations.AddConstraint(
model_name='circuitgroupassignment',
constraint=models.UniqueConstraint(
fields=('circuit', 'group'), name='circuits_circuitgroupassignment_unique_circuit_group'
),
),
]

View File

@ -6,11 +6,13 @@ from django.utils.translation import gettext_lazy as _
from circuits.choices import *
from dcim.models import CabledObjectModel
from netbox.models import ChangeLoggedModel, OrganizationalModel, PrimaryModel
from netbox.models.features import ContactsMixin, CustomFieldsMixin, CustomLinksMixin, ImageAttachmentsMixin, TagsMixin
from netbox.models.features import ContactsMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, ImageAttachmentsMixin, TagsMixin
from utilities.fields import ColorField
__all__ = (
'Circuit',
'CircuitGroup',
'CircuitGroupAssignment',
'CircuitTermination',
'CircuitType',
)
@ -151,6 +153,75 @@ class Circuit(ContactsMixin, ImageAttachmentsMixin, PrimaryModel):
raise ValidationError({'provider_account': "The assigned account must belong to the assigned provider."})
class CircuitGroup(OrganizationalModel):
"""
An administrative grouping of Circuits.
"""
tenant = models.ForeignKey(
to='tenancy.Tenant',
on_delete=models.PROTECT,
related_name='circuit_groups',
blank=True,
null=True
)
class Meta:
ordering = ('name',)
verbose_name = _('circuit group')
verbose_name_plural = _('circuit groups')
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse('circuits:circuitgroup', args=[self.pk])
class CircuitGroupAssignment(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel):
"""
Assignment of a Circuit to a CircuitGroup with an optional priority.
"""
circuit = models.ForeignKey(
Circuit,
on_delete=models.CASCADE,
related_name='assignments'
)
group = models.ForeignKey(
CircuitGroup,
on_delete=models.CASCADE,
related_name='assignments'
)
priority = models.CharField(
verbose_name=_('priority'),
max_length=50,
choices=CircuitPriorityChoices,
blank=True
)
prerequisite_models = (
'circuits.Circuit',
'circuits.CircuitGroup',
)
class Meta:
ordering = ('circuit', 'priority', 'pk')
constraints = (
models.UniqueConstraint(
fields=('circuit', 'group'),
name='%(app_label)s_%(class)s_unique_circuit_group'
),
)
verbose_name = _('Circuit group assignment')
verbose_name_plural = _('Circuit group assignments')
def __str__(self):
if self.priority:
return f"{self.group} ({self.get_priority_display()})"
return str(self.group)
def get_absolute_url(self):
return reverse('circuits:circuitgroupassignment', args=[self.pk])
class CircuitTermination(
CustomFieldsMixin,
CustomLinksMixin,

View File

@ -13,6 +13,17 @@ class CircuitIndex(SearchIndex):
display_attrs = ('provider', 'provider_account', 'type', 'status', 'tenant', 'description')
@register_search
class CircuitGroupIndex(SearchIndex):
model = models.CircuitGroup
fields = (
('name', 100),
('slug', 110),
('description', 500),
)
display_attrs = ('description',)
@register_search
class CircuitTerminationIndex(SearchIndex):
model = models.CircuitTermination

View File

@ -9,6 +9,8 @@ from netbox.tables import NetBoxTable, columns
from .columns import CommitRateColumn
__all__ = (
'CircuitGroupAssignmentTable',
'CircuitGroupTable',
'CircuitTable',
'CircuitTerminationTable',
'CircuitTypeTable',
@ -119,3 +121,50 @@ class CircuitTerminationTable(NetBoxTable):
'xconnect_id', 'pp_info', 'description', 'created', 'last_updated', 'actions',
)
default_columns = ('pk', 'id', 'circuit', 'provider', 'term_side', 'description')
class CircuitGroupTable(NetBoxTable):
name = tables.Column(
verbose_name=_('Name'),
linkify=True
)
circuit_group_assignment_count = columns.LinkedCountColumn(
viewname='circuits:circuitgroupassignment_list',
url_params={'group_id': 'pk'},
verbose_name=_('Circuits')
)
tags = columns.TagColumn(
url_name='circuits:circuitgroup_list'
)
class Meta(NetBoxTable.Meta):
model = CircuitGroup
fields = (
'pk', 'name', 'description', 'circuit_group_assignment_count', 'tags',
'created', 'last_updated', 'actions',
)
default_columns = ('pk', 'name', 'description', 'circuit_group_assignment_count')
class CircuitGroupAssignmentTable(NetBoxTable):
group = tables.Column(
verbose_name=_('Group'),
linkify=True
)
circuit = tables.Column(
verbose_name=_('Circuit'),
linkify=True
)
priority = tables.Column(
verbose_name=_('Priority'),
)
tags = columns.TagColumn(
url_name='circuits:circuitgroupassignment_list'
)
class Meta(NetBoxTable.Meta):
model = CircuitGroupAssignment
fields = (
'pk', 'id', 'group', 'circuit', 'priority', 'created', 'last_updated', 'actions', 'tags',
)
default_columns = ('pk', 'group', 'circuit', 'priority')

View File

@ -206,6 +206,38 @@ class CircuitTerminationTest(APIViewTestCases.APIViewTestCase):
}
class CircuitGroupTest(APIViewTestCases.APIViewTestCase):
model = CircuitGroup
brief_fields = ['display', 'id', 'name', 'url']
bulk_update_data = {
'description': 'New description',
}
@classmethod
def setUpTestData(cls):
circuit_groups = (
CircuitGroup(name="Circuit Group 1", slug='circuit-group-1'),
CircuitGroup(name="Circuit Group 2", slug='circuit-group-2'),
CircuitGroup(name="Circuit Group 3", slug='circuit-group-3'),
)
CircuitGroup.objects.bulk_create(circuit_groups)
cls.create_data = [
{
'name': 'Circuit Group 4',
'slug': 'circuit-group-4',
},
{
'name': 'Circuit Group 5',
'slug': 'circuit-group-5',
},
{
'name': 'Circuit Group 6',
'slug': 'circuit-group-6',
},
]
class ProviderAccountTest(APIViewTestCases.APIViewTestCase):
model = ProviderAccount
brief_fields = ['account', 'description', 'display', 'id', 'name', 'url']
@ -249,6 +281,77 @@ class ProviderAccountTest(APIViewTestCases.APIViewTestCase):
}
class CircuitGroupAssignmentTest(APIViewTestCases.APIViewTestCase):
model = CircuitGroupAssignment
brief_fields = ['circuit', 'display', 'group', 'id', 'priority', 'url']
bulk_update_data = {
'priority': CircuitPriorityChoices.PRIORITY_INACTIVE,
}
@classmethod
def setUpTestData(cls):
circuit_groups = (
CircuitGroup(name='Circuit Group 1', slug='circuit-group-1'),
CircuitGroup(name='Circuit Group 2', slug='circuit-group-2'),
CircuitGroup(name='Circuit Group 3', slug='circuit-group-3'),
CircuitGroup(name='Circuit Group 4', slug='circuit-group-4'),
CircuitGroup(name='Circuit Group 5', slug='circuit-group-5'),
CircuitGroup(name='Circuit Group 6', slug='circuit-group-6'),
)
CircuitGroup.objects.bulk_create(circuit_groups)
provider = Provider.objects.create(name='Provider 1', slug='provider-1')
circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
circuits = (
Circuit(cid='Circuit 1', provider=provider, type=circuittype),
Circuit(cid='Circuit 2', provider=provider, type=circuittype),
Circuit(cid='Circuit 3', provider=provider, type=circuittype),
Circuit(cid='Circuit 4', provider=provider, type=circuittype),
Circuit(cid='Circuit 5', provider=provider, type=circuittype),
Circuit(cid='Circuit 6', provider=provider, type=circuittype),
)
Circuit.objects.bulk_create(circuits)
assignments = (
CircuitGroupAssignment(
group=circuit_groups[0],
circuit=circuits[0],
priority=CircuitPriorityChoices.PRIORITY_PRIMARY
),
CircuitGroupAssignment(
group=circuit_groups[1],
circuit=circuits[1],
priority=CircuitPriorityChoices.PRIORITY_SECONDARY
),
CircuitGroupAssignment(
group=circuit_groups[2],
circuit=circuits[2],
priority=CircuitPriorityChoices.PRIORITY_TERTIARY
),
)
CircuitGroupAssignment.objects.bulk_create(assignments)
cls.create_data = [
{
'group': circuit_groups[3].pk,
'circuit': circuits[3].pk,
'priority': CircuitPriorityChoices.PRIORITY_PRIMARY,
},
{
'group': circuit_groups[4].pk,
'circuit': circuits[4].pk,
'priority': CircuitPriorityChoices.PRIORITY_SECONDARY,
},
{
'group': circuit_groups[5].pk,
'circuit': circuits[5].pk,
'priority': CircuitPriorityChoices.PRIORITY_TERTIARY,
},
]
class ProviderNetworkTest(APIViewTestCases.APIViewTestCase):
model = ProviderNetwork
brief_fields = ['description', 'display', 'id', 'name', 'url']

View File

@ -451,6 +451,122 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7)
class CircuitGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = CircuitGroup.objects.all()
filterset = CircuitGroupFilterSet
@classmethod
def setUpTestData(cls):
tenant_groups = (
TenantGroup(name='Tenant group 1', slug='tenant-group-1'),
TenantGroup(name='Tenant group 2', slug='tenant-group-2'),
TenantGroup(name='Tenant group 3', slug='tenant-group-3'),
)
for tenantgroup in tenant_groups:
tenantgroup.save()
tenants = (
Tenant(name='Tenant 1', slug='tenant-1', group=tenant_groups[0]),
Tenant(name='Tenant 2', slug='tenant-2', group=tenant_groups[1]),
Tenant(name='Tenant 3', slug='tenant-3', group=tenant_groups[2]),
)
Tenant.objects.bulk_create(tenants)
CircuitGroup.objects.bulk_create((
CircuitGroup(name='Circuit Group 1', slug='circuit-group-1', description='foobar1', tenant=tenants[0]),
CircuitGroup(name='Circuit Group 2', slug='circuit-group-2', description='foobar2', tenant=tenants[1]),
CircuitGroup(name='Circuit Group 3', slug='circuit-group-3', tenant=tenants[1]),
))
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['Circuit Group 1']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_slug(self):
params = {'slug': ['circuit-group-1']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_tenant(self):
tenants = Tenant.objects.all()[:2]
params = {'tenant_id': [tenants[0].pk, tenants[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
params = {'tenant': [tenants[0].slug, tenants[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_tenant_group(self):
tenant_groups = TenantGroup.objects.all()[:2]
params = {'tenant_group_id': [tenant_groups[0].pk, tenant_groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
class CircuitGroupAssignmentTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = CircuitGroupAssignment.objects.all()
filterset = CircuitGroupAssignmentFilterSet
@classmethod
def setUpTestData(cls):
circuit_groups = (
CircuitGroup(name='Circuit Group 1', slug='circuit-group-1'),
CircuitGroup(name='Circuit Group 2', slug='circuit-group-2'),
CircuitGroup(name='Circuit Group 3', slug='circuit-group-3'),
CircuitGroup(name='Circuit Group 4', slug='circuit-group-4'),
)
CircuitGroup.objects.bulk_create(circuit_groups)
provider = Provider.objects.create(name='Provider 1', slug='provider-1')
circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
circuits = (
Circuit(cid='Circuit 1', provider=provider, type=circuittype),
Circuit(cid='Circuit 2', provider=provider, type=circuittype),
Circuit(cid='Circuit 3', provider=provider, type=circuittype),
Circuit(cid='Circuit 4', provider=provider, type=circuittype),
)
Circuit.objects.bulk_create(circuits)
assignments = (
CircuitGroupAssignment(
group=circuit_groups[0],
circuit=circuits[0],
priority=CircuitPriorityChoices.PRIORITY_PRIMARY
),
CircuitGroupAssignment(
group=circuit_groups[1],
circuit=circuits[1],
priority=CircuitPriorityChoices.PRIORITY_SECONDARY
),
CircuitGroupAssignment(
group=circuit_groups[2],
circuit=circuits[2],
priority=CircuitPriorityChoices.PRIORITY_TERTIARY
),
)
CircuitGroupAssignment.objects.bulk_create(assignments)
def test_group_id(self):
groups = CircuitGroup.objects.filter(name__in=['Circuit Group 1', 'Circuit Group 2'])
params = {'group_id': [groups[0].pk, groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'group': [groups[0].slug, groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_circuit_id(self):
circuits = Circuit.objects.filter(cid__in=['Circuit 1', 'Circuit 2'])
params = {'circuit_id': [circuits[0].pk, circuits[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class ProviderNetworkTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = ProviderNetwork.objects.all()
filterset = ProviderNetworkFilterSet

View File

@ -404,3 +404,109 @@ class CircuitTerminationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
response = self.client.get(reverse('circuits:circuittermination_trace', kwargs={'pk': circuittermination.pk}))
self.assertHttpStatus(response, 200)
class CircuitGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = CircuitGroup
@classmethod
def setUpTestData(cls):
circuit_groups = (
CircuitGroup(name='Circuit Group 1', slug='circuit-group-1'),
CircuitGroup(name='Circuit Group 2', slug='circuit-group-2'),
CircuitGroup(name='Circuit Group 3', slug='circuit-group-3'),
)
CircuitGroup.objects.bulk_create(circuit_groups)
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'name': 'Circuit Group X',
'slug': 'circuit-group-x',
'description': 'A new Circuit Group',
'tags': [t.pk for t in tags],
}
cls.csv_data = (
"name,slug",
"Circuit Group 4,circuit-group-4",
"Circuit Group 5,circuit-group-5",
"Circuit Group 6,circuit-group-6",
)
cls.csv_update_data = (
"id,name,description",
f"{circuit_groups[0].pk},Circuit Group 7,New description7",
f"{circuit_groups[1].pk},Circuit Group 8,New description8",
f"{circuit_groups[2].pk},Circuit Group 9,New description9",
)
cls.bulk_edit_data = {
'description': 'Foo',
}
class CircuitGroupAssignmentTestCase(
ViewTestCases.CreateObjectViewTestCase,
ViewTestCases.EditObjectViewTestCase,
ViewTestCases.DeleteObjectViewTestCase,
ViewTestCases.ListObjectsViewTestCase,
ViewTestCases.BulkEditObjectsViewTestCase,
ViewTestCases.BulkDeleteObjectsViewTestCase
):
model = CircuitGroupAssignment
@classmethod
def setUpTestData(cls):
circuit_groups = (
CircuitGroup(name='Circuit Group 1', slug='circuit-group-1'),
CircuitGroup(name='Circuit Group 2', slug='circuit-group-2'),
CircuitGroup(name='Circuit Group 3', slug='circuit-group-3'),
CircuitGroup(name='Circuit Group 4', slug='circuit-group-4'),
)
CircuitGroup.objects.bulk_create(circuit_groups)
provider = Provider.objects.create(name='Provider 1', slug='provider-1')
circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
circuits = (
Circuit(cid='Circuit 1', provider=provider, type=circuittype),
Circuit(cid='Circuit 2', provider=provider, type=circuittype),
Circuit(cid='Circuit 3', provider=provider, type=circuittype),
Circuit(cid='Circuit 4', provider=provider, type=circuittype),
)
Circuit.objects.bulk_create(circuits)
assignments = (
CircuitGroupAssignment(
group=circuit_groups[0],
circuit=circuits[0],
priority=CircuitPriorityChoices.PRIORITY_PRIMARY
),
CircuitGroupAssignment(
group=circuit_groups[1],
circuit=circuits[1],
priority=CircuitPriorityChoices.PRIORITY_SECONDARY
),
CircuitGroupAssignment(
group=circuit_groups[2],
circuit=circuits[2],
priority=CircuitPriorityChoices.PRIORITY_TERTIARY
),
)
CircuitGroupAssignment.objects.bulk_create(assignments)
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'group': circuit_groups[3].pk,
'circuit': circuits[3].pk,
'priority': CircuitPriorityChoices.PRIORITY_INACTIVE,
'tags': [t.pk for t in tags],
}
cls.bulk_edit_data = {
'priority': CircuitPriorityChoices.PRIORITY_INACTIVE,
}

View File

@ -55,4 +55,19 @@ urlpatterns = [
path('circuit-terminations/delete/', views.CircuitTerminationBulkDeleteView.as_view(), name='circuittermination_bulk_delete'),
path('circuit-terminations/<int:pk>/', include(get_model_urls('circuits', 'circuittermination'))),
# Circuit Groups
path('circuit-groups/', views.CircuitGroupListView.as_view(), name='circuitgroup_list'),
path('circuit-groups/add/', views.CircuitGroupEditView.as_view(), name='circuitgroup_add'),
path('circuit-groups/import/', views.CircuitGroupBulkImportView.as_view(), name='circuitgroup_import'),
path('circuit-groups/edit/', views.CircuitGroupBulkEditView.as_view(), name='circuitgroup_bulk_edit'),
path('circuit-groups/delete/', views.CircuitGroupBulkDeleteView.as_view(), name='circuitgroup_bulk_delete'),
path('circuit-groups/<int:pk>/', include(get_model_urls('circuits', 'circuitgroup'))),
# Circuit Group Assignments
path('circuit-group-assignments/', views.CircuitGroupAssignmentListView.as_view(), name='circuitgroupassignment_list'),
path('circuit-group-assignments/add/', views.CircuitGroupAssignmentEditView.as_view(), name='circuitgroupassignment_add'),
path('circuit-group-assignments/import/', views.CircuitGroupAssignmentBulkImportView.as_view(), name='circuitgroupassignment_import'),
path('circuit-group-assignments/edit/', views.CircuitGroupAssignmentBulkEditView.as_view(), name='circuitgroupassignment_bulk_edit'),
path('circuit-group-assignments/delete/', views.CircuitGroupAssignmentBulkDeleteView.as_view(), name='circuitgroupassignment_bulk_delete'),
path('circuit-group-assignments/<int:pk>/', include(get_model_urls('circuits', 'circuitgroupassignment'))),
]

View File

@ -440,3 +440,100 @@ class CircuitTerminationBulkDeleteView(generic.BulkDeleteView):
# Trace view
register_model_view(CircuitTermination, 'trace', kwargs={'model': CircuitTermination})(PathTraceView)
#
# Circuit Groups
#
class CircuitGroupListView(generic.ObjectListView):
queryset = CircuitGroup.objects.annotate(
circuit_group_assignment_count=count_related(CircuitGroupAssignment, 'group')
)
filterset = filtersets.CircuitGroupFilterSet
filterset_form = forms.CircuitGroupFilterForm
table = tables.CircuitGroupTable
@register_model_view(CircuitGroup)
class CircuitGroupView(GetRelatedModelsMixin, generic.ObjectView):
queryset = CircuitGroup.objects.all()
def get_extra_context(self, request, instance):
return {
'related_models': self.get_related_models(request, instance),
}
@register_model_view(CircuitGroup, 'edit')
class CircuitGroupEditView(generic.ObjectEditView):
queryset = CircuitGroup.objects.all()
form = forms.CircuitGroupForm
@register_model_view(CircuitGroup, 'delete')
class CircuitGroupDeleteView(generic.ObjectDeleteView):
queryset = CircuitGroup.objects.all()
class CircuitGroupBulkImportView(generic.BulkImportView):
queryset = CircuitGroup.objects.all()
model_form = forms.CircuitGroupImportForm
class CircuitGroupBulkEditView(generic.BulkEditView):
queryset = CircuitGroup.objects.all()
filterset = filtersets.CircuitGroupFilterSet
table = tables.CircuitGroupTable
form = forms.CircuitGroupBulkEditForm
class CircuitGroupBulkDeleteView(generic.BulkDeleteView):
queryset = CircuitGroup.objects.all()
filterset = filtersets.CircuitGroupFilterSet
table = tables.CircuitGroupTable
#
# Circuit Groups
#
class CircuitGroupAssignmentListView(generic.ObjectListView):
queryset = CircuitGroupAssignment.objects.all()
filterset = filtersets.CircuitGroupAssignmentFilterSet
filterset_form = forms.CircuitGroupAssignmentFilterForm
table = tables.CircuitGroupAssignmentTable
@register_model_view(CircuitGroupAssignment)
class CircuitGroupAssignmentView(generic.ObjectView):
queryset = CircuitGroupAssignment.objects.all()
@register_model_view(CircuitGroupAssignment, 'edit')
class CircuitGroupAssignmentEditView(generic.ObjectEditView):
queryset = CircuitGroupAssignment.objects.all()
form = forms.CircuitGroupAssignmentForm
@register_model_view(CircuitGroupAssignment, 'delete')
class CircuitGroupAssignmentDeleteView(generic.ObjectDeleteView):
queryset = CircuitGroupAssignment.objects.all()
class CircuitGroupAssignmentBulkImportView(generic.BulkImportView):
queryset = CircuitGroupAssignment.objects.all()
model_form = forms.CircuitGroupAssignmentImportForm
class CircuitGroupAssignmentBulkEditView(generic.BulkEditView):
queryset = CircuitGroupAssignment.objects.all()
filterset = filtersets.CircuitGroupAssignmentFilterSet
table = tables.CircuitGroupAssignmentTable
form = forms.CircuitGroupAssignmentBulkEditForm
class CircuitGroupAssignmentBulkDeleteView(generic.BulkDeleteView):
queryset = CircuitGroupAssignment.objects.all()
filterset = filtersets.CircuitGroupAssignmentFilterSet
table = tables.CircuitGroupAssignmentTable

View File

@ -1,6 +1,6 @@
from django.utils.translation import gettext as _
from netbox.events import *
from netbox.events import Event, EVENT_TYPE_DANGER, EVENT_TYPE_SUCCESS, EVENT_TYPE_WARNING
__all__ = (
'JOB_COMPLETED',
@ -24,10 +24,10 @@ JOB_FAILED = 'job_failed'
JOB_ERRORED = 'job_errored'
# Register core events
Event(name=OBJECT_CREATED, text=_('Object created')).register()
Event(name=OBJECT_UPDATED, text=_('Object updated')).register()
Event(name=OBJECT_DELETED, text=_('Object deleted')).register()
Event(name=JOB_STARTED, text=_('Job started')).register()
Event(name=JOB_COMPLETED, text=_('Job completed'), type=EVENT_TYPE_SUCCESS).register()
Event(name=JOB_FAILED, text=_('Job failed'), type=EVENT_TYPE_WARNING).register()
Event(name=JOB_ERRORED, text=_('Job errored'), type=EVENT_TYPE_DANGER).register()
Event(OBJECT_CREATED, _('Object created')).register()
Event(OBJECT_UPDATED, _('Object updated')).register()
Event(OBJECT_DELETED, _('Object deleted')).register()
Event(JOB_STARTED, _('Job started')).register()
Event(JOB_COMPLETED, _('Job completed'), type=EVENT_TYPE_SUCCESS).register()
Event(JOB_FAILED, _('Job failed'), type=EVENT_TYPE_WARNING).register()
Event(JOB_ERRORED, _('Job errored'), type=EVENT_TYPE_DANGER).register()

209
netbox/core/plugins.py Normal file
View File

@ -0,0 +1,209 @@
import datetime
import importlib
import importlib.util
from dataclasses import dataclass, field
from typing import Optional
import requests
from django.conf import settings
from django.core.cache import cache
from django.utils.translation import gettext_lazy as _
from netbox.plugins import PluginConfig
from utilities.datetime import datetime_from_timestamp
USER_AGENT_STRING = f'NetBox/{settings.RELEASE.version} {settings.RELEASE.edition}'
@dataclass
class PluginAuthor:
"""
Identifying information for the author of a plugin.
"""
name: str
org_id: str = ''
url: str = ''
@dataclass
class PluginVersion:
"""
Details for a specific versioned release of a plugin.
"""
date: datetime.datetime = None
version: str = ''
netbox_min_version: str = ''
netbox_max_version: str = ''
has_model: bool = False
is_certified: bool = False
is_feature: bool = False
is_integration: bool = False
is_netboxlabs_supported: bool = False
@dataclass
class Plugin:
"""
The representation of a NetBox plugin in the catalog API.
"""
id: str = ''
status: str = ''
title_short: str = ''
title_long: str = ''
tag_line: str = ''
description_short: str = ''
slug: str = ''
author: Optional[PluginAuthor] = None
created_at: datetime.datetime = None
updated_at: datetime.datetime = None
license_type: str = ''
homepage_url: str = ''
package_name_pypi: str = ''
config_name: str = ''
is_certified: bool = False
release_latest: PluginVersion = field(default_factory=PluginVersion)
release_recent_history: list[PluginVersion] = field(default_factory=list)
is_local: bool = False # extra field for locally installed plugins
is_installed: bool = False
installed_version: str = ''
def get_local_plugins():
"""
Return a dictionary of all locally-installed plugins, mapped by name.
"""
plugins = {}
for plugin_name in settings.PLUGINS:
plugin = importlib.import_module(plugin_name)
plugin_config: PluginConfig = plugin.config
plugins[plugin_config.name] = Plugin(
slug=plugin_config.name,
title_short=plugin_config.verbose_name,
tag_line=plugin_config.description,
description_short=plugin_config.description,
is_local=True,
is_installed=True,
installed_version=plugin_config.version,
)
return plugins
def get_catalog_plugins():
"""
Return a dictionary of all entries in the plugins catalog, mapped by name.
"""
session = requests.Session()
plugins = {}
def get_pages():
# TODO: pagination is currently broken in API
payload = {'page': '1', 'per_page': '50'}
first_page = session.get(
settings.PLUGIN_CATALOG_URL,
headers={'User-Agent': USER_AGENT_STRING},
proxies=settings.HTTP_PROXIES,
timeout=3,
params=payload
).json()
yield first_page
num_pages = first_page['metadata']['pagination']['last_page']
for page in range(2, num_pages + 1):
payload['page'] = page
next_page = session.get(
settings.PLUGIN_CATALOG_URL,
headers={'User-Agent': USER_AGENT_STRING},
proxies=settings.HTTP_PROXIES,
timeout=3,
params=payload
).json()
yield next_page
for page in get_pages():
for data in page['data']:
# Populate releases
releases = []
for version in data['release_recent_history']:
releases.append(
PluginVersion(
date=datetime_from_timestamp(version['date']),
version=version['version'],
netbox_min_version=version['netbox_min_version'],
netbox_max_version=version['netbox_max_version'],
has_model=version['has_model'],
is_certified=version['is_certified'],
is_feature=version['is_feature'],
is_integration=version['is_integration'],
is_netboxlabs_supported=version['is_netboxlabs_supported'],
)
)
releases = sorted(releases, key=lambda x: x.date, reverse=True)
latest_release = PluginVersion(
date=datetime_from_timestamp(data['release_latest']['date']),
version=data['release_latest']['version'],
netbox_min_version=data['release_latest']['netbox_min_version'],
netbox_max_version=data['release_latest']['netbox_max_version'],
has_model=data['release_latest']['has_model'],
is_certified=data['release_latest']['is_certified'],
is_feature=data['release_latest']['is_feature'],
is_integration=data['release_latest']['is_integration'],
is_netboxlabs_supported=data['release_latest']['is_netboxlabs_supported'],
)
# Populate author (if any)
if data['author']:
print(data['author'])
author = PluginAuthor(
name=data['author']['name'],
org_id=data['author']['org_id'],
url=data['author']['url'],
)
else:
author = None
# Populate plugin data
plugins[data['slug']] = Plugin(
id=data['id'],
status=data['status'],
title_short=data['title_short'],
title_long=data['title_long'],
tag_line=data['tag_line'],
description_short=data['description_short'],
slug=data['slug'],
author=author,
created_at=datetime_from_timestamp(data['created_at']),
updated_at=datetime_from_timestamp(data['updated_at']),
license_type=data['license_type'],
homepage_url=data['homepage_url'],
package_name_pypi=data['package_name_pypi'],
config_name=data['config_name'],
is_certified=data['is_certified'],
release_latest=latest_release,
release_recent_history=releases,
)
return plugins
def get_plugins():
"""
Return a dictionary of all plugins (both catalog and locally installed), mapped by name.
"""
local_plugins = get_local_plugins()
catalog_plugins = cache.get('plugins-catalog-feed')
if not catalog_plugins:
catalog_plugins = get_catalog_plugins()
cache.set('plugins-catalog-feed', catalog_plugins, 3600)
plugins = catalog_plugins
for k, v in local_plugins.items():
if k in plugins:
plugins[k].is_local = True
plugins[k].is_installed = True
else:
plugins[k] = v
return plugins

View File

@ -1,39 +1,80 @@
import django_tables2 as tables
from django.utils.translation import gettext_lazy as _
from netbox.tables import BaseTable
from netbox.tables import BaseTable, columns
__all__ = (
'PluginTable',
'CatalogPluginTable',
'PluginVersionTable',
)
class PluginTable(BaseTable):
name = tables.Column(
accessor=tables.A('verbose_name'),
verbose_name=_('Name')
)
class PluginVersionTable(BaseTable):
version = tables.Column(
verbose_name=_('Version')
)
package = tables.Column(
accessor=tables.A('name'),
verbose_name=_('Package')
last_updated = columns.DateTimeColumn(
accessor=tables.A('date'),
timespec='minutes',
verbose_name=_('Last Updated')
)
author = tables.Column(
verbose_name=_('Author')
min_version = tables.Column(
accessor=tables.A('netbox_min_version'),
verbose_name=_('Minimum NetBox Version')
)
author_email = tables.Column(
verbose_name=_('Author Email')
)
description = tables.Column(
verbose_name=_('Description')
max_version = tables.Column(
accessor=tables.A('netbox_max_version'),
verbose_name=_('Maximum NetBox Version')
)
class Meta(BaseTable.Meta):
empty_text = _('No plugins found')
empty_text = _('No plugin data found')
fields = (
'name', 'version', 'package', 'author', 'author_email', 'description',
'version', 'last_updated', 'min_version', 'max_version',
)
default_columns = (
'name', 'version', 'package', 'description',
'version', 'last_updated', 'min_version', 'max_version',
)
orderable = False
class CatalogPluginTable(BaseTable):
title_short = tables.Column(
linkify=('core:plugin', [tables.A('slug')]),
verbose_name=_('Name')
)
author = tables.Column(
accessor=tables.A('author.name'),
verbose_name=_('Author')
)
is_local = columns.BooleanColumn(
verbose_name=_('Local')
)
is_installed = columns.BooleanColumn(
verbose_name=_('Installed')
)
is_certified = columns.BooleanColumn(
verbose_name=_('Certified')
)
created_at = columns.DateTimeColumn(
verbose_name=_('Published')
)
updated_at = columns.DateTimeColumn(
verbose_name=_('Updated')
)
installed_version = tables.Column(
verbose_name=_('Installed version')
)
class Meta(BaseTable.Meta):
empty_text = _('No plugin data found')
fields = (
'title_short', 'author', 'is_local', 'is_installed', 'is_certified', 'created_at', 'updated_at',
'installed_version',
)
default_columns = (
'title_short', 'author', 'is_local', 'is_installed', 'is_certified', 'created_at', 'updated_at',
)
# List installed plugins first, then certified plugins, then
# everything else (with each tranche ordered alphabetically)
order_by = ('-is_installed', '-is_certified', 'name')

View File

@ -49,4 +49,8 @@ urlpatterns = (
# System
path('system/', views.SystemView.as_view(), name='system'),
# Plugins
path('plugins/', views.PluginListView.as_view(), name='plugin_list'),
path('plugins/<str:name>/', views.PluginView.as_view(), name='plugin'),
)

View File

@ -2,7 +2,6 @@ import json
import platform
from django import __version__ as DJANGO_VERSION
from django.apps import apps
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.mixins import UserPassesTestMixin
@ -36,6 +35,8 @@ from utilities.query import count_related
from utilities.views import ContentTypePermissionRequiredMixin, GetRelatedModelsMixin, register_model_view
from . import filtersets, forms, tables
from .models import *
from .plugins import get_plugins
from .tables import CatalogPluginTable, PluginVersionTable
#
@ -581,7 +582,7 @@ class WorkerView(BaseRQView):
#
# Plugins
# System
#
class SystemView(UserPassesTestMixin, View):
@ -614,12 +615,6 @@ class SystemView(UserPassesTestMixin, View):
'rq_worker_count': Worker.count(get_connection('default')),
}
# Plugins
plugins = [
# Look up app config by package name
apps.get_app_config(plugin.rsplit('.', 1)[-1]) for plugin in settings.PLUGINS
]
# Configuration
try:
config = ConfigRevision.objects.get(pk=cache.get('config_version'))
@ -631,9 +626,6 @@ class SystemView(UserPassesTestMixin, View):
if 'export' in request.GET:
data = {
**stats,
'plugins': {
plugin.name: plugin.version for plugin in plugins
},
'config': {
k: config.data[k] for k in sorted(config.data)
},
@ -642,11 +634,58 @@ class SystemView(UserPassesTestMixin, View):
response['Content-Disposition'] = 'attachment; filename="netbox.json"'
return response
plugins_table = tables.PluginTable(plugins, orderable=False)
plugins_table.configure(request)
return render(request, 'core/system.html', {
'stats': stats,
'plugins_table': plugins_table,
'config': config,
})
#
# Plugins
#
class PluginListView(UserPassesTestMixin, View):
def test_func(self):
return self.request.user.is_staff
def get(self, request):
q = request.GET.get('q', None)
plugins = get_plugins().values()
if q:
plugins = [obj for obj in plugins if q.casefold() in obj.title_short.casefold()]
table = CatalogPluginTable(plugins, user=request.user)
table.configure(request)
# If this is an HTMX request, return only the rendered table HTML
if htmx_partial(request):
return render(request, 'htmx/table.html', {
'table': table,
})
return render(request, 'core/plugin_list.html', {
'table': table,
})
class PluginView(UserPassesTestMixin, View):
def test_func(self):
return self.request.user.is_staff
def get(self, request, name):
plugins = get_plugins()
if name not in plugins:
raise Http404(_("Plugin {name} not found").format(name=name))
plugin = plugins[name]
table = PluginVersionTable(plugin.release_recent_history, user=request.user)
table.configure(request)
return render(request, 'core/plugin.html', {
'plugin': plugin,
'table': table,
})

View File

@ -62,13 +62,27 @@ class DeviceTypeSerializer(NetBoxModelSerializer):
class ModuleTypeSerializer(NetBoxModelSerializer):
manufacturer = ManufacturerSerializer(nested=True)
weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False, allow_null=True)
manufacturer = ManufacturerSerializer(
nested=True
)
weight_unit = ChoiceField(
choices=WeightUnitChoices,
allow_blank=True,
required=False,
allow_null=True
)
airflow = ChoiceField(
choices=ModuleAirflowChoices,
allow_blank=True,
required=False,
allow_null=True
)
class Meta:
model = ModuleType
fields = [
'id', 'url', 'display_url', 'display', 'manufacturer', 'model', 'part_number', 'weight', 'weight_unit',
'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
'id', 'url', 'display_url', 'display', 'manufacturer', 'model', 'part_number', 'airflow',
'weight', 'weight_unit', 'description', 'comments', 'tags', 'custom_fields',
'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'description')

View File

@ -64,14 +64,19 @@ class RackTypeSerializer(RackBaseSerializer):
manufacturer = ManufacturerSerializer(
nested=True
)
airflow = ChoiceField(
choices=RackAirflowChoices,
allow_blank=True,
required=False
)
class Meta:
model = RackType
fields = [
'id', 'url', 'display_url', 'display', 'manufacturer', 'name', 'slug', 'description', 'form_factor',
'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'weight',
'max_weight', 'weight_unit', 'mounting_depth', 'description', 'comments', 'tags', 'custom_fields',
'created', 'last_updated',
'max_weight', 'weight_unit', 'mounting_depth', 'airflow', 'description', 'comments', 'tags',
'custom_fields', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'manufacturer', 'name', 'slug', 'description')
@ -95,6 +100,11 @@ class RackSerializer(RackBaseSerializer):
choices=RackStatusChoices,
required=False
)
airflow = ChoiceField(
choices=RackAirflowChoices,
allow_blank=True,
required=False
)
role = RackRoleSerializer(
nested=True,
required=False,
@ -124,7 +134,7 @@ class RackSerializer(RackBaseSerializer):
'id', 'url', 'display_url', 'display', 'name', 'facility_id', 'site', 'location', 'tenant', 'status',
'role', 'serial', 'asset_tag', 'rack_type', 'form_factor', 'width', 'u_height', 'starting_unit', 'weight',
'max_weight', 'weight_unit', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth',
'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count',
'airflow', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count',
'powerfeed_count',
]
brief_fields = ('id', 'url', 'display', 'name', 'description', 'device_count')

View File

@ -127,6 +127,17 @@ class RackElevationDetailRenderChoices(ChoiceSet):
)
class RackAirflowChoices(ChoiceSet):
AIRFLOW_FRONT_TO_REAR = 'front-to-rear'
AIRFLOW_REAR_TO_FRONT = 'rear-to-front'
CHOICES = (
(AIRFLOW_FRONT_TO_REAR, _('Front to rear')),
(AIRFLOW_REAR_TO_FRONT, _('Rear to front')),
)
#
# DeviceTypes
#
@ -224,6 +235,25 @@ class ModuleStatusChoices(ChoiceSet):
]
class ModuleAirflowChoices(ChoiceSet):
AIRFLOW_FRONT_TO_REAR = 'front-to-rear'
AIRFLOW_REAR_TO_FRONT = 'rear-to-front'
AIRFLOW_LEFT_TO_RIGHT = 'left-to-right'
AIRFLOW_RIGHT_TO_LEFT = 'right-to-left'
AIRFLOW_SIDE_TO_REAR = 'side-to-rear'
AIRFLOW_PASSIVE = 'passive'
CHOICES = (
(AIRFLOW_FRONT_TO_REAR, _('Front to rear')),
(AIRFLOW_REAR_TO_FRONT, _('Rear to front')),
(AIRFLOW_LEFT_TO_RIGHT, _('Left to right')),
(AIRFLOW_RIGHT_TO_LEFT, _('Right to left')),
(AIRFLOW_SIDE_TO_REAR, _('Side to rear')),
(AIRFLOW_PASSIVE, _('Passive')),
)
#
# ConsolePorts
#

View File

@ -312,7 +312,7 @@ class RackTypeFilterSet(NetBoxModelFilterSet):
model = RackType
fields = (
'id', 'name', 'slug', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit',
'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description',
'mounting_depth', 'airflow', 'weight', 'max_weight', 'weight_unit', 'description',
)
def search(self, queryset, name, value):
@ -413,7 +413,8 @@ class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe
model = Rack
fields = (
'id', 'name', 'facility_id', 'asset_tag', 'u_height', 'starting_unit', 'desc_units', 'outer_width',
'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description',
'outer_depth', 'outer_unit', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'weight_unit',
'description',
)
def search(self, queryset, name, value):
@ -698,7 +699,7 @@ class ModuleTypeFilterSet(NetBoxModelFilterSet):
class Meta:
model = ModuleType
fields = ('id', 'model', 'part_number', 'weight', 'weight_unit', 'description')
fields = ('id', 'model', 'part_number', 'airflow', 'weight', 'weight_unit', 'description')
def search(self, queryset, name, value):
if not value.strip():

View File

@ -268,6 +268,11 @@ class RackTypeBulkEditForm(NetBoxModelBulkEditForm):
required=False,
min_value=1
)
airflow = forms.ChoiceField(
label=_('Airflow'),
choices=add_blank_choice(RackAirflowChoices),
required=False
)
weight = forms.DecimalField(
label=_('Weight'),
min_value=0,
@ -293,10 +298,8 @@ class RackTypeBulkEditForm(NetBoxModelBulkEditForm):
model = RackType
fieldsets = (
FieldSet('manufacturer', 'description', 'form_factor', name=_('Rack Type')),
FieldSet('manufacturer', 'description', 'form_factor', 'width', 'u_height', 'airflow', name=_('Rack Type')),
FieldSet(
'width',
'u_height',
InlineFields('outer_width', 'outer_depth', 'outer_unit', label=_('Outer Dimensions')),
InlineFields('weight', 'max_weight', 'weight_unit', label=_('Weight')),
'mounting_depth',
@ -409,6 +412,11 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
required=False,
min_value=1
)
airflow = forms.ChoiceField(
label=_('Airflow'),
choices=add_blank_choice(RackAirflowChoices),
required=False
)
weight = forms.DecimalField(
label=_('Weight'),
min_value=0,
@ -437,7 +445,7 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
FieldSet('status', 'role', 'tenant', 'serial', 'asset_tag', 'description', name=_('Rack')),
FieldSet('region', 'site_group', 'site', 'location', name=_('Location')),
FieldSet(
'form_factor', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit',
'form_factor', 'width', 'u_height', 'desc_units', 'airflow', 'outer_width', 'outer_depth', 'outer_unit',
'mounting_depth', name=_('Hardware')
),
FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
@ -563,6 +571,11 @@ class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm):
label=_('Part number'),
required=False
)
airflow = forms.ChoiceField(
label=_('Airflow'),
choices=add_blank_choice(ModuleAirflowChoices),
required=False
)
weight = forms.DecimalField(
label=_('Weight'),
min_value=0,
@ -584,7 +597,11 @@ class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm):
model = ModuleType
fieldsets = (
FieldSet('manufacturer', 'part_number', 'description', name=_('Module Type')),
FieldSet('weight', 'weight_unit', name=_('Weight')),
FieldSet(
'airflow',
InlineFields('weight', 'max_weight', 'weight_unit', label=_('Weight')),
name=_('Chassis')
),
)
nullable_fields = ('part_number', 'weight', 'weight_unit', 'description', 'comments')

View File

@ -206,6 +206,12 @@ class RackTypeImportForm(NetBoxModelImportForm):
required=False,
help_text=_('Unit for outer dimensions')
)
airflow = CSVChoiceField(
label=_('Airflow'),
choices=RackAirflowChoices,
required=False,
help_text=_('Airflow direction')
)
weight_unit = CSVChoiceField(
label=_('Weight unit'),
choices=WeightUnitChoices,
@ -217,8 +223,8 @@ class RackTypeImportForm(NetBoxModelImportForm):
model = RackType
fields = (
'manufacturer', 'name', 'slug', 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units',
'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit',
'description', 'comments', 'tags',
'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'airflow', 'weight', 'max_weight',
'weight_unit', 'description', 'comments', 'tags',
)
def __init__(self, data=None, *args, **kwargs):
@ -273,6 +279,12 @@ class RackImportForm(NetBoxModelImportForm):
required=False,
help_text=_('Unit for outer dimensions')
)
airflow = CSVChoiceField(
label=_('Airflow'),
choices=RackAirflowChoices,
required=False,
help_text=_('Airflow direction')
)
weight_unit = CSVChoiceField(
label=_('Weight unit'),
choices=WeightUnitChoices,
@ -284,8 +296,8 @@ class RackImportForm(NetBoxModelImportForm):
model = Rack
fields = (
'site', 'location', 'name', 'facility_id', 'tenant', 'status', 'role', 'form_factor', 'serial', 'asset_tag',
'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight',
'max_weight', 'weight_unit', 'description', 'comments', 'tags',
'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'airflow',
'weight', 'max_weight', 'weight_unit', 'description', 'comments', 'tags',
)
def __init__(self, data=None, *args, **kwargs):
@ -400,6 +412,12 @@ class ModuleTypeImportForm(NetBoxModelImportForm):
queryset=Manufacturer.objects.all(),
to_field_name='name'
)
airflow = CSVChoiceField(
label=_('Airflow'),
choices=ModuleAirflowChoices,
required=False,
help_text=_('Airflow direction')
)
weight = forms.DecimalField(
label=_('Weight'),
required=False,
@ -414,7 +432,7 @@ class ModuleTypeImportForm(NetBoxModelImportForm):
class Meta:
model = ModuleType
fields = ['manufacturer', 'model', 'part_number', 'description', 'weight', 'weight_unit', 'comments', 'tags']
fields = ['manufacturer', 'model', 'part_number', 'description', 'airflow', 'weight', 'weight_unit', 'comments', 'tags']
class DeviceRoleImportForm(NetBoxModelImportForm):

View File

@ -267,6 +267,11 @@ class RackBaseFilterForm(NetBoxModelFilterSetForm):
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
airflow = forms.MultipleChoiceField(
label=_('Airflow'),
choices=add_blank_choice(RackAirflowChoices),
required=False
)
weight = forms.DecimalField(
label=_('Weight'),
required=False,
@ -288,7 +293,7 @@ class RackTypeFilterForm(RackBaseFilterForm):
model = RackType
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('form_factor', 'width', 'u_height', name=_('Rack Type')),
FieldSet('form_factor', 'width', 'u_height', 'airflow', name=_('Rack Type')),
FieldSet('starting_unit', 'desc_units', name=_('Numbering')),
FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
)
@ -308,7 +313,7 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, RackBaseFilterFo
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('status', 'role_id', 'serial', 'asset_tag', name=_('Rack')),
FieldSet('form_factor', 'width', 'u_height', name=_('Rack Type')),
FieldSet('form_factor', 'width', 'u_height', 'airflow', name=_('Rack Type')),
FieldSet('starting_unit', 'desc_units', name=_('Numbering')),
FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
@ -578,7 +583,7 @@ class ModuleTypeFilterForm(NetBoxModelFilterSetForm):
model = ModuleType
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('manufacturer_id', 'part_number', name=_('Hardware')),
FieldSet('manufacturer_id', 'part_number', 'airflow', name=_('Hardware')),
FieldSet(
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces',
'pass_through_ports', name=_('Components')
@ -638,6 +643,11 @@ class ModuleTypeFilterForm(NetBoxModelFilterSetForm):
)
)
tag = TagFilterField(model)
airflow = forms.MultipleChoiceField(
label=_('Airflow'),
choices=add_blank_choice(ModuleAirflowChoices),
required=False
)
weight = forms.DecimalField(
label=_('Weight'),
required=False

View File

@ -211,7 +211,7 @@ class RackTypeForm(NetBoxModelForm):
slug = SlugField()
fieldsets = (
FieldSet('manufacturer', 'name', 'slug', 'description', 'form_factor', 'tags', name=_('Rack Type')),
FieldSet('manufacturer', 'name', 'slug', 'description', 'form_factor', 'airflow', 'tags', name=_('Rack Type')),
FieldSet(
'width', 'u_height',
InlineFields('outer_width', 'outer_depth', 'outer_unit', label=_('Outer Dimensions')),
@ -226,7 +226,7 @@ class RackTypeForm(NetBoxModelForm):
fields = [
'manufacturer', 'name', 'slug', 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units',
'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit',
'description', 'comments', 'tags',
'airflow', 'description', 'comments', 'tags',
]
@ -268,8 +268,8 @@ class RackForm(TenancyForm, NetBoxModelForm):
fields = [
'site', 'location', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', 'role', 'serial',
'asset_tag', 'rack_type', 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width',
'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description',
'comments', 'tags',
'outer_depth', 'outer_unit', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'weight_unit',
'description', 'comments', 'tags',
]
def __init__(self, *args, **kwargs):
@ -290,7 +290,7 @@ class RackForm(TenancyForm, NetBoxModelForm):
self.fieldsets = (
*self.fieldsets,
FieldSet(
'form_factor', 'width', 'starting_unit', 'u_height',
'form_factor', 'width', 'starting_unit', 'u_height', 'airflow',
InlineFields('outer_width', 'outer_depth', 'outer_unit', label=_('Outer Dimensions')),
InlineFields('weight', 'max_weight', 'weight_unit', label=_('Weight')),
'mounting_depth', 'desc_units', name=_('Dimensions')
@ -398,13 +398,14 @@ class ModuleTypeForm(NetBoxModelForm):
fieldsets = (
FieldSet('manufacturer', 'model', 'part_number', 'description', 'tags', name=_('Module Type')),
FieldSet('weight', 'weight_unit', name=_('Weight'))
FieldSet('airflow', 'weight', 'weight_unit', name=_('Chassis'))
)
class Meta:
model = ModuleType
fields = [
'manufacturer', 'model', 'part_number', 'weight', 'weight_unit', 'description', 'comments', 'tags',
'manufacturer', 'model', 'part_number', 'airflow', 'weight', 'weight_unit', 'description',
'comments', 'tags',
]

View File

@ -0,0 +1,28 @@
# Generated by Django 5.0.7 on 2024-07-25 07:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0188_racktype'),
]
operations = [
migrations.AddField(
model_name='moduletype',
name='airflow',
field=models.CharField(blank=True, max_length=50),
),
migrations.AddField(
model_name='rack',
name='airflow',
field=models.CharField(blank=True, max_length=50),
),
migrations.AddField(
model_name='racktype',
name='airflow',
field=models.CharField(blank=True, max_length=50),
),
]

View File

@ -388,8 +388,14 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
blank=True,
help_text=_('Discrete part number (optional)')
)
airflow = models.CharField(
verbose_name=_('airflow'),
max_length=50,
choices=ModuleAirflowChoices,
blank=True
)
clone_fields = ('manufacturer', 'weight', 'weight_unit',)
clone_fields = ('manufacturer', 'weight', 'weight_unit', 'airflow')
prerequisite_models = (
'dcim.Manufacturer',
)

View File

@ -53,6 +53,12 @@ class RackBase(WeightMixin, PrimaryModel):
verbose_name=_('width'),
help_text=_('Rail-to-rail width')
)
airflow = models.CharField(
verbose_name=_('airflow'),
max_length=50,
choices=RackAirflowChoices,
blank=True
)
# Numbering
u_height = models.PositiveSmallIntegerField(
@ -232,10 +238,10 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, RackBase):
Each Rack is assigned to a Site and (optionally) a Location.
"""
# Fields which cannot be set locally if a RackType is assigned
RACKTYPE_FIELDS = [
'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit',
'mounting_depth', 'weight', 'weight_unit', 'max_weight'
]
RACKTYPE_FIELDS = (
'form_factor', 'width', 'airflow', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_depth',
'outer_unit', 'mounting_depth', 'weight', 'weight_unit', 'max_weight',
)
rack_type = models.ForeignKey(
to='dcim.RackType',
@ -316,8 +322,8 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, RackBase):
)
clone_fields = (
'site', 'location', 'tenant', 'status', 'role', 'form_factor', 'width', 'u_height', 'desc_units', 'outer_width',
'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit',
'site', 'location', 'tenant', 'status', 'role', 'form_factor', 'width', 'airflow', 'u_height', 'desc_units',
'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit',
)
prerequisite_models = (
'dcim.Site',

View File

@ -40,7 +40,7 @@ class ModuleTypeTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = ModuleType
fields = (
'pk', 'id', 'model', 'manufacturer', 'part_number', 'weight', 'description', 'comments', 'tags',
'pk', 'id', 'model', 'manufacturer', 'part_number', 'airflow', 'weight', 'description', 'comments', 'tags',
)
default_columns = (
'pk', 'model', 'manufacturer', 'part_number',

View File

@ -92,8 +92,8 @@ class RackTypeTable(NetBoxTable):
model = RackType
fields = (
'pk', 'id', 'name', 'manufacturer', 'form_factor', 'u_height', 'starting_unit', 'width', 'outer_width',
'outer_depth', 'mounting_depth', 'weight', 'max_weight', 'description', 'comments', 'tags', 'created',
'last_updated',
'outer_depth', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'description', 'comments', 'tags',
'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'manufacturer', 'type', 'u_height', 'description',
@ -171,7 +171,7 @@ class RackTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
fields = (
'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'tenant_group', 'role', 'serial',
'asset_tag', 'form_factor', 'u_height', 'starting_unit', 'width', 'outer_width', 'outer_depth',
'mounting_depth', 'weight', 'max_weight', 'comments', 'device_count', 'get_utilization',
'mounting_depth', 'airflow', 'weight', 'max_weight', 'comments', 'device_count', 'get_utilization',
'get_power_utilization', 'description', 'contacts', 'tags', 'created', 'last_updated',
)
default_columns = (

View File

@ -34,9 +34,9 @@ class EventRuleSerializer(NetBoxModelSerializer):
class Meta:
model = EventRule
fields = [
'id', 'url', 'display_url', 'display', 'object_types', 'name', 'type_create', 'type_update', 'type_delete',
'type_job_start', 'type_job_end', 'enabled', 'conditions', 'action_type', 'action_object_type',
'action_object_id', 'action_object', 'description', 'custom_fields', 'tags', 'created', 'last_updated',
'id', 'url', 'display_url', 'display', 'object_types', 'name', 'enabled', 'event_types', 'conditions',
'action_type', 'action_object_type', 'action_object_id', 'action_object', 'description', 'custom_fields',
'tags', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'name', 'description')

View File

@ -1,3 +1,4 @@
from collections import defaultdict
import logging
from django.conf import settings
@ -152,35 +153,20 @@ def process_event_queue(events):
"""
Flush a list of object representation to RQ for EventRule processing.
"""
events_cache = {
'type_create': {},
'type_update': {},
'type_delete': {},
}
event_actions = {
# TODO: Add EventRule support for dynamically registered event types
OBJECT_CREATED: 'type_create',
OBJECT_UPDATED: 'type_update',
OBJECT_DELETED: 'type_delete',
JOB_STARTED: 'type_job_start',
JOB_COMPLETED: 'type_job_end',
# Map failed & errored jobs to type_job_end
JOB_FAILED: 'type_job_end',
JOB_ERRORED: 'type_job_end',
}
events_cache = defaultdict(dict)
for event in events:
action_flag = event_actions[event['event_type']]
event_type = event['event_type']
object_type = event['object_type']
# Cache applicable Event Rules
if object_type not in events_cache[action_flag]:
events_cache[action_flag][object_type] = EventRule.objects.filter(
**{action_flag: True},
if object_type not in events_cache[event_type]:
events_cache[event_type][object_type] = EventRule.objects.filter(
event_types__contains=[event['event_type']],
object_types=object_type,
enabled=True
)
event_rules = events_cache[action_flag][object_type]
event_rules = events_cache[event_type][object_type]
process_event_rules(
event_rules=event_rules,

View File

@ -99,6 +99,9 @@ class EventRuleFilterSet(NetBoxModelFilterSet):
object_type = ContentTypeFilter(
field_name='object_types'
)
event_type = MultiValueCharFilter(
method='filter_event_type'
)
action_type = django_filters.MultipleChoiceFilter(
choices=EventRuleActionChoices
)
@ -108,8 +111,7 @@ class EventRuleFilterSet(NetBoxModelFilterSet):
class Meta:
model = EventRule
fields = (
'id', 'name', 'type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end', 'enabled',
'action_type', 'description',
'id', 'name', 'enabled', 'action_type', 'description',
)
def search(self, queryset, name, value):
@ -121,6 +123,9 @@ class EventRuleFilterSet(NetBoxModelFilterSet):
Q(comments__icontains=value)
)
def filter_event_type(self, queryset, name, value):
return queryset.filter(event_types__overlap=value)
class CustomFieldFilterSet(ChangeLoggedModelFilterSet):
q = django_filters.CharFilter(

View File

@ -3,6 +3,7 @@ from django.utils.translation import gettext_lazy as _
from extras.choices import *
from extras.models import *
from netbox.events import get_event_type_choices
from netbox.forms import NetBoxModelBulkEditForm
from utilities.forms import BulkEditForm, add_blank_choice
from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField
@ -248,33 +249,18 @@ class EventRuleBulkEditForm(NetBoxModelBulkEditForm):
required=False,
widget=BulkEditNullBooleanSelect()
)
type_create = forms.NullBooleanField(
label=_('On create'),
event_types = forms.MultipleChoiceField(
choices=get_event_type_choices(),
required=False,
widget=BulkEditNullBooleanSelect()
label=_('Event types')
)
type_update = forms.NullBooleanField(
label=_('On update'),
required=False,
widget=BulkEditNullBooleanSelect()
)
type_delete = forms.NullBooleanField(
label=_('On delete'),
required=False,
widget=BulkEditNullBooleanSelect()
)
type_job_start = forms.NullBooleanField(
label=_('On job start'),
required=False,
widget=BulkEditNullBooleanSelect()
)
type_job_end = forms.NullBooleanField(
label=_('On job end'),
required=False,
widget=BulkEditNullBooleanSelect()
description = forms.CharField(
label=_('Description'),
max_length=200,
required=False
)
nullable_fields = ('description', 'conditions',)
nullable_fields = ('description', 'conditions')
class TagBulkEditForm(BulkEditForm):

View File

@ -8,12 +8,13 @@ from django.utils.translation import gettext_lazy as _
from core.models import ObjectType
from extras.choices import *
from extras.models import *
from netbox.events import get_event_type_choices
from netbox.forms import NetBoxModelImportForm
from users.models import Group, User
from utilities.forms import CSVModelForm
from utilities.forms.fields import (
CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVModelMultipleChoiceField, CSVMultipleContentTypeField,
SlugField,
CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVModelMultipleChoiceField, CSVMultipleChoiceField,
CSVMultipleContentTypeField, SlugField,
)
__all__ = (
@ -187,6 +188,11 @@ class EventRuleImportForm(NetBoxModelImportForm):
queryset=ObjectType.objects.with_feature('event_rules'),
help_text=_("One or more assigned object types")
)
event_types = CSVMultipleChoiceField(
choices=get_event_type_choices(),
label=_('Event types'),
help_text=_('The event type(s) which will trigger this rule')
)
action_object = forms.CharField(
label=_('Action object'),
required=True,
@ -196,8 +202,8 @@ class EventRuleImportForm(NetBoxModelImportForm):
class Meta:
model = EventRule
fields = (
'name', 'description', 'enabled', 'conditions', 'object_types', 'type_create', 'type_update',
'type_delete', 'type_job_start', 'type_job_end', 'action_type', 'action_object', 'comments', 'tags'
'name', 'description', 'enabled', 'conditions', 'object_types', 'event_types', 'action_type',
'action_object', 'comments', 'tags'
)
def clean(self):

View File

@ -6,6 +6,7 @@ from core.models import ObjectType, DataFile, DataSource
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
from extras.choices import *
from extras.models import *
from netbox.events import get_event_type_choices
from netbox.forms.base import NetBoxModelFilterSetForm
from netbox.forms.mixins import SavedFiltersMixin
from tenancy.models import Tenant, TenantGroup
@ -274,14 +275,18 @@ class EventRuleFilterForm(NetBoxModelFilterSetForm):
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('object_type_id', 'action_type', 'enabled', name=_('Attributes')),
FieldSet('type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end', name=_('Events')),
FieldSet('object_type_id', 'event_type', 'action_type', 'enabled', name=_('Attributes')),
)
object_type_id = ContentTypeMultipleChoiceField(
queryset=ObjectType.objects.with_feature('event_rules'),
required=False,
label=_('Object type')
)
event_type = forms.MultipleChoiceField(
choices=get_event_type_choices,
required=False,
label=_('Event type')
)
action_type = forms.ChoiceField(
choices=add_blank_choice(EventRuleActionChoices),
required=False,
@ -294,41 +299,6 @@ class EventRuleFilterForm(NetBoxModelFilterSetForm):
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
type_create = forms.NullBooleanField(
required=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
),
label=_('Object creations')
)
type_update = forms.NullBooleanField(
required=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
),
label=_('Object updates')
)
type_delete = forms.NullBooleanField(
required=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
),
label=_('Object deletions')
)
type_job_start = forms.NullBooleanField(
required=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
),
label=_('Job starts')
)
type_job_end = forms.NullBooleanField(
required=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
),
label=_('Job terminations')
)
class TagFilterForm(SavedFiltersMixin, FilterForm):

View File

@ -10,6 +10,7 @@ from core.models import ObjectType
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
from extras.choices import *
from extras.models import *
from netbox.events import get_event_type_choices
from netbox.forms import NetBoxModelForm
from tenancy.models import Tenant, TenantGroup
from users.models import Group, User
@ -303,6 +304,10 @@ class EventRuleForm(NetBoxModelForm):
label=_('Object types'),
queryset=ObjectType.objects.with_feature('event_rules'),
)
event_types = forms.MultipleChoiceField(
choices=get_event_type_choices(),
label=_('Event types')
)
action_choice = forms.ChoiceField(
label=_('Action choice'),
choices=[]
@ -319,25 +324,16 @@ class EventRuleForm(NetBoxModelForm):
fieldsets = (
FieldSet('name', 'description', 'object_types', 'enabled', 'tags', name=_('Event Rule')),
FieldSet('type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end', name=_('Events')),
FieldSet('conditions', name=_('Conditions')),
FieldSet('event_types', 'conditions', name=_('Triggers')),
FieldSet('action_type', 'action_choice', 'action_data', name=_('Action')),
)
class Meta:
model = EventRule
fields = (
'object_types', 'name', 'description', 'type_create', 'type_update', 'type_delete', 'type_job_start',
'type_job_end', 'enabled', 'conditions', 'action_type', 'action_object_type', 'action_object_id',
'action_data', 'comments', 'tags'
'object_types', 'name', 'description', 'enabled', 'event_types', 'conditions', 'action_type',
'action_object_type', 'action_object_id', 'action_data', 'comments', 'tags'
)
labels = {
'type_create': _('Creations'),
'type_update': _('Updates'),
'type_delete': _('Deletions'),
'type_job_start': _('Job executions'),
'type_job_end': _('Job terminations'),
}
widgets = {
'conditions': forms.Textarea(attrs={'class': 'font-monospace'}),
'action_type': HTMXSelect(),

View File

@ -0,0 +1,75 @@
import django.contrib.postgres.fields
from django.db import migrations, models
from core.events import *
def set_event_types(apps, schema_editor):
EventRule = apps.get_model('extras', 'EventRule')
event_rules = EventRule.objects.all()
for event_rule in event_rules:
event_rule.event_types = []
if event_rule.type_create:
event_rule.event_types.append(OBJECT_CREATED)
if event_rule.type_update:
event_rule.event_types.append(OBJECT_UPDATED)
if event_rule.type_delete:
event_rule.event_types.append(OBJECT_DELETED)
if event_rule.type_job_start:
event_rule.event_types.append(JOB_STARTED)
if event_rule.type_job_end:
# Map type_job_end to all job termination events
event_rule.event_types.extend([JOB_COMPLETED, JOB_ERRORED, JOB_FAILED])
EventRule.objects.bulk_update(event_rules, ['event_types'])
class Migration(migrations.Migration):
dependencies = [
('extras', '0118_notifications'),
]
operations = [
migrations.AddField(
model_name='eventrule',
name='event_types',
field=django.contrib.postgres.fields.ArrayField(
base_field=models.CharField(max_length=50),
blank=True,
null=True,
size=None
),
),
migrations.RunPython(
code=set_event_types,
reverse_code=migrations.RunPython.noop
),
migrations.AlterField(
model_name='eventrule',
name='event_types',
field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=50), size=None),
preserve_default=False,
),
migrations.RemoveField(
model_name='eventrule',
name='type_create',
),
migrations.RemoveField(
model_name='eventrule',
name='type_delete',
),
migrations.RemoveField(
model_name='eventrule',
name='type_job_end',
),
migrations.RemoveField(
model_name='eventrule',
name='type_job_start',
),
migrations.RemoveField(
model_name='eventrule',
name='type_update',
),
]

View File

@ -3,6 +3,7 @@ import urllib.parse
from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.contrib.postgres.fields import ArrayField
from django.core.validators import ValidationError
from django.db import models
from django.http import HttpResponse
@ -17,6 +18,7 @@ from extras.conditions import ConditionSet
from extras.constants import *
from extras.utils import image_upload
from netbox.config import get_config
from netbox.events import get_event_type_choices
from netbox.models import ChangeLoggedModel
from netbox.models.features import (
CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin,
@ -60,30 +62,9 @@ class EventRule(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLogged
max_length=200,
blank=True
)
type_create = models.BooleanField(
verbose_name=_('on create'),
default=False,
help_text=_("Triggers when a matching object is created.")
)
type_update = models.BooleanField(
verbose_name=_('on update'),
default=False,
help_text=_("Triggers when a matching object is updated.")
)
type_delete = models.BooleanField(
verbose_name=_('on delete'),
default=False,
help_text=_("Triggers when a matching object is deleted.")
)
type_job_start = models.BooleanField(
verbose_name=_('on job start'),
default=False,
help_text=_("Triggers when a job for a matching object is started.")
)
type_job_end = models.BooleanField(
verbose_name=_('on job end'),
default=False,
help_text=_("Triggers when a job for a matching object terminates.")
event_types = ArrayField(
base_field=models.CharField(max_length=50, choices=get_event_type_choices),
help_text=_("The types of event which will trigger this rule.")
)
enabled = models.BooleanField(
verbose_name=_('enabled'),
@ -144,14 +125,6 @@ class EventRule(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLogged
def clean(self):
super().clean()
# At least one action type must be selected
if not any([
self.type_create, self.type_update, self.type_delete, self.type_job_start, self.type_job_end
]):
raise ValidationError(
_("At least one event type must be selected: create, update, delete, job start, and/or job end.")
)
# Validate that any conditions are in the correct format
if self.conditions:
try:

View File

@ -274,7 +274,11 @@ def process_job_start_event_rules(sender, **kwargs):
"""
Process event rules for jobs starting.
"""
event_rules = EventRule.objects.filter(type_job_start=True, enabled=True, object_types=sender.object_type)
event_rules = EventRule.objects.filter(
event_types__contains=[JOB_STARTED],
enabled=True,
object_types=sender.object_type
)
username = sender.user.username if sender.user else None
process_event_rules(
event_rules=event_rules,
@ -290,7 +294,11 @@ def process_job_end_event_rules(sender, **kwargs):
"""
Process event rules for jobs terminating.
"""
event_rules = EventRule.objects.filter(type_job_end=True, enabled=True, object_types=sender.object_type)
event_rules = EventRule.objects.filter(
event_types__contains=[JOB_COMPLETED],
enabled=True,
object_types=sender.object_type
)
username = sender.user.username if sender.user else None
process_event_rules(
event_rules=event_rules,

View File

@ -6,6 +6,7 @@ from django.utils.translation import gettext_lazy as _
from extras.models import *
from netbox.constants import EMPTY_TABLE_TEXT
from netbox.events import get_event_text
from netbox.tables import BaseTable, NetBoxTable, columns
from .columns import NotificationActionsColumn
@ -399,20 +400,10 @@ class EventRuleTable(NetBoxTable):
enabled = columns.BooleanColumn(
verbose_name=_('Enabled'),
)
type_create = columns.BooleanColumn(
verbose_name=_('Create')
)
type_update = columns.BooleanColumn(
verbose_name=_('Update')
)
type_delete = columns.BooleanColumn(
verbose_name=_('Delete')
)
type_job_start = columns.BooleanColumn(
verbose_name=_('Job Start')
)
type_job_end = columns.BooleanColumn(
verbose_name=_('Job End')
event_types = columns.ArrayColumn(
verbose_name=_('Event Types'),
func=get_event_text,
orderable=False
)
tags = columns.TagColumn(
url_name='extras:webhook_list'
@ -422,12 +413,10 @@ class EventRuleTable(NetBoxTable):
model = EventRule
fields = (
'pk', 'id', 'name', 'enabled', 'description', 'action_type', 'action_object', 'object_types',
'type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end', 'tags', 'created',
'last_updated',
'event_types', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'enabled', 'action_type', 'action_object', 'object_types', 'type_create', 'type_update',
'type_delete', 'type_job_start', 'type_job_end',
'pk', 'name', 'enabled', 'action_type', 'action_object', 'object_types', 'event_types',
)

View File

@ -1,6 +1,5 @@
import datetime
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
from django.utils.timezone import make_aware
@ -13,6 +12,7 @@ from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Loca
from extras.choices import *
from extras.models import *
from extras.scripts import BooleanVar, IntegerVar, Script as PythonClass, StringVar
from netbox.events import *
from users.models import Group, User
from utilities.testing import APITestCase, APIViewTestCases
@ -113,9 +113,9 @@ class EventRuleTest(APIViewTestCases.APIViewTestCase):
Webhook.objects.bulk_create(webhooks)
event_rules = (
EventRule(name='EventRule 1', type_create=True, action_object=webhooks[0]),
EventRule(name='EventRule 2', type_create=True, action_object=webhooks[1]),
EventRule(name='EventRule 3', type_create=True, action_object=webhooks[2]),
EventRule(name='EventRule 1', event_types=[OBJECT_CREATED], action_object=webhooks[0]),
EventRule(name='EventRule 2', event_types=[OBJECT_CREATED], action_object=webhooks[1]),
EventRule(name='EventRule 3', event_types=[OBJECT_CREATED], action_object=webhooks[2]),
)
EventRule.objects.bulk_create(event_rules)
@ -123,7 +123,7 @@ class EventRuleTest(APIViewTestCases.APIViewTestCase):
{
'name': 'EventRule 4',
'object_types': ['dcim.device', 'dcim.devicetype'],
'type_create': True,
'event_types': [OBJECT_CREATED],
'action_type': EventRuleActionChoices.WEBHOOK,
'action_object_type': 'extras.webhook',
'action_object_id': webhooks[3].pk,
@ -131,7 +131,7 @@ class EventRuleTest(APIViewTestCases.APIViewTestCase):
{
'name': 'EventRule 5',
'object_types': ['dcim.device', 'dcim.devicetype'],
'type_create': True,
'event_types': [OBJECT_CREATED],
'action_type': EventRuleActionChoices.WEBHOOK,
'action_object_type': 'extras.webhook',
'action_object_id': webhooks[4].pk,
@ -139,7 +139,7 @@ class EventRuleTest(APIViewTestCases.APIViewTestCase):
{
'name': 'EventRule 6',
'object_types': ['dcim.device', 'dcim.devicetype'],
'type_create': True,
'event_types': [OBJECT_CREATED],
'action_type': EventRuleActionChoices.WEBHOOK,
'action_object_type': 'extras.webhook',
'action_object_id': webhooks[5].pk,

View File

@ -1,6 +1,7 @@
from django.contrib.contenttypes.models import ContentType
from django.test import TestCase
from core.events import *
from dcim.choices import SiteStatusChoices
from dcim.models import Site
from extras.conditions import Condition, ConditionSet
@ -230,8 +231,7 @@ class ConditionSetTest(TestCase):
"""
event_rule = EventRule(
name='Event Rule 1',
type_create=True,
type_update=True,
event_types=[OBJECT_CREATED, OBJECT_UPDATED],
conditions={
'attr': 'status.value',
'value': 'active',
@ -251,8 +251,7 @@ class ConditionSetTest(TestCase):
"""
event_rule = EventRule(
name='Event Rule 1',
type_create=True,
type_update=True,
event_types=[OBJECT_CREATED, OBJECT_UPDATED],
conditions={
"attr": "status.value",
"value": ["planned", "staging"],
@ -273,8 +272,7 @@ class ConditionSetTest(TestCase):
"""
event_rule = EventRule(
name='Event Rule 1',
type_create=True,
type_update=True,
event_types=[OBJECT_CREATED, OBJECT_UPDATED],
conditions={
"attr": "status.value",
"value": ["planned", "staging"],
@ -300,8 +298,7 @@ class ConditionSetTest(TestCase):
webhook = Webhook.objects.create(name='Webhook 100', payload_url='http://example.com/?1', http_method='POST')
form = EventRuleForm({
"name": "Event Rule 1",
"type_create": True,
"type_update": True,
"event_types": [OBJECT_CREATED, OBJECT_UPDATED],
"action_object_type": ct.pk,
"action_type": "webhook",
"action_choice": webhook.pk,

View File

@ -46,22 +46,22 @@ class EventRuleTest(APITestCase):
webhook_type = ObjectType.objects.get(app_label='extras', model='webhook')
event_rules = EventRule.objects.bulk_create((
EventRule(
name='Webhook Event 1',
type_create=True,
name='Event Rule 1',
event_types=[OBJECT_CREATED],
action_type=EventRuleActionChoices.WEBHOOK,
action_object_type=webhook_type,
action_object_id=webhooks[0].id
),
EventRule(
name='Webhook Event 2',
type_update=True,
name='Event Rule 2',
event_types=[OBJECT_UPDATED],
action_type=EventRuleActionChoices.WEBHOOK,
action_object_type=webhook_type,
action_object_id=webhooks[0].id
),
EventRule(
name='Webhook Event 3',
type_delete=True,
name='Event Rule 3',
event_types=[OBJECT_DELETED],
action_type=EventRuleActionChoices.WEBHOOK,
action_object_type=webhook_type,
action_object_id=webhooks[0].id
@ -82,8 +82,7 @@ class EventRuleTest(APITestCase):
"""
event_rule = EventRule(
name='Event Rule 1',
type_create=True,
type_update=True,
event_types=[OBJECT_CREATED, OBJECT_UPDATED],
conditions={
'and': [
{
@ -131,7 +130,7 @@ class EventRuleTest(APITestCase):
# Verify that a background task was queued for the new object
self.assertEqual(self.queue.count, 1)
job = self.queue.jobs[0]
self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(type_create=True))
self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(name='Event Rule 1'))
self.assertEqual(job.kwargs['event_type'], OBJECT_CREATED)
self.assertEqual(job.kwargs['model_name'], 'site')
self.assertEqual(job.kwargs['data']['id'], response.data['id'])
@ -181,7 +180,7 @@ class EventRuleTest(APITestCase):
# Verify that a background task was queued for each new object
self.assertEqual(self.queue.count, 3)
for i, job in enumerate(self.queue.jobs):
self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(type_create=True))
self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(name='Event Rule 1'))
self.assertEqual(job.kwargs['event_type'], OBJECT_CREATED)
self.assertEqual(job.kwargs['model_name'], 'site')
self.assertEqual(job.kwargs['data']['id'], response.data[i]['id'])
@ -212,7 +211,7 @@ class EventRuleTest(APITestCase):
# Verify that a background task was queued for the updated object
self.assertEqual(self.queue.count, 1)
job = self.queue.jobs[0]
self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(type_update=True))
self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(name='Event Rule 2'))
self.assertEqual(job.kwargs['event_type'], OBJECT_UPDATED)
self.assertEqual(job.kwargs['model_name'], 'site')
self.assertEqual(job.kwargs['data']['id'], site.pk)
@ -268,7 +267,7 @@ class EventRuleTest(APITestCase):
# Verify that a background task was queued for each updated object
self.assertEqual(self.queue.count, 3)
for i, job in enumerate(self.queue.jobs):
self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(type_update=True))
self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(name='Event Rule 2'))
self.assertEqual(job.kwargs['event_type'], OBJECT_UPDATED)
self.assertEqual(job.kwargs['model_name'], 'site')
self.assertEqual(job.kwargs['data']['id'], data[i]['id'])
@ -294,7 +293,7 @@ class EventRuleTest(APITestCase):
# Verify that a task was queued for the deleted object
self.assertEqual(self.queue.count, 1)
job = self.queue.jobs[0]
self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(type_delete=True))
self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(name='Event Rule 3'))
self.assertEqual(job.kwargs['event_type'], OBJECT_DELETED)
self.assertEqual(job.kwargs['model_name'], 'site')
self.assertEqual(job.kwargs['data']['id'], site.pk)
@ -327,7 +326,7 @@ class EventRuleTest(APITestCase):
# Verify that a background task was queued for each deleted object
self.assertEqual(self.queue.count, 3)
for i, job in enumerate(self.queue.jobs):
self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(type_delete=True))
self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(name='Event Rule 3'))
self.assertEqual(job.kwargs['event_type'], OBJECT_DELETED)
self.assertEqual(job.kwargs['model_name'], 'site')
self.assertEqual(job.kwargs['data']['id'], sites[i].pk)
@ -342,7 +341,7 @@ class EventRuleTest(APITestCase):
A dummy implementation of Session.send() to be used for testing.
Always returns a 200 HTTP response.
"""
event = EventRule.objects.get(type_create=True)
event = EventRule.objects.get(name='Event Rule 1')
webhook = event.action_object
signature = generate_signature(request.body, webhook.secret)

View File

@ -6,6 +6,7 @@ from django.test import TestCase
from circuits.models import Provider
from core.choices import ManagedFileRootPathChoices, ObjectChangeActionChoices
from core.events import *
from core.models import ObjectChange, ObjectType
from dcim.filtersets import SiteFilterSet
from dcim.models import DeviceRole, DeviceType, Manufacturer, Platform, Rack, Region, Site, SiteGroup
@ -251,7 +252,7 @@ class WebhookTestCase(TestCase, BaseFilterSetTests):
class EventRuleTestCase(TestCase, BaseFilterSetTests):
queryset = EventRule.objects.all()
filterset = EventRuleFilterSet
ignore_fields = ('action_data', 'conditions')
ignore_fields = ('action_data', 'conditions', 'event_types')
@classmethod
def setUpTestData(cls):
@ -292,11 +293,7 @@ class EventRuleTestCase(TestCase, BaseFilterSetTests):
name='Event Rule 1',
action_object=webhooks[0],
enabled=True,
type_create=True,
type_update=False,
type_delete=False,
type_job_start=False,
type_job_end=False,
event_types=[OBJECT_CREATED],
action_type=EventRuleActionChoices.WEBHOOK,
description='foobar1'
),
@ -304,11 +301,7 @@ class EventRuleTestCase(TestCase, BaseFilterSetTests):
name='Event Rule 2',
action_object=webhooks[1],
enabled=True,
type_create=False,
type_update=True,
type_delete=False,
type_job_start=False,
type_job_end=False,
event_types=[OBJECT_UPDATED],
action_type=EventRuleActionChoices.WEBHOOK,
description='foobar2'
),
@ -316,11 +309,7 @@ class EventRuleTestCase(TestCase, BaseFilterSetTests):
name='Event Rule 3',
action_object=webhooks[2],
enabled=False,
type_create=False,
type_update=False,
type_delete=True,
type_job_start=False,
type_job_end=False,
event_types=[OBJECT_DELETED],
action_type=EventRuleActionChoices.WEBHOOK,
description='foobar3'
),
@ -328,22 +317,14 @@ class EventRuleTestCase(TestCase, BaseFilterSetTests):
name='Event Rule 4',
action_object=scripts[0],
enabled=False,
type_create=False,
type_update=False,
type_delete=False,
type_job_start=True,
type_job_end=False,
event_types=[JOB_STARTED],
action_type=EventRuleActionChoices.SCRIPT,
),
EventRule(
name='Event Rule 5',
action_object=scripts[1],
enabled=False,
type_create=False,
type_update=False,
type_delete=False,
type_job_start=False,
type_job_end=True,
event_types=[JOB_COMPLETED],
action_type=EventRuleActionChoices.SCRIPT,
),
)
@ -384,25 +365,9 @@ class EventRuleTestCase(TestCase, BaseFilterSetTests):
params = {'enabled': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_type_create(self):
params = {'type_create': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_type_update(self):
params = {'type_update': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_type_delete(self):
params = {'type_delete': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_type_job_start(self):
params = {'type_job_start': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_type_job_end(self):
params = {'type_job_end': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_event_type(self):
params = {'event_type': [OBJECT_CREATED, OBJECT_UPDATED]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class CustomLinkTestCase(TestCase, ChangeLoggedFilterSetTests):
@ -1133,6 +1098,8 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
'asnrange',
'cable',
'circuit',
'circuitgroup',
'circuitgroupassignment',
'circuittermination',
'circuittype',
'cluster',

View File

@ -1,6 +1,7 @@
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
from core.events import *
from core.models import ObjectType
from dcim.models import DeviceType, Manufacturer, Site
from extras.choices import *
@ -394,9 +395,9 @@ class EventRulesTestCase(ViewTestCases.PrimaryObjectViewTestCase):
site_type = ObjectType.objects.get_for_model(Site)
event_rules = (
EventRule(name='EventRule 1', type_create=True, action_object=webhooks[0]),
EventRule(name='EventRule 2', type_create=True, action_object=webhooks[1]),
EventRule(name='EventRule 3', type_create=True, action_object=webhooks[2]),
EventRule(name='EventRule 1', event_types=[OBJECT_CREATED], action_object=webhooks[0]),
EventRule(name='EventRule 2', event_types=[OBJECT_CREATED], action_object=webhooks[1]),
EventRule(name='EventRule 3', event_types=[OBJECT_CREATED], action_object=webhooks[2]),
)
for event in event_rules:
event.save()
@ -406,9 +407,7 @@ class EventRulesTestCase(ViewTestCases.PrimaryObjectViewTestCase):
cls.form_data = {
'name': 'Event X',
'object_types': [site_type.pk],
'type_create': False,
'type_update': True,
'type_delete': True,
'event_types': [OBJECT_UPDATED, OBJECT_DELETED],
'conditions': None,
'action_type': 'webhook',
'action_object_type': webhook_ct.pk,
@ -418,8 +417,8 @@ class EventRulesTestCase(ViewTestCases.PrimaryObjectViewTestCase):
}
cls.csv_data = (
"name,object_types,type_create,action_type,action_object",
"Webhook 4,dcim.site,True,webhook,Webhook 1",
f'name,object_types,event_types,action_type,action_object',
f'Webhook 4,dcim.site,"{OBJECT_CREATED},{OBJECT_UPDATED}",webhook,Webhook 1',
)
cls.csv_update_data = (
@ -430,7 +429,7 @@ class EventRulesTestCase(ViewTestCases.PrimaryObjectViewTestCase):
)
cls.bulk_edit_data = {
'type_update': True,
'description': 'New description',
}

View File

@ -19,6 +19,7 @@ from extras.choices import LogLevelChoices
from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm
from extras.dashboard.utils import get_widget_class
from netbox.constants import DEFAULT_ACTION_PERMISSIONS
from netbox.registry import registry
from netbox.views import generic
from netbox.views.generic.mixins import TableMixin
from utilities.forms import ConfirmationForm, get_field_value
@ -550,6 +551,14 @@ class EventRuleListView(generic.ObjectListView):
class EventRuleView(generic.ObjectView):
queryset = EventRule.objects.all()
def get_extra_context(self, request, instance):
return {
'event_types': [
event for name, event in registry['events'].items()
if name in instance.event_types
]
}
@register_model_view(EventRule, 'edit')
class EventRuleEditView(generic.ObjectEditView):

View File

@ -13,11 +13,39 @@ __all__ = (
'EVENT_TYPE_SUCCESS',
'EVENT_TYPE_WARNING',
'Event',
'get_event',
'get_event_type_choices',
'get_event_text',
)
def get_event(name):
return registry['events'].get(name)
def get_event_text(name):
if event := registry['events'].get(name):
return event.text
return ''
def get_event_type_choices():
return [
(event.name, event.text) for event in registry['events'].values()
]
@dataclass
class Event:
"""
A type of event which can occur in NetBox. Event rules can be defined to automatically
perform some action in response to an event.
Args:
name: The unique name under which the event is registered.
text: The human-friendly event name. This should support translation.
type: The event's classification (info, success, warning, or danger). The default type is info.
"""
name: str
text: str
type: str = EVENT_TYPE_INFO
@ -26,6 +54,8 @@ class Event:
return self.text
def register(self):
if self.name in registry['events']:
raise Exception(f"An event named {self.name} has already been registered!")
registry['events'][self.name] = self
def color(self):

View File

@ -259,6 +259,8 @@ CIRCUITS_MENU = Menu(
items=(
get_model_item('circuits', 'circuit', _('Circuits')),
get_model_item('circuits', 'circuittype', _('Circuit Types')),
get_model_item('circuits', 'circuitgroup', _('Circuit Groups')),
get_model_item('circuits', 'circuitgroupassignment', _('Group Assignments')),
get_model_item('circuits', 'circuittermination', _('Circuit Terminations')),
),
),
@ -435,6 +437,11 @@ ADMIN_MENU = Menu(
link_text=_('System'),
auth_required=True
),
MenuItem(
link='core:plugin_list',
link_text=_('Plugins'),
auth_required=True
),
MenuItem(
link='core:configrevision_list',
link_text=_('Configuration History'),

View File

@ -769,6 +769,8 @@ STRAWBERRY_DJANGO = {
# Plugins
#
PLUGIN_CATALOG_URL = 'https://api.netbox.oss.netboxlabs.com/v1/plugins'
# Register any configured plugins
for plugin_name in PLUGINS:
try:

Binary file not shown.

View File

@ -91,6 +91,10 @@ pre {
// Dark mode overrides
body[data-bs-theme=dark] {
.btn-primary {
color: $rich-black!important;
}
// Change content color when primary teal changes with theme
.bg-primary {
.card-title,a,i {
@ -115,6 +119,10 @@ body[data-bs-theme=dark] {
background-color: $rich-black-light!important;
}
.page-link.active, .active>.page-link {
color: $rich-black;
}
// Adjusting text colors
.text- {
&bg-primary {

View File

@ -77,6 +77,11 @@ body[data-bs-theme=dark] {
table.object-list {
a {
color: var(--#{$prefix}body-color)!important;
// Ensures primary button color remains legible
&.btn-primary {
color: $rich-black!important;
}
}
}
}

View File

@ -61,6 +61,19 @@
</tr>
</table>
</div>
<div class="card">
<h2 class="card-header">
{% trans "Group Assignments" %}
{% if perms.circuits.add_circuitgroupassignment %}
<div class="card-actions">
<a href="{% url 'circuits:circuitgroupassignment_add' %}?circuit={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-ghost-primary btn-sm">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Assign Group" %}
</a>
</div>
{% endif %}
</h2>
{% htmx_table 'circuits:circuitgroupassignment_list' circuit_id=object.pk %}
</div>
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/tags.html' %}
{% include 'inc/panels/comments.html' %}

View File

@ -0,0 +1,56 @@
{% extends 'generic/object.html' %}
{% load static %}
{% load helpers %}
{% load plugins %}
{% load render_table from django_tables2 %}
{% load i18n %}
{% block breadcrumbs %}
{{ block.super }}
<li class="breadcrumb-item"><a href="{% url 'circuits:circuitgroup_list' %}?circuitgroup_id={{ object.id }}">{{ object.name }}</a></li>
{% endblock %}
{% block extra_controls %}
{% if perms.circuit.add_circuitgroupassignment %}
<a href="{% url 'circuits:circuitgroupassignment_add' %}?group={{ object.pk }}" class="btn btn-primary">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Assign Circuit" %}
</a>
{% endif %}
{% endblock extra_controls %}
{% block content %}
<div class="row mb-3">
<div class="col col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Circuit Group" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Tenant" %}</th>
<td>
{% if object.tenant.group %}
{{ object.tenant.group|linkify }} /
{% endif %}
{{ object.tenant|linkify|placeholder }}
</td>
</tr>
</table>
</div>
{% include 'inc/panels/tags.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/comments.html' %}
{% include 'inc/panels/custom_fields.html' %}
{% plugin_right_page object %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,48 @@
{% extends 'generic/object.html' %}
{% load static %}
{% load helpers %}
{% load plugins %}
{% load render_table from django_tables2 %}
{% load i18n %}
{% block breadcrumbs %}
{{ block.super }}
<li class="breadcrumb-item">
<a href="{% url 'circuits:circuitgroupassignment_list' %}?group_id={{ object.group_id }}">{{ object.group }}</a>
</li>
{% endblock %}
{% block content %}
<div class="row mb-3">
<div class="col col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Circuit Group Assignment" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Group" %}</th>
<td>{{ object.group }}</td>
</tr>
<tr>
<th scope="row">{% trans "Circuit" %}</th>
<td>{{ object.circuit }}</td>
</tr>
<tr>
<th scope="row">{% trans "Priority" %}</th>
<td>{{ object.priority }}</td>
</tr>
</table>
</div>
{% include 'inc/panels/tags.html' %}
{% include 'inc/panels/custom_fields.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,29 @@
<p>You can install this plugin from the command line with PyPI.</p>
<p>The following commands may be helpful; always refer to <a href="{{ plugin.homepage_url }}" target="_blank">the plugin's own documentation <i class="mdi mdi-launch"></i></a> and the <a href="https://netboxlabs.com/docs/netbox/en/stable/plugins/installation/" target="_blank">Installing a Plugin unit <i class="mdi mdi-launch"></i></a> of the NetBox documentation.</p>
<p>1. Enter the NetBox virtual environment and install the plugin package:</p>
<pre class="block">
source /opt/netbox/venv/bin/activate
pip install {{ plugin.slug }}
</pre>
<p>2. In /opt/netbox/netbox/netbox/configuration.py, add the plugin to the PLUGINS list:</p>
<pre class="block">
PLUGINS=
"{{ plugin.config_name }}",
]
</pre>
<p>3. Still from the NetBox virtual environment, run database migrations and collect static files:</p>
<pre class="block">
python3 /opt/netbox/netbox/netbox/manage.py migrate
python3 /opt/netbox/netbox/netbox/manage.py collectstatic
</pre>
<p>4. Restart the NetBox services to complete the plugin installation:</p>
<pre class="block">
sudo systemctl restart netbox netbox-rq
</pre>

View File

@ -0,0 +1,113 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load form_helpers %}
{% load i18n %}
{% block title %}{{ plugin.title_short }}{% endblock %}
{% block object_identifier %}
{% endblock object_identifier %}
{% block breadcrumbs %}
<li class="breadcrumb-item"><a href="{% url 'core:plugin_list' %}">{% trans "Plugins" %}</a></li>
{% endblock breadcrumbs %}
{% block subtitle %}
<span class="text-secondary fs-5">
{% checkmark plugin.is_installed %}
{% if plugin.is_installed %}
v{{ plugin.installed_version }} {% trans "installed" %}
{% else %}
{% trans "Not installed" %}
{% endif %}
</span>
{% endblock %}
{% block controls %}{% endblock %}
{% block tabs %}
<ul class="nav nav-tabs" role="tablist">
<li class="nav-item" role="presentation">
<a class="nav-link active" id="overview-tab" data-bs-toggle="tab" data-bs-target="#overview" type="button" role="tab" aria-controls="edit-form" aria-selected="true">
{% trans "Overview" %}
</a>
</li>
{% if True or not plugin.is_local and 'commercial' not in settings.RELEASE.features %}
<li class="nav-item" role="presentation">
<button class="nav-link" id="install-tab" data-bs-toggle="tab" data-bs-target="#install" type="button" role="tab" aria-controls="object-list" aria-selected="false">
{% trans "Install" %}
</button>
</li>
{% endif %}
</ul>
{% endblock tabs %}
{% block content %}
<div class="tab-pane show active" id="overview" role="tabpanel" aria-labelledby="overview-tab">
<div class="row">
<div class="col col-6">
<div class="card">
<h2 class="card-header">{% trans "Plugin Details" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ plugin.title_short }}</td>
</tr>
<tr>
<th scope="row">{% trans "Summary" %}</th>
<td>{{ plugin.tag_line|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Author" %}</th>
<td>{{ plugin.author.name|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "URL" %}</th>
<td>
{% if plugin.homepage_url %}
<a href="{{ plugin.homepage_url }}">{{ plugin.homepage_url }}</a>
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "License" %}</th>
<td>{{ plugin.license_type|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ plugin.description_short|markdown }}</td>
</tr>
<tr>
<th scope="row">{% trans "Certified" %}</th>
<td>{% checkmark plugin.is_certified %}</td>
</tr>
<tr>
<th scope="row">{% trans "Local" %}</th>
<td>{% checkmark plugin.is_local %}</td>
</tr>
</table>
</div>
</div>
<div class="col col-6">
<div class="card">
<h2 class="card-header">{% trans "Version History" %}</h2>
<div class="htmx-container table-responsive" id="object_list">
{% include 'htmx/table.html' %}
</div>
</div>
</div>
</div>
</div>
{% if True or not plugin.is_local and 'commercial' not in settings.RELEASE.features %}
<div class="tab-pane" id="install" role="tabpanel" aria-labelledby="install-tab">
<div class="card">
<h2 class="card-header">{% trans "Local Installation Instructions" %}</h2>
<div class="card-body">
{% include 'core/inc/plugin_installation.html' %}
</div>
</div>
</div>
{% endif %}
{% endblock content %}

View File

@ -0,0 +1,16 @@
{% extends 'generic/object_list.html' %}
{% load buttons %}
{% load helpers %}
{% load i18n %}
{% load render_table from django_tables2 %}
{% block title %}{% trans "Plugins" %}{% endblock %}
{% block tabs %}
<ul class="nav nav-tabs px-3">
<li class="nav-item" role="presentation">
<a class="nav-link active" role="tab">{% trans "Plugins" %}</a>
</li>
</ul>
{% endblock tabs %}

View File

@ -78,16 +78,6 @@
</div>
</div>
{# Plugins #}
<div class="row mb-3">
<div class="col col-md-12">
<div class="card">
<h2 class="card-header">{% trans "Plugins" %}</h2>
{% render_table plugins_table %}
</div>
</div>
</div>
{# Configuration #}
<div class="row mb-3">
<div class="col col-md-12">

View File

@ -26,6 +26,12 @@
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Airflow" %}</th>
<td>
{{ object.get_airflow_display|placeholder }}
</td>
</tr>
<tr>
<th scope="row">{% trans "Weight" %}</th>
<td>

View File

@ -61,6 +61,10 @@
<th scope="row">{% trans "Asset Tag" %}</th>
<td class="font-monospace">{{ object.asset_tag|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Airflow" %}</th>
<td>{{ object.get_airflow_display|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Space Utilization" %}</th>
<td>{% utilization_graph object.get_utilization %}</td>

View File

@ -10,7 +10,7 @@
<div class="row">
<div class="col col-6">
<div class="card">
<h5 class="card-header">{% trans "Rack Type" %}</h5>
<h2 class="card-header">{% trans "Rack Type" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Manufacturer" %}</th>
@ -24,6 +24,10 @@
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Airflow" %}</th>
<td>{{ object.get_airflow_display|placeholder }}</td>
</tr>
</table>
</div>
{% include 'dcim/inc/panels/racktype_dimensions.html' %}
@ -34,7 +38,7 @@
<div class="col col-6">
{% include 'dcim/inc/panels/racktype_numbering.html' %}
<div class="card">
<h5 class="card-header">{% trans "Weight" %}</h5>
<h2 class="card-header">{% trans "Weight" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Rack Weight" %}</th>

View File

@ -34,29 +34,25 @@
</table>
</div>
<div class="card">
<h2 class="card-header">{% trans "Events" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Create" %}</th>
<td>{% checkmark object.type_create %}</td>
</tr>
<tr>
<th scope="row">{% trans "Update" %}</th>
<td>{% checkmark object.type_update %}</td>
</tr>
<tr>
<th scope="row">{% trans "Delete" %}</th>
<td>{% checkmark object.type_delete %}</td>
</tr>
<tr>
<th scope="row">{% trans "Job start" %}</th>
<td>{% checkmark object.type_job_start %}</td>
</tr>
<tr>
<th scope="row">{% trans "Job end" %}</th>
<td>{% checkmark object.type_job_end %}</td>
</tr>
</table>
<h2 class="card-header">{% trans "Event Types" %}</h2>
<ul class="list-group list-group-flush">
{% for name, event in registry.events.items %}
<li class="list-group-item">
<div class="row align-items-center">
<div class="col-auto">
{% if name in object.event_types %}
{% checkmark True %}
{% else %}
{{ ''|placeholder }}
{% endif %}
</div>
<div class="col">
{{ event }}
</div>
</div>
</li>
{% endfor %}
</ul>
</div>
{% plugin_left_page object %}
</div>

View File

@ -8,7 +8,7 @@
<div class="row">
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">{% trans "Notification Group" %}</h5>
<h2 class="card-header">{% trans "Notification Group" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
@ -28,7 +28,7 @@
</div>
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">{% trans "Groups" %}</h5>
<h2 class="card-header">{% trans "Groups" %}</h2>
<div class="list-group list-group-flush">
{% for group in object.groups.all %}
<a href="{{ group.get_absolute_url }}" class="list-group-item list-group-item-action">{{ group }}</a>
@ -38,7 +38,7 @@
</div>
</div>
<div class="card">
<h5 class="card-header">{% trans "Users" %}</h5>
<h2 class="card-header">{% trans "Users" %}</h2>
<div class="list-group list-group-flush">
{% for user in object.users.all %}
<a href="{{ user.get_absolute_url }}" class="list-group-item list-group-item-action">{{ user }}</a>

View File

@ -1,11 +1,18 @@
{% load i18n %}
{% load navigation %}
{% if 'help-center' in settings.RELEASE.features %}
{# Help center control #}
<a href="#" class="nav-link px-1" aria-label="{% trans "Help center" %}">
<i class="mdi mdi-forum-outline"></i>
</a>
{% endif %}
{% if request.user.is_authenticated %}
{# Notifications #}
{% with notifications=request.user.notifications.unread.exists %}
<div class="nav-item dropdown">
<a href="#" class="nav-link" data-bs-toggle="dropdown" hx-get="{% url 'extras:notifications' %}" hx-target="next .notifications" aria-label="Notifications">
<div class="dropdown">
<a href="#" class="nav-link px-1" data-bs-toggle="dropdown" hx-get="{% url 'extras:notifications' %}" hx-target="next .notifications" aria-label="{% trans "Notifications" %}">
{% include 'inc/notification_bell.html' %}
</a>
<div class="dropdown-menu dropdown-menu-end dropdown-menu-arrow notifications"></div>

View File

@ -49,7 +49,7 @@
<div class="row">
<div class="col">
<div class="card">
<h5 class="card-header d-flex justify-content-between">
<h2 class="card-header d-flex justify-content-between">
{% trans "Rendered Config" %}
<a href="?export=True" class="btn btn-primary lh-1" role="button">
<i class="mdi mdi-download" aria-hidden="true"></i> {% trans "Download" %}

View File

@ -137,7 +137,10 @@ class ModelTestCase(TestCase):
# Convert ArrayFields to CSV strings
if type(field) is ArrayField:
if type(field.base_field) is ArrayField:
if getattr(field.base_field, 'choices', None):
# Values for fields with pre-defined choices can be returned as lists
model_dict[key] = value
elif type(field.base_field) is ArrayField:
# Handle nested arrays (e.g. choice sets)
model_dict[key] = '\n'.join([f'{k},{v}' for k, v in value])
elif issubclass(type(field.base_field), RangeField):

View File

@ -1,4 +1,4 @@
from django.urls import include, path
from django.urls import include, path, re_path
from utilities.urls import get_model_urls
from . import views
@ -49,12 +49,16 @@ urlpatterns = [
path('virtual-machines/interfaces/add/', views.VirtualMachineBulkAddInterfaceView.as_view(), name='virtualmachine_bulk_add_vminterface'),
# Virtual disks
path('disks/', views.VirtualDiskListView.as_view(), name='virtualdisk_list'),
path('disks/add/', views.VirtualDiskCreateView.as_view(), name='virtualdisk_add'),
path('disks/import/', views.VirtualDiskBulkImportView.as_view(), name='virtualdisk_import'),
path('disks/edit/', views.VirtualDiskBulkEditView.as_view(), name='virtualdisk_bulk_edit'),
path('disks/rename/', views.VirtualDiskBulkRenameView.as_view(), name='virtualdisk_bulk_rename'),
path('disks/delete/', views.VirtualDiskBulkDeleteView.as_view(), name='virtualdisk_bulk_delete'),
path('disks/<int:pk>/', include(get_model_urls('virtualization', 'virtualdisk'))),
path('virtual-disks/', views.VirtualDiskListView.as_view(), name='virtualdisk_list'),
path('virtual-disks/add/', views.VirtualDiskCreateView.as_view(), name='virtualdisk_add'),
path('virtual-disks/import/', views.VirtualDiskBulkImportView.as_view(), name='virtualdisk_import'),
path('virtual-disks/edit/', views.VirtualDiskBulkEditView.as_view(), name='virtualdisk_bulk_edit'),
path('virtual-disks/rename/', views.VirtualDiskBulkRenameView.as_view(), name='virtualdisk_bulk_rename'),
path('virtual-disks/delete/', views.VirtualDiskBulkDeleteView.as_view(), name='virtualdisk_bulk_delete'),
path('virtual-disks/<int:pk>/', include(get_model_urls('virtualization', 'virtualdisk'))),
path('virtual-machines/disks/add/', views.VirtualMachineBulkAddVirtualDiskView.as_view(), name='virtualmachine_bulk_add_virtualdisk'),
# TODO: Remove in v4.2
# Redirect old (pre-v4.1) URLs for VirtualDisk views
re_path('disks/(?P<path>[a-z0-9/-]*)', views.VirtualDiskRedirectView.as_view()),
]

View File

@ -7,6 +7,7 @@ from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.translation import gettext as _
from django.views.generic.base import RedirectView
from jinja2.exceptions import TemplateError
from dcim.filtersets import DeviceFilterSet
@ -630,6 +631,15 @@ class VirtualDiskBulkDeleteView(generic.BulkDeleteView):
table = tables.VirtualDiskTable
# TODO: Remove in v4.2
class VirtualDiskRedirectView(RedirectView):
"""
Redirect old (pre-v4.1) URLs for VirtualDisk views.
"""
def get_redirect_url(self, path):
return f"{reverse('virtualization:virtualdisk_list')}{path}"
#
# Bulk Device component creation
#