Merge branch 'develop-2.9' into docs-refresh

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

8
.github/stale.yml vendored
View File

@ -4,19 +4,19 @@
only: issues only: issues
# Number of days of inactivity before an issue becomes stale # 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 # Number of days of inactivity before a stale issue is closed
daysUntilClose: 7 daysUntilClose: 15
# Issues with these labels will never be considered stale # Issues with these labels will never be considered stale
exemptLabels: exemptLabels:
- "status: accepted" - "status: accepted"
- "status: gathering feedback"
- "status: blocked" - "status: blocked"
- "status: needs milestone"
# Label to use when marking an issue as stale # 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 # Comment to post when marking an issue as stale. Set to `false` to disable
markComment: > 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 implement. When suggesting a new feature, also make sure it won't conflict with
any work that's already in progress. 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. * Any pull request which does _not_ relate to an accepted issue will be closed.
* All major new functionality must include relevant tests where applicable. * 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) The core maintainers group has chosen to make use of GitHub's [Stale bot](https://github.com/apps/stale)
to aid in issue management. to aid in issue management.
* Issues will be marked as stale after 14 days of no activity. * Issues will be marked as stale after 45 days of no activity.
* Then after 7 more days of inactivity, the issue will be closed. * 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 * Any issue bearing one of the following labels will be exempt from all Stale
bot actions: bot actions:
* `status: accepted` * `status: accepted`
* `status: gathering feedback`
* `status: blocked` * `status: blocked`
* `status: needs milestone`
It is natural that some new issues get more attention than others. Often this It is natural that some new issues get more attention than others. Stale bot
is a metric of an issues's overall value to the project. In other cases in helps bring renewed attention to potentially valuable issues that may have been
which issues merely get lost in the shuffle, notifications from Stale bot can overlooked.
bring renewed attention to potentially meaningful issues.
## Maintainer Guidance ## 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. * `commit` - A boolean indicating whether database changes will be committed.
!!! note !!! 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 ## 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 # 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: Also copy the LDAP configuration if using LDAP:
```no-highlight ```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`) | | `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`) | | `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 ### 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`): 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 # 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 ### 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 * [#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 * [#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 * [#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 # 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 ### Bug Fixes
* [#4905](https://github.com/netbox-community/netbox/issues/4905) - Fix front port count on device type view * [#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 * [#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.PowerPortTemplate: Added `description` and `label` fields
* dcim.PowerOutlet: Added `label` field * dcim.PowerOutlet: Added `label` field
* dcim.PowerOutletTemplate: Added `description` and `label` fields * 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.RackGroup: Added a `_depth` attribute indicating an object's position in the tree.
* dcim.RackReservation: Added `tags` field * dcim.RackReservation: Added `tags` field
* dcim.RearPort: Added `label` 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. 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 ## 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: 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) name = serializers.CharField(read_only=True)
face = ChoiceField(choices=DeviceFaceChoices, read_only=True) face = ChoiceField(choices=DeviceFaceChoices, read_only=True)
device = NestedDeviceSerializer(read_only=True) device = NestedDeviceSerializer(read_only=True)
occupied = serializers.BooleanField(read_only=True)
class RackReservationSerializer(TaggedObjectSerializer, ValidatedModelSerializer): class RackReservationSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
@ -639,8 +640,8 @@ class InventoryItemSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
class Meta: class Meta:
model = InventoryItem model = InventoryItem
fields = [ fields = [
'id', 'url', 'device', 'parent', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'id', 'url', 'device', 'parent', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag',
'description', 'tags', '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 # Render and return the elevation as an SVG drawing with the correct content type
drawing = rack.get_elevation_svg( drawing = rack.get_elevation_svg(
face=data['face'], face=data['face'],
user=request.user,
unit_width=data['unit_width'], unit_width=data['unit_width'],
unit_height=data['unit_height'], unit_height=data['unit_height'],
legend_width=data['legend_width'], legend_width=data['legend_width'],
@ -188,6 +189,7 @@ class RackViewSet(CustomFieldModelViewSet):
# Return a JSON representation of the rack units in the elevation # Return a JSON representation of the rack units in the elevation
elevation = rack.get_rack_units( elevation = rack.get_rack_units(
face=data['face'], face=data['face'],
user=request.user,
exclude=data['exclude'], exclude=data['exclude'],
expand_devices=data['expand_devices'] 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. Use this class to render a rack elevation as an SVG image.
:param rack: A NetBox Rack instance :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 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. :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.rack = rack
self.include_images = include_images self.include_images = include_images
if base_url is not None: if base_url is not None:
@ -25,7 +26,14 @@ class RackElevationSVG:
else: else:
self.base_url = '' 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( return '{} ({}) — {} ({}U) {} {}'.format(
device.name, device.name,
device.device_role, device.device_role,
@ -174,10 +182,13 @@ class RackElevationSVG:
text_cordinates = (x_offset + (unit_width / 2), y_offset + end_y / 2) text_cordinates = (x_offset + (unit_width / 2), y_offset + end_y / 2)
# Draw the device # 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) 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) 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: else:
# Draw shallow devices, reservations, or empty units # Draw shallow devices, reservations, or empty units
class_ = 'slot' class_ = 'slot'

View File

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

View File

@ -656,12 +656,14 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
def get_status_class(self): def get_status_class(self):
return self.STATUS_CLASS_MAP.get(self.status) 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'} 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. 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 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 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 :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 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() elevation = OrderedDict()
for u in self.units: 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 # Add devices to rack units list
if self.pk: if self.pk:
# Retrieve all devices installed within the rack
queryset = Device.objects.prefetch_related( queryset = Device.objects.prefetch_related(
'device_type', 'device_type',
'device_type__manufacturer', 'device_type__manufacturer',
@ -689,12 +699,22 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
).filter( ).filter(
Q(face=face) | Q(device_type__is_full_depth=True) 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: for device in queryset:
if expand_devices: if expand_devices:
for u in range(device.position, device.position + device.device_type.u_height): 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: 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 elevation[device.position]['height'] = device.device_type.u_height
for u in range(device.position + 1, device.position + device.device_type.u_height): for u in range(device.position + 1, device.position + device.device_type.u_height):
elevation.pop(u, None) elevation.pop(u, None)
@ -750,6 +770,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
def get_elevation_svg( def get_elevation_svg(
self, self,
face=DeviceFaceChoices.FACE_FRONT, face=DeviceFaceChoices.FACE_FRONT,
user=None,
unit_width=settings.RACK_ELEVATION_DEFAULT_UNIT_WIDTH, unit_width=settings.RACK_ELEVATION_DEFAULT_UNIT_WIDTH,
unit_height=settings.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT, unit_height=settings.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT,
legend_width=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT, legend_width=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT,
@ -760,6 +781,8 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
Return an SVG of the rack elevation Return an SVG of the rack elevation
:param face: Enum of [front, rear] representing the desired side of the rack elevation to render :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_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 :param unit_height: Height of each rack unit for the rendered drawing. Note this is not the total
height of the elevation height of the elevation
@ -767,7 +790,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
:param include_images: Embed front/rear device images where available :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. :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) return elevation.render(face, unit_width, unit_height, legend_width)

View File

@ -100,6 +100,7 @@ class ConsolePortTemplate(ComponentTemplateModel):
return ConsolePort( return ConsolePort(
device=device, device=device,
name=self.name, name=self.name,
label=self.label,
type=self.type type=self.type
) )
@ -122,6 +123,7 @@ class ConsoleServerPortTemplate(ComponentTemplateModel):
return ConsoleServerPort( return ConsoleServerPort(
device=device, device=device,
name=self.name, name=self.name,
label=self.label,
type=self.type type=self.type
) )
@ -156,6 +158,7 @@ class PowerPortTemplate(ComponentTemplateModel):
return PowerPort( return PowerPort(
device=device, device=device,
name=self.name, name=self.name,
label=self.label,
type=self.type, type=self.type,
maximum_draw=self.maximum_draw, maximum_draw=self.maximum_draw,
allocated_draw=self.allocated_draw allocated_draw=self.allocated_draw
@ -205,6 +208,7 @@ class PowerOutletTemplate(ComponentTemplateModel):
return PowerOutlet( return PowerOutlet(
device=device, device=device,
name=self.name, name=self.name,
label=self.label,
type=self.type, type=self.type,
power_port=power_port, power_port=power_port,
feed_leg=self.feed_leg feed_leg=self.feed_leg
@ -239,6 +243,7 @@ class InterfaceTemplate(ComponentTemplateModel):
return Interface( return Interface(
device=device, device=device,
name=self.name, name=self.name,
label=self.label,
type=self.type, type=self.type,
mgmt_only=self.mgmt_only mgmt_only=self.mgmt_only
) )
@ -293,6 +298,7 @@ class FrontPortTemplate(ComponentTemplateModel):
return FrontPort( return FrontPort(
device=device, device=device,
name=self.name, name=self.name,
label=self.label,
type=self.type, type=self.type,
rear_port=rear_port, rear_port=rear_port,
rear_port_position=self.rear_port_position rear_port_position=self.rear_port_position
@ -320,6 +326,7 @@ class RearPortTemplate(ComponentTemplateModel):
return RearPort( return RearPort(
device=device, device=device,
name=self.name, name=self.name,
label=self.label,
type=self.type, type=self.type,
positions=self.positions positions=self.positions
) )
@ -336,5 +343,6 @@ class DeviceBayTemplate(ComponentTemplateModel):
def instantiate(self, device): def instantiate(self, device):
return DeviceBay( return DeviceBay(
device=device, 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): def to_objectchange(self, action):
# Annotate the parent Device # Annotate the parent Device
try:
device = self.device
except ObjectDoesNotExist:
# The parent Device has already been deleted
device = None
return ObjectChange( return ObjectChange(
changed_object=self, changed_object=self,
object_repr=str(self), object_repr=str(self),
action=action, action=action,
related_object=self.device, related_object=device,
object_data=serialize_object(self) 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> <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> <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 = """ STATUS_LABEL = """
<span class="label label-{{ record.get_status_class }}">{{ record.get_status_display }}</span> <span class="label label-{{ record.get_status_class }}">{{ record.get_status_display }}</span>
""" """
@ -495,11 +534,11 @@ class DeviceBayTemplateTable(ComponentTemplateTable):
class DeviceRoleTable(BaseTable): class DeviceRoleTable(BaseTable):
pk = ToggleColumn() pk = ToggleColumn()
device_count = tables.TemplateColumn( device_count = tables.TemplateColumn(
template_code=DEVICE_COUNT, template_code=DEVICEROLE_DEVICE_COUNT,
verbose_name='Devices' verbose_name='Devices'
) )
vm_count = tables.TemplateColumn( vm_count = tables.TemplateColumn(
template_code=VM_COUNT, template_code=DEVICEROLE_VM_COUNT,
verbose_name='VMs' verbose_name='VMs'
) )
color = tables.TemplateColumn( color = tables.TemplateColumn(
@ -522,11 +561,11 @@ class DeviceRoleTable(BaseTable):
class PlatformTable(BaseTable): class PlatformTable(BaseTable):
pk = ToggleColumn() pk = ToggleColumn()
device_count = tables.TemplateColumn( device_count = tables.TemplateColumn(
template_code=DEVICE_COUNT, template_code=PLATFORM_DEVICE_COUNT,
verbose_name='Devices' verbose_name='Devices'
) )
vm_count = tables.TemplateColumn( vm_count = tables.TemplateColumn(
template_code=VM_COUNT, template_code=PLATFORM_VM_COUNT,
verbose_name='VMs' verbose_name='VMs'
) )
actions = ButtonsColumn(Platform, pk_field='slug') actions = ButtonsColumn(Platform, pk_field='slug')
@ -718,8 +757,8 @@ class InterfaceTable(DeviceComponentTable, BaseInterfaceTable):
class Meta(DeviceComponentTable.Meta): class Meta(DeviceComponentTable.Meta):
model = Interface model = Interface
fields = ( fields = (
'pk', 'device', 'name', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'description', 'cable', 'pk', 'device', 'name', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address',
'ip_addresses', 'untagged_vlan', 'tagged_vlans', 'description', 'cable', 'ip_addresses', 'untagged_vlan', 'tagged_vlans',
) )
default_columns = ('pk', 'device', 'name', 'label', 'enabled', 'type', 'description') default_columns = ('pk', 'device', 'name', 'label', 'enabled', 'type', 'description')

View File

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

View File

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

View File

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

View File

@ -4,6 +4,7 @@ import logging
import os import os
import pkgutil import pkgutil
import traceback import traceback
import warnings
from collections import OrderedDict from collections import OrderedDict
import yaml 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 # Add the current request as a property of the script
script.request = request 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) # Determine whether the script accepts a 'commit' argument (this was introduced in v2.7.8)
kwargs = { kwargs = {
'data': data 'data': data
} }
if 'commit' in inspect.signature(script.run).parameters: if 'commit' in inspect.signature(script.run).parameters:
kwargs['commit'] = commit 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: try:
with transaction.atomic(): with transaction.atomic():

View File

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

View File

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

View File

@ -121,7 +121,7 @@ class SecretForm(BootstrapMixin, CustomFieldModelForm):
device=self.cleaned_data['device'], device=self.cleaned_data['device'],
role=self.cleaned_data['role'], role=self.cleaned_data['role'],
name=self.cleaned_data['name'] name=self.cleaned_data['name']
).exists(): ).exclude(pk=self.instance.pk).exists():
raise forms.ValidationError( raise forms.ValidationError(
"Each secret assigned to a device must have a unique combination of role and name" "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 %} {% block title %}Page Not Found{% endblock %}
<div class="row" style="margin-top: 150px;">
<div class="col-sm-4 col-sm-offset-4"> {% block icon %}<i class="glyphicon glyphicon-warning-sign"></i>{% endblock %}
<div class="panel panel-default">
<div class="panel-heading"> {% block message %}
<strong><i class="glyphicon glyphicon-warning-sign"></i> Page Not Found</strong> The requested page does not exist.
</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>
{% endblock %} {% 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"> <table class="table table-hover panel-body attr-table">
<tr> <tr>
<td>Type</td> <td>Type</td>
<td>{{ cable.get_type_display }}</td> <td>{{ cable.get_type_display|placeholder }}</td>
</tr> </tr>
<tr> <tr>
<td>Status</td> <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>
<tr> <tr>
<td>Label</td> <td>Label</td>

View File

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

View File

@ -22,7 +22,7 @@
{# LAG #} {# LAG #}
<td> <td>
{% if iface.lag %} {% 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 %} {% endif %}
</td> </td>

View File

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

View File

@ -33,7 +33,7 @@
<strong>Interface Assignment</strong> <strong>Interface Assignment</strong>
</div> </div>
<div class="panel-body"> <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"> <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 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> <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 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 # Users & groups
# #
@ -24,40 +67,13 @@ class GroupAdmin(admin.ModelAdmin):
list_display = ('name', 'user_count') list_display = ('name', 'user_count')
ordering = ('name',) ordering = ('name',)
search_fields = ('name',) search_fields = ('name',)
inlines = [GroupObjectPermissionInline]
def user_count(self, obj): @staticmethod
def user_count(obj):
return obj.user_set.count() 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) @admin.register(AdminUser)
class UserAdmin(UserAdmin_): class UserAdmin(UserAdmin_):
list_display = [ list_display = [
@ -71,9 +87,13 @@ class UserAdmin(UserAdmin_):
}), }),
('Important dates', {'fields': ('last_login', 'date_joined')}), ('Important dates', {'fields': ('last_login', 'date_joined')}),
) )
inlines = [ObjectPermissionInline, UserConfigInline]
filter_horizontal = ('groups',) filter_horizontal = ('groups',)
def get_inlines(self, request, obj):
if obj is not None:
return (UserObjectPermissionInline, UserConfigInline)
return ()
# #
# REST API tokens # REST API tokens
@ -212,7 +232,7 @@ class ObjectPermissionAdmin(admin.ModelAdmin):
actions = ('enable', 'disable') actions = ('enable', 'disable')
fieldsets = ( fieldsets = (
(None, { (None, {
'fields': ('name', 'enabled') 'fields': ('name', 'description', 'enabled')
}), }),
('Actions', { ('Actions', {
'fields': (('can_view', 'can_add', 'can_change', 'can_delete'), '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') filter_horizontal = ('object_types', 'groups', 'users')
form = ObjectPermissionForm form = ObjectPermissionForm
list_display = [ 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 = [ list_filter = [
'enabled', ActionListFilter, ObjectTypeListFilter, 'groups', 'users' 'enabled', ActionListFilter, ObjectTypeListFilter, 'groups', 'users'
] ]
search_fields = ['actions', 'constraints', 'description', 'name']
def get_queryset(self, request): def get_queryset(self, request):
return super().get_queryset(request).prefetch_related('object_types', 'users', 'groups') 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): def list_models(self, obj):
return ', '.join([f"{ct}" for ct in obj.object_types.all()]) return ', '.join([f"{ct}" for ct in obj.object_types.all()])
list_models.short_description = 'Models' list_models.short_description = 'Models'

View File

@ -54,4 +54,6 @@ class ObjectPermissionSerializer(ValidatedModelSerializer):
class Meta: class Meta:
model = ObjectPermission 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', name='ObjectPermission',
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), ('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)), ('enabled', models.BooleanField(default=True)),
('constraints', django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=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)), ('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)), ('users', models.ManyToManyField(blank=True, related_name='object_permissions', to=settings.AUTH_USER_MODEL)),
], ],
options={ options={
'ordering': ['name'],
'verbose_name': 'permission', '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 # TODO: Optimize this iteration so that ObjectPermissions with identical sets of users and groups
# are combined into a single ObjectPermission instance. # 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: if perm.codename.split('_')[0] in ACTIONS:
action = perm.codename.split('_')[0] action = perm.codename.split('_')[0]
elif perm.codename == 'activate_userkey': elif perm.codename == 'activate_userkey':
@ -24,7 +24,11 @@ def replicate_permissions(apps, schema_editor):
action = perm.codename action = perm.codename
if perm.group_set.exists() or perm.user_set.exists(): 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.save()
obj_perm.object_types.add(perm.content_type) obj_perm.object_types.add(perm.content_type)
if perm.group_set.exists(): if perm.group_set.exists():

View File

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

View File

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

View File

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

View File

@ -418,13 +418,14 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
try: try:
with transaction.atomic(): with transaction.atomic():
object_created = form.instance.pk is None
obj = form.save() obj = form.save()
# Check that the new object conforms with any assigned object-level permissions # Check that the new object conforms with any assigned object-level permissions
self.queryset.get(pk=obj.pk) self.queryset.get(pk=obj.pk)
msg = '{} {}'.format( msg = '{} {}'.format(
'Created' if not form.instance.pk else 'Modified', 'Created' if object_created else 'Modified',
self.queryset.model._meta.verbose_name self.queryset.model._meta.verbose_name
) )
logger.info(f"{msg} {obj} (PK: {obj.pk})") 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 django.shortcuts import get_object_or_404
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.response import Response from rest_framework.response import Response
@ -7,7 +7,6 @@ from dcim.models import Device
from extras.api.serializers import RenderedGraphSerializer from extras.api.serializers import RenderedGraphSerializer
from extras.api.views import CustomFieldModelViewSet from extras.api.views import CustomFieldModelViewSet
from extras.models import Graph from extras.models import Graph
from ipam.models import VLAN
from utilities.api import ModelViewSet from utilities.api import ModelViewSet
from utilities.utils import get_subquery from utilities.utils import get_subquery
from virtualization import filters from virtualization import filters

View File

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

View File

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