diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 55a979eef..cceea27b6 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -16,7 +16,7 @@ For real-time discussion, you can join the #netbox Slack channel on [NetworkToCo
## Reporting Bugs
-* First, ensure that you've installed the [latest stable version](https://github.com/netbox-community/netbox/releases)
+* First, ensure that you're running the [latest stable version](https://github.com/netbox-community/netbox/releases)
of NetBox. If you're running an older version, it's possible that the bug has
already been fixed.
@@ -28,27 +28,26 @@ up (+1). You might also want to add a comment describing how it's affecting your
installation. This will allow us to prioritize bugs based on how many users are
affected.
-* If you haven't found an existing issue that describes your suspected bug,
-please inquire about it on the mailing list. **Do not** file an issue until you
-have received confirmation that it is in fact a bug. Invalid issues are very
-distracting and slow the pace at which NetBox is developed.
-
* When submitting an issue, please be as descriptive as possible. Be sure to
-include:
+provide all information request in the issue template, including:
* The environment in which NetBox is running
- * The exact steps that can be taken to reproduce the issue (if applicable)
+ * The exact steps that can be taken to reproduce the issue
+ * Expected and observed behavior
* Any error messages generated
* Screenshots (if applicable)
* Please avoid prepending any sort of tag (e.g. "[Bug]") to the issue title.
-The issue will be reviewed by a moderator after submission and the appropriate
+The issue will be reviewed by a maintainer after submission and the appropriate
labels will be applied for categorization.
* Keep in mind that we prioritize bugs based on their severity and how much
work is required to resolve them. It may take some time for someone to address
your issue.
+* For more information on how bug reports are handled, please see our [issue
+intake policy](https://github.com/netbox-community/netbox/wiki/Issue-Intake-Policy).
+
## Feature Requests
* First, check the GitHub [issues list](https://github.com/netbox-community/netbox/issues)
@@ -61,10 +60,10 @@ free to add a comment with any additional justification for the feature.
(However, note that comments with no substance other than a "+1" will be
deleted. Please use GitHub's reactions feature to indicate your support.)
-* Due to an excessive backlog of feature requests, we are not currently
-accepting any proposals which substantially extend NetBox's functionality
-beyond its current feature set. This includes the introduction of any new views
-or models which have not already been proposed in an existing feature request.
+* Due to a large backlog of feature requests, we are not currently accepting
+any proposals which substantially extend NetBox's functionality beyond its
+current feature set. This includes the introduction of any new views or models
+which have not already been proposed in an existing feature request.
* Before filing a new feature request, consider raising your idea on the
mailing list first. Feedback you receive there will help validate and shape the
@@ -75,8 +74,8 @@ describe the functionality and data model(s) being proposed. The more effort
you put into writing a feature request, the better its chance is of being
implemented. Overly broad feature requests will be closed.
-* When submitting a feature request on GitHub, be sure to include the
-following:
+* When submitting a feature request on GitHub, be sure to include all
+information requested by the issue template, including:
* A detailed description of the proposed functionality
* A use case for the feature; who would use it and what value it would add
@@ -89,6 +88,9 @@ following:
title. The issue will be reviewed by a moderator after submission and the
appropriate labels will be applied for categorization.
+* For more information on how feature requests are handled, please see our
+[issue intake policy](https://github.com/netbox-community/netbox/wiki/Issue-Intake-Policy).
+
## Submitting Pull Requests
* Be sure to open an issue **before** starting work on a pull request, and
@@ -103,7 +105,7 @@ any work that's already in progress.
* When submitting a pull request, please be sure to work off of the `develop`
branch, rather than `master`. The `develop` branch is used for ongoing
-development, while `master` is used for tagging new stable releases.
+development, while `master` is used for tagging stable releases.
* All code submissions should meet the following criteria (CI will enforce
these checks):
@@ -122,27 +124,26 @@ reduce noise in the discussion.
## Issue Lifecycle
-When a correctly formatted issue is submitted it is evaluated by a moderator
-who may elect to immediately label the issue as accepted in addition to another
-issue type label. In other cases, the issue may be labeled as "status: gathering feedback"
-which will often be accompanied by a comment from a moderator asking for further dialog from the community.
-If an issue is labeled as "status: revisions needed" a moderator has identified a problem with
-the issue itself and is asking for the submitter himself to update the original post with
-the requested information. If the original post is not updated in a reasonable amount of time,
-the issue will be closed as invalid.
+New issues are handled according to our [issue intake policy](https://github.com/netbox-community/netbox/wiki/Issue-Intake-Policy).
+Maintainers will assign label(s) and/or close new issues as the policy
+dictates. This helps ensure a productive development environment and avoid
+accumulating a large backlog of work.
-The core maintainers group has chosen to make use of the GitHub Stale bot to aid in issue management.
+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.
-* Any issue bearing one of the following labels will be exempt from all Stale bot actions:
+* Any issue bearing one of the following labels will be exempt from all Stale
+ bot actions:
* `status: accepted`
* `status: gathering feedback`
* `status: blocked`
-It is natural that some new issues get more attention than others. Often this is a metric of an issues's
-overall usefulness 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. 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.
## Maintainer Guidance
diff --git a/README.md b/README.md
index 38961c286..478f37e5e 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-
+
NetBox is an IP address management (IPAM) and data center infrastructure
management (DCIM) tool. Initially conceived by the network engineering team at
diff --git a/docs/additional-features/custom-scripts.md b/docs/additional-features/custom-scripts.md
index cdb49c82a..c4dffb4b9 100644
--- a/docs/additional-features/custom-scripts.md
+++ b/docs/additional-features/custom-scripts.md
@@ -71,6 +71,18 @@ The checkbox to commit database changes when executing a script is checked by de
commit_default = False
```
+## Accessing Request Data
+
+Details of the current HTTP request (the one being made to execute the script) are available as the instance attribute `self.request`. This can be used to infer, for example, the user executing the script and the client IP address:
+
+```python
+username = self.request.user.username
+ip_address = self.request.META.get('HTTP_X_FORWARDED_FOR') or self.request.META.get('REMOTE_ADDR')
+self.log_info("Running as user {} (IP: {})...".format(username, ip_address))
+```
+
+For a complete list of available request parameters, please see the [Django documentation](https://docs.djangoproject.com/en/stable/ref/request-response/).
+
## Reading Data from Files
The Script class provides two convenience methods for reading data from files:
diff --git a/docs/index.md b/docs/index.md
index 84c39ccde..a68d5a6bf 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -1,4 +1,4 @@
-
+
# What is NetBox?
diff --git a/docs/installation/2-netbox.md b/docs/installation/2-netbox.md
index 053beb7ed..cbe2c70c0 100644
--- a/docs/installation/2-netbox.md
+++ b/docs/installation/2-netbox.md
@@ -14,7 +14,7 @@ This section of the documentation discusses installing and configuring the NetBo
# yum install -y epel-release
# yum install -y gcc python36 python36-devel python36-setuptools libxml2-devel libxslt-devel libffi-devel openssl-devel redhat-rpm-config redis
# easy_install-3.6 pip
-# ln -s /usr/bin/python36 /usr/bin/python3
+# ln -s /usr/bin/python3.6 /usr/bin/python3
```
You may opt to install NetBox either from a numbered release or by cloning the master branch of its repository on GitHub.
diff --git a/docs/installation/4-ldap.md b/docs/installation/4-ldap.md
index 32623439a..a41400808 100644
--- a/docs/installation/4-ldap.md
+++ b/docs/installation/4-ldap.md
@@ -80,6 +80,7 @@ AUTH_LDAP_USER_ATTR_MAP = {
```
# User Groups for Permissions
+
!!! info
When using Microsoft Active Directory, support for nested groups can be activated by using `NestedGroupOfNamesType()` instead of `GroupOfNamesType()` for `AUTH_LDAP_GROUP_TYPE`. You will also need to modify the import line to use `NestedGroupOfNamesType` instead of `GroupOfNamesType` .
@@ -117,6 +118,9 @@ AUTH_LDAP_GROUP_CACHE_TIMEOUT = 3600
* `is_staff` - Users mapped to this group are enabled for access to the administration tools; this is the equivalent of checking the "staff status" box on a manually created user. This doesn't grant any specific permissions.
* `is_superuser` - Users mapped to this group will be granted superuser status. Superusers are implicitly granted all permissions.
+!!! warning
+ Authentication will fail if the groups (the distinguished names) do not exist in the LDAP directory.
+
# Troubleshooting LDAP
`supervisorctl restart netbox` restarts the Netbox service, and initiates any changes made to `ldap_config.py`. If there are syntax errors present, the NetBox process will not spawn an instance, and errors should be logged to `/var/log/supervisor/`.
diff --git a/docs/netbox_logo.svg b/docs/netbox_logo.svg
new file mode 100644
index 000000000..5321be100
--- /dev/null
+++ b/docs/netbox_logo.svg
@@ -0,0 +1,21 @@
+
diff --git a/docs/release-notes/version-2.6.md b/docs/release-notes/version-2.6.md
index dbeefe2af..c6e3934e7 100644
--- a/docs/release-notes/version-2.6.md
+++ b/docs/release-notes/version-2.6.md
@@ -1,3 +1,46 @@
+# v2.6.10 (2020-01-02)
+
+## Enhancements
+
+* [#2233](https://github.com/netbox-community/netbox/issues/2233) - Add ability to move inventory items between devices
+* [#2892](https://github.com/netbox-community/netbox/issues/2892) - Extend admin UI to allow deleting old report results
+* [#3062](https://github.com/netbox-community/netbox/issues/3062) - Add `assigned_to_interface` filter for IP addresses
+* [#3461](https://github.com/netbox-community/netbox/issues/3461) - Fail gracefully on custom link rendering exception
+* [#3705](https://github.com/netbox-community/netbox/issues/3705) - Provide request context when executing custom scripts
+* [#3762](https://github.com/netbox-community/netbox/issues/3762) - Add date/time picker widgets
+* [#3788](https://github.com/netbox-community/netbox/issues/3788) - Enable partial search for inventory items
+* [#3812](https://github.com/netbox-community/netbox/issues/3812) - Optimize size of pages containing a dynamic selection field
+* [#3827](https://github.com/netbox-community/netbox/issues/3827) - Allow filtering console/power/interface connections by device ID
+
+## Bug Fixes
+
+* [#3106](https://github.com/netbox-community/netbox/issues/3106) - Restrict queryset of chained fields when form validation fails
+* [#3695](https://github.com/netbox-community/netbox/issues/3695) - Include A/Z termination sites for circuits in global search
+* [#3712](https://github.com/netbox-community/netbox/issues/3712) - Scrolling to target (hash) did not account for the header size
+* [#3780](https://github.com/netbox-community/netbox/issues/3780) - Fix AttributeError exception in API docs
+* [#3809](https://github.com/netbox-community/netbox/issues/3809) - Filter platform by manufacturer when editing devices
+* [#3811](https://github.com/netbox-community/netbox/issues/3811) - Fix filtering of racks by group on device list
+* [#3822](https://github.com/netbox-community/netbox/issues/3822) - Fix exception when editing a device bay (regression from #3596)
+
+---
+
+# v2.6.9 (2019-12-16)
+
+## Enhancements
+
+* [#3152](https://github.com/netbox-community/netbox/issues/3152) - Include direct link to rack elevations on site view
+* [#3441](https://github.com/netbox-community/netbox/issues/3441) - Move virtual machine results near devices in global search
+* [#3761](https://github.com/netbox-community/netbox/issues/3761) - Added copy button for API tokens
+
+## Bug Fixes
+
+* [#2170](https://github.com/netbox-community/netbox/issues/2170) - Prevent the deletion of a virtual chassis when a cross-member LAG is present
+* [#2358](https://github.com/netbox-community/netbox/issues/2358) - Respect custom field default values when creating objects via the REST API
+* [#3749](https://github.com/netbox-community/netbox/issues/3749) - Fix exception on password change page for local users
+* [#3757](https://github.com/netbox-community/netbox/issues/3757) - Fix unable to assign IP to interface
+
+---
+
# v2.6.8 (2019-12-10)
## Enhancements
diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py
index a0458aa42..afd6f0abb 100644
--- a/netbox/circuits/forms.py
+++ b/netbox/circuits/forms.py
@@ -7,7 +7,7 @@ from tenancy.forms import TenancyFilterForm, TenancyForm
from tenancy.models import Tenant
from utilities.forms import (
APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, CommentField, CSVChoiceField,
- FilterChoiceField, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple
+ DatePicker, FilterChoiceField, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple
)
from .choices import CircuitStatusChoices
from .models import Circuit, CircuitTermination, CircuitType, Provider
@@ -161,7 +161,6 @@ class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldForm):
]
help_texts = {
'cid': "Unique circuit ID",
- 'install_date': "Format: YYYY-MM-DD",
'commit_rate': "Committed rate",
}
widgets = {
@@ -172,7 +171,7 @@ class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldForm):
api_url="/api/circuits/circuit-types/"
),
'status': StaticSelect2(),
-
+ 'install_date': DatePicker(),
}
diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py
index fba17ff78..3e7fcf3a1 100644
--- a/netbox/dcim/filters.py
+++ b/netbox/dcim/filters.py
@@ -926,8 +926,8 @@ class InventoryItemFilter(DeviceComponentFilterSet):
qs_filter = (
Q(name__icontains=value) |
Q(part_id__icontains=value) |
- Q(serial__iexact=value) |
- Q(asset_tag__iexact=value) |
+ Q(serial__icontains=value) |
+ Q(asset_tag__icontains=value) |
Q(description__icontains=value)
)
return queryset.filter(qs_filter)
@@ -993,7 +993,7 @@ class CableFilter(django_filters.FilterSet):
device_id = MultiValueNumberFilter(
method='filter_device'
)
- device = MultiValueNumberFilter(
+ device = MultiValueCharFilter(
method='filter_device',
field_name='device__name'
)
@@ -1036,9 +1036,12 @@ class ConsoleConnectionFilter(django_filters.FilterSet):
method='filter_site',
label='Site (slug)',
)
- device = django_filters.CharFilter(
+ device_id = MultiValueNumberFilter(
+ method='filter_device'
+ )
+ device = MultiValueCharFilter(
method='filter_device',
- label='Device',
+ field_name='device__name'
)
class Meta:
@@ -1051,11 +1054,11 @@ class ConsoleConnectionFilter(django_filters.FilterSet):
return queryset.filter(connected_endpoint__device__site__slug=value)
def filter_device(self, queryset, name, value):
- if not value.strip():
+ if not value:
return queryset
return queryset.filter(
- Q(device__name__icontains=value) |
- Q(connected_endpoint__device__name__icontains=value)
+ Q(**{'{}__in'.format(name): value}) |
+ Q(**{'connected_endpoint__{}__in'.format(name): value})
)
@@ -1064,9 +1067,12 @@ class PowerConnectionFilter(django_filters.FilterSet):
method='filter_site',
label='Site (slug)',
)
- device = django_filters.CharFilter(
+ device_id = MultiValueNumberFilter(
+ method='filter_device'
+ )
+ device = MultiValueCharFilter(
method='filter_device',
- label='Device',
+ field_name='device__name'
)
class Meta:
@@ -1079,11 +1085,11 @@ class PowerConnectionFilter(django_filters.FilterSet):
return queryset.filter(_connected_poweroutlet__device__site__slug=value)
def filter_device(self, queryset, name, value):
- if not value.strip():
+ if not value:
return queryset
return queryset.filter(
- Q(device__name__icontains=value) |
- Q(_connected_poweroutlet__device__name__icontains=value)
+ Q(**{'{}__in'.format(name): value}) |
+ Q(**{'_connected_poweroutlet__{}__in'.format(name): value})
)
@@ -1092,9 +1098,12 @@ class InterfaceConnectionFilter(django_filters.FilterSet):
method='filter_site',
label='Site (slug)',
)
- device = django_filters.CharFilter(
+ device_id = MultiValueNumberFilter(
+ method='filter_device'
+ )
+ device = MultiValueCharFilter(
method='filter_device',
- label='Device',
+ field_name='device__name'
)
class Meta:
@@ -1110,11 +1119,11 @@ class InterfaceConnectionFilter(django_filters.FilterSet):
)
def filter_device(self, queryset, name, value):
- if not value.strip():
+ if not value:
return queryset
return queryset.filter(
- Q(device__name__icontains=value) |
- Q(_connected_interface__device__name__icontains=value)
+ Q(**{'{}__in'.format(name): value}) |
+ Q(**{'_connected_interface__{}__in'.format(name): value})
)
diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py
index 0ff827218..3141e7737 100644
--- a/netbox/dcim/forms.py
+++ b/netbox/dcim/forms.py
@@ -1484,7 +1484,8 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm):
widget=APISelect(
api_url="/api/dcim/manufacturers/",
filter_for={
- 'device_type': 'manufacturer_id'
+ 'device_type': 'manufacturer_id',
+ 'platform': 'manufacturer_id'
}
)
)
@@ -1553,7 +1554,10 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm):
),
'status': StaticSelect2(),
'platform': APISelect(
- api_url="/api/dcim/platforms/"
+ api_url="/api/dcim/platforms/",
+ additional_query_params={
+ "manufacturer_id": "null"
+ }
),
'primary_ip4': StaticSelect2(),
'primary_ip6': StaticSelect2(),
@@ -1870,7 +1874,7 @@ class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldFilterForm):
model = Device
field_order = [
- 'q', 'region', 'site', 'rack_group_id', 'rack_id', 'status', 'role', 'tenant_group', 'tenant',
+ 'q', 'region', 'site', 'group_id', 'rack_id', 'status', 'role', 'tenant_group', 'tenant',
'manufacturer_id', 'device_type_id', 'mac_address', 'has_primary_ip',
]
q = forms.CharField(
@@ -1896,12 +1900,12 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt
api_url="/api/dcim/sites/",
value_field="slug",
filter_for={
- 'rack_group_id': 'site',
+ 'group_id': 'site',
'rack_id': 'site',
}
)
)
- rack_group_id = FilterChoiceField(
+ group_id = FilterChoiceField(
queryset=RackGroup.objects.prefetch_related(
'site'
),
@@ -1909,7 +1913,7 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt
widget=APISelectMultiple(
api_url="/api/dcim/rack-groups/",
filter_for={
- 'rack_id': 'rack_group_id',
+ 'rack_id': 'group_id',
}
)
)
@@ -3605,9 +3609,13 @@ class CableFilterForm(BootstrapMixin, forms.Form):
required=False,
widget=ColorSelect()
)
- device = forms.CharField(
+ device_id = FilterChoiceField(
+ queryset=Device.objects.all(),
required=False,
- label='Device name'
+ label='Device',
+ widget=APISelectMultiple(
+ api_url='/api/dcim/devices/',
+ )
)
@@ -3726,38 +3734,59 @@ class DeviceBayBulkRenameForm(BulkRenameForm):
#
class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form):
- site = forms.ModelChoiceField(
+ site = FilterChoiceField(
queryset=Site.objects.all(),
- required=False,
- to_field_name='slug'
+ to_field_name='slug',
+ widget=APISelectMultiple(
+ api_url="/api/dcim/sites/",
+ value_field="slug",
+ )
)
- device = forms.CharField(
+ device_id = FilterChoiceField(
+ queryset=Device.objects.all(),
required=False,
- label='Device name'
+ label='Device',
+ widget=APISelectMultiple(
+ api_url='/api/dcim/devices/',
+ )
)
class PowerConnectionFilterForm(BootstrapMixin, forms.Form):
- site = forms.ModelChoiceField(
+ site = FilterChoiceField(
queryset=Site.objects.all(),
- required=False,
- to_field_name='slug'
+ to_field_name='slug',
+ widget=APISelectMultiple(
+ api_url="/api/dcim/sites/",
+ value_field="slug",
+ )
)
- device = forms.CharField(
+ device_id = FilterChoiceField(
+ queryset=Device.objects.all(),
required=False,
- label='Device name'
+ label='Device',
+ widget=APISelectMultiple(
+ api_url='/api/dcim/devices/',
+ )
)
class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form):
- site = forms.ModelChoiceField(
+ site = FilterChoiceField(
queryset=Site.objects.all(),
- required=False,
- to_field_name='slug'
+ to_field_name='slug',
+ widget=APISelectMultiple(
+ api_url="/api/dcim/sites/",
+ value_field="slug",
+ )
)
- device = forms.CharField(
+ device_id = FilterChoiceField(
+ queryset=Device.objects.all(),
required=False,
- label='Device name'
+ label='Device',
+ widget=APISelectMultiple(
+ api_url='/api/dcim/devices/',
+ )
)
@@ -3773,9 +3802,12 @@ class InventoryItemForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = InventoryItem
fields = [
- 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'tags',
+ 'name', 'device', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'tags',
]
widgets = {
+ 'device': APISelect(
+ api_url="/api/dcim/devices/"
+ ),
'manufacturer': APISelect(
api_url="/api/dcim/manufacturers/"
)
@@ -3811,9 +3843,19 @@ class InventoryItemBulkEditForm(BootstrapMixin, BulkEditForm):
queryset=InventoryItem.objects.all(),
widget=forms.MultipleHiddenInput()
)
+ device = forms.ModelChoiceField(
+ queryset=Device.objects.all(),
+ required=False,
+ widget=APISelect(
+ api_url="/api/dcim/devices/"
+ )
+ )
manufacturer = forms.ModelChoiceField(
queryset=Manufacturer.objects.all(),
- required=False
+ required=False,
+ widget=APISelect(
+ api_url="/api/dcim/manufacturers/"
+ )
)
part_id = forms.CharField(
max_length=50,
@@ -3837,18 +3879,25 @@ class InventoryItemFilterForm(BootstrapMixin, forms.Form):
required=False,
label='Search'
)
- device = forms.CharField(
+ device_id = FilterChoiceField(
+ queryset=Device.objects.all(),
required=False,
- label='Device name'
+ label='Device',
+ widget=APISelect(
+ api_url='/api/dcim/devices/',
+ )
)
manufacturer = FilterChoiceField(
queryset=Manufacturer.objects.all(),
to_field_name='slug',
- null_label='-- None --'
+ widget=APISelect(
+ api_url="/api/dcim/manufacturers/",
+ value_field="slug",
+ )
)
discovered = forms.NullBooleanField(
required=False,
- widget=forms.Select(
+ widget=StaticSelect2(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py
index f2611febd..724150977 100644
--- a/netbox/dcim/models.py
+++ b/netbox/dcim/models.py
@@ -10,7 +10,7 @@ from django.contrib.postgres.fields import ArrayField, JSONField
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
-from django.db.models import Count, Q, Sum
+from django.db.models import Count, F, ProtectedError, Q, Sum
from django.urls import reverse
from django.utils.http import urlencode
from mptt.models import MPTTModel, TreeForeignKey
@@ -2811,7 +2811,7 @@ class DeviceBay(ComponentModel):
# Check that the installed device is not already installed elsewhere
if self.installed_device:
current_bay = DeviceBay.objects.filter(installed_device=self.installed_device).first()
- if current_bay:
+ if current_bay and current_bay != self:
raise ValidationError({
'installed_device': "Cannot install the specified device; device is already installed in {}".format(
current_bay
@@ -2944,6 +2944,24 @@ class VirtualChassis(ChangeLoggedModel):
'master': "The selected master is not assigned to this virtual chassis."
})
+ def delete(self, *args, **kwargs):
+
+ # Check for LAG interfaces split across member chassis
+ interfaces = Interface.objects.filter(
+ device__in=self.members.all(),
+ lag__isnull=False
+ ).exclude(
+ lag__device=F('device')
+ )
+ if interfaces:
+ raise ProtectedError(
+ "Unable to delete virtual chassis {}. There are member interfaces which form a cross-chassis "
+ "LAG".format(self),
+ interfaces
+ )
+
+ return super().delete(*args, **kwargs)
+
def to_csv(self):
return (
self.master,
diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py
index fd26d4445..61bedd189 100644
--- a/netbox/extras/admin.py
+++ b/netbox/extras/admin.py
@@ -3,7 +3,8 @@ from django.contrib import admin
from netbox.admin import admin_site
from utilities.forms import LaxURLField
-from .models import CustomField, CustomFieldChoice, CustomLink, Graph, ExportTemplate, Webhook
+from .models import CustomField, CustomFieldChoice, CustomLink, Graph, ExportTemplate, ReportResult, Webhook
+from .reports import get_report
def order_content_types(field):
@@ -164,3 +165,33 @@ class ExportTemplateAdmin(admin.ModelAdmin):
'content_type',
]
form = ExportTemplateForm
+
+
+#
+# Reports
+#
+
+@admin.register(ReportResult, site=admin_site)
+class ReportResultAdmin(admin.ModelAdmin):
+ list_display = [
+ 'report', 'active', 'created', 'user', 'passing',
+ ]
+ fields = [
+ 'report', 'user', 'passing', 'data',
+ ]
+ list_filter = [
+ 'failed',
+ ]
+ readonly_fields = fields
+
+ def has_add_permission(self, request):
+ return False
+
+ def active(self, obj):
+ module, report_name = obj.report.split('.')
+ return True if get_report(module, report_name) else False
+ active.boolean = True
+
+ def passing(self, obj):
+ return not obj.failed
+ passing.boolean = True
diff --git a/netbox/extras/api/customfields.py b/netbox/extras/api/customfields.py
index 0def32fde..9a3041238 100644
--- a/netbox/extras/api/customfields.py
+++ b/netbox/extras/api/customfields.py
@@ -22,7 +22,9 @@ class CustomFieldsSerializer(serializers.BaseSerializer):
def to_internal_value(self, data):
content_type = ContentType.objects.get_for_model(self.parent.Meta.model)
- custom_fields = {field.name: field for field in CustomField.objects.filter(obj_type=content_type)}
+ custom_fields = {
+ field.name: field for field in CustomField.objects.filter(obj_type=content_type)
+ }
for field_name, value in data.items():
@@ -107,11 +109,11 @@ class CustomFieldModelSerializer(ValidatedModelSerializer):
super().__init__(*args, **kwargs)
- if self.instance is not None:
+ # Retrieve the set of CustomFields which apply to this type of object
+ content_type = ContentType.objects.get_for_model(self.Meta.model)
+ fields = CustomField.objects.filter(obj_type=content_type)
- # Retrieve the set of CustomFields which apply to this type of object
- content_type = ContentType.objects.get_for_model(self.Meta.model)
- fields = CustomField.objects.filter(obj_type=content_type)
+ if self.instance is not None:
# Populate CustomFieldValues for each instance from database
try:
@@ -120,6 +122,26 @@ class CustomFieldModelSerializer(ValidatedModelSerializer):
except TypeError:
_populate_custom_fields(self.instance, fields)
+ else:
+
+ if not hasattr(self, 'initial_data'):
+ self.initial_data = {}
+
+ # Populate default values
+ if fields and 'custom_fields' not in self.initial_data:
+ self.initial_data['custom_fields'] = {}
+
+ # Populate initial data using custom field default values
+ for field in fields:
+ if field.name not in self.initial_data['custom_fields'] and field.default:
+ if field.type == CustomFieldTypeChoices.TYPE_SELECT:
+ field_value = field.choices.get(value=field.default).pk
+ elif field.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
+ field_value = bool(field.default)
+ else:
+ field_value = field.default
+ self.initial_data['custom_fields'][field.name] = field_value
+
def _save_custom_fields(self, instance, custom_fields):
content_type = ContentType.objects.get_for_model(self.Meta.model)
for field_name, value in custom_fields.items():
diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py
index c34b9225c..edde6c6c5 100644
--- a/netbox/extras/forms.py
+++ b/netbox/extras/forms.py
@@ -10,8 +10,8 @@ from dcim.models import DeviceRole, Platform, Region, Site
from tenancy.models import Tenant, TenantGroup
from utilities.forms import (
add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect,
- CommentField, ContentTypeSelect, FilterChoiceField, LaxURLField, JSONField, SlugField, StaticSelect2,
- BOOLEAN_WITH_BLANK_CHOICES,
+ CommentField, ContentTypeSelect, DatePicker, DateTimePicker, FilterChoiceField, LaxURLField, JSONField,
+ SlugField, StaticSelect2, BOOLEAN_WITH_BLANK_CHOICES,
)
from .choices import *
from .models import ConfigContext, CustomField, CustomFieldValue, ImageAttachment, ObjectChange, Tag
@@ -52,12 +52,12 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F
else:
initial = None
field = forms.NullBooleanField(
- required=cf.required, initial=initial, widget=forms.Select(choices=choices)
+ required=cf.required, initial=initial, widget=StaticSelect2(choices=choices)
)
# Date
elif cf.type == CustomFieldTypeChoices.TYPE_DATE:
- field = forms.DateField(required=cf.required, initial=initial, help_text="Date format: YYYY-MM-DD")
+ field = forms.DateField(required=cf.required, initial=initial, widget=DatePicker())
# Select
elif cf.type == CustomFieldTypeChoices.TYPE_SELECT:
@@ -71,7 +71,9 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F
default_choice = cf.choices.get(value=initial).pk
except ObjectDoesNotExist:
pass
- field = forms.TypedChoiceField(choices=choices, coerce=int, required=cf.required, initial=default_choice)
+ field = forms.TypedChoiceField(
+ choices=choices, coerce=int, required=cf.required, initial=default_choice, widget=StaticSelect2()
+ )
# URL
elif cf.type == CustomFieldTypeChoices.TYPE_URL:
@@ -404,16 +406,12 @@ class ObjectChangeFilterForm(BootstrapMixin, forms.Form):
time_after = forms.DateTimeField(
label='After',
required=False,
- widget=forms.TextInput(
- attrs={'placeholder': 'YYYY-MM-DD hh:mm:ss'}
- )
+ widget=DateTimePicker()
)
time_before = forms.DateTimeField(
label='Before',
required=False,
- widget=forms.TextInput(
- attrs={'placeholder': 'YYYY-MM-DD hh:mm:ss'}
- )
+ widget=DateTimePicker()
)
action = forms.ChoiceField(
choices=add_blank_choice(ObjectChangeActionChoices),
diff --git a/netbox/extras/models.py b/netbox/extras/models.py
index ee6c929f5..6e1693dd4 100644
--- a/netbox/extras/models.py
+++ b/netbox/extras/models.py
@@ -10,11 +10,10 @@ from django.db import models
from django.http import HttpResponse
from django.template import Template, Context
from django.urls import reverse
-from jinja2 import Environment
from taggit.models import TagBase, GenericTaggedItemBase
from utilities.fields import ColorField
-from utilities.utils import deepmerge, model_names_to_filter_dict
+from utilities.utils import deepmerge, model_names_to_filter_dict, render_jinja2
from .choices import *
from .constants import *
from .querysets import ConfigContextQuerySet
@@ -511,8 +510,7 @@ class ExportTemplate(models.Model):
output = template.render(Context(context))
elif self.template_language == ExportTemplateLanguageChoices.LANGUAGE_JINJA2:
- template = Environment().from_string(source=self.template_code)
- output = template.render(**context)
+ output = render_jinja2(self.template_code, context)
else:
return None
@@ -792,6 +790,13 @@ class ReportResult(models.Model):
class Meta:
ordering = ['report']
+ def __str__(self):
+ return "{} {} at {}".format(
+ self.report,
+ "passed" if not self.failed else "failed",
+ self.created
+ )
+
#
# Change logging
diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py
index 01bc97be9..f43a2e36e 100644
--- a/netbox/extras/scripts.py
+++ b/netbox/extras/scripts.py
@@ -235,6 +235,9 @@ class BaseScript:
# Initiate the log
self.log = []
+ # Declare the placeholder for the current request
+ self.request = None
+
# Grab some info about the script
self.filename = inspect.getfile(self.__class__)
self.source = inspect.getsource(self.__class__)
@@ -342,7 +345,7 @@ def is_variable(obj):
return isinstance(obj, ScriptVariable)
-def run_script(script, data, files, commit=True):
+def run_script(script, data, request, commit=True):
"""
A wrapper for calling Script.run(). This performs error handling and provides a hook for committing changes. It
exists outside of the Script class to ensure it cannot be overridden by a script author.
@@ -352,9 +355,13 @@ def run_script(script, data, files, commit=True):
end_time = None
# Add files to form data
+ files = request.FILES
for field_name, fileobj in files.items():
data[field_name] = fileobj
+ # Add the current request as a property of the script
+ script.request = request
+
try:
with transaction.atomic():
start_time = time.time()
diff --git a/netbox/extras/templatetags/custom_links.py b/netbox/extras/templatetags/custom_links.py
index ce6cc482a..8c927a0ae 100644
--- a/netbox/extras/templatetags/custom_links.py
+++ b/netbox/extras/templatetags/custom_links.py
@@ -3,9 +3,9 @@ from collections import OrderedDict
from django import template
from django.contrib.contenttypes.models import ContentType
from django.utils.safestring import mark_safe
-from jinja2 import Environment
from extras.models import CustomLink
+from utilities.utils import render_jinja2
register = template.Library()
@@ -46,12 +46,17 @@ def custom_links(obj):
# Add non-grouped links
else:
- text_rendered = Environment().from_string(source=cl.text).render(**context)
- if text_rendered:
- link_target = ' target="_blank"' if cl.new_window else ''
- template_code += LINK_BUTTON.format(
- cl.url, link_target, cl.button_class, text_rendered
- )
+ try:
+ text_rendered = render_jinja2(cl.text, context)
+ if text_rendered:
+ link_rendered = render_jinja2(cl.url, context)
+ link_target = ' target="_blank"' if cl.new_window else ''
+ template_code += LINK_BUTTON.format(
+ link_rendered, link_target, cl.button_class, text_rendered
+ )
+ except Exception as e:
+ template_code += '' \
+ ' {}\n'.format(e, cl.name)
# Add grouped links to template
for group, links in group_names.items():
@@ -59,11 +64,17 @@ def custom_links(obj):
links_rendered = []
for cl in links:
- text_rendered = Environment().from_string(source=cl.text).render(**context)
- if text_rendered:
- link_target = ' target="_blank"' if cl.new_window else ''
+ try:
+ text_rendered = render_jinja2(cl.text, context)
+ if text_rendered:
+ link_target = ' target="_blank"' if cl.new_window else ''
+ links_rendered.append(
+ GROUP_LINK.format(cl.url, link_target, cl.text)
+ )
+ except Exception as e:
links_rendered.append(
- GROUP_LINK.format(cl.url, link_target, cl.text)
+ '