1
0
mirror of https://github.com/netbox-community/netbox.git synced 2024-05-10 07:54:54 +00:00

Merge branch 'develop-2.9' into docs-refresh

This commit is contained in:
Jeremy Stretch
2020-08-05 13:53:06 -04:00
42 changed files with 333 additions and 122 deletions

8
.github/stale.yml vendored
View File

@ -4,19 +4,19 @@
only: issues
# Number of days of inactivity before an issue becomes stale
daysUntilStale: 14
daysUntilStale: 45
# Number of days of inactivity before a stale issue is closed
daysUntilClose: 7
daysUntilClose: 15
# Issues with these labels will never be considered stale
exemptLabels:
- "status: accepted"
- "status: gathering feedback"
- "status: blocked"
- "status: needs milestone"
# Label to use when marking an issue as stale
staleLabel: wontfix
staleLabel: "pending closure"
# Comment to post when marking an issue as stale. Set to `false` to disable
markComment: >

View File

@ -99,6 +99,10 @@ help prevent wasting time on something that might we might not be able to
implement. When suggesting a new feature, also make sure it won't conflict with
any work that's already in progress.
* Once you've opened or identified an issue you'd like to work on, ask that it
be assigned to you so that others are aware it's being worked on. A maintainer
will then mark the issue as "accepted."
* Any pull request which does _not_ relate to an accepted issue will be closed.
* All major new functionality must include relevant tests where applicable.
@ -132,18 +136,17 @@ accumulating a large backlog of work.
The core maintainers group has chosen to make use of GitHub's [Stale bot](https://github.com/apps/stale)
to aid in issue management.
* Issues will be marked as stale after 14 days of no activity.
* Then after 7 more days of inactivity, the issue will be closed.
* Issues will be marked as stale after 45 days of no activity.
* Then after 15 more days of inactivity, the issue will be closed.
* Any issue bearing one of the following labels will be exempt from all Stale
bot actions:
* `status: accepted`
* `status: gathering feedback`
* `status: blocked`
* `status: needs milestone`
It is natural that some new issues get more attention than others. Often this
is a metric of an issues's overall value to the project. In other cases in
which issues merely get lost in the shuffle, notifications from Stale bot can
bring renewed attention to potentially meaningful issues.
It is natural that some new issues get more attention than others. Stale bot
helps bring renewed attention to potentially valuable issues that may have been
overlooked.
## Maintainer Guidance

View File

@ -39,9 +39,11 @@ The `run()` method should accept two arguments:
* `commit` - A boolean indicating whether database changes will be committed.
!!! note
The `commit` argument was introduced in NetBox v2.7.8. Backward compatibility is maintained for scripts which accept only the `data` argument, however moving forward scripts should accept both arguments. This backward compatibility will be removed in v2.10.
The `commit` argument was introduced in NetBox v2.7.8. Backward compatibility is maintained for scripts which accept only the `data` argument, however beginning with v2.10 NetBox will require the `run()` method of every script to accept both arguments. (Either argument may still be ignored within the method.)
Returning output from your script is optional. Any raw output generated by the script will be displayed under the "output" tab in the UI.
Defining script variables is optional: You may create a script with only a `run()` method if no user input is needed.
Any output generated by the script during its execution will be displayed under the "output" tab in the UI.
## Module Attributes

View File

@ -30,6 +30,12 @@ Copy the 'configuration.py' you created when first installing to the new version
# cp netbox-X.Y.Z/netbox/netbox/configuration.py netbox/netbox/netbox/configuration.py
```
Copy your local requirements file if used:
```no-highlight
# cp netbox-X.Y.Z/local_requirements.txt netbox/local_requirements.txt
```
Also copy the LDAP configuration if using LDAP:
```no-highlight

View File

@ -110,6 +110,8 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i
| `template_extensions` | The dotted path to the list of template extension classes (default: `template_content.template_extensions`) |
| `menu_items` | The dotted path to the list of menu items provided by the plugin (default: `navigation.menu_items`) |
All required settings must be configured by the user. If a configuration parameter is listed in both `required_settings` and `default_settings`, the default setting will be ignored.
### Install the Plugin for Development
To ease development, it is recommended to go ahead and install the plugin at this point using setuptools' `develop` mode. This will create symbolic links within your Python environment to the plugin development directory. Call `setup.py` from the plugin's root directory with the `develop` argument (instead of `install`):

View File

@ -1,13 +1,25 @@
# NetBox v2.8
## v2.8.9 (FUTURE)
## v2.8.9 (2020-08-04)
### Enhancements
* [#4898](https://github.com/netbox-community/netbox/issues/4898) - Add MAC address search field to interfaces list
* [#4899](https://github.com/netbox-community/netbox/issues/4899) - Add MAC address column to interfaces table
### Bug Fixes
* [#4455](https://github.com/netbox-community/netbox/issues/4455) - Fix ordering of prefixes beneath aggregate when available space is hidden
* [#4875](https://github.com/netbox-community/netbox/issues/4875) - Fix documentation for image attachments
* [#4876](https://github.com/netbox-community/netbox/issues/4876) - Fix labels for sites in staging or decommissioning status
* [#4880](https://github.com/netbox-community/netbox/issues/4880) - Fix remove tagged vlans if not assigned in bulk interface editting
* [#4880](https://github.com/netbox-community/netbox/issues/4880) - Fix removal of tagged VLANs if not assigned in bulk interface editing
* [#4887](https://github.com/netbox-community/netbox/issues/4887) - Don't disable NAPALM tabs when device has no primary IP
* [#4894](https://github.com/netbox-community/netbox/issues/4894) - Fix display of device/VM counts on platforms list
* [#4895](https://github.com/netbox-community/netbox/issues/4895) - Force UTF-8 encoding when embedding model documentation
* [#4910](https://github.com/netbox-community/netbox/issues/4910) - Unpin redis dependency to fix exception in RQ worker
* [#4926](https://github.com/netbox-community/netbox/issues/4926) - Fix ordering of VM interfaces in REST API endpoint
* [#4927](https://github.com/netbox-community/netbox/issues/4927) - Fix validation error when updating an existing secret
* [#4929](https://github.com/netbox-community/netbox/issues/4929) - Correct log message when creating a new object
---

View File

@ -1,11 +1,31 @@
# NetBox v2.9
## v2.9.0 (FUTURE)
## v2.9-beta2 (FUTURE)
### Enhancements
* [#4919](https://github.com/netbox-community/netbox/issues/4919) - Allow adding/changing assigned permissions within group and user admin views
* [#4940](https://github.com/netbox-community/netbox/issues/4940) - Add an `occupied` field to rack unit representations for rack elevation views
* [#4945](https://github.com/netbox-community/netbox/issues/4945) - Add a user-friendly 403 error page
### Bug Fixes
* [#4905](https://github.com/netbox-community/netbox/issues/4905) - Fix front port count on device type view
* [#4912](https://github.com/netbox-community/netbox/issues/4912) - Fix image attachment API endpoint
* [#4914](https://github.com/netbox-community/netbox/issues/4914) - Fix toggling cable status under device view
* [#4921](https://github.com/netbox-community/netbox/issues/4921) - Render non-viewable devices as unavailable space in rack elevations
* [#4930](https://github.com/netbox-community/netbox/issues/4930) - Replicate label values when instantiating device type components
* [#4931](https://github.com/netbox-community/netbox/issues/4931) - Fix DoesNotExist exception when deleting devices
* [#4938](https://github.com/netbox-community/netbox/issues/4938) - Show add, import buttons on virtual chassis list view
* [#4939](https://github.com/netbox-community/netbox/issues/4939) - Fix linking to LAG interfaces on other VC members
* [#4950](https://github.com/netbox-community/netbox/issues/4950) - Include inventory item label in API serializer, UI view
* [#4952](https://github.com/netbox-community/netbox/issues/4952) - Default to VM tab when creating/editing an IP address for a VM
### Other Changes
* [#4940](https://github.com/netbox-community/netbox/issues/4940) - Add an `occupied` field to rack unit representations for rack elevation views
* [#4942](https://github.com/netbox-community/netbox/issues/4942) - Make ObjectPermission's `name` field required
* [#4943](https://github.com/netbox-community/netbox/issues/4943) - Add a `description` field to ObjectPermission
---
@ -76,6 +96,7 @@ When running a report or custom script, its execution is now queued for backgrou
* dcim.PowerPortTemplate: Added `description` and `label` fields
* dcim.PowerOutlet: Added `label` field
* dcim.PowerOutletTemplate: Added `description` and `label` fields
* dcim.Rack: Added an `occupied` field to rack unit representations for rack elevation views
* dcim.RackGroup: Added a `_depth` attribute indicating an object's position in the tree.
* dcim.RackReservation: Added `tags` field
* dcim.RearPort: Added `label` field

View File

@ -267,6 +267,10 @@ GET /api/ipam/prefixes/13980/?brief=1
The brief format is supported for both lists and individual objects.
### Excluding Config Contexts
When retrieving devices and virtual machines via the REST API, each will included its rendered [configuration context data](../models/extras/configcontext/) by default. Users with large amounts of context data will likely observe suboptimal performance when returning multiple objects, particularly with very high page sizes. To combat this, context data may be excluded from the response data by attaching the query parameter `?exclude=config_context` to the request. This parameter works for both list and detail views.
## Pagination
API responses which contain a list of many objects will be paginated for efficiency. The root JSON object returned by a list endpoint contains the following attributes:

View File

@ -165,6 +165,7 @@ class RackUnitSerializer(serializers.Serializer):
name = serializers.CharField(read_only=True)
face = ChoiceField(choices=DeviceFaceChoices, read_only=True)
device = NestedDeviceSerializer(read_only=True)
occupied = serializers.BooleanField(read_only=True)
class RackReservationSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
@ -639,8 +640,8 @@ class InventoryItemSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
class Meta:
model = InventoryItem
fields = [
'id', 'url', 'device', 'parent', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered',
'description', 'tags',
'id', 'url', 'device', 'parent', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag',
'discovered', 'description', 'tags',
]

View File

@ -176,6 +176,7 @@ class RackViewSet(CustomFieldModelViewSet):
# Render and return the elevation as an SVG drawing with the correct content type
drawing = rack.get_elevation_svg(
face=data['face'],
user=request.user,
unit_width=data['unit_width'],
unit_height=data['unit_height'],
legend_width=data['legend_width'],
@ -188,6 +189,7 @@ class RackViewSet(CustomFieldModelViewSet):
# Return a JSON representation of the rack units in the elevation
elevation = rack.get_rack_units(
face=data['face'],
user=request.user,
exclude=data['exclude'],
expand_devices=data['expand_devices']
)

View File

@ -14,10 +14,11 @@ class RackElevationSVG:
Use this class to render a rack elevation as an SVG image.
:param rack: A NetBox Rack instance
:param user: User instance. If specified, only devices viewable by this user will be fully displayed.
:param include_images: If true, the SVG document will embed front/rear device face images, where available
:param base_url: Base URL for links within the SVG document. If none, links will be relative.
"""
def __init__(self, rack, include_images=True, base_url=None):
def __init__(self, rack, user=None, include_images=True, base_url=None):
self.rack = rack
self.include_images = include_images
if base_url is not None:
@ -25,7 +26,14 @@ class RackElevationSVG:
else:
self.base_url = ''
def _get_device_description(self, device):
# Determine the subset of devices within this rack that are viewable by the user, if any
permitted_devices = self.rack.devices
if user is not None:
permitted_devices = permitted_devices.restrict(user, 'view')
self.permitted_device_ids = permitted_devices.values_list('pk', flat=True)
@staticmethod
def _get_device_description(device):
return '{} ({}) — {} ({}U) {} {}'.format(
device.name,
device.device_role,
@ -174,10 +182,13 @@ class RackElevationSVG:
text_cordinates = (x_offset + (unit_width / 2), y_offset + end_y / 2)
# Draw the device
if device and device.face == face:
if device and device.face == face and device.pk in self.permitted_device_ids:
self._draw_device_front(drawing, device, start_cordinates, end_cordinates, text_cordinates)
elif device and device.device_type.is_full_depth:
elif device and device.device_type.is_full_depth and device.pk in self.permitted_device_ids:
self._draw_device_rear(drawing, device, start_cordinates, end_cordinates, text_cordinates)
elif device:
# Devices which the user does not have permission to view are rendered only as unavailable space
drawing.add(drawing.rect(start_cordinates, end_cordinates, class_='blocked'))
else:
# Draw shallow devices, reservations, or empty units
class_ = 'slot'

View File

@ -2703,6 +2703,10 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
mac_address = forms.CharField(
required=False,
label='MAC address'
)
tag = TagFilterField(model)

View File

@ -656,12 +656,14 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
def get_status_class(self):
return self.STATUS_CLASS_MAP.get(self.status)
def get_rack_units(self, face=DeviceFaceChoices.FACE_FRONT, exclude=None, expand_devices=True):
def get_rack_units(self, user=None, face=DeviceFaceChoices.FACE_FRONT, exclude=None, expand_devices=True):
"""
Return a list of rack units as dictionaries. Example: {'device': None, 'face': 0, 'id': 48, 'name': 'U48'}
Each key 'device' is either a Device or None. By default, multi-U devices are repeated for each U they occupy.
:param face: Rack face (front or rear)
:param user: User instance to be used for evaluating device view permissions. If None, all devices
will be included.
:param exclude: PK of a Device to exclude (optional); helpful when relocating a Device within a Rack
:param expand_devices: When True, all units that a device occupies will be listed with each containing a
reference to the device. When False, only the bottom most unit for a device is included and that unit
@ -670,10 +672,18 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
elevation = OrderedDict()
for u in self.units:
elevation[u] = {'id': u, 'name': 'U{}'.format(u), 'face': face, 'device': None}
elevation[u] = {
'id': u,
'name': f'U{u}',
'face': face,
'device': None,
'occupied': False
}
# Add devices to rack units list
if self.pk:
# Retrieve all devices installed within the rack
queryset = Device.objects.prefetch_related(
'device_type',
'device_type__manufacturer',
@ -689,12 +699,22 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
).filter(
Q(face=face) | Q(device_type__is_full_depth=True)
)
# Determine which devices the user has permission to view
permitted_device_ids = []
if user is not None:
permitted_device_ids = self.devices.restrict(user, 'view').values_list('pk', flat=True)
for device in queryset:
if expand_devices:
for u in range(device.position, device.position + device.device_type.u_height):
elevation[u]['device'] = device
if user is None or device.pk in permitted_device_ids:
elevation[u]['device'] = device
elevation[u]['occupied'] = True
else:
elevation[device.position]['device'] = device
if user is None or device.pk in permitted_device_ids:
elevation[device.position]['device'] = device
elevation[device.position]['occupied'] = True
elevation[device.position]['height'] = device.device_type.u_height
for u in range(device.position + 1, device.position + device.device_type.u_height):
elevation.pop(u, None)
@ -750,6 +770,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
def get_elevation_svg(
self,
face=DeviceFaceChoices.FACE_FRONT,
user=None,
unit_width=settings.RACK_ELEVATION_DEFAULT_UNIT_WIDTH,
unit_height=settings.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT,
legend_width=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT,
@ -760,6 +781,8 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
Return an SVG of the rack elevation
:param face: Enum of [front, rear] representing the desired side of the rack elevation to render
:param user: User instance to be used for evaluating device view permissions. If None, all devices
will be included.
:param unit_width: Width in pixels for the rendered drawing
:param unit_height: Height of each rack unit for the rendered drawing. Note this is not the total
height of the elevation
@ -767,7 +790,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
:param include_images: Embed front/rear device images where available
:param base_url: Base URL for links and images. If none, URLs will be relative.
"""
elevation = RackElevationSVG(self, include_images=include_images, base_url=base_url)
elevation = RackElevationSVG(self, user=user, include_images=include_images, base_url=base_url)
return elevation.render(face, unit_width, unit_height, legend_width)

View File

@ -100,6 +100,7 @@ class ConsolePortTemplate(ComponentTemplateModel):
return ConsolePort(
device=device,
name=self.name,
label=self.label,
type=self.type
)
@ -122,6 +123,7 @@ class ConsoleServerPortTemplate(ComponentTemplateModel):
return ConsoleServerPort(
device=device,
name=self.name,
label=self.label,
type=self.type
)
@ -156,6 +158,7 @@ class PowerPortTemplate(ComponentTemplateModel):
return PowerPort(
device=device,
name=self.name,
label=self.label,
type=self.type,
maximum_draw=self.maximum_draw,
allocated_draw=self.allocated_draw
@ -205,6 +208,7 @@ class PowerOutletTemplate(ComponentTemplateModel):
return PowerOutlet(
device=device,
name=self.name,
label=self.label,
type=self.type,
power_port=power_port,
feed_leg=self.feed_leg
@ -239,6 +243,7 @@ class InterfaceTemplate(ComponentTemplateModel):
return Interface(
device=device,
name=self.name,
label=self.label,
type=self.type,
mgmt_only=self.mgmt_only
)
@ -293,6 +298,7 @@ class FrontPortTemplate(ComponentTemplateModel):
return FrontPort(
device=device,
name=self.name,
label=self.label,
type=self.type,
rear_port=rear_port,
rear_port_position=self.rear_port_position
@ -320,6 +326,7 @@ class RearPortTemplate(ComponentTemplateModel):
return RearPort(
device=device,
name=self.name,
label=self.label,
type=self.type,
positions=self.positions
)
@ -336,5 +343,6 @@ class DeviceBayTemplate(ComponentTemplateModel):
def instantiate(self, device):
return DeviceBay(
device=device,
name=self.name
name=self.name,
label=self.label
)

View File

@ -71,11 +71,16 @@ class ComponentModel(models.Model):
def to_objectchange(self, action):
# Annotate the parent Device
try:
device = self.device
except ObjectDoesNotExist:
# The parent Device has already been deleted
device = None
return ObjectChange(
changed_object=self,
object_repr=str(self),
action=action,
related_object=self.device,
related_object=device,
object_data=serialize_object(self)
)

View File

@ -56,10 +56,49 @@ DEVICE_COUNT = """
<a href="{% url 'dcim:device_list' %}?role={{ record.slug }}">{{ value|default:0 }}</a>
"""
VM_COUNT = """
RACKRESERVATION_ACTIONS = """
<a href="{% url 'dcim:rackreservation_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Change log">
<i class="fa fa-history"></i>
</a>
{% if perms.dcim.change_rackreservation %}
<a href="{% url 'dcim:rackreservation_edit' pk=record.pk %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %}
"""
MANUFACTURER_ACTIONS = """
<a href="{% url 'dcim:manufacturer_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">
<i class="fa fa-history"></i>
</a>
{% if perms.dcim.change_manufacturer %}
<a href="{% url 'dcim:manufacturer_edit' slug=record.slug %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %}
"""
DEVICEROLE_DEVICE_COUNT = """
<a href="{% url 'dcim:device_list' %}?role={{ record.slug }}">{{ value|default:0 }}</a>
"""
DEVICEROLE_VM_COUNT = """
<a href="{% url 'virtualization:virtualmachine_list' %}?role={{ record.slug }}">{{ value|default:0 }}</a>
"""
DEVICEROLE_ACTIONS = """
<a href="{% url 'dcim:devicerole_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">
<i class="fa fa-history"></i>
</a>
{% if perms.dcim.change_devicerole %}
<a href="{% url 'dcim:devicerole_edit' slug=record.slug %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %}
"""
PLATFORM_DEVICE_COUNT = """
<a href="{% url 'dcim:device_list' %}?platform={{ record.slug }}">{{ value|default:0 }}</a>
"""
PLATFORM_VM_COUNT = """
<a href="{% url 'virtualization:virtualmachine_list' %}?platform={{ record.slug }}">{{ value|default:0 }}</a>
"""
STATUS_LABEL = """
<span class="label label-{{ record.get_status_class }}">{{ record.get_status_display }}</span>
"""
@ -495,11 +534,11 @@ class DeviceBayTemplateTable(ComponentTemplateTable):
class DeviceRoleTable(BaseTable):
pk = ToggleColumn()
device_count = tables.TemplateColumn(
template_code=DEVICE_COUNT,
template_code=DEVICEROLE_DEVICE_COUNT,
verbose_name='Devices'
)
vm_count = tables.TemplateColumn(
template_code=VM_COUNT,
template_code=DEVICEROLE_VM_COUNT,
verbose_name='VMs'
)
color = tables.TemplateColumn(
@ -522,11 +561,11 @@ class DeviceRoleTable(BaseTable):
class PlatformTable(BaseTable):
pk = ToggleColumn()
device_count = tables.TemplateColumn(
template_code=DEVICE_COUNT,
template_code=PLATFORM_DEVICE_COUNT,
verbose_name='Devices'
)
vm_count = tables.TemplateColumn(
template_code=VM_COUNT,
template_code=PLATFORM_VM_COUNT,
verbose_name='VMs'
)
actions = ButtonsColumn(Platform, pk_field='slug')
@ -718,8 +757,8 @@ class InterfaceTable(DeviceComponentTable, BaseInterfaceTable):
class Meta(DeviceComponentTable.Meta):
model = Interface
fields = (
'pk', 'device', 'name', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'description', 'cable',
'ip_addresses', 'untagged_vlan', 'tagged_vlans',
'pk', 'device', 'name', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address',
'description', 'cable', 'ip_addresses', 'untagged_vlan', 'tagged_vlans',
)
default_columns = ('pk', 'device', 'name', 'label', 'enabled', 'type', 'description')

View File

@ -953,8 +953,8 @@ class DeviceRoleBulkDeleteView(BulkDeleteView):
class PlatformListView(ObjectListView):
queryset = Platform.objects.annotate(
device_count=get_subquery(Device, 'device_role'),
vm_count=get_subquery(VirtualMachine, 'role')
device_count=get_subquery(Device, 'platform'),
vm_count=get_subquery(VirtualMachine, 'platform')
)
table = tables.PlatformTable
@ -2185,7 +2185,6 @@ class VirtualChassisListView(ObjectListView):
table = tables.VirtualChassisTable
filterset = filters.VirtualChassisFilterSet
filterset_form = forms.VirtualChassisFilterForm
action_buttons = ('export',)
class VirtualChassisView(ObjectView):

View File

@ -3,7 +3,6 @@ from django.contrib import admin
from utilities.forms import LaxURLField
from .models import CustomField, CustomFieldChoice, CustomLink, Graph, ExportTemplate, JobResult, Webhook
from .reports import get_report
def order_content_types(field):
@ -160,6 +159,10 @@ class GraphForm(forms.ModelForm):
class Meta:
model = Graph
exclude = ()
help_texts = {
'template_language': "<a href=\"https://jinja.palletsprojects.com\">Jinja2</a> is strongly recommended for "
"new graphs."
}
widgets = {
'source': forms.Textarea,
'link': forms.Textarea,
@ -195,6 +198,11 @@ class ExportTemplateForm(forms.ModelForm):
class Meta:
model = ExportTemplate
exclude = []
help_texts = {
'template_language': "<strong>Warning:</strong> Support for Django templating will be dropped in NetBox "
"v2.10. <a href=\"https://jinja.palletsprojects.com\">Jinja2</a> is strongly "
"recommended."
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

View File

@ -85,12 +85,12 @@ class ObjectChangeActionChoices(ChoiceSet):
class TemplateLanguageChoices(ChoiceSet):
LANGUAGE_DJANGO = 'django'
LANGUAGE_JINJA2 = 'jinja2'
LANGUAGE_DJANGO = 'django'
CHOICES = (
(LANGUAGE_DJANGO, 'Django'),
(LANGUAGE_JINJA2, 'Jinja2'),
(LANGUAGE_DJANGO, 'Django (Legacy)'),
)

View File

@ -4,6 +4,7 @@ import logging
import os
import pkgutil
import traceback
import warnings
from collections import OrderedDict
import yaml
@ -405,12 +406,16 @@ def run_script(data, request, commit=True, *args, **kwargs):
# Add the current request as a property of the script
script.request = request
# TODO: Drop backward-compatibility for absent 'commit' argument in v2.10
# Determine whether the script accepts a 'commit' argument (this was introduced in v2.7.8)
kwargs = {
'data': data
}
if 'commit' in inspect.signature(script.run).parameters:
kwargs['commit'] = commit
else:
warnings.warn(f"The run() method of script {script} should support a 'commit' argument. This will be required "
f"beginning with NetBox v2.10.")
try:
with transaction.atomic():

View File

@ -219,6 +219,8 @@ class AggregateView(ObjectView):
prefix__net_contained_or_equal=str(aggregate.prefix)
).prefetch_related(
'site', 'role'
).order_by(
'prefix'
).annotate_depth(
limit=0
)

View File

@ -16,7 +16,7 @@ from django.core.validators import URLValidator
# Environment setup
#
VERSION = '2.9-beta1'
VERSION = '2.9-beta2'
# Hostname
HOSTNAME = platform.node()

View File

@ -121,7 +121,7 @@ class SecretForm(BootstrapMixin, CustomFieldModelForm):
device=self.cleaned_data['device'],
role=self.cleaned_data['role'],
name=self.cleaned_data['name']
).exists():
).exclude(pk=self.instance.pk).exists():
raise forms.ValidationError(
"Each secret assigned to a device must have a unique combination of role and name"
)

View File

@ -0,0 +1,9 @@
{% extends '40x.html' %}
{% block title %}Access Denied{% endblock %}
{% block icon %}<i class="glyphicon glyphicon-lock"></i>{% endblock %}
{% block message %}
You do not have permission to access this page.
{% endblock %}

View File

@ -1,19 +1,9 @@
{% extends 'base.html' %}
{% extends '40x.html' %}
{% block content %}
<div class="row" style="margin-top: 150px;">
<div class="col-sm-4 col-sm-offset-4">
<div class="panel panel-default">
<div class="panel-heading">
<strong><i class="glyphicon glyphicon-warning-sign"></i> Page Not Found</strong>
</div>
<div class="panel-body">
The requested page does not exist.
</div>
<div class="panel-footer text-right">
<a href="{% url 'home' %}" class="btn btn-xs btn-primary">Home Page</a>
</div>
</div>
</div>
</div>
{% block title %}Page Not Found{% endblock %}
{% block icon %}<i class="glyphicon glyphicon-warning-sign"></i>{% endblock %}
{% block message %}
The requested page does not exist.
{% endblock %}

19
netbox/templates/40x.html Normal file
View File

@ -0,0 +1,19 @@
{% extends 'base.html' %}
{% block content %}
<div class="row" style="margin-top: 150px;">
<div class="col-sm-4 col-sm-offset-4">
<div class="panel panel-default">
<div class="panel-heading">
<strong>{% block icon %}{% endblock %} {% block title %}{% endblock %}</strong>
</div>
<div class="panel-body">
{% block message %}{% endblock %}
</div>
<div class="panel-footer text-right">
<a href="{% url 'home' %}" class="btn btn-xs btn-primary">Home Page</a>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -49,11 +49,13 @@
<table class="table table-hover panel-body attr-table">
<tr>
<td>Type</td>
<td>{{ cable.get_type_display }}</td>
<td>{{ cable.get_type_display|placeholder }}</td>
</tr>
<tr>
<td>Status</td>
<td>{{ cable.get_status_display }}</td>
<td>
<span class="label label-{{ cable.get_status_class }}">{{ cable.get_status_display }}</span>
</td>
</tr>
<tr>
<td>Label</td>

View File

@ -975,7 +975,7 @@ function toggleConnection(elem) {
xhr.setRequestHeader("X-CSRFToken", "{{ csrf_token }}");
},
data: {
'status': 'False'
'status': 'planned'
},
context: this,
success: function() {
@ -994,7 +994,7 @@ function toggleConnection(elem) {
xhr.setRequestHeader("X-CSRFToken", "{{ csrf_token }}");
},
data: {
'status': 'True'
'status': 'connected'
},
context: this,
success: function() {

View File

@ -22,7 +22,7 @@
{# LAG #}
<td>
{% if iface.lag %}
<a href="#interface_{{ iface.lag }}" class="label label-primary" title="{{ iface.lag.description }}">{{ iface.lag }}</a>
<a href="{{ iface.lag.device.get_absolute_url }}#interface_{{ iface.lag }}" class="label label-primary" title="{{ iface.lag.description }}">{{ iface.lag }}</a>
{% endif %}
</td>

View File

@ -30,6 +30,10 @@
<td>Name</td>
<td>{{ instance.name }}</td>
</tr>
<tr>
<td>Label</td>
<td>{{ instance.label|placeholder }}</td>
</tr>
<tr>
<td>Manufacturer</td>
<td>

View File

@ -33,7 +33,7 @@
<strong>Interface Assignment</strong>
</div>
<div class="panel-body">
{% with vm_tab_active=obj.vminterface.exists %}
{% with vm_tab_active=form.initial.vminterface %}
<ul class="nav nav-tabs" role="tablist">
<li role="presentation"{% if not vm_tab_active %} class="active"{% endif %}><a href="#device" role="tab" data-toggle="tab">Device</a></li>
<li role="presentation"{% if vm_tab_active %} class="active"{% endif %}><a href="#virtualmachine" role="tab" data-toggle="tab">Virtual Machine</a></li>

View File

@ -9,6 +9,49 @@ from extras.admin import order_content_types
from .models import AdminGroup, AdminUser, ObjectPermission, Token, UserConfig
#
# Inline models
#
class ObjectPermissionInline(admin.TabularInline):
exclude = None
extra = 3
readonly_fields = ['object_types', 'actions', 'constraints']
verbose_name = 'Permission'
verbose_name_plural = 'Permissions'
def get_queryset(self, request):
return super().get_queryset(request).prefetch_related('objectpermission__object_types')
@staticmethod
def object_types(instance):
# Don't call .values_list() here because we want to reference the pre-fetched object_types
return ', '.join([ot.name for ot in instance.objectpermission.object_types.all()])
@staticmethod
def actions(instance):
return ', '.join(instance.objectpermission.actions)
@staticmethod
def constraints(instance):
return instance.objectpermission.constraints
class GroupObjectPermissionInline(ObjectPermissionInline):
model = AdminGroup.object_permissions.through
class UserObjectPermissionInline(ObjectPermissionInline):
model = AdminUser.object_permissions.through
class UserConfigInline(admin.TabularInline):
model = UserConfig
readonly_fields = ('data',)
can_delete = False
verbose_name = 'Preferences'
#
# Users & groups
#
@ -24,40 +67,13 @@ class GroupAdmin(admin.ModelAdmin):
list_display = ('name', 'user_count')
ordering = ('name',)
search_fields = ('name',)
inlines = [GroupObjectPermissionInline]
def user_count(self, obj):
@staticmethod
def user_count(obj):
return obj.user_set.count()
class UserConfigInline(admin.TabularInline):
model = UserConfig
readonly_fields = ('data',)
can_delete = False
verbose_name = 'Preferences'
class ObjectPermissionInline(admin.TabularInline):
model = AdminUser.object_permissions.through
fields = ['object_types', 'actions', 'constraints']
readonly_fields = fields
extra = 0
verbose_name = 'Permission'
verbose_name_plural = 'Permissions'
def object_types(self, instance):
return ', '.join(instance.objectpermission.object_types.values_list('model', flat=True))
def actions(self, instance):
return ', '.join(instance.objectpermission.actions)
def constraints(self, instance):
return instance.objectpermission.constraints
def has_add_permission(self, request, obj):
# Don't allow the creation of new ObjectPermission assignments via this form
return False
@admin.register(AdminUser)
class UserAdmin(UserAdmin_):
list_display = [
@ -71,9 +87,13 @@ class UserAdmin(UserAdmin_):
}),
('Important dates', {'fields': ('last_login', 'date_joined')}),
)
inlines = [ObjectPermissionInline, UserConfigInline]
filter_horizontal = ('groups',)
def get_inlines(self, request, obj):
if obj is not None:
return (UserObjectPermissionInline, UserConfigInline)
return ()
#
# REST API tokens
@ -212,7 +232,7 @@ class ObjectPermissionAdmin(admin.ModelAdmin):
actions = ('enable', 'disable')
fieldsets = (
(None, {
'fields': ('name', 'enabled')
'fields': ('name', 'description', 'enabled')
}),
('Actions', {
'fields': (('can_view', 'can_add', 'can_change', 'can_delete'), 'actions')
@ -231,19 +251,16 @@ class ObjectPermissionAdmin(admin.ModelAdmin):
filter_horizontal = ('object_types', 'groups', 'users')
form = ObjectPermissionForm
list_display = [
'get_name', 'enabled', 'list_models', 'list_users', 'list_groups', 'actions', 'constraints',
'name', 'enabled', 'list_models', 'list_users', 'list_groups', 'actions', 'constraints', 'description',
]
list_filter = [
'enabled', ActionListFilter, ObjectTypeListFilter, 'groups', 'users'
]
search_fields = ['actions', 'constraints', 'description', 'name']
def get_queryset(self, request):
return super().get_queryset(request).prefetch_related('object_types', 'users', 'groups')
def get_name(self, obj):
return obj.name or f'Permission #{obj.pk}'
get_name.short_description = 'Name'
def list_models(self, obj):
return ', '.join([f"{ct}" for ct in obj.object_types.all()])
list_models.short_description = 'Models'

View File

@ -54,4 +54,6 @@ class ObjectPermissionSerializer(ValidatedModelSerializer):
class Meta:
model = ObjectPermission
fields = ('id', 'url', 'name', 'enabled', 'object_types', 'groups', 'users', 'actions', 'constraints')
fields = (
'id', 'url', 'name', 'description', 'enabled', 'object_types', 'groups', 'users', 'actions', 'constraints',
)

View File

@ -18,7 +18,8 @@ class Migration(migrations.Migration):
name='ObjectPermission',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
('name', models.CharField(blank=True, max_length=100)),
('name', models.CharField(max_length=100)),
('description', models.CharField(blank=True, max_length=200)),
('enabled', models.BooleanField(default=True)),
('constraints', django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True)),
('actions', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=30), size=None)),
@ -27,6 +28,7 @@ class Migration(migrations.Migration):
('users', models.ManyToManyField(blank=True, related_name='object_permissions', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['name'],
'verbose_name': 'permission',
},
),

View File

@ -13,7 +13,7 @@ def replicate_permissions(apps, schema_editor):
# TODO: Optimize this iteration so that ObjectPermissions with identical sets of users and groups
# are combined into a single ObjectPermission instance.
for perm in Permission.objects.all():
for perm in Permission.objects.select_related('content_type'):
if perm.codename.split('_')[0] in ACTIONS:
action = perm.codename.split('_')[0]
elif perm.codename == 'activate_userkey':
@ -24,7 +24,11 @@ def replicate_permissions(apps, schema_editor):
action = perm.codename
if perm.group_set.exists() or perm.user_set.exists():
obj_perm = ObjectPermission(actions=[action])
obj_perm = ObjectPermission(
# Copy name from original Permission object
name=f'{perm.content_type.app_label}.{perm.codename}'[:100],
actions=[action]
)
obj_perm.save()
obj_perm.object_types.add(perm.content_type)
if perm.group_set.exists():

View File

@ -16,6 +16,8 @@ from utilities.utils import flatten_dict
__all__ = (
'AdminGroup',
'AdminUser',
'ObjectPermission',
'Token',
'UserConfig',
@ -237,7 +239,10 @@ class ObjectPermission(models.Model):
identified by ORM query parameters.
"""
name = models.CharField(
max_length=100,
max_length=100
)
description = models.CharField(
max_length=200,
blank=True
)
enabled = models.BooleanField(
@ -275,12 +280,8 @@ class ObjectPermission(models.Model):
objects = RestrictedQuerySet.as_manager()
class Meta:
ordering = ['name']
verbose_name = "permission"
def __str__(self):
if self.name:
return self.name
return '{}: {}'.format(
', '.join(self.object_types.values_list('model', flat=True)),
', '.join(self.actions)
)
return self.name

View File

@ -97,6 +97,7 @@ class ObjectPermissionTest(APIViewTestCases.APIViewTestCase):
for i in range(0, 3):
objectpermission = ObjectPermission(
name=f'Permission {i+1}',
actions=['view', 'add', 'change', 'delete'],
constraints={'name': f'TEST{i+1}'}
)
@ -107,6 +108,7 @@ class ObjectPermissionTest(APIViewTestCases.APIViewTestCase):
cls.create_data = [
{
'name': 'Permission 4',
'object_types': ['dcim.site'],
'groups': [groups[0].pk],
'users': [users[0].pk],
@ -114,6 +116,7 @@ class ObjectPermissionTest(APIViewTestCases.APIViewTestCase):
'constraints': {'name': 'TEST4'},
},
{
'name': 'Permission 5',
'object_types': ['dcim.site'],
'groups': [groups[1].pk],
'users': [users[1].pk],
@ -121,6 +124,7 @@ class ObjectPermissionTest(APIViewTestCases.APIViewTestCase):
'constraints': {'name': 'TEST5'},
},
{
'name': 'Permission 6',
'object_types': ['dcim.site'],
'groups': [groups[2].pk],
'users': [users[2].pk],

View File

@ -178,7 +178,7 @@ def get_docs(model):
model._meta.model_name
)
try:
with open(path) as docfile:
with open(path, encoding='utf-8') as docfile:
content = docfile.read()
except FileNotFoundError:
return "Unable to load documentation, file not found: {}".format(path)

View File

@ -418,13 +418,14 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
try:
with transaction.atomic():
object_created = form.instance.pk is None
obj = form.save()
# Check that the new object conforms with any assigned object-level permissions
self.queryset.get(pk=obj.pk)
msg = '{} {}'.format(
'Created' if not form.instance.pk else 'Modified',
'Created' if object_created else 'Modified',
self.queryset.model._meta.verbose_name
)
logger.info(f"{msg} {obj} (PK: {obj.pk})")

View File

@ -1,4 +1,4 @@
from django.db.models import Count, Prefetch
from django.db.models import Count
from django.shortcuts import get_object_or_404
from rest_framework.decorators import action
from rest_framework.response import Response
@ -7,7 +7,6 @@ from dcim.models import Device
from extras.api.serializers import RenderedGraphSerializer
from extras.api.views import CustomFieldModelViewSet
from extras.models import Graph
from ipam.models import VLAN
from utilities.api import ModelViewSet
from utilities.utils import get_subquery
from virtualization import filters

View File

@ -20,5 +20,4 @@ Pillow==7.2.0
psycopg2-binary==2.8.5
pycryptodome==3.9.8
PyYAML==5.3.1
redis==3.5.3
svgwrite==1.4

View File

@ -40,11 +40,12 @@ echo "Installing core dependencies ($COMMAND)..."
eval $COMMAND || exit 1
# Install optional packages (if any)
if [ -f "local_requirements.txt" ]
then
if [ -s "local_requirements.txt" ]; then
COMMAND="pip3 install -r local_requirements.txt"
echo "Installing local dependencies ($COMMAND)..."
eval $COMMAND || exit 1
elif [ -f "local_requirements.txt" ]; then
echo "Skipping local dependencies (local_requirements.txt is empty)"
else
echo "Skipping local dependencies (local_requirements.txt not found)"
fi