mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-23 04:22:01 -06:00
Merge branch 'develop' into develop-2.8
This commit is contained in:
commit
9df238c5f2
@ -1,5 +1,20 @@
|
||||
# NetBox v2.7 Release Notes
|
||||
|
||||
## v2.7.11 (FUTURE)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#4309](https://github.com/netbox-community/netbox/issues/4309) - Add descriptive tooltip to custom fields on object views
|
||||
* [#4369](https://github.com/netbox-community/netbox/issues/4369) - Add a dedicated view for rack reservations
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#4340](https://github.com/netbox-community/netbox/issues/4340) - Enforce unique constraints for device and virtual machine names in the API
|
||||
* [#4343](https://github.com/netbox-community/netbox/issues/4343) - Fix Markdown support for tables
|
||||
* [#4365](https://github.com/netbox-community/netbox/issues/4365) - Fix exception raised on IP address bulk add view
|
||||
|
||||
---
|
||||
|
||||
## v2.7.10 (2020-03-10)
|
||||
|
||||
**Note:** If your deployment requires any non-core Python packages (such as `napalm`, `django-storages`, or `django-auth-ldap`), list them in a file named `local_requirements.txt` in the NetBox root directory (alongside `requirements.txt`). This will ensure they are detected and re-installed by the upgrade script when the Python virtual environment is rebuilt.
|
||||
|
@ -7,6 +7,7 @@ from dcim.constants import CONNECTION_STATUS_CHOICES
|
||||
from dcim.fields import ASNField
|
||||
from dcim.models import CableTermination
|
||||
from extras.models import CustomFieldModel, ObjectChange, TaggedItem
|
||||
from extras.utils import extras_features
|
||||
from utilities.models import ChangeLoggedModel
|
||||
from utilities.utils import serialize_object
|
||||
from .choices import *
|
||||
@ -21,6 +22,7 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'graphs', 'export_templates', 'webhooks')
|
||||
class Provider(ChangeLoggedModel, CustomFieldModel):
|
||||
"""
|
||||
Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model
|
||||
@ -131,6 +133,7 @@ class CircuitType(ChangeLoggedModel):
|
||||
)
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
|
||||
class Circuit(ChangeLoggedModel, CustomFieldModel):
|
||||
"""
|
||||
A communications circuit connects two points. Each Circuit belongs to a Provider; Providers may have multiple
|
||||
|
@ -823,6 +823,13 @@ class RackElevationFilterForm(RackFilterForm):
|
||||
#
|
||||
|
||||
class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm):
|
||||
rack = forms.ModelChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
required=False,
|
||||
widget=forms.HiddenInput()
|
||||
)
|
||||
# TODO: Change this to an API-backed form field. We can't do this currently because we want to retain
|
||||
# the multi-line <select> widget for easy selection of multiple rack units.
|
||||
units = SimpleArrayField(
|
||||
base_field=forms.IntegerField(),
|
||||
widget=ArrayFieldSelectMultiple(
|
||||
@ -841,7 +848,7 @@ class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm):
|
||||
class Meta:
|
||||
model = RackReservation
|
||||
fields = [
|
||||
'units', 'user', 'tenant_group', 'tenant', 'description',
|
||||
'rack', 'units', 'user', 'tenant_group', 'tenant', 'description',
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@ -849,7 +856,8 @@ class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Populate rack unit choices
|
||||
self.fields['units'].widget.choices = self._get_unit_choices()
|
||||
if hasattr(self.instance, 'rack'):
|
||||
self.fields['units'].widget.choices = self._get_unit_choices()
|
||||
|
||||
def _get_unit_choices(self):
|
||||
rack = self.instance.rack
|
||||
|
@ -21,6 +21,7 @@ from dcim.constants import *
|
||||
from dcim.fields import ASNField
|
||||
from dcim.elevations import RackElevationSVG
|
||||
from extras.models import ConfigContextModel, CustomFieldModel, ObjectChange, TaggedItem
|
||||
from extras.utils import extras_features
|
||||
from utilities.fields import ColorField, NaturalOrderingField
|
||||
from utilities.models import ChangeLoggedModel
|
||||
from utilities.utils import serialize_object, to_meters
|
||||
@ -75,6 +76,7 @@ __all__ = (
|
||||
# Regions
|
||||
#
|
||||
|
||||
@extras_features('export_templates', 'webhooks')
|
||||
class Region(MPTTModel, ChangeLoggedModel):
|
||||
"""
|
||||
Sites can be grouped within geographic Regions.
|
||||
@ -138,6 +140,7 @@ class Region(MPTTModel, ChangeLoggedModel):
|
||||
# Sites
|
||||
#
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'graphs', 'export_templates', 'webhooks')
|
||||
class Site(ChangeLoggedModel, CustomFieldModel):
|
||||
"""
|
||||
A Site represents a geographic location within a network; typically a building or campus. The optional facility
|
||||
@ -288,6 +291,7 @@ class Site(ChangeLoggedModel, CustomFieldModel):
|
||||
# Racks
|
||||
#
|
||||
|
||||
@extras_features('export_templates')
|
||||
class RackGroup(MPTTModel, ChangeLoggedModel):
|
||||
"""
|
||||
Racks can be grouped as subsets within a Site. The scope of a group will depend on how Sites are defined. For
|
||||
@ -396,6 +400,7 @@ class RackRole(ChangeLoggedModel):
|
||||
)
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
|
||||
class Rack(ChangeLoggedModel, CustomFieldModel):
|
||||
"""
|
||||
Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face.
|
||||
@ -806,6 +811,9 @@ class RackReservation(ChangeLoggedModel):
|
||||
def __str__(self):
|
||||
return "Reservation for rack {}".format(self.rack)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:rackreservation', args=[self.pk])
|
||||
|
||||
def clean(self):
|
||||
|
||||
if self.units:
|
||||
@ -857,6 +865,7 @@ class RackReservation(ChangeLoggedModel):
|
||||
# Device Types
|
||||
#
|
||||
|
||||
@extras_features('export_templates', 'webhooks')
|
||||
class Manufacturer(ChangeLoggedModel):
|
||||
"""
|
||||
A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell.
|
||||
@ -892,6 +901,7 @@ class Manufacturer(ChangeLoggedModel):
|
||||
)
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
|
||||
class DeviceType(ChangeLoggedModel, CustomFieldModel):
|
||||
"""
|
||||
A DeviceType represents a particular make (Manufacturer) and model of device. It specifies rack height and depth, as
|
||||
@ -1240,6 +1250,7 @@ class Platform(ChangeLoggedModel):
|
||||
)
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'graphs', 'export_templates', 'webhooks')
|
||||
class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
|
||||
"""
|
||||
A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType,
|
||||
@ -1675,6 +1686,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
|
||||
# Virtual chassis
|
||||
#
|
||||
|
||||
@extras_features('export_templates', 'webhooks')
|
||||
class VirtualChassis(ChangeLoggedModel):
|
||||
"""
|
||||
A collection of Devices which operate with a shared control plane (e.g. a switch stack).
|
||||
@ -1741,6 +1753,7 @@ class VirtualChassis(ChangeLoggedModel):
|
||||
# Power
|
||||
#
|
||||
|
||||
@extras_features('custom_links', 'export_templates', 'webhooks')
|
||||
class PowerPanel(ChangeLoggedModel):
|
||||
"""
|
||||
A distribution point for electrical power; e.g. a data center RPP.
|
||||
@ -1787,6 +1800,7 @@ class PowerPanel(ChangeLoggedModel):
|
||||
))
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
|
||||
class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
|
||||
"""
|
||||
An electrical circuit delivered from a PowerPanel.
|
||||
@ -1948,6 +1962,7 @@ class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
|
||||
# Cables
|
||||
#
|
||||
|
||||
@extras_features('custom_links', 'export_templates', 'webhooks')
|
||||
class Cable(ChangeLoggedModel):
|
||||
"""
|
||||
A physical connection between two endpoints.
|
||||
|
@ -11,6 +11,7 @@ from dcim.constants import *
|
||||
from dcim.exceptions import LoopDetected
|
||||
from dcim.fields import MACAddressField
|
||||
from extras.models import ObjectChange, TaggedItem
|
||||
from extras.utils import extras_features
|
||||
from utilities.fields import NaturalOrderingField
|
||||
from utilities.ordering import naturalize_interface
|
||||
from utilities.utils import serialize_object
|
||||
@ -169,6 +170,7 @@ class CableTermination(models.Model):
|
||||
# Console ports
|
||||
#
|
||||
|
||||
@extras_features('export_templates', 'webhooks')
|
||||
class ConsolePort(CableTermination, ComponentModel):
|
||||
"""
|
||||
A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
|
||||
@ -229,6 +231,7 @@ class ConsolePort(CableTermination, ComponentModel):
|
||||
# Console server ports
|
||||
#
|
||||
|
||||
@extras_features('webhooks')
|
||||
class ConsoleServerPort(CableTermination, ComponentModel):
|
||||
"""
|
||||
A physical port within a Device (typically a designated console server) which provides access to ConsolePorts.
|
||||
@ -282,6 +285,7 @@ class ConsoleServerPort(CableTermination, ComponentModel):
|
||||
# Power ports
|
||||
#
|
||||
|
||||
@extras_features('export_templates', 'webhooks')
|
||||
class PowerPort(CableTermination, ComponentModel):
|
||||
"""
|
||||
A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets.
|
||||
@ -443,6 +447,7 @@ class PowerPort(CableTermination, ComponentModel):
|
||||
# Power outlets
|
||||
#
|
||||
|
||||
@extras_features('webhooks')
|
||||
class PowerOutlet(CableTermination, ComponentModel):
|
||||
"""
|
||||
A physical power outlet (output) within a Device which provides power to a PowerPort.
|
||||
@ -519,6 +524,7 @@ class PowerOutlet(CableTermination, ComponentModel):
|
||||
# Interfaces
|
||||
#
|
||||
|
||||
@extras_features('graphs', 'export_templates', 'webhooks')
|
||||
class Interface(CableTermination, ComponentModel):
|
||||
"""
|
||||
A network interface within a Device or VirtualMachine. A physical Interface can connect to exactly one other
|
||||
@ -792,6 +798,7 @@ class Interface(CableTermination, ComponentModel):
|
||||
# Pass-through ports
|
||||
#
|
||||
|
||||
@extras_features('webhooks')
|
||||
class FrontPort(CableTermination, ComponentModel):
|
||||
"""
|
||||
A pass-through port on the front of a Device.
|
||||
@ -864,6 +871,7 @@ class FrontPort(CableTermination, ComponentModel):
|
||||
)
|
||||
|
||||
|
||||
@extras_features('webhooks')
|
||||
class RearPort(CableTermination, ComponentModel):
|
||||
"""
|
||||
A pass-through port on the rear of a Device.
|
||||
@ -915,6 +923,7 @@ class RearPort(CableTermination, ComponentModel):
|
||||
# Device bays
|
||||
#
|
||||
|
||||
@extras_features('webhooks')
|
||||
class DeviceBay(ComponentModel):
|
||||
"""
|
||||
An empty space within a Device which can house a child device
|
||||
@ -989,6 +998,7 @@ class DeviceBay(ComponentModel):
|
||||
# Inventory items
|
||||
#
|
||||
|
||||
@extras_features('export_templates', 'webhooks')
|
||||
class InventoryItem(ComponentModel):
|
||||
"""
|
||||
An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply.
|
||||
|
@ -341,21 +341,38 @@ class RackDetailTable(RackTable):
|
||||
|
||||
class RackReservationTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
reservation = tables.LinkColumn(
|
||||
viewname='dcim:rackreservation',
|
||||
args=[Accessor('pk')],
|
||||
accessor='pk'
|
||||
)
|
||||
site = tables.LinkColumn(
|
||||
viewname='dcim:site',
|
||||
accessor=Accessor('rack.site'),
|
||||
args=[Accessor('rack.site.slug')],
|
||||
)
|
||||
tenant = tables.TemplateColumn(template_code=COL_TENANT)
|
||||
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')])
|
||||
unit_list = tables.Column(orderable=False, verbose_name='Units')
|
||||
tenant = tables.TemplateColumn(
|
||||
template_code=COL_TENANT
|
||||
)
|
||||
rack = tables.LinkColumn(
|
||||
viewname='dcim:rack',
|
||||
args=[Accessor('rack.pk')]
|
||||
)
|
||||
unit_list = tables.Column(
|
||||
orderable=False,
|
||||
verbose_name='Units'
|
||||
)
|
||||
actions = tables.TemplateColumn(
|
||||
template_code=RACKRESERVATION_ACTIONS, attrs={'td': {'class': 'text-right noprint'}}, verbose_name=''
|
||||
template_code=RACKRESERVATION_ACTIONS,
|
||||
attrs={'td': {'class': 'text-right noprint'}},
|
||||
verbose_name=''
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = RackReservation
|
||||
fields = ('pk', 'site', 'rack', 'unit_list', 'user', 'created', 'tenant', 'description', 'actions')
|
||||
fields = (
|
||||
'pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'created', 'tenant', 'description', 'actions',
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
|
@ -2017,6 +2017,20 @@ class DeviceTest(APITestCase):
|
||||
|
||||
self.assertFalse('config_context' in response.data['results'][0])
|
||||
|
||||
def test_unique_name_per_site_constraint(self):
|
||||
|
||||
data = {
|
||||
'device_type': self.devicetype1.pk,
|
||||
'device_role': self.devicerole1.pk,
|
||||
'name': 'Test Device 1',
|
||||
'site': self.site1.pk,
|
||||
}
|
||||
|
||||
url = reverse('dcim-api:device-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class ConsolePortTest(APITestCase):
|
||||
|
||||
|
@ -176,10 +176,6 @@ class RackRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
|
||||
class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
model = RackReservation
|
||||
|
||||
# Disable inapplicable tests
|
||||
test_get_object = None
|
||||
test_create_object = None
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
|
@ -2,7 +2,6 @@ from django.urls import path
|
||||
|
||||
from extras.views import ObjectChangeLogView, ImageAttachmentEditView
|
||||
from ipam.views import ServiceCreateView
|
||||
from secrets.views import secret_add
|
||||
from . import views
|
||||
from .models import (
|
||||
Cable, ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, FrontPort, Interface, Manufacturer, Platform,
|
||||
@ -51,9 +50,11 @@ urlpatterns = [
|
||||
|
||||
# Rack reservations
|
||||
path('rack-reservations/', views.RackReservationListView.as_view(), name='rackreservation_list'),
|
||||
path('rack-reservations/add/', views.RackReservationCreateView.as_view(), name='rackreservation_add'),
|
||||
path('rack-reservations/import/', views.RackReservationImportView.as_view(), name='rackreservation_import'),
|
||||
path('rack-reservations/edit/', views.RackReservationBulkEditView.as_view(), name='rackreservation_bulk_edit'),
|
||||
path('rack-reservations/delete/', views.RackReservationBulkDeleteView.as_view(), name='rackreservation_bulk_delete'),
|
||||
path('rack-reservations/<int:pk>/', views.RackReservationView.as_view(), name='rackreservation'),
|
||||
path('rack-reservations/<int:pk>/edit/', views.RackReservationEditView.as_view(), name='rackreservation_edit'),
|
||||
path('rack-reservations/<int:pk>/delete/', views.RackReservationDeleteView.as_view(), name='rackreservation_delete'),
|
||||
path('rack-reservations/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rackreservation_changelog', kwargs={'model': RackReservation}),
|
||||
@ -69,7 +70,6 @@ urlpatterns = [
|
||||
path('racks/<int:pk>/edit/', views.RackEditView.as_view(), name='rack_edit'),
|
||||
path('racks/<int:pk>/delete/', views.RackDeleteView.as_view(), name='rack_delete'),
|
||||
path('racks/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rack_changelog', kwargs={'model': Rack}),
|
||||
path('racks/<int:rack>/reservations/add/', views.RackReservationCreateView.as_view(), name='rack_add_reservation'),
|
||||
path('racks/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='rack_add_image', kwargs={'model': Rack}),
|
||||
|
||||
# Manufacturers
|
||||
@ -179,7 +179,6 @@ urlpatterns = [
|
||||
path('devices/<int:pk>/status/', views.DeviceStatusView.as_view(), name='device_status'),
|
||||
path('devices/<int:pk>/lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
|
||||
path('devices/<int:pk>/config/', views.DeviceConfigView.as_view(), name='device_config'),
|
||||
path('devices/<int:pk>/add-secret/', secret_add, name='device_addsecret'),
|
||||
path('devices/<int:device>/services/assign/', ServiceCreateView.as_view(), name='device_service_assign'),
|
||||
path('devices/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}),
|
||||
|
||||
|
@ -479,20 +479,32 @@ class RackReservationListView(PermissionRequiredMixin, ObjectListView):
|
||||
action_buttons = ('export',)
|
||||
|
||||
|
||||
class RackReservationView(PermissionRequiredMixin, View):
|
||||
permission_required = 'dcim.view_rackreservation'
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
rackreservation = get_object_or_404(RackReservation.objects.prefetch_related('rack'), pk=pk)
|
||||
|
||||
return render(request, 'dcim/rackreservation.html', {
|
||||
'rackreservation': rackreservation,
|
||||
})
|
||||
|
||||
|
||||
class RackReservationCreateView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'dcim.add_rackreservation'
|
||||
model = RackReservation
|
||||
model_form = forms.RackReservationForm
|
||||
template_name = 'dcim/rackreservation_edit.html'
|
||||
default_return_url = 'dcim:rackreservation_list'
|
||||
|
||||
def alter_obj(self, obj, request, args, kwargs):
|
||||
if not obj.pk:
|
||||
obj.rack = get_object_or_404(Rack, pk=kwargs['rack'])
|
||||
if 'rack' in request.GET:
|
||||
obj.rack = get_object_or_404(Rack, pk=request.GET.get('rack'))
|
||||
obj.user = request.user
|
||||
return obj
|
||||
|
||||
def get_return_url(self, request, obj):
|
||||
return obj.rack.get_absolute_url()
|
||||
|
||||
|
||||
class RackReservationEditView(RackReservationCreateView):
|
||||
permission_required = 'dcim.change_rackreservation'
|
||||
@ -501,9 +513,7 @@ class RackReservationEditView(RackReservationCreateView):
|
||||
class RackReservationDeleteView(PermissionRequiredMixin, ObjectDeleteView):
|
||||
permission_required = 'dcim.delete_rackreservation'
|
||||
model = RackReservation
|
||||
|
||||
def get_return_url(self, request, obj):
|
||||
return obj.rack.get_absolute_url()
|
||||
default_return_url = 'dcim:rackreservation_list'
|
||||
|
||||
|
||||
class RackReservationImportView(PermissionRequiredMixin, BulkImportView):
|
||||
|
@ -13,6 +13,7 @@ from extras.constants import *
|
||||
from extras.models import (
|
||||
ConfigContext, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, Tag,
|
||||
)
|
||||
from extras.utils import FeatureQuerySet
|
||||
from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from users.api.nested_serializers import NestedUserSerializer
|
||||
@ -31,7 +32,7 @@ from .nested_serializers import *
|
||||
|
||||
class GraphSerializer(ValidatedModelSerializer):
|
||||
type = ContentTypeField(
|
||||
queryset=ContentType.objects.filter(GRAPH_MODELS),
|
||||
queryset=ContentType.objects.filter(FeatureQuerySet('graphs').get_queryset()),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@ -67,7 +68,7 @@ class RenderedGraphSerializer(serializers.ModelSerializer):
|
||||
|
||||
class ExportTemplateSerializer(ValidatedModelSerializer):
|
||||
content_type = ContentTypeField(
|
||||
queryset=ContentType.objects.filter(EXPORTTEMPLATE_MODELS),
|
||||
queryset=ContentType.objects.filter(FeatureQuerySet('export_templates').get_queryset()),
|
||||
)
|
||||
template_language = ChoiceField(
|
||||
choices=TemplateLanguageChoices,
|
||||
|
@ -1,129 +1,3 @@
|
||||
from django.db.models import Q
|
||||
|
||||
|
||||
# Models which support custom fields
|
||||
CUSTOMFIELD_MODELS = Q(
|
||||
Q(app_label='circuits', model__in=[
|
||||
'circuit',
|
||||
'provider',
|
||||
]) |
|
||||
Q(app_label='dcim', model__in=[
|
||||
'device',
|
||||
'devicetype',
|
||||
'powerfeed',
|
||||
'rack',
|
||||
'site',
|
||||
]) |
|
||||
Q(app_label='ipam', model__in=[
|
||||
'aggregate',
|
||||
'ipaddress',
|
||||
'prefix',
|
||||
'service',
|
||||
'vlan',
|
||||
'vrf',
|
||||
]) |
|
||||
Q(app_label='secrets', model__in=[
|
||||
'secret',
|
||||
]) |
|
||||
Q(app_label='tenancy', model__in=[
|
||||
'tenant',
|
||||
]) |
|
||||
Q(app_label='virtualization', model__in=[
|
||||
'cluster',
|
||||
'virtualmachine',
|
||||
])
|
||||
)
|
||||
|
||||
# Custom links
|
||||
CUSTOMLINK_MODELS = Q(
|
||||
Q(app_label='circuits', model__in=[
|
||||
'circuit',
|
||||
'provider',
|
||||
]) |
|
||||
Q(app_label='dcim', model__in=[
|
||||
'cable',
|
||||
'device',
|
||||
'devicetype',
|
||||
'powerpanel',
|
||||
'powerfeed',
|
||||
'rack',
|
||||
'site',
|
||||
]) |
|
||||
Q(app_label='ipam', model__in=[
|
||||
'aggregate',
|
||||
'ipaddress',
|
||||
'prefix',
|
||||
'service',
|
||||
'vlan',
|
||||
'vrf',
|
||||
]) |
|
||||
Q(app_label='secrets', model__in=[
|
||||
'secret',
|
||||
]) |
|
||||
Q(app_label='tenancy', model__in=[
|
||||
'tenant',
|
||||
]) |
|
||||
Q(app_label='virtualization', model__in=[
|
||||
'cluster',
|
||||
'virtualmachine',
|
||||
])
|
||||
)
|
||||
|
||||
# Models which can have Graphs associated with them
|
||||
GRAPH_MODELS = Q(
|
||||
Q(app_label='circuits', model__in=[
|
||||
'provider',
|
||||
]) |
|
||||
Q(app_label='dcim', model__in=[
|
||||
'device',
|
||||
'interface',
|
||||
'site',
|
||||
])
|
||||
)
|
||||
|
||||
# Models which support export templates
|
||||
EXPORTTEMPLATE_MODELS = Q(
|
||||
Q(app_label='circuits', model__in=[
|
||||
'circuit',
|
||||
'provider',
|
||||
]) |
|
||||
Q(app_label='dcim', model__in=[
|
||||
'cable',
|
||||
'consoleport',
|
||||
'device',
|
||||
'devicetype',
|
||||
'interface',
|
||||
'inventoryitem',
|
||||
'manufacturer',
|
||||
'powerpanel',
|
||||
'powerport',
|
||||
'powerfeed',
|
||||
'rack',
|
||||
'rackgroup',
|
||||
'region',
|
||||
'site',
|
||||
'virtualchassis',
|
||||
]) |
|
||||
Q(app_label='ipam', model__in=[
|
||||
'aggregate',
|
||||
'ipaddress',
|
||||
'prefix',
|
||||
'service',
|
||||
'vlan',
|
||||
'vrf',
|
||||
]) |
|
||||
Q(app_label='secrets', model__in=[
|
||||
'secret',
|
||||
]) |
|
||||
Q(app_label='tenancy', model__in=[
|
||||
'tenant',
|
||||
]) |
|
||||
Q(app_label='virtualization', model__in=[
|
||||
'cluster',
|
||||
'virtualmachine',
|
||||
])
|
||||
)
|
||||
|
||||
# Report logging levels
|
||||
LOG_DEFAULT = 0
|
||||
LOG_SUCCESS = 10
|
||||
@ -138,51 +12,14 @@ LOG_LEVEL_CODES = {
|
||||
LOG_FAILURE: 'failure',
|
||||
}
|
||||
|
||||
# Webhook content types
|
||||
HTTP_CONTENT_TYPE_JSON = 'application/json'
|
||||
|
||||
# Models which support registered webhooks
|
||||
WEBHOOK_MODELS = Q(
|
||||
Q(app_label='circuits', model__in=[
|
||||
'circuit',
|
||||
'provider',
|
||||
]) |
|
||||
Q(app_label='dcim', model__in=[
|
||||
'cable',
|
||||
'consoleport',
|
||||
'consoleserverport',
|
||||
'device',
|
||||
'devicebay',
|
||||
'devicetype',
|
||||
'frontport',
|
||||
'interface',
|
||||
'inventoryitem',
|
||||
'manufacturer',
|
||||
'poweroutlet',
|
||||
'powerpanel',
|
||||
'powerport',
|
||||
'powerfeed',
|
||||
'rack',
|
||||
'rearport',
|
||||
'region',
|
||||
'site',
|
||||
'virtualchassis',
|
||||
]) |
|
||||
Q(app_label='ipam', model__in=[
|
||||
'aggregate',
|
||||
'ipaddress',
|
||||
'prefix',
|
||||
'service',
|
||||
'vlan',
|
||||
'vrf',
|
||||
]) |
|
||||
Q(app_label='secrets', model__in=[
|
||||
'secret',
|
||||
]) |
|
||||
Q(app_label='tenancy', model__in=[
|
||||
'tenant',
|
||||
]) |
|
||||
Q(app_label='virtualization', model__in=[
|
||||
'cluster',
|
||||
'virtualmachine',
|
||||
])
|
||||
)
|
||||
# Registerable extras features
|
||||
EXTRAS_FEATURES = [
|
||||
'custom_fields',
|
||||
'custom_links',
|
||||
'graphs',
|
||||
'export_templates',
|
||||
'webhooks'
|
||||
]
|
||||
|
@ -0,0 +1,40 @@
|
||||
# Generated by Django 2.2.11 on 2020-03-14 06:50
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import extras.utils
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0038_webhook_template_support'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='customfield',
|
||||
name='obj_type',
|
||||
field=models.ManyToManyField(limit_choices_to=extras.utils.FeatureQuerySet('custom_fields'), related_name='custom_fields', to='contenttypes.ContentType'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='customlink',
|
||||
name='content_type',
|
||||
field=models.ForeignKey(limit_choices_to=extras.utils.FeatureQuerySet('custom_links'), on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='exporttemplate',
|
||||
name='content_type',
|
||||
field=models.ForeignKey(limit_choices_to=extras.utils.FeatureQuerySet('export_templates'), on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='graph',
|
||||
name='type',
|
||||
field=models.ForeignKey(limit_choices_to=extras.utils.FeatureQuerySet('graphs'), on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='webhook',
|
||||
name='obj_type',
|
||||
field=models.ManyToManyField(limit_choices_to=extras.utils.FeatureQuerySet('webhooks'), related_name='webhooks', to='contenttypes.ContentType'),
|
||||
),
|
||||
]
|
@ -22,6 +22,7 @@ from utilities.utils import deepmerge, render_jinja2
|
||||
from .choices import *
|
||||
from .constants import *
|
||||
from .querysets import ConfigContextQuerySet
|
||||
from .utils import FeatureQuerySet
|
||||
|
||||
|
||||
__all__ = (
|
||||
@ -58,7 +59,7 @@ class Webhook(models.Model):
|
||||
to=ContentType,
|
||||
related_name='webhooks',
|
||||
verbose_name='Object types',
|
||||
limit_choices_to=WEBHOOK_MODELS,
|
||||
limit_choices_to=FeatureQuerySet('webhooks'),
|
||||
help_text="The object(s) to which this Webhook applies."
|
||||
)
|
||||
name = models.CharField(
|
||||
@ -223,7 +224,7 @@ class CustomField(models.Model):
|
||||
to=ContentType,
|
||||
related_name='custom_fields',
|
||||
verbose_name='Object(s)',
|
||||
limit_choices_to=CUSTOMFIELD_MODELS,
|
||||
limit_choices_to=FeatureQuerySet('custom_fields'),
|
||||
help_text='The object(s) to which this field applies.'
|
||||
)
|
||||
type = models.CharField(
|
||||
@ -470,7 +471,7 @@ class CustomLink(models.Model):
|
||||
content_type = models.ForeignKey(
|
||||
to=ContentType,
|
||||
on_delete=models.CASCADE,
|
||||
limit_choices_to=CUSTOMLINK_MODELS
|
||||
limit_choices_to=FeatureQuerySet('custom_links')
|
||||
)
|
||||
name = models.CharField(
|
||||
max_length=100,
|
||||
@ -518,7 +519,7 @@ class Graph(models.Model):
|
||||
type = models.ForeignKey(
|
||||
to=ContentType,
|
||||
on_delete=models.CASCADE,
|
||||
limit_choices_to=GRAPH_MODELS
|
||||
limit_choices_to=FeatureQuerySet('graphs')
|
||||
)
|
||||
weight = models.PositiveSmallIntegerField(
|
||||
default=1000
|
||||
@ -579,7 +580,7 @@ class ExportTemplate(models.Model):
|
||||
content_type = models.ForeignKey(
|
||||
to=ContentType,
|
||||
on_delete=models.CASCADE,
|
||||
limit_choices_to=EXPORTTEMPLATE_MODELS
|
||||
limit_choices_to=FeatureQuerySet('export_templates')
|
||||
)
|
||||
name = models.CharField(
|
||||
max_length=100
|
||||
|
@ -9,6 +9,7 @@ from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Platform,
|
||||
from extras.api.views import ScriptViewSet
|
||||
from extras.models import ConfigContext, Graph, ExportTemplate, Tag
|
||||
from extras.scripts import BooleanVar, IntegerVar, Script, StringVar
|
||||
from extras.utils import FeatureQuerySet
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from utilities.testing import APITestCase
|
||||
|
||||
|
@ -3,8 +3,8 @@ from django.test import TestCase
|
||||
|
||||
from dcim.models import DeviceRole, Platform, Region, Site
|
||||
from extras.choices import *
|
||||
from extras.constants import GRAPH_MODELS
|
||||
from extras.filters import *
|
||||
from extras.utils import FeatureQuerySet
|
||||
from extras.models import ConfigContext, ExportTemplate, Graph
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from virtualization.models import Cluster, ClusterGroup, ClusterType
|
||||
@ -18,7 +18,7 @@ class GraphTestCase(TestCase):
|
||||
def setUpTestData(cls):
|
||||
|
||||
# Get the first three available types
|
||||
content_types = ContentType.objects.filter(GRAPH_MODELS)[:3]
|
||||
content_types = ContentType.objects.filter(FeatureQuerySet('graphs').get_queryset())[:3]
|
||||
|
||||
graphs = (
|
||||
Graph(name='Graph 1', type=content_types[0], template_language=TemplateLanguageChoices.LANGUAGE_DJANGO, source='http://example.com/1'),
|
||||
@ -32,7 +32,7 @@ class GraphTestCase(TestCase):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_type(self):
|
||||
content_type = ContentType.objects.filter(GRAPH_MODELS).first()
|
||||
content_type = ContentType.objects.filter(FeatureQuerySet('graphs').get_queryset()).first()
|
||||
params = {'type': content_type.pk}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
|
@ -1,6 +1,12 @@
|
||||
import collections
|
||||
|
||||
from django.db.models import Q
|
||||
from django.utils.deconstruct import deconstructible
|
||||
from taggit.managers import _TaggableManager
|
||||
from utilities.querysets import DummyQuerySet
|
||||
|
||||
from extras.constants import EXTRAS_FEATURES
|
||||
|
||||
|
||||
def is_taggable(obj):
|
||||
"""
|
||||
@ -13,3 +19,65 @@ def is_taggable(obj):
|
||||
if isinstance(obj.tags, DummyQuerySet):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
#
|
||||
# Dynamic feature registration
|
||||
#
|
||||
|
||||
class Registry:
|
||||
"""
|
||||
The registry is a place to hook into for data storage across components
|
||||
"""
|
||||
|
||||
def add_store(self, store_name, initial_value=None):
|
||||
"""
|
||||
Given the name of some new data parameter and an optional initial value, setup the registry store
|
||||
"""
|
||||
if not hasattr(Registry, store_name):
|
||||
setattr(Registry, store_name, initial_value)
|
||||
|
||||
|
||||
registry = Registry()
|
||||
|
||||
|
||||
@deconstructible
|
||||
class FeatureQuerySet:
|
||||
"""
|
||||
Helper class that delays evaluation of the registry contents for the functionaility store
|
||||
until it has been populated.
|
||||
"""
|
||||
|
||||
def __init__(self, feature):
|
||||
self.feature = feature
|
||||
|
||||
def __call__(self):
|
||||
return self.get_queryset()
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
Given an extras feature, return a Q object for content type lookup
|
||||
"""
|
||||
query = Q()
|
||||
for app_label, models in registry.model_feature_store[self.feature].items():
|
||||
query |= Q(app_label=app_label, model__in=models)
|
||||
|
||||
return query
|
||||
|
||||
|
||||
registry.add_store('model_feature_store', {f: collections.defaultdict(list) for f in EXTRAS_FEATURES})
|
||||
|
||||
|
||||
def extras_features(*features):
|
||||
"""
|
||||
Decorator used to register extras provided features to a model
|
||||
"""
|
||||
def wrapper(model_class):
|
||||
for feature in features:
|
||||
if feature in EXTRAS_FEATURES:
|
||||
app_label, model_name = model_class._meta.label_lower.split('.')
|
||||
registry.model_feature_store[feature][app_label].append(model_name)
|
||||
else:
|
||||
raise ValueError('{} is not a valid extras feature!'.format(feature))
|
||||
return model_class
|
||||
return wrapper
|
||||
|
@ -8,6 +8,7 @@ from extras.models import Webhook
|
||||
from utilities.api import get_serializer_for_model
|
||||
from .choices import *
|
||||
from .constants import *
|
||||
from .utils import FeatureQuerySet
|
||||
|
||||
|
||||
def generate_signature(request_body, secret):
|
||||
@ -29,7 +30,7 @@ def enqueue_webhooks(instance, user, request_id, action):
|
||||
"""
|
||||
obj_type = ContentType.objects.get_for_model(instance.__class__)
|
||||
|
||||
webhook_models = ContentType.objects.filter(WEBHOOK_MODELS)
|
||||
webhook_models = ContentType.objects.filter(FeatureQuerySet('webhooks').get_queryset())
|
||||
if obj_type not in webhook_models:
|
||||
return
|
||||
|
||||
|
@ -10,6 +10,7 @@ from taggit.managers import TaggableManager
|
||||
|
||||
from dcim.models import Device, Interface
|
||||
from extras.models import CustomFieldModel, ObjectChange, TaggedItem
|
||||
from extras.utils import extras_features
|
||||
from utilities.models import ChangeLoggedModel
|
||||
from utilities.utils import serialize_object
|
||||
from virtualization.models import VirtualMachine
|
||||
@ -34,6 +35,7 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
|
||||
class VRF(ChangeLoggedModel, CustomFieldModel):
|
||||
"""
|
||||
A virtual routing and forwarding (VRF) table represents a discrete layer three forwarding domain (e.g. a routing
|
||||
@ -150,6 +152,7 @@ class RIR(ChangeLoggedModel):
|
||||
)
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
|
||||
class Aggregate(ChangeLoggedModel, CustomFieldModel):
|
||||
"""
|
||||
An aggregate exists at the root level of the IP address space hierarchy in NetBox. Aggregates are used to organize
|
||||
@ -287,6 +290,7 @@ class Role(ChangeLoggedModel):
|
||||
)
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
|
||||
class Prefix(ChangeLoggedModel, CustomFieldModel):
|
||||
"""
|
||||
A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be assigned to Sites and
|
||||
@ -552,6 +556,7 @@ class Prefix(ChangeLoggedModel, CustomFieldModel):
|
||||
return int(float(child_count) / prefix_size * 100)
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
|
||||
class IPAddress(ChangeLoggedModel, CustomFieldModel):
|
||||
"""
|
||||
An IPAddress represents an individual IPv4 or IPv6 address and its mask. The mask length should match what is
|
||||
@ -858,6 +863,7 @@ class VLANGroup(ChangeLoggedModel):
|
||||
return None
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
|
||||
class VLAN(ChangeLoggedModel, CustomFieldModel):
|
||||
"""
|
||||
A VLAN is a distinct layer two forwarding domain identified by a 12-bit integer (1-4094). Each VLAN must be assigned
|
||||
@ -982,6 +988,7 @@ class VLAN(ChangeLoggedModel, CustomFieldModel):
|
||||
).distinct()
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
|
||||
class Service(ChangeLoggedModel, CustomFieldModel):
|
||||
"""
|
||||
A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device or VirtualMachine. A Service may
|
||||
|
@ -71,6 +71,12 @@ class SecretRoleCSVForm(forms.ModelForm):
|
||||
#
|
||||
|
||||
class SecretForm(BootstrapMixin, CustomFieldModelForm):
|
||||
device = DynamicModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
widget=APISelect(
|
||||
api_url="/api/dcim/devices/"
|
||||
)
|
||||
)
|
||||
plaintext = forms.CharField(
|
||||
max_length=SECRET_PLAINTEXT_MAX_LENGTH,
|
||||
required=False,
|
||||
@ -100,7 +106,7 @@ class SecretForm(BootstrapMixin, CustomFieldModelForm):
|
||||
class Meta:
|
||||
model = Secret
|
||||
fields = [
|
||||
'role', 'name', 'plaintext', 'plaintext2', 'tags',
|
||||
'device', 'role', 'name', 'plaintext', 'plaintext2', 'tags',
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
@ -16,6 +16,7 @@ from taggit.managers import TaggableManager
|
||||
|
||||
from dcim.models import Device
|
||||
from extras.models import CustomFieldModel, TaggedItem
|
||||
from extras.utils import extras_features
|
||||
from utilities.models import ChangeLoggedModel
|
||||
from .exceptions import InvalidKey
|
||||
from .hashers import SecretValidationHasher
|
||||
@ -295,6 +296,7 @@ class SecretRole(ChangeLoggedModel):
|
||||
return user in self.users.all() or user.groups.filter(pk__in=self.groups.all()).exists()
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
|
||||
class Secret(ChangeLoggedModel, CustomFieldModel):
|
||||
"""
|
||||
A Secret stores an AES256-encrypted copy of sensitive data, such as passwords or secret keys. An irreversible
|
||||
|
@ -17,6 +17,7 @@ urlpatterns = [
|
||||
|
||||
# Secrets
|
||||
path('secrets/', views.SecretListView.as_view(), name='secret_list'),
|
||||
path('secrets/add/', views.secret_add, name='secret_add'),
|
||||
path('secrets/import/', views.SecretBulkImportView.as_view(), name='secret_import'),
|
||||
path('secrets/edit/', views.SecretBulkEditView.as_view(), name='secret_bulk_edit'),
|
||||
path('secrets/delete/', views.SecretBulkDeleteView.as_view(), name='secret_bulk_delete'),
|
||||
|
@ -8,9 +8,8 @@ from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse
|
||||
from django.views.generic import View
|
||||
|
||||
from dcim.models import Device
|
||||
from utilities.views import (
|
||||
BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
|
||||
BulkDeleteView, BulkEditView, BulkImportView, GetReturnURLMixin, ObjectDeleteView, ObjectEditView, ObjectListView,
|
||||
)
|
||||
from . import filters, forms, tables
|
||||
from .decorators import userkey_required
|
||||
@ -89,12 +88,9 @@ class SecretView(PermissionRequiredMixin, View):
|
||||
|
||||
@permission_required('secrets.add_secret')
|
||||
@userkey_required()
|
||||
def secret_add(request, pk):
|
||||
def secret_add(request):
|
||||
|
||||
# Retrieve device
|
||||
device = get_object_or_404(Device, pk=pk)
|
||||
|
||||
secret = Secret(device=device)
|
||||
secret = Secret()
|
||||
session_key = get_session_key(request)
|
||||
|
||||
if request.method == 'POST':
|
||||
@ -123,17 +119,20 @@ def secret_add(request, pk):
|
||||
|
||||
messages.success(request, "Added new secret: {}.".format(secret))
|
||||
if '_addanother' in request.POST:
|
||||
return redirect('dcim:device_addsecret', pk=device.pk)
|
||||
return redirect('secrets:secret_add')
|
||||
else:
|
||||
return redirect('secrets:secret', pk=secret.pk)
|
||||
|
||||
else:
|
||||
form = forms.SecretForm(instance=secret)
|
||||
initial_data = {
|
||||
'device': request.GET.get('device'),
|
||||
}
|
||||
form = forms.SecretForm(initial=initial_data)
|
||||
|
||||
return render(request, 'secrets/secret_edit.html', {
|
||||
'secret': secret,
|
||||
'form': form,
|
||||
'return_url': device.get_absolute_url(),
|
||||
'return_url': GetReturnURLMixin().get_return_url(request, secret)
|
||||
})
|
||||
|
||||
|
||||
|
@ -426,7 +426,7 @@
|
||||
{% csrf_token %}
|
||||
</form>
|
||||
<div class="panel-footer text-right noprint">
|
||||
<a href="{% url 'dcim:device_addsecret' pk=device.pk %}" class="btn btn-xs btn-primary">
|
||||
<a href="{% url 'secrets:secret_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-xs btn-primary">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
|
||||
Add secret
|
||||
</a>
|
||||
|
@ -271,7 +271,9 @@
|
||||
</tr>
|
||||
{% for resv in reservations %}
|
||||
<tr>
|
||||
<td>{{ resv.unit_list }}</td>
|
||||
<td>
|
||||
<a href="{{ resv.get_absolute_url }}">{{ resv.unit_list }}</a>
|
||||
</td>
|
||||
<td>
|
||||
{% if resv.tenant %}
|
||||
<a href="{{ resv.tenant.get_absolute_url }}">{{ resv.tenant }}</a>
|
||||
@ -285,12 +287,12 @@
|
||||
</td>
|
||||
<td class="text-right noprint">
|
||||
{% if perms.dcim.change_rackreservation %}
|
||||
<a href="{% url 'dcim:rackreservation_edit' pk=resv.pk %}" class="btn btn-warning btn-xs" title="Edit reservation">
|
||||
<a href="{% url 'dcim:rackreservation_edit' pk=resv.pk %}&return_url={{ rack.get_absolute_url }}" class="btn btn-warning btn-xs" title="Edit reservation">
|
||||
<i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if perms.dcim.delete_rackreservation %}
|
||||
<a href="{% url 'dcim:rackreservation_delete' pk=resv.pk %}" class="btn btn-danger btn-xs" title="Delete reservation">
|
||||
<a href="{% url 'dcim:rackreservation_delete' pk=resv.pk %}&return_url={{ rack.get_absolute_url }}" class="btn btn-danger btn-xs" title="Delete reservation">
|
||||
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
@ -303,7 +305,7 @@
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_rackreservation %}
|
||||
<div class="panel-footer text-right noprint">
|
||||
<a href="{% url 'dcim:rack_add_reservation' rack=rack.pk %}" class="btn btn-primary btn-xs">
|
||||
<a href="{% url 'dcim:rackreservation_add' %}?rack={{ rack.pk }}&return_url={{ rack.get_absolute_url }}" class="btn btn-primary btn-xs">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
|
||||
Add a reservation
|
||||
</a>
|
||||
|
146
netbox/templates/dcim/rackreservation.html
Normal file
146
netbox/templates/dcim/rackreservation.html
Normal file
@ -0,0 +1,146 @@
|
||||
{% extends '_base.html' %}
|
||||
{% load buttons %}
|
||||
{% load custom_links %}
|
||||
{% load helpers %}
|
||||
{% load static %}
|
||||
|
||||
{% block header %}
|
||||
<div class="row noprint">
|
||||
<div class="col-sm-8 col-md-9">
|
||||
<ol class="breadcrumb">
|
||||
<li><a href="{% url 'dcim:rackreservation_list' %}">Rack Reservations</a></li>
|
||||
<li><a href="{{ rackreservation.rack.get_absolute_url }}">{{ rackreservation.rack }}</a></li>
|
||||
<li>Units {{ rackreservation.unit_list }}</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="col-sm-4 col-md-3">
|
||||
<form action="{% url 'dcim:rackreservation_list' %}" method="get">
|
||||
<div class="input-group">
|
||||
<input type="text" name="q" class="form-control" placeholder="Search racks" />
|
||||
<span class="input-group-btn">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<span class="fa fa-search" aria-hidden="true"></span>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pull-right noprint">
|
||||
{% if perms.dcim.change_rackreservation %}
|
||||
{% edit_button rackreservation %}
|
||||
{% endif %}
|
||||
{% if perms.dcim.delete_rackreservation %}
|
||||
{% delete_button rackreservation %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<h1>{% block title %}{{ rackreservation }}{% endblock %}</h1>
|
||||
{% include 'inc/created_updated.html' with obj=rackreservation %}
|
||||
<ul class="nav nav-tabs">
|
||||
<li role="presentation"{% if not active_tab %} class="active"{% endif %}>
|
||||
<a href="{{ rackreservation.get_absolute_url }}">Rack</a>
|
||||
</li>
|
||||
{% if perms.extras.view_objectchange %}
|
||||
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
||||
<a href="{% url 'dcim:rackreservation_changelog' pk=rackreservation.pk %}">Change Log</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Rack</strong>
|
||||
</div>
|
||||
<table class="table table-hover panel-body attr-table">
|
||||
{% with rack=rackreservation.rack %}
|
||||
<tr>
|
||||
<td>Site</td>
|
||||
<td>
|
||||
{% if rack.site.region %}
|
||||
<a href="{{ rack.site.region.get_absolute_url }}">{{ rack.site.region }}</a>
|
||||
<i class="fa fa-angle-right"></i>
|
||||
{% endif %}
|
||||
<a href="{% url 'dcim:site' slug=rack.site.slug %}">{{ rack.site }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Group</td>
|
||||
<td>
|
||||
{% if rack.group %}
|
||||
<a href="{% url 'dcim:rack_list' %}?site={{ rack.site.slug }}&group={{ rack.group.slug }}">{{ rack.group }}</a>
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Rack</td>
|
||||
<td>
|
||||
<a href="{{ rack.get_absolute_url }}">{{ rack }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endwith %}
|
||||
</table>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Reservation Details</strong>
|
||||
</div>
|
||||
<table class="table table-hover panel-body attr-table">
|
||||
<tr>
|
||||
<td>Units</td>
|
||||
<td>{{ rackreservation.unit_list }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Tenant</td>
|
||||
<td>
|
||||
{% if rackreservation.tenant %}
|
||||
{% if rackreservation.tenant.group %}
|
||||
<a href="{{ rackreservation.tenant.group.get_absolute_url }}">{{ rackreservation.tenant.group }}</a>
|
||||
<i class="fa fa-angle-right"></i>
|
||||
{% endif %}
|
||||
<a href="{{ rackreservation.tenant.get_absolute_url }}">{{ rackreservation.tenant }}</a>
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>User</td>
|
||||
<td>{{ rackreservation.user }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Description</td>
|
||||
<td>{{ rackreservation.description }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
{% with rack=rackreservation.rack %}
|
||||
<div class="row" style="margin-bottom: 20px">
|
||||
<div class="col-md-6 col-sm-6 col-xs-12">
|
||||
<div class="rack_header">
|
||||
<h4>Front</h4>
|
||||
</div>
|
||||
{% include 'dcim/inc/rack_elevation.html' with face='front' %}
|
||||
</div>
|
||||
<div class="col-md-6 col-sm-6 col-xs-12">
|
||||
<div class="rack_header">
|
||||
<h4>Rear</h4>
|
||||
</div>
|
||||
{% include 'dcim/inc/rack_elevation.html' with face='rear' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endwith %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block javascript %}
|
||||
<script src="{% static 'js/rack_elevations.js' %}?v{{ settings.VERSION }}"></script>
|
||||
{% endblock %}
|
21
netbox/templates/dcim/rackreservation_edit.html
Normal file
21
netbox/templates/dcim/rackreservation_edit.html
Normal file
@ -0,0 +1,21 @@
|
||||
{% extends 'utilities/obj_edit.html' %}
|
||||
{% load form_helpers %}
|
||||
|
||||
{% block form %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>{{ obj_type|capfirst }}</strong></div>
|
||||
<div class="panel-body">
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label">Rack</label>
|
||||
<div class="col-md-9">
|
||||
<p class="form-control-static">{{ obj.rack }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% render_field form.units %}
|
||||
{% render_field form.user %}
|
||||
{% render_field form.tenant_group %}
|
||||
{% render_field form.tenant %}
|
||||
{% render_field form.description %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
@ -7,7 +7,7 @@
|
||||
<table class="table table-hover panel-body attr-table">
|
||||
{% for field, value in custom_fields.items %}
|
||||
<tr>
|
||||
<td>{{ field }}</td>
|
||||
<td><span title="{{ field.description }}">{{ field }}</span></td>
|
||||
<td>
|
||||
{% if field.type == 'boolean' and value == True %}
|
||||
<i class="glyphicon glyphicon-ok text-success" title="True"></i>
|
||||
|
@ -462,6 +462,7 @@
|
||||
<li{% if not perms.secrets.view_secret %} class="disabled"{% endif %}>
|
||||
{% if perms.secrets.add_secret %}
|
||||
<div class="buttons pull-right">
|
||||
<a href="{% url 'secrets:secret_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
|
||||
<a href="{% url 'secrets:secret_import' %}" class="btn btn-xs btn-info" title="Import"><i class="fa fa-download"></i></a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
@ -21,12 +21,7 @@
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Secret Attributes</strong></div>
|
||||
<div class="panel-body">
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label required">Device</label>
|
||||
<div class="col-md-9">
|
||||
<p class="form-control-static">{{ secret.device }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% render_field form.device %}
|
||||
{% render_field form.role %}
|
||||
{% render_field form.name %}
|
||||
{% render_field form.userkeys %}
|
||||
|
@ -51,7 +51,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% if settings.DOCS_ROOT %}
|
||||
{% if obj and settings.DOCS_ROOT %}
|
||||
{% include 'inc/modal.html' with name='docs' content=obj|get_docs %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
@ -5,6 +5,7 @@ from mptt.models import MPTTModel, TreeForeignKey
|
||||
from taggit.managers import TaggableManager
|
||||
|
||||
from extras.models import CustomFieldModel, ObjectChange, TaggedItem
|
||||
from extras.utils import extras_features
|
||||
from utilities.models import ChangeLoggedModel
|
||||
from utilities.utils import serialize_object
|
||||
|
||||
@ -71,6 +72,7 @@ class TenantGroup(MPTTModel, ChangeLoggedModel):
|
||||
)
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
|
||||
class Tenant(ChangeLoggedModel, CustomFieldModel):
|
||||
"""
|
||||
A Tenant represents an organization served by the NetBox owner. This is typically a customer or an internal
|
||||
|
@ -235,6 +235,7 @@ class ValidatedModelSerializer(ModelSerializer):
|
||||
for k, v in attrs.items():
|
||||
setattr(instance, k, v)
|
||||
instance.clean()
|
||||
instance.validate_unique()
|
||||
|
||||
return data
|
||||
|
||||
|
@ -40,7 +40,7 @@ def render_markdown(value):
|
||||
value = strip_tags(value)
|
||||
|
||||
# Render Markdown
|
||||
html = markdown(value, extensions=['fenced_code'])
|
||||
html = markdown(value, extensions=['fenced_code', 'tables'])
|
||||
|
||||
return mark_safe(html)
|
||||
|
||||
@ -196,7 +196,7 @@ def get_docs(model):
|
||||
return "Unable to load documentation, error reading file: {}".format(path)
|
||||
|
||||
# Render Markdown with the admonition extension
|
||||
content = markdown(content, extensions=['admonition', 'fenced_code'])
|
||||
content = markdown(content, extensions=['admonition', 'fenced_code', 'tables'])
|
||||
|
||||
return mark_safe(content)
|
||||
|
||||
|
@ -7,6 +7,7 @@ from taggit.managers import TaggableManager
|
||||
|
||||
from dcim.models import Device
|
||||
from extras.models import ConfigContextModel, CustomFieldModel, TaggedItem
|
||||
from extras.utils import extras_features
|
||||
from utilities.models import ChangeLoggedModel
|
||||
from .choices import *
|
||||
|
||||
@ -101,6 +102,7 @@ class ClusterGroup(ChangeLoggedModel):
|
||||
# Clusters
|
||||
#
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
|
||||
class Cluster(ChangeLoggedModel, CustomFieldModel):
|
||||
"""
|
||||
A cluster of VirtualMachines. Each Cluster may optionally be associated with one or more Devices.
|
||||
@ -187,6 +189,7 @@ class Cluster(ChangeLoggedModel, CustomFieldModel):
|
||||
# Virtual machines
|
||||
#
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
|
||||
class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
|
||||
"""
|
||||
A virtual machine which runs inside a Cluster.
|
||||
|
@ -488,6 +488,18 @@ class VirtualMachineTest(APITestCase):
|
||||
|
||||
self.assertFalse('config_context' in response.data['results'][0])
|
||||
|
||||
def test_unique_name_per_cluster_constraint(self):
|
||||
|
||||
data = {
|
||||
'name': 'Test Virtual Machine 1',
|
||||
'cluster': self.cluster1.pk,
|
||||
}
|
||||
|
||||
url = reverse('virtualization-api:virtualmachine-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class InterfaceTest(APITestCase):
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user