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
|
# 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)
|
## 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.
|
**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.fields import ASNField
|
||||||
from dcim.models import CableTermination
|
from dcim.models import CableTermination
|
||||||
from extras.models import CustomFieldModel, ObjectChange, TaggedItem
|
from extras.models import CustomFieldModel, ObjectChange, TaggedItem
|
||||||
|
from extras.utils import extras_features
|
||||||
from utilities.models import ChangeLoggedModel
|
from utilities.models import ChangeLoggedModel
|
||||||
from utilities.utils import serialize_object
|
from utilities.utils import serialize_object
|
||||||
from .choices import *
|
from .choices import *
|
||||||
@ -21,6 +22,7 @@ __all__ = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@extras_features('custom_fields', 'custom_links', 'graphs', 'export_templates', 'webhooks')
|
||||||
class Provider(ChangeLoggedModel, CustomFieldModel):
|
class Provider(ChangeLoggedModel, CustomFieldModel):
|
||||||
"""
|
"""
|
||||||
Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model
|
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):
|
class Circuit(ChangeLoggedModel, CustomFieldModel):
|
||||||
"""
|
"""
|
||||||
A communications circuit connects two points. Each Circuit belongs to a Provider; Providers may have multiple
|
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):
|
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(
|
units = SimpleArrayField(
|
||||||
base_field=forms.IntegerField(),
|
base_field=forms.IntegerField(),
|
||||||
widget=ArrayFieldSelectMultiple(
|
widget=ArrayFieldSelectMultiple(
|
||||||
@ -841,7 +848,7 @@ class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = RackReservation
|
model = RackReservation
|
||||||
fields = [
|
fields = [
|
||||||
'units', 'user', 'tenant_group', 'tenant', 'description',
|
'rack', 'units', 'user', 'tenant_group', 'tenant', 'description',
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
@ -849,7 +856,8 @@ class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm):
|
|||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
# Populate rack unit choices
|
# 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):
|
def _get_unit_choices(self):
|
||||||
rack = self.instance.rack
|
rack = self.instance.rack
|
||||||
|
@ -21,6 +21,7 @@ from dcim.constants import *
|
|||||||
from dcim.fields import ASNField
|
from dcim.fields import ASNField
|
||||||
from dcim.elevations import RackElevationSVG
|
from dcim.elevations import RackElevationSVG
|
||||||
from extras.models import ConfigContextModel, CustomFieldModel, ObjectChange, TaggedItem
|
from extras.models import ConfigContextModel, CustomFieldModel, ObjectChange, TaggedItem
|
||||||
|
from extras.utils import extras_features
|
||||||
from utilities.fields import ColorField, NaturalOrderingField
|
from utilities.fields import ColorField, NaturalOrderingField
|
||||||
from utilities.models import ChangeLoggedModel
|
from utilities.models import ChangeLoggedModel
|
||||||
from utilities.utils import serialize_object, to_meters
|
from utilities.utils import serialize_object, to_meters
|
||||||
@ -75,6 +76,7 @@ __all__ = (
|
|||||||
# Regions
|
# Regions
|
||||||
#
|
#
|
||||||
|
|
||||||
|
@extras_features('export_templates', 'webhooks')
|
||||||
class Region(MPTTModel, ChangeLoggedModel):
|
class Region(MPTTModel, ChangeLoggedModel):
|
||||||
"""
|
"""
|
||||||
Sites can be grouped within geographic Regions.
|
Sites can be grouped within geographic Regions.
|
||||||
@ -138,6 +140,7 @@ class Region(MPTTModel, ChangeLoggedModel):
|
|||||||
# Sites
|
# Sites
|
||||||
#
|
#
|
||||||
|
|
||||||
|
@extras_features('custom_fields', 'custom_links', 'graphs', 'export_templates', 'webhooks')
|
||||||
class Site(ChangeLoggedModel, CustomFieldModel):
|
class Site(ChangeLoggedModel, CustomFieldModel):
|
||||||
"""
|
"""
|
||||||
A Site represents a geographic location within a network; typically a building or campus. The optional facility
|
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
|
# Racks
|
||||||
#
|
#
|
||||||
|
|
||||||
|
@extras_features('export_templates')
|
||||||
class RackGroup(MPTTModel, ChangeLoggedModel):
|
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
|
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):
|
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.
|
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):
|
def __str__(self):
|
||||||
return "Reservation for rack {}".format(self.rack)
|
return "Reservation for rack {}".format(self.rack)
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return reverse('dcim:rackreservation', args=[self.pk])
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
|
|
||||||
if self.units:
|
if self.units:
|
||||||
@ -857,6 +865,7 @@ class RackReservation(ChangeLoggedModel):
|
|||||||
# Device Types
|
# Device Types
|
||||||
#
|
#
|
||||||
|
|
||||||
|
@extras_features('export_templates', 'webhooks')
|
||||||
class Manufacturer(ChangeLoggedModel):
|
class Manufacturer(ChangeLoggedModel):
|
||||||
"""
|
"""
|
||||||
A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell.
|
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):
|
class DeviceType(ChangeLoggedModel, CustomFieldModel):
|
||||||
"""
|
"""
|
||||||
A DeviceType represents a particular make (Manufacturer) and model of device. It specifies rack height and depth, as
|
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):
|
class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
|
||||||
"""
|
"""
|
||||||
A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType,
|
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
|
# Virtual chassis
|
||||||
#
|
#
|
||||||
|
|
||||||
|
@extras_features('export_templates', 'webhooks')
|
||||||
class VirtualChassis(ChangeLoggedModel):
|
class VirtualChassis(ChangeLoggedModel):
|
||||||
"""
|
"""
|
||||||
A collection of Devices which operate with a shared control plane (e.g. a switch stack).
|
A collection of Devices which operate with a shared control plane (e.g. a switch stack).
|
||||||
@ -1741,6 +1753,7 @@ class VirtualChassis(ChangeLoggedModel):
|
|||||||
# Power
|
# Power
|
||||||
#
|
#
|
||||||
|
|
||||||
|
@extras_features('custom_links', 'export_templates', 'webhooks')
|
||||||
class PowerPanel(ChangeLoggedModel):
|
class PowerPanel(ChangeLoggedModel):
|
||||||
"""
|
"""
|
||||||
A distribution point for electrical power; e.g. a data center RPP.
|
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):
|
class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
|
||||||
"""
|
"""
|
||||||
An electrical circuit delivered from a PowerPanel.
|
An electrical circuit delivered from a PowerPanel.
|
||||||
@ -1948,6 +1962,7 @@ class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
|
|||||||
# Cables
|
# Cables
|
||||||
#
|
#
|
||||||
|
|
||||||
|
@extras_features('custom_links', 'export_templates', 'webhooks')
|
||||||
class Cable(ChangeLoggedModel):
|
class Cable(ChangeLoggedModel):
|
||||||
"""
|
"""
|
||||||
A physical connection between two endpoints.
|
A physical connection between two endpoints.
|
||||||
|
@ -11,6 +11,7 @@ from dcim.constants import *
|
|||||||
from dcim.exceptions import LoopDetected
|
from dcim.exceptions import LoopDetected
|
||||||
from dcim.fields import MACAddressField
|
from dcim.fields import MACAddressField
|
||||||
from extras.models import ObjectChange, TaggedItem
|
from extras.models import ObjectChange, TaggedItem
|
||||||
|
from extras.utils import extras_features
|
||||||
from utilities.fields import NaturalOrderingField
|
from utilities.fields import NaturalOrderingField
|
||||||
from utilities.ordering import naturalize_interface
|
from utilities.ordering import naturalize_interface
|
||||||
from utilities.utils import serialize_object
|
from utilities.utils import serialize_object
|
||||||
@ -169,6 +170,7 @@ class CableTermination(models.Model):
|
|||||||
# Console ports
|
# Console ports
|
||||||
#
|
#
|
||||||
|
|
||||||
|
@extras_features('export_templates', 'webhooks')
|
||||||
class ConsolePort(CableTermination, ComponentModel):
|
class ConsolePort(CableTermination, ComponentModel):
|
||||||
"""
|
"""
|
||||||
A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
|
A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
|
||||||
@ -229,6 +231,7 @@ class ConsolePort(CableTermination, ComponentModel):
|
|||||||
# Console server ports
|
# Console server ports
|
||||||
#
|
#
|
||||||
|
|
||||||
|
@extras_features('webhooks')
|
||||||
class ConsoleServerPort(CableTermination, ComponentModel):
|
class ConsoleServerPort(CableTermination, ComponentModel):
|
||||||
"""
|
"""
|
||||||
A physical port within a Device (typically a designated console server) which provides access to ConsolePorts.
|
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
|
# Power ports
|
||||||
#
|
#
|
||||||
|
|
||||||
|
@extras_features('export_templates', 'webhooks')
|
||||||
class PowerPort(CableTermination, ComponentModel):
|
class PowerPort(CableTermination, ComponentModel):
|
||||||
"""
|
"""
|
||||||
A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets.
|
A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets.
|
||||||
@ -443,6 +447,7 @@ class PowerPort(CableTermination, ComponentModel):
|
|||||||
# Power outlets
|
# Power outlets
|
||||||
#
|
#
|
||||||
|
|
||||||
|
@extras_features('webhooks')
|
||||||
class PowerOutlet(CableTermination, ComponentModel):
|
class PowerOutlet(CableTermination, ComponentModel):
|
||||||
"""
|
"""
|
||||||
A physical power outlet (output) within a Device which provides power to a PowerPort.
|
A physical power outlet (output) within a Device which provides power to a PowerPort.
|
||||||
@ -519,6 +524,7 @@ class PowerOutlet(CableTermination, ComponentModel):
|
|||||||
# Interfaces
|
# Interfaces
|
||||||
#
|
#
|
||||||
|
|
||||||
|
@extras_features('graphs', 'export_templates', 'webhooks')
|
||||||
class Interface(CableTermination, ComponentModel):
|
class Interface(CableTermination, ComponentModel):
|
||||||
"""
|
"""
|
||||||
A network interface within a Device or VirtualMachine. A physical Interface can connect to exactly one other
|
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
|
# Pass-through ports
|
||||||
#
|
#
|
||||||
|
|
||||||
|
@extras_features('webhooks')
|
||||||
class FrontPort(CableTermination, ComponentModel):
|
class FrontPort(CableTermination, ComponentModel):
|
||||||
"""
|
"""
|
||||||
A pass-through port on the front of a Device.
|
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):
|
class RearPort(CableTermination, ComponentModel):
|
||||||
"""
|
"""
|
||||||
A pass-through port on the rear of a Device.
|
A pass-through port on the rear of a Device.
|
||||||
@ -915,6 +923,7 @@ class RearPort(CableTermination, ComponentModel):
|
|||||||
# Device bays
|
# Device bays
|
||||||
#
|
#
|
||||||
|
|
||||||
|
@extras_features('webhooks')
|
||||||
class DeviceBay(ComponentModel):
|
class DeviceBay(ComponentModel):
|
||||||
"""
|
"""
|
||||||
An empty space within a Device which can house a child device
|
An empty space within a Device which can house a child device
|
||||||
@ -989,6 +998,7 @@ class DeviceBay(ComponentModel):
|
|||||||
# Inventory items
|
# Inventory items
|
||||||
#
|
#
|
||||||
|
|
||||||
|
@extras_features('export_templates', 'webhooks')
|
||||||
class InventoryItem(ComponentModel):
|
class InventoryItem(ComponentModel):
|
||||||
"""
|
"""
|
||||||
An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply.
|
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):
|
class RackReservationTable(BaseTable):
|
||||||
pk = ToggleColumn()
|
pk = ToggleColumn()
|
||||||
|
reservation = tables.LinkColumn(
|
||||||
|
viewname='dcim:rackreservation',
|
||||||
|
args=[Accessor('pk')],
|
||||||
|
accessor='pk'
|
||||||
|
)
|
||||||
site = tables.LinkColumn(
|
site = tables.LinkColumn(
|
||||||
viewname='dcim:site',
|
viewname='dcim:site',
|
||||||
accessor=Accessor('rack.site'),
|
accessor=Accessor('rack.site'),
|
||||||
args=[Accessor('rack.site.slug')],
|
args=[Accessor('rack.site.slug')],
|
||||||
)
|
)
|
||||||
tenant = tables.TemplateColumn(template_code=COL_TENANT)
|
tenant = tables.TemplateColumn(
|
||||||
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')])
|
template_code=COL_TENANT
|
||||||
unit_list = tables.Column(orderable=False, verbose_name='Units')
|
)
|
||||||
|
rack = tables.LinkColumn(
|
||||||
|
viewname='dcim:rack',
|
||||||
|
args=[Accessor('rack.pk')]
|
||||||
|
)
|
||||||
|
unit_list = tables.Column(
|
||||||
|
orderable=False,
|
||||||
|
verbose_name='Units'
|
||||||
|
)
|
||||||
actions = tables.TemplateColumn(
|
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):
|
class Meta(BaseTable.Meta):
|
||||||
model = RackReservation
|
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])
|
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):
|
class ConsolePortTest(APITestCase):
|
||||||
|
|
||||||
|
@ -176,10 +176,6 @@ class RackRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
|
|||||||
class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||||
model = RackReservation
|
model = RackReservation
|
||||||
|
|
||||||
# Disable inapplicable tests
|
|
||||||
test_get_object = None
|
|
||||||
test_create_object = None
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
|
|
||||||
|
@ -2,7 +2,6 @@ from django.urls import path
|
|||||||
|
|
||||||
from extras.views import ObjectChangeLogView, ImageAttachmentEditView
|
from extras.views import ObjectChangeLogView, ImageAttachmentEditView
|
||||||
from ipam.views import ServiceCreateView
|
from ipam.views import ServiceCreateView
|
||||||
from secrets.views import secret_add
|
|
||||||
from . import views
|
from . import views
|
||||||
from .models import (
|
from .models import (
|
||||||
Cable, ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, FrontPort, Interface, Manufacturer, Platform,
|
Cable, ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, FrontPort, Interface, Manufacturer, Platform,
|
||||||
@ -51,9 +50,11 @@ urlpatterns = [
|
|||||||
|
|
||||||
# Rack reservations
|
# Rack reservations
|
||||||
path('rack-reservations/', views.RackReservationListView.as_view(), name='rackreservation_list'),
|
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/import/', views.RackReservationImportView.as_view(), name='rackreservation_import'),
|
||||||
path('rack-reservations/edit/', views.RackReservationBulkEditView.as_view(), name='rackreservation_bulk_edit'),
|
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/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>/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>/delete/', views.RackReservationDeleteView.as_view(), name='rackreservation_delete'),
|
||||||
path('rack-reservations/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rackreservation_changelog', kwargs={'model': RackReservation}),
|
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>/edit/', views.RackEditView.as_view(), name='rack_edit'),
|
||||||
path('racks/<int:pk>/delete/', views.RackDeleteView.as_view(), name='rack_delete'),
|
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: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}),
|
path('racks/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='rack_add_image', kwargs={'model': Rack}),
|
||||||
|
|
||||||
# Manufacturers
|
# Manufacturers
|
||||||
@ -179,7 +179,6 @@ urlpatterns = [
|
|||||||
path('devices/<int:pk>/status/', views.DeviceStatusView.as_view(), name='device_status'),
|
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>/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>/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: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}),
|
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',)
|
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):
|
class RackReservationCreateView(PermissionRequiredMixin, ObjectEditView):
|
||||||
permission_required = 'dcim.add_rackreservation'
|
permission_required = 'dcim.add_rackreservation'
|
||||||
model = RackReservation
|
model = RackReservation
|
||||||
model_form = forms.RackReservationForm
|
model_form = forms.RackReservationForm
|
||||||
|
template_name = 'dcim/rackreservation_edit.html'
|
||||||
|
default_return_url = 'dcim:rackreservation_list'
|
||||||
|
|
||||||
def alter_obj(self, obj, request, args, kwargs):
|
def alter_obj(self, obj, request, args, kwargs):
|
||||||
if not obj.pk:
|
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
|
obj.user = request.user
|
||||||
return obj
|
return obj
|
||||||
|
|
||||||
def get_return_url(self, request, obj):
|
|
||||||
return obj.rack.get_absolute_url()
|
|
||||||
|
|
||||||
|
|
||||||
class RackReservationEditView(RackReservationCreateView):
|
class RackReservationEditView(RackReservationCreateView):
|
||||||
permission_required = 'dcim.change_rackreservation'
|
permission_required = 'dcim.change_rackreservation'
|
||||||
@ -501,9 +513,7 @@ class RackReservationEditView(RackReservationCreateView):
|
|||||||
class RackReservationDeleteView(PermissionRequiredMixin, ObjectDeleteView):
|
class RackReservationDeleteView(PermissionRequiredMixin, ObjectDeleteView):
|
||||||
permission_required = 'dcim.delete_rackreservation'
|
permission_required = 'dcim.delete_rackreservation'
|
||||||
model = RackReservation
|
model = RackReservation
|
||||||
|
default_return_url = 'dcim:rackreservation_list'
|
||||||
def get_return_url(self, request, obj):
|
|
||||||
return obj.rack.get_absolute_url()
|
|
||||||
|
|
||||||
|
|
||||||
class RackReservationImportView(PermissionRequiredMixin, BulkImportView):
|
class RackReservationImportView(PermissionRequiredMixin, BulkImportView):
|
||||||
|
@ -13,6 +13,7 @@ from extras.constants import *
|
|||||||
from extras.models import (
|
from extras.models import (
|
||||||
ConfigContext, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, Tag,
|
ConfigContext, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, Tag,
|
||||||
)
|
)
|
||||||
|
from extras.utils import FeatureQuerySet
|
||||||
from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer
|
from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer
|
||||||
from tenancy.models import Tenant, TenantGroup
|
from tenancy.models import Tenant, TenantGroup
|
||||||
from users.api.nested_serializers import NestedUserSerializer
|
from users.api.nested_serializers import NestedUserSerializer
|
||||||
@ -31,7 +32,7 @@ from .nested_serializers import *
|
|||||||
|
|
||||||
class GraphSerializer(ValidatedModelSerializer):
|
class GraphSerializer(ValidatedModelSerializer):
|
||||||
type = ContentTypeField(
|
type = ContentTypeField(
|
||||||
queryset=ContentType.objects.filter(GRAPH_MODELS),
|
queryset=ContentType.objects.filter(FeatureQuerySet('graphs').get_queryset()),
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -67,7 +68,7 @@ class RenderedGraphSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class ExportTemplateSerializer(ValidatedModelSerializer):
|
class ExportTemplateSerializer(ValidatedModelSerializer):
|
||||||
content_type = ContentTypeField(
|
content_type = ContentTypeField(
|
||||||
queryset=ContentType.objects.filter(EXPORTTEMPLATE_MODELS),
|
queryset=ContentType.objects.filter(FeatureQuerySet('export_templates').get_queryset()),
|
||||||
)
|
)
|
||||||
template_language = ChoiceField(
|
template_language = ChoiceField(
|
||||||
choices=TemplateLanguageChoices,
|
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
|
# Report logging levels
|
||||||
LOG_DEFAULT = 0
|
LOG_DEFAULT = 0
|
||||||
LOG_SUCCESS = 10
|
LOG_SUCCESS = 10
|
||||||
@ -138,51 +12,14 @@ LOG_LEVEL_CODES = {
|
|||||||
LOG_FAILURE: 'failure',
|
LOG_FAILURE: 'failure',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Webhook content types
|
||||||
HTTP_CONTENT_TYPE_JSON = 'application/json'
|
HTTP_CONTENT_TYPE_JSON = 'application/json'
|
||||||
|
|
||||||
# Models which support registered webhooks
|
# Registerable extras features
|
||||||
WEBHOOK_MODELS = Q(
|
EXTRAS_FEATURES = [
|
||||||
Q(app_label='circuits', model__in=[
|
'custom_fields',
|
||||||
'circuit',
|
'custom_links',
|
||||||
'provider',
|
'graphs',
|
||||||
]) |
|
'export_templates',
|
||||||
Q(app_label='dcim', model__in=[
|
'webhooks'
|
||||||
'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',
|
|
||||||
])
|
|
||||||
)
|
|
||||||
|
@ -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 .choices import *
|
||||||
from .constants import *
|
from .constants import *
|
||||||
from .querysets import ConfigContextQuerySet
|
from .querysets import ConfigContextQuerySet
|
||||||
|
from .utils import FeatureQuerySet
|
||||||
|
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
@ -58,7 +59,7 @@ class Webhook(models.Model):
|
|||||||
to=ContentType,
|
to=ContentType,
|
||||||
related_name='webhooks',
|
related_name='webhooks',
|
||||||
verbose_name='Object types',
|
verbose_name='Object types',
|
||||||
limit_choices_to=WEBHOOK_MODELS,
|
limit_choices_to=FeatureQuerySet('webhooks'),
|
||||||
help_text="The object(s) to which this Webhook applies."
|
help_text="The object(s) to which this Webhook applies."
|
||||||
)
|
)
|
||||||
name = models.CharField(
|
name = models.CharField(
|
||||||
@ -223,7 +224,7 @@ class CustomField(models.Model):
|
|||||||
to=ContentType,
|
to=ContentType,
|
||||||
related_name='custom_fields',
|
related_name='custom_fields',
|
||||||
verbose_name='Object(s)',
|
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.'
|
help_text='The object(s) to which this field applies.'
|
||||||
)
|
)
|
||||||
type = models.CharField(
|
type = models.CharField(
|
||||||
@ -470,7 +471,7 @@ class CustomLink(models.Model):
|
|||||||
content_type = models.ForeignKey(
|
content_type = models.ForeignKey(
|
||||||
to=ContentType,
|
to=ContentType,
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
limit_choices_to=CUSTOMLINK_MODELS
|
limit_choices_to=FeatureQuerySet('custom_links')
|
||||||
)
|
)
|
||||||
name = models.CharField(
|
name = models.CharField(
|
||||||
max_length=100,
|
max_length=100,
|
||||||
@ -518,7 +519,7 @@ class Graph(models.Model):
|
|||||||
type = models.ForeignKey(
|
type = models.ForeignKey(
|
||||||
to=ContentType,
|
to=ContentType,
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
limit_choices_to=GRAPH_MODELS
|
limit_choices_to=FeatureQuerySet('graphs')
|
||||||
)
|
)
|
||||||
weight = models.PositiveSmallIntegerField(
|
weight = models.PositiveSmallIntegerField(
|
||||||
default=1000
|
default=1000
|
||||||
@ -579,7 +580,7 @@ class ExportTemplate(models.Model):
|
|||||||
content_type = models.ForeignKey(
|
content_type = models.ForeignKey(
|
||||||
to=ContentType,
|
to=ContentType,
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
limit_choices_to=EXPORTTEMPLATE_MODELS
|
limit_choices_to=FeatureQuerySet('export_templates')
|
||||||
)
|
)
|
||||||
name = models.CharField(
|
name = models.CharField(
|
||||||
max_length=100
|
max_length=100
|
||||||
|
@ -9,6 +9,7 @@ from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Platform,
|
|||||||
from extras.api.views import ScriptViewSet
|
from extras.api.views import ScriptViewSet
|
||||||
from extras.models import ConfigContext, Graph, ExportTemplate, Tag
|
from extras.models import ConfigContext, Graph, ExportTemplate, Tag
|
||||||
from extras.scripts import BooleanVar, IntegerVar, Script, StringVar
|
from extras.scripts import BooleanVar, IntegerVar, Script, StringVar
|
||||||
|
from extras.utils import FeatureQuerySet
|
||||||
from tenancy.models import Tenant, TenantGroup
|
from tenancy.models import Tenant, TenantGroup
|
||||||
from utilities.testing import APITestCase
|
from utilities.testing import APITestCase
|
||||||
|
|
||||||
|
@ -3,8 +3,8 @@ from django.test import TestCase
|
|||||||
|
|
||||||
from dcim.models import DeviceRole, Platform, Region, Site
|
from dcim.models import DeviceRole, Platform, Region, Site
|
||||||
from extras.choices import *
|
from extras.choices import *
|
||||||
from extras.constants import GRAPH_MODELS
|
|
||||||
from extras.filters import *
|
from extras.filters import *
|
||||||
|
from extras.utils import FeatureQuerySet
|
||||||
from extras.models import ConfigContext, ExportTemplate, Graph
|
from extras.models import ConfigContext, ExportTemplate, Graph
|
||||||
from tenancy.models import Tenant, TenantGroup
|
from tenancy.models import Tenant, TenantGroup
|
||||||
from virtualization.models import Cluster, ClusterGroup, ClusterType
|
from virtualization.models import Cluster, ClusterGroup, ClusterType
|
||||||
@ -18,7 +18,7 @@ class GraphTestCase(TestCase):
|
|||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
|
|
||||||
# Get the first three available types
|
# 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 = (
|
graphs = (
|
||||||
Graph(name='Graph 1', type=content_types[0], template_language=TemplateLanguageChoices.LANGUAGE_DJANGO, source='http://example.com/1'),
|
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)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
def test_type(self):
|
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}
|
params = {'type': content_type.pk}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
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 taggit.managers import _TaggableManager
|
||||||
from utilities.querysets import DummyQuerySet
|
from utilities.querysets import DummyQuerySet
|
||||||
|
|
||||||
|
from extras.constants import EXTRAS_FEATURES
|
||||||
|
|
||||||
|
|
||||||
def is_taggable(obj):
|
def is_taggable(obj):
|
||||||
"""
|
"""
|
||||||
@ -13,3 +19,65 @@ def is_taggable(obj):
|
|||||||
if isinstance(obj.tags, DummyQuerySet):
|
if isinstance(obj.tags, DummyQuerySet):
|
||||||
return True
|
return True
|
||||||
return False
|
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 utilities.api import get_serializer_for_model
|
||||||
from .choices import *
|
from .choices import *
|
||||||
from .constants import *
|
from .constants import *
|
||||||
|
from .utils import FeatureQuerySet
|
||||||
|
|
||||||
|
|
||||||
def generate_signature(request_body, secret):
|
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__)
|
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:
|
if obj_type not in webhook_models:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
@ -10,6 +10,7 @@ from taggit.managers import TaggableManager
|
|||||||
|
|
||||||
from dcim.models import Device, Interface
|
from dcim.models import Device, Interface
|
||||||
from extras.models import CustomFieldModel, ObjectChange, TaggedItem
|
from extras.models import CustomFieldModel, ObjectChange, TaggedItem
|
||||||
|
from extras.utils import extras_features
|
||||||
from utilities.models import ChangeLoggedModel
|
from utilities.models import ChangeLoggedModel
|
||||||
from utilities.utils import serialize_object
|
from utilities.utils import serialize_object
|
||||||
from virtualization.models import VirtualMachine
|
from virtualization.models import VirtualMachine
|
||||||
@ -34,6 +35,7 @@ __all__ = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
|
||||||
class VRF(ChangeLoggedModel, CustomFieldModel):
|
class VRF(ChangeLoggedModel, CustomFieldModel):
|
||||||
"""
|
"""
|
||||||
A virtual routing and forwarding (VRF) table represents a discrete layer three forwarding domain (e.g. a routing
|
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):
|
class Aggregate(ChangeLoggedModel, CustomFieldModel):
|
||||||
"""
|
"""
|
||||||
An aggregate exists at the root level of the IP address space hierarchy in NetBox. Aggregates are used to organize
|
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):
|
class Prefix(ChangeLoggedModel, CustomFieldModel):
|
||||||
"""
|
"""
|
||||||
A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be assigned to Sites and
|
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)
|
return int(float(child_count) / prefix_size * 100)
|
||||||
|
|
||||||
|
|
||||||
|
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
|
||||||
class IPAddress(ChangeLoggedModel, CustomFieldModel):
|
class IPAddress(ChangeLoggedModel, CustomFieldModel):
|
||||||
"""
|
"""
|
||||||
An IPAddress represents an individual IPv4 or IPv6 address and its mask. The mask length should match what is
|
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
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
|
||||||
class VLAN(ChangeLoggedModel, CustomFieldModel):
|
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
|
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()
|
).distinct()
|
||||||
|
|
||||||
|
|
||||||
|
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
|
||||||
class Service(ChangeLoggedModel, CustomFieldModel):
|
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
|
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):
|
class SecretForm(BootstrapMixin, CustomFieldModelForm):
|
||||||
|
device = DynamicModelChoiceField(
|
||||||
|
queryset=Device.objects.all(),
|
||||||
|
widget=APISelect(
|
||||||
|
api_url="/api/dcim/devices/"
|
||||||
|
)
|
||||||
|
)
|
||||||
plaintext = forms.CharField(
|
plaintext = forms.CharField(
|
||||||
max_length=SECRET_PLAINTEXT_MAX_LENGTH,
|
max_length=SECRET_PLAINTEXT_MAX_LENGTH,
|
||||||
required=False,
|
required=False,
|
||||||
@ -100,7 +106,7 @@ class SecretForm(BootstrapMixin, CustomFieldModelForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Secret
|
model = Secret
|
||||||
fields = [
|
fields = [
|
||||||
'role', 'name', 'plaintext', 'plaintext2', 'tags',
|
'device', 'role', 'name', 'plaintext', 'plaintext2', 'tags',
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
|
@ -16,6 +16,7 @@ from taggit.managers import TaggableManager
|
|||||||
|
|
||||||
from dcim.models import Device
|
from dcim.models import Device
|
||||||
from extras.models import CustomFieldModel, TaggedItem
|
from extras.models import CustomFieldModel, TaggedItem
|
||||||
|
from extras.utils import extras_features
|
||||||
from utilities.models import ChangeLoggedModel
|
from utilities.models import ChangeLoggedModel
|
||||||
from .exceptions import InvalidKey
|
from .exceptions import InvalidKey
|
||||||
from .hashers import SecretValidationHasher
|
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()
|
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):
|
class Secret(ChangeLoggedModel, CustomFieldModel):
|
||||||
"""
|
"""
|
||||||
A Secret stores an AES256-encrypted copy of sensitive data, such as passwords or secret keys. An irreversible
|
A Secret stores an AES256-encrypted copy of sensitive data, such as passwords or secret keys. An irreversible
|
||||||
|
@ -17,6 +17,7 @@ urlpatterns = [
|
|||||||
|
|
||||||
# Secrets
|
# Secrets
|
||||||
path('secrets/', views.SecretListView.as_view(), name='secret_list'),
|
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/import/', views.SecretBulkImportView.as_view(), name='secret_import'),
|
||||||
path('secrets/edit/', views.SecretBulkEditView.as_view(), name='secret_bulk_edit'),
|
path('secrets/edit/', views.SecretBulkEditView.as_view(), name='secret_bulk_edit'),
|
||||||
path('secrets/delete/', views.SecretBulkDeleteView.as_view(), name='secret_bulk_delete'),
|
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.urls import reverse
|
||||||
from django.views.generic import View
|
from django.views.generic import View
|
||||||
|
|
||||||
from dcim.models import Device
|
|
||||||
from utilities.views import (
|
from utilities.views import (
|
||||||
BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
|
BulkDeleteView, BulkEditView, BulkImportView, GetReturnURLMixin, ObjectDeleteView, ObjectEditView, ObjectListView,
|
||||||
)
|
)
|
||||||
from . import filters, forms, tables
|
from . import filters, forms, tables
|
||||||
from .decorators import userkey_required
|
from .decorators import userkey_required
|
||||||
@ -89,12 +88,9 @@ class SecretView(PermissionRequiredMixin, View):
|
|||||||
|
|
||||||
@permission_required('secrets.add_secret')
|
@permission_required('secrets.add_secret')
|
||||||
@userkey_required()
|
@userkey_required()
|
||||||
def secret_add(request, pk):
|
def secret_add(request):
|
||||||
|
|
||||||
# Retrieve device
|
secret = Secret()
|
||||||
device = get_object_or_404(Device, pk=pk)
|
|
||||||
|
|
||||||
secret = Secret(device=device)
|
|
||||||
session_key = get_session_key(request)
|
session_key = get_session_key(request)
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
@ -123,17 +119,20 @@ def secret_add(request, pk):
|
|||||||
|
|
||||||
messages.success(request, "Added new secret: {}.".format(secret))
|
messages.success(request, "Added new secret: {}.".format(secret))
|
||||||
if '_addanother' in request.POST:
|
if '_addanother' in request.POST:
|
||||||
return redirect('dcim:device_addsecret', pk=device.pk)
|
return redirect('secrets:secret_add')
|
||||||
else:
|
else:
|
||||||
return redirect('secrets:secret', pk=secret.pk)
|
return redirect('secrets:secret', pk=secret.pk)
|
||||||
|
|
||||||
else:
|
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', {
|
return render(request, 'secrets/secret_edit.html', {
|
||||||
'secret': secret,
|
'secret': secret,
|
||||||
'form': form,
|
'form': form,
|
||||||
'return_url': device.get_absolute_url(),
|
'return_url': GetReturnURLMixin().get_return_url(request, secret)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@ -426,7 +426,7 @@
|
|||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
</form>
|
</form>
|
||||||
<div class="panel-footer text-right noprint">
|
<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>
|
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
|
||||||
Add secret
|
Add secret
|
||||||
</a>
|
</a>
|
||||||
|
@ -271,7 +271,9 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{% for resv in reservations %}
|
{% for resv in reservations %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ resv.unit_list }}</td>
|
<td>
|
||||||
|
<a href="{{ resv.get_absolute_url }}">{{ resv.unit_list }}</a>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if resv.tenant %}
|
{% if resv.tenant %}
|
||||||
<a href="{{ resv.tenant.get_absolute_url }}">{{ resv.tenant }}</a>
|
<a href="{{ resv.tenant.get_absolute_url }}">{{ resv.tenant }}</a>
|
||||||
@ -285,12 +287,12 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="text-right noprint">
|
<td class="text-right noprint">
|
||||||
{% if perms.dcim.change_rackreservation %}
|
{% 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>
|
<i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if perms.dcim.delete_rackreservation %}
|
{% 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>
|
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -303,7 +305,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% if perms.dcim.add_rackreservation %}
|
{% if perms.dcim.add_rackreservation %}
|
||||||
<div class="panel-footer text-right noprint">
|
<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>
|
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
|
||||||
Add a reservation
|
Add a reservation
|
||||||
</a>
|
</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">
|
<table class="table table-hover panel-body attr-table">
|
||||||
{% for field, value in custom_fields.items %}
|
{% for field, value in custom_fields.items %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ field }}</td>
|
<td><span title="{{ field.description }}">{{ field }}</span></td>
|
||||||
<td>
|
<td>
|
||||||
{% if field.type == 'boolean' and value == True %}
|
{% if field.type == 'boolean' and value == True %}
|
||||||
<i class="glyphicon glyphicon-ok text-success" title="True"></i>
|
<i class="glyphicon glyphicon-ok text-success" title="True"></i>
|
||||||
|
@ -462,6 +462,7 @@
|
|||||||
<li{% if not perms.secrets.view_secret %} class="disabled"{% endif %}>
|
<li{% if not perms.secrets.view_secret %} class="disabled"{% endif %}>
|
||||||
{% if perms.secrets.add_secret %}
|
{% if perms.secrets.add_secret %}
|
||||||
<div class="buttons pull-right">
|
<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>
|
<a href="{% url 'secrets:secret_import' %}" class="btn btn-xs btn-info" title="Import"><i class="fa fa-download"></i></a>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -21,12 +21,7 @@
|
|||||||
<div class="panel panel-default">
|
<div class="panel panel-default">
|
||||||
<div class="panel-heading"><strong>Secret Attributes</strong></div>
|
<div class="panel-heading"><strong>Secret Attributes</strong></div>
|
||||||
<div class="panel-body">
|
<div class="panel-body">
|
||||||
<div class="form-group">
|
{% render_field form.device %}
|
||||||
<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.role %}
|
{% render_field form.role %}
|
||||||
{% render_field form.name %}
|
{% render_field form.name %}
|
||||||
{% render_field form.userkeys %}
|
{% render_field form.userkeys %}
|
||||||
|
@ -51,7 +51,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{% if settings.DOCS_ROOT %}
|
{% if obj and settings.DOCS_ROOT %}
|
||||||
{% include 'inc/modal.html' with name='docs' content=obj|get_docs %}
|
{% include 'inc/modal.html' with name='docs' content=obj|get_docs %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -5,6 +5,7 @@ from mptt.models import MPTTModel, TreeForeignKey
|
|||||||
from taggit.managers import TaggableManager
|
from taggit.managers import TaggableManager
|
||||||
|
|
||||||
from extras.models import CustomFieldModel, ObjectChange, TaggedItem
|
from extras.models import CustomFieldModel, ObjectChange, TaggedItem
|
||||||
|
from extras.utils import extras_features
|
||||||
from utilities.models import ChangeLoggedModel
|
from utilities.models import ChangeLoggedModel
|
||||||
from utilities.utils import serialize_object
|
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):
|
class Tenant(ChangeLoggedModel, CustomFieldModel):
|
||||||
"""
|
"""
|
||||||
A Tenant represents an organization served by the NetBox owner. This is typically a customer or an internal
|
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():
|
for k, v in attrs.items():
|
||||||
setattr(instance, k, v)
|
setattr(instance, k, v)
|
||||||
instance.clean()
|
instance.clean()
|
||||||
|
instance.validate_unique()
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
@ -40,7 +40,7 @@ def render_markdown(value):
|
|||||||
value = strip_tags(value)
|
value = strip_tags(value)
|
||||||
|
|
||||||
# Render Markdown
|
# Render Markdown
|
||||||
html = markdown(value, extensions=['fenced_code'])
|
html = markdown(value, extensions=['fenced_code', 'tables'])
|
||||||
|
|
||||||
return mark_safe(html)
|
return mark_safe(html)
|
||||||
|
|
||||||
@ -196,7 +196,7 @@ def get_docs(model):
|
|||||||
return "Unable to load documentation, error reading file: {}".format(path)
|
return "Unable to load documentation, error reading file: {}".format(path)
|
||||||
|
|
||||||
# Render Markdown with the admonition extension
|
# 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)
|
return mark_safe(content)
|
||||||
|
|
||||||
|
@ -7,6 +7,7 @@ from taggit.managers import TaggableManager
|
|||||||
|
|
||||||
from dcim.models import Device
|
from dcim.models import Device
|
||||||
from extras.models import ConfigContextModel, CustomFieldModel, TaggedItem
|
from extras.models import ConfigContextModel, CustomFieldModel, TaggedItem
|
||||||
|
from extras.utils import extras_features
|
||||||
from utilities.models import ChangeLoggedModel
|
from utilities.models import ChangeLoggedModel
|
||||||
from .choices import *
|
from .choices import *
|
||||||
|
|
||||||
@ -101,6 +102,7 @@ class ClusterGroup(ChangeLoggedModel):
|
|||||||
# Clusters
|
# Clusters
|
||||||
#
|
#
|
||||||
|
|
||||||
|
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
|
||||||
class Cluster(ChangeLoggedModel, CustomFieldModel):
|
class Cluster(ChangeLoggedModel, CustomFieldModel):
|
||||||
"""
|
"""
|
||||||
A cluster of VirtualMachines. Each Cluster may optionally be associated with one or more Devices.
|
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
|
# Virtual machines
|
||||||
#
|
#
|
||||||
|
|
||||||
|
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
|
||||||
class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
|
class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
|
||||||
"""
|
"""
|
||||||
A virtual machine which runs inside a Cluster.
|
A virtual machine which runs inside a Cluster.
|
||||||
|
@ -488,6 +488,18 @@ class VirtualMachineTest(APITestCase):
|
|||||||
|
|
||||||
self.assertFalse('config_context' in response.data['results'][0])
|
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):
|
class InterfaceTest(APITestCase):
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user