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 7bae23d77..6d2706eb0 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 graphviz 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 afad93fde..2bf55d857 100644
--- a/docs/release-notes/version-2.6.md
+++ b/docs/release-notes/version-2.6.md
@@ -1,3 +1,38 @@
+# v2.6.11 (2020-01-03)
+
+## Bug Fixes
+
+* [#3831](https://github.com/netbox-community/netbox/issues/3831) - Fix API-driven filter field rendering (#3812 regression)
+* [#3833](https://github.com/netbox-community/netbox/issues/3833) - Add missing region filters for multiple objects
+
+---
+
+# 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
@@ -13,6 +48,8 @@
* [#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
@@ -35,6 +72,8 @@
* [#3724](https://github.com/netbox-community/netbox/issues/3724) - Fix API filtering of interfaces by more than one device name
* [#3725](https://github.com/netbox-community/netbox/issues/3725) - Enforce client validation for minimum service port number
+---
+
# v2.6.7 (2019-11-01)
## Enhancements
diff --git a/netbox/circuits/filters.py b/netbox/circuits/filters.py
index 502d2d103..0ac5ec170 100644
--- a/netbox/circuits/filters.py
+++ b/netbox/circuits/filters.py
@@ -18,6 +18,17 @@ class ProviderFilter(CustomFieldFilterSet, CreatedUpdatedFilterSet):
method='search',
label='Search',
)
+ region_id = TreeNodeMultipleChoiceFilter(
+ queryset=Region.objects.all(),
+ field_name='circuits__terminations__site__region__in',
+ label='Region (ID)',
+ )
+ region = TreeNodeMultipleChoiceFilter(
+ queryset=Region.objects.all(),
+ field_name='circuits__terminations__site__region__in',
+ to_field_name='slug',
+ label='Region (slug)',
+ )
site_id = django_filters.ModelMultipleChoiceFilter(
field_name='circuits__terminations__site',
queryset=Site.objects.all(),
diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py
index dfe4f46e4..4a5c06a6e 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 .constants import *
from .models import Circuit, CircuitTermination, CircuitType, Provider
@@ -104,6 +104,18 @@ class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm):
required=False,
label='Search'
)
+ region = FilterChoiceField(
+ queryset=Region.objects.all(),
+ to_field_name='slug',
+ required=False,
+ widget=APISelectMultiple(
+ api_url="/api/dcim/regions/",
+ value_field="slug",
+ filter_for={
+ 'site': 'region'
+ }
+ )
+ )
site = FilterChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',
@@ -161,7 +173,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 +183,7 @@ class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldForm):
api_url="/api/circuits/circuit-types/"
),
'status': StaticSelect2(),
-
+ 'install_date': DatePicker(),
}
@@ -303,6 +314,9 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm
widget=APISelectMultiple(
api_url="/api/dcim/regions/",
value_field="slug",
+ filter_for={
+ 'site': 'region'
+ }
)
)
site = FilterChoiceField(
diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py
index e8152725d..933535482 100644
--- a/netbox/dcim/filters.py
+++ b/netbox/dcim/filters.py
@@ -93,6 +93,17 @@ class SiteFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet
class RackGroupFilter(NameSlugSearchFilterSet):
+ region_id = TreeNodeMultipleChoiceFilter(
+ queryset=Region.objects.all(),
+ field_name='site__region__in',
+ label='Region (ID)',
+ )
+ region = TreeNodeMultipleChoiceFilter(
+ queryset=Region.objects.all(),
+ field_name='site__region__in',
+ to_field_name='slug',
+ label='Region (slug)',
+ )
site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(),
label='Site (ID)',
@@ -125,6 +136,17 @@ class RackFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet
method='search',
label='Search',
)
+ region_id = TreeNodeMultipleChoiceFilter(
+ queryset=Region.objects.all(),
+ field_name='site__region__in',
+ label='Region (ID)',
+ )
+ region = TreeNodeMultipleChoiceFilter(
+ queryset=Region.objects.all(),
+ field_name='site__region__in',
+ to_field_name='slug',
+ label='Region (slug)',
+ )
site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(),
label='Site (ID)',
@@ -831,6 +853,28 @@ class InventoryItemFilter(DeviceComponentFilterSet):
method='search',
label='Search',
)
+ region_id = TreeNodeMultipleChoiceFilter(
+ queryset=Region.objects.all(),
+ field_name='device__site__region__in',
+ label='Region (ID)',
+ )
+ region = TreeNodeMultipleChoiceFilter(
+ queryset=Region.objects.all(),
+ field_name='device__site__region__in',
+ to_field_name='slug',
+ label='Region (slug)',
+ )
+ site_id = django_filters.ModelMultipleChoiceFilter(
+ field_name='device__site',
+ queryset=Site.objects.all(),
+ label='Site (ID)',
+ )
+ site = django_filters.ModelMultipleChoiceFilter(
+ field_name='device__site__slug',
+ queryset=Site.objects.all(),
+ to_field_name='slug',
+ label='Site name (slug)',
+ )
device_id = django_filters.ModelChoiceFilter(
queryset=Device.objects.all(),
label='Device (ID)',
@@ -868,8 +912,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)
@@ -880,6 +924,17 @@ class VirtualChassisFilter(django_filters.FilterSet):
method='search',
label='Search',
)
+ region_id = TreeNodeMultipleChoiceFilter(
+ queryset=Region.objects.all(),
+ field_name='master__site__region__in',
+ label='Region (ID)',
+ )
+ region = TreeNodeMultipleChoiceFilter(
+ queryset=Region.objects.all(),
+ field_name='master__site__region__in',
+ to_field_name='slug',
+ label='Region (slug)',
+ )
site_id = django_filters.ModelMultipleChoiceFilter(
field_name='master__site',
queryset=Site.objects.all(),
@@ -935,7 +990,7 @@ class CableFilter(django_filters.FilterSet):
device_id = MultiValueNumberFilter(
method='filter_device'
)
- device = MultiValueNumberFilter(
+ device = MultiValueCharFilter(
method='filter_device',
field_name='device__name'
)
@@ -978,9 +1033,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:
@@ -993,11 +1051,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})
)
@@ -1006,9 +1064,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:
@@ -1021,11 +1082,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})
)
@@ -1034,9 +1095,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:
@@ -1052,11 +1116,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})
)
@@ -1069,6 +1133,17 @@ class PowerPanelFilter(django_filters.FilterSet):
method='search',
label='Search',
)
+ region_id = TreeNodeMultipleChoiceFilter(
+ queryset=Region.objects.all(),
+ field_name='site__region__in',
+ label='Region (ID)',
+ )
+ region = TreeNodeMultipleChoiceFilter(
+ queryset=Region.objects.all(),
+ field_name='site__region__in',
+ to_field_name='slug',
+ label='Region (slug)',
+ )
site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(),
label='Site (ID)',
@@ -1107,6 +1182,17 @@ class PowerFeedFilter(CustomFieldFilterSet, CreatedUpdatedFilterSet):
method='search',
label='Search',
)
+ region_id = TreeNodeMultipleChoiceFilter(
+ queryset=Region.objects.all(),
+ field_name='power_panel__site__region__in',
+ label='Region (ID)',
+ )
+ region = TreeNodeMultipleChoiceFilter(
+ queryset=Region.objects.all(),
+ field_name='power_panel__site__region__in',
+ to_field_name='slug',
+ label='Region (slug)',
+ )
site_id = django_filters.ModelMultipleChoiceFilter(
field_name='power_panel__site',
queryset=Site.objects.all(),
diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py
index 0b9f53ec5..59f72396c 100644
--- a/netbox/dcim/forms.py
+++ b/netbox/dcim/forms.py
@@ -364,6 +364,18 @@ class RackGroupCSVForm(forms.ModelForm):
class RackGroupFilterForm(BootstrapMixin, forms.Form):
+ region = FilterChoiceField(
+ queryset=Region.objects.all(),
+ to_field_name='slug',
+ required=False,
+ widget=APISelectMultiple(
+ api_url="/api/dcim/regions/",
+ value_field="slug",
+ filter_for={
+ 'site': 'region'
+ }
+ )
+ )
site = FilterChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',
@@ -635,11 +647,23 @@ class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor
class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
model = Rack
- field_order = ['q', 'site', 'group_id', 'status', 'role', 'tenant_group', 'tenant']
+ field_order = ['q', 'region', 'site', 'group_id', 'status', 'role', 'tenant_group', 'tenant']
q = forms.CharField(
required=False,
label='Search'
)
+ region = FilterChoiceField(
+ queryset=Region.objects.all(),
+ to_field_name='slug',
+ required=False,
+ widget=APISelectMultiple(
+ api_url="/api/dcim/regions/",
+ value_field="slug",
+ filter_for={
+ 'site': 'region'
+ }
+ )
+ )
site = FilterChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',
@@ -651,16 +675,15 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
}
)
)
- group_id = ChainedModelChoiceField(
- label='Rack group',
- queryset=RackGroup.objects.prefetch_related('site'),
- chains=(
- ('site', 'site'),
+ group_id = FilterChoiceField(
+ queryset=RackGroup.objects.prefetch_related(
+ 'site'
),
- required=False,
+ label='Rack group',
+ null_label='-- None --',
widget=APISelectMultiple(
api_url="/api/dcim/rack-groups/",
- null_option=True,
+ null_option=True
)
)
status = forms.MultipleChoiceField(
@@ -1339,7 +1362,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'
}
)
)
@@ -1408,7 +1432,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(),
@@ -1725,7 +1752,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(
@@ -1751,12 +1778,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'
),
@@ -1764,7 +1791,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',
}
)
)
@@ -3167,9 +3194,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/',
+ )
)
@@ -3234,38 +3265,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/',
+ )
)
@@ -3281,9 +3333,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/"
)
@@ -3319,9 +3374,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,
@@ -3345,18 +3410,48 @@ class InventoryItemFilterForm(BootstrapMixin, forms.Form):
required=False,
label='Search'
)
- device = forms.CharField(
+ region = FilterChoiceField(
+ queryset=Region.objects.all(),
+ to_field_name='slug',
required=False,
- label='Device name'
+ widget=APISelectMultiple(
+ api_url="/api/dcim/regions/",
+ value_field="slug",
+ filter_for={
+ 'site': 'region'
+ }
+ )
+ )
+ site = FilterChoiceField(
+ queryset=Site.objects.all(),
+ to_field_name='slug',
+ widget=APISelectMultiple(
+ api_url="/api/dcim/sites/",
+ value_field="slug",
+ filter_for={
+ 'device_id': 'site'
+ }
+ )
+ )
+ device_id = FilterChoiceField(
+ queryset=Device.objects.all(),
+ required=False,
+ 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
)
)
@@ -3503,6 +3598,18 @@ class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm):
required=False,
label='Search'
)
+ region = FilterChoiceField(
+ queryset=Region.objects.all(),
+ to_field_name='slug',
+ required=False,
+ widget=APISelectMultiple(
+ api_url="/api/dcim/regions/",
+ value_field="slug",
+ filter_for={
+ 'site': 'region'
+ }
+ )
+ )
site = FilterChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',
@@ -3608,6 +3715,18 @@ class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm):
required=False,
label='Search'
)
+ region = FilterChoiceField(
+ queryset=Region.objects.all(),
+ to_field_name='slug',
+ required=False,
+ widget=APISelectMultiple(
+ api_url="/api/dcim/regions/",
+ value_field="slug",
+ filter_for={
+ 'site': 'region'
+ }
+ )
+ )
site = FilterChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',
@@ -3828,6 +3947,18 @@ class PowerFeedFilterForm(BootstrapMixin, CustomFieldFilterForm):
required=False,
label='Search'
)
+ region = FilterChoiceField(
+ queryset=Region.objects.all(),
+ to_field_name='slug',
+ required=False,
+ widget=APISelectMultiple(
+ api_url="/api/dcim/regions/",
+ value_field="slug",
+ filter_for={
+ 'site': 'region'
+ }
+ )
+ )
site = FilterChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',
diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py
index d523e8f68..9999f89dc 100644
--- a/netbox/dcim/models.py
+++ b/netbox/dcim/models.py
@@ -2602,7 +2602,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
diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py
index f99848b1b..ee21b4f5d 100644
--- a/netbox/extras/admin.py
+++ b/netbox/extras/admin.py
@@ -3,7 +3,10 @@ from django.contrib import admin
from netbox.admin import admin_site
from utilities.forms import LaxURLField
-from .models import CustomField, CustomFieldChoice, CustomLink, Graph, ExportTemplate, TopologyMap, Webhook
+from .models import (
+ CustomField, CustomFieldChoice, CustomLink, Graph, ExportTemplate, ReportResult, TopologyMap, Webhook,
+)
+from .reports import get_report
def order_content_types(field):
@@ -166,6 +169,36 @@ class ExportTemplateAdmin(admin.ModelAdmin):
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
+
+
#
# Topology maps
#
diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py
index efb92b2ce..4f7f57fff 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 .constants 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 == CF_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 == CF_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 == CF_TYPE_URL:
@@ -388,16 +390,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(OBJECTCHANGE_ACTION_CHOICES),
diff --git a/netbox/extras/models.py b/netbox/extras/models.py
index 170035eb7..038576b63 100644
--- a/netbox/extras/models.py
+++ b/netbox/extras/models.py
@@ -12,12 +12,11 @@ from django.db.models import F, Q
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 dcim.constants import CONNECTION_STATUS_CONNECTED
from utilities.fields import ColorField
-from utilities.utils import deepmerge, foreground_color, model_names_to_filter_dict
+from utilities.utils import deepmerge, foreground_color, model_names_to_filter_dict, render_jinja2
from .constants import *
from .querysets import ConfigContextQuerySet
@@ -502,8 +501,7 @@ class ExportTemplate(models.Model):
output = template.render(Context(context))
elif self.template_language == TEMPLATE_LANGUAGE_JINJA2:
- template = Environment().from_string(source=self.template_code)
- output = template.render(**context)
+ output = render_jinja2(self.template_code, context)
else:
return None
@@ -917,6 +915,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 4e0934a6a..28238b008 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__)
@@ -337,7 +340,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.
@@ -347,9 +350,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)
+ '