diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index 337b41b1b..83a56d2c4 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -299,6 +299,24 @@ When determining the primary IP address for a device, IPv6 is preferred over IPv --- +## RELEASE_CHECK_TIMEOUT + +Default: 86,400 (24 hours) + +The number of seconds to retain the latest version that is fetched from the GitHub API before automatically invalidating it and fetching it from the API again. This must be set to at least one hour (3600 seconds). + +--- + +## RELEASE_CHECK_URL + +Default: None + +The releases of this repository are checked to detect new releases, which are shown on the home page of the web interface. You can change this to your own fork of the NetBox repository, or set it to `None` to disable the check. The URL provided **must** be compatible with the GitHub API. + +Use `'https://api.github.com/repos/netbox-community/netbox/releases'` to check for release in the official NetBox repository. + +--- + ## REPORTS_ROOT Default: $BASE_DIR/netbox/reports/ diff --git a/docs/configuration/required-settings.md b/docs/configuration/required-settings.md index e86b2810a..053e2d3d4 100644 --- a/docs/configuration/required-settings.md +++ b/docs/configuration/required-settings.md @@ -46,9 +46,9 @@ DATABASE = { [Redis](https://redis.io/) is an in-memory data store similar to memcached. While Redis has been an optional component of NetBox since the introduction of webhooks in version 2.4, it is required starting in 2.6 to support NetBox's caching functionality (as well as other planned features). In 2.7, the connection settings were broken down into two sections for -webhooks and caching, allowing the user to connect to different Redis instances/databases per feature. +task queuing and caching, allowing the user to connect to different Redis instances/databases per feature. -Redis is configured using a configuration setting similar to `DATABASE` and these settings are the same for both of the `webhooks` and `caching` subsections: +Redis is configured using a configuration setting similar to `DATABASE` and these settings are the same for both of the `tasks` and `caching` subsections: * `HOST` - Name or IP address of the Redis server (use `localhost` if running locally) * `PORT` - TCP port of the Redis service; leave blank for default port (6379) @@ -61,7 +61,7 @@ Example: ```python REDIS = { - 'webhooks': { + 'tasks': { 'HOST': 'redis.example.com', 'PORT': 1234, 'PASSWORD': 'foobar', @@ -84,9 +84,9 @@ REDIS = { If you are upgrading from a version prior to v2.7, please note that the Redis connection configuration settings have changed. Manual modification to bring the `REDIS` section inline with the above specification is necessary -!!! note - It is highly recommended to keep the webhook and cache databases separate. Using the same database number on the - same Redis instance for both may result in webhook processing data being lost during cache flushing events. +!!! warning + It is highly recommended to keep the task and cache databases separate. Using the same database number on the + same Redis instance for both may result in queued background tasks being lost during cache flushing events. ### Using Redis Sentinel @@ -102,7 +102,7 @@ Example: ```python REDIS = { - 'webhooks': { + 'tasks': { 'SENTINELS': [('mysentinel.redis.example.com', 6379)], 'SENTINEL_SERVICE': 'netbox', 'PASSWORD': '', @@ -126,7 +126,7 @@ REDIS = { !!! note It is possible to have only one or the other Redis configurations to use Sentinel functionality. It is possible - for example to have the webhook use sentinel via `HOST`/`PORT` and for caching to use Sentinel via + for example to have the tasks database use sentinel via `HOST`/`PORT` and for caching to use Sentinel via `SENTINELS`/`SENTINEL_SERVICE`. diff --git a/docs/extra.css b/docs/extra.css new file mode 100644 index 000000000..1bdba5a13 --- /dev/null +++ b/docs/extra.css @@ -0,0 +1,12 @@ +/* Custom table styling */ +table { + margin-bottom: 24px; + width: 100%; +} +th { + background-color: #f0f0f0; + padding: 6px; +} +td { + padding: 6px; +} diff --git a/docs/installation/3-netbox.md b/docs/installation/3-netbox.md index fabad20eb..b9b68be1b 100644 --- a/docs/installation/3-netbox.md +++ b/docs/installation/3-netbox.md @@ -172,7 +172,7 @@ Redis is a in-memory key-value store required as part of the NetBox installation ```python REDIS = { - 'webhooks': { + 'tasks': { 'HOST': 'redis.example.com', 'PORT': 1234, 'PASSWORD': 'foobar', diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 69be137d7..047ee3549 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -1,5 +1,31 @@ # NetBox v2.7 Release Notes +## v2.7.11 (2020-03-27) + +### Enhancements + +* [#738](https://github.com/netbox-community/netbox/issues/738) - Add ability to automatically check for new releases (must be enabled by setting `RELEASE_CHECK_URL`) +* [#4255](https://github.com/netbox-community/netbox/issues/4255) - Custom script object variables now utilize dynamic form widgets +* [#4309](https://github.com/netbox-community/netbox/issues/4309) - Add descriptive tooltip to custom fields on object views +* [#4369](https://github.com/netbox-community/netbox/issues/4369) - Add a dedicated view for rack reservations +* [#4380](https://github.com/netbox-community/netbox/issues/4380) - Enable webhooks for rack reservations +* [#4381](https://github.com/netbox-community/netbox/issues/4381) - Enable export templates for rack reservations +* [#4382](https://github.com/netbox-community/netbox/issues/4382) - Enable custom links for rack reservations +* [#4386](https://github.com/netbox-community/netbox/issues/4386) - Update admin links for Django RQ to reflect multiple queues +* [#4389](https://github.com/netbox-community/netbox/issues/4389) - Add a bulk edit view for device bays +* [#4404](https://github.com/netbox-community/netbox/issues/4404) - Add cable trace button for circuit terminations + +### Bug Fixes + +* [#2769](https://github.com/netbox-community/netbox/issues/2769) - Improve `prefix_length` validation on available-prefixes API +* [#3193](https://github.com/netbox-community/netbox/issues/3193) - Fix cable tracing across multiple rear ports +* [#4340](https://github.com/netbox-community/netbox/issues/4340) - Enforce unique constraints for device and virtual machine names in the API +* [#4343](https://github.com/netbox-community/netbox/issues/4343) - Fix Markdown support for tables +* [#4365](https://github.com/netbox-community/netbox/issues/4365) - Fix exception raised on IP address bulk add view +* [#4415](https://github.com/netbox-community/netbox/issues/4415) - Fix duplicate name validation on device model + +--- + ## v2.7.10 (2020-03-10) **Note:** If your deployment requires any non-core Python packages (such as `napalm`, `django-storages`, or `django-auth-ldap`), list them in a file named `local_requirements.txt` in the NetBox root directory (alongside `requirements.txt`). This will ensure they are detected and re-installed by the upgrade script when the Python virtual environment is rebuilt. diff --git a/mkdocs.yml b/mkdocs.yml index 4bc6c955d..1d829975b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -7,11 +7,12 @@ python: theme: name: readthedocs navigation_depth: 3 +extra_css: + - extra.css markdown_extensions: - admonition: - markdown_include.include: headingOffset: 1 - nav: - Introduction: 'index.md' - Installation: diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 0b0378a7a..c6f0dfdc4 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -113,7 +113,6 @@ class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/regions/", value_field="slug", filter_for={ 'site': 'region' @@ -125,7 +124,6 @@ class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/sites/", value_field="slug", ) ) @@ -167,16 +165,10 @@ class CircuitTypeCSVForm(forms.ModelForm): class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): provider = DynamicModelChoiceField( - queryset=Provider.objects.all(), - widget=APISelect( - api_url="/api/circuits/providers/" - ) + queryset=Provider.objects.all() ) type = DynamicModelChoiceField( - queryset=CircuitType.objects.all(), - widget=APISelect( - api_url="/api/circuits/circuit-types/" - ) + queryset=CircuitType.objects.all() ) comments = CommentField() tags = TagField( @@ -245,17 +237,11 @@ class CircuitBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit ) type = DynamicModelChoiceField( queryset=CircuitType.objects.all(), - required=False, - widget=APISelect( - api_url="/api/circuits/circuit-types/" - ) + required=False ) provider = DynamicModelChoiceField( queryset=Provider.objects.all(), - required=False, - widget=APISelect( - api_url="/api/circuits/providers/" - ) + required=False ) status = forms.ChoiceField( choices=add_blank_choice(CircuitStatusChoices), @@ -265,10 +251,7 @@ class CircuitBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), - required=False, - widget=APISelect( - api_url="/api/tenancy/tenants/" - ) + required=False ) commit_rate = forms.IntegerField( required=False, @@ -303,7 +286,6 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/circuits/circuit-types/", value_field="slug", ) ) @@ -312,7 +294,6 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/circuits/providers/", value_field="slug", ) ) @@ -326,7 +307,6 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/regions/", value_field="slug", filter_for={ 'site': 'region' @@ -338,7 +318,6 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/sites/", value_field="slug", ) ) @@ -355,6 +334,9 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm # class CircuitTerminationForm(BootstrapMixin, forms.ModelForm): + site = DynamicModelChoiceField( + queryset=Site.objects.all() + ) class Meta: model = CircuitTermination @@ -368,7 +350,4 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm): } widgets = { 'term_side': forms.HiddenInput(), - 'site': APISelect( - api_url="/api/dcim/sites/" - ) } diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index 812eaa79e..919fc45a5 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -7,6 +7,7 @@ from dcim.constants import CONNECTION_STATUS_CHOICES from dcim.fields import ASNField from dcim.models import CableTermination from extras.models import CustomFieldModel, ObjectChange, TaggedItem +from extras.utils import extras_features from utilities.models import ChangeLoggedModel from utilities.utils import serialize_object from .choices import * @@ -21,6 +22,7 @@ __all__ = ( ) +@extras_features('custom_fields', 'custom_links', 'graphs', 'export_templates', 'webhooks') class Provider(ChangeLoggedModel, CustomFieldModel): """ Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model @@ -131,6 +133,7 @@ class CircuitType(ChangeLoggedModel): ) +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class Circuit(ChangeLoggedModel, CustomFieldModel): """ A communications circuit connects two points. Each Circuit belongs to a Provider; Providers may have multiple diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index c4a159126..34ba0c47d 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -77,7 +77,7 @@ class CableTraceMixin(object): # Initialize the path array path = [] - for near_end, cable, far_end in obj.trace(follow_circuits=True): + for near_end, cable, far_end in obj.trace(): # Serialize each object serializer_a = get_serializer_for_model(near_end, prefix='Nested') diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index ac8fc40d5..311a8e69e 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -71,7 +71,6 @@ class DeviceComponentFilterForm(BootstrapMixin, forms.Form): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url='/api/dcim/regions/', value_field='slug', filter_for={ 'site': 'region' @@ -83,7 +82,6 @@ class DeviceComponentFilterForm(BootstrapMixin, forms.Form): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/sites/", value_field="slug", filter_for={ 'device_id': 'site', @@ -93,10 +91,7 @@ class DeviceComponentFilterForm(BootstrapMixin, forms.Form): device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), required=False, - label='Device', - widget=APISelectMultiple( - api_url='/api/dcim/devices/', - ) + label='Device' ) @@ -327,10 +322,7 @@ class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), - required=False, - widget=APISelect( - api_url="/api/tenancy/tenants", - ) + required=False ) asn = forms.IntegerField( min_value=BGP_ASN_MIN, @@ -371,7 +363,6 @@ class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/regions/", value_field="slug", ) ) @@ -384,10 +375,7 @@ class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): class RackGroupForm(BootstrapMixin, forms.ModelForm): site = DynamicModelChoiceField( - queryset=Site.objects.all(), - widget=APISelect( - api_url="/api/dcim/sites/" - ) + queryset=Site.objects.all() ) slug = SlugField() @@ -423,7 +411,6 @@ class RackGroupFilterForm(BootstrapMixin, forms.Form): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/regions/", value_field="slug", filter_for={ 'site': 'region' @@ -435,7 +422,6 @@ class RackGroupFilterForm(BootstrapMixin, forms.Form): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/sites/", value_field="slug", ) ) @@ -475,7 +461,6 @@ class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): site = DynamicModelChoiceField( queryset=Site.objects.all(), widget=APISelect( - api_url="/api/dcim/sites/", filter_for={ 'group': 'site_id', } @@ -483,17 +468,11 @@ class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): ) group = DynamicModelChoiceField( queryset=RackGroup.objects.all(), - required=False, - widget=APISelect( - api_url='/api/dcim/rack-groups/', - ) + required=False ) role = DynamicModelChoiceField( queryset=RackRole.objects.all(), - required=False, - widget=APISelect( - api_url='/api/dcim/rack-roles/', - ) + required=False ) comments = CommentField() tags = TagField( @@ -617,7 +596,6 @@ class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor queryset=Site.objects.all(), required=False, widget=APISelect( - api_url="/api/dcim/sites", filter_for={ 'group': 'site_id', } @@ -625,17 +603,11 @@ class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor ) group = DynamicModelChoiceField( queryset=RackGroup.objects.all(), - required=False, - widget=APISelect( - api_url="/api/dcim/rack-groups", - ) + required=False ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), - required=False, - widget=APISelect( - api_url="/api/tenancy/tenants", - ) + required=False ) status = forms.ChoiceField( choices=add_blank_choice(RackStatusChoices), @@ -645,10 +617,7 @@ class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor ) role = DynamicModelChoiceField( queryset=RackRole.objects.all(), - required=False, - widget=APISelect( - api_url="/api/dcim/rack-roles", - ) + required=False ) serial = forms.CharField( max_length=50, @@ -714,7 +683,6 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/regions/", value_field="slug", filter_for={ 'site': 'region' @@ -726,7 +694,6 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/sites/", value_field="slug", filter_for={ 'group_id': 'site' @@ -740,7 +707,6 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): required=False, label='Rack group', widget=APISelectMultiple( - api_url="/api/dcim/rack-groups/", null_option=True ) ) @@ -754,7 +720,6 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/rack-roles/", value_field="slug", null_option=True, ) @@ -773,7 +738,6 @@ class RackElevationFilterForm(RackFilterForm): label='Rack', required=False, widget=APISelectMultiple( - api_url='/api/dcim/racks/', display_field='display_name', ) ) @@ -791,6 +755,13 @@ class RackElevationFilterForm(RackFilterForm): # class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm): + rack = forms.ModelChoiceField( + queryset=Rack.objects.all(), + required=False, + widget=forms.HiddenInput() + ) + # TODO: Change this to an API-backed form field. We can't do this currently because we want to retain + # the multi-line + + + + + + + +
+ {% if perms.dcim.change_rackreservation %} + {% edit_button rackreservation %} + {% endif %} + {% if perms.dcim.delete_rackreservation %} + {% delete_button rackreservation %} + {% endif %} +
+

{% block title %}{{ rackreservation }}{% endblock %}

+ {% include 'inc/created_updated.html' with obj=rackreservation %} +
+ {% custom_links rackreservation %} +
+ +{% endblock %} + +{% block content %} +
+
+
+
+ Rack +
+ + {% with rack=rackreservation.rack %} + + + + + + + + + + + + + {% endwith %} +
Site + {% if rack.site.region %} + {{ rack.site.region }} + + {% endif %} + {{ rack.site }} +
Group + {% if rack.group %} + {{ rack.group }} + {% else %} + None + {% endif %} +
Rack + {{ rack }} +
+
+
+
+ Reservation Details +
+ + + + + + + + + + + + + + + + + +
Units{{ rackreservation.unit_list }}
Tenant + {% if rackreservation.tenant %} + {% if rackreservation.tenant.group %} + {{ rackreservation.tenant.group }} + + {% endif %} + {{ rackreservation.tenant }} + {% else %} + None + {% endif %} +
User{{ rackreservation.user }}
Description{{ rackreservation.description }}
+
+
+
+ {% with rack=rackreservation.rack %} +
+
+
+

Front

+
+ {% include 'dcim/inc/rack_elevation.html' with face='front' %} +
+
+
+

Rear

+
+ {% include 'dcim/inc/rack_elevation.html' with face='rear' %} +
+
+ {% endwith %} +
+
+{% endblock %} + +{% block javascript %} + +{% endblock %} diff --git a/netbox/templates/dcim/rackreservation_edit.html b/netbox/templates/dcim/rackreservation_edit.html new file mode 100644 index 000000000..b2304974e --- /dev/null +++ b/netbox/templates/dcim/rackreservation_edit.html @@ -0,0 +1,21 @@ +{% extends 'utilities/obj_edit.html' %} +{% load form_helpers %} + +{% block form %} +
+
{{ obj_type|capfirst }}
+
+
+ +
+

{{ obj.rack }}

+
+
+ {% render_field form.units %} + {% render_field form.user %} + {% render_field form.tenant_group %} + {% render_field form.tenant %} + {% render_field form.description %} +
+
+{% endblock %} diff --git a/netbox/templates/home.html b/netbox/templates/home.html index 6977bba4c..d3885b88f 100644 --- a/netbox/templates/home.html +++ b/netbox/templates/home.html @@ -1,6 +1,19 @@ {% extends '_base.html' %} {% load helpers %} +{% block header %} + {{ block.super }} + {% if new_release %} + {# new_release is set only if the current user is a superuser or staff member #} + + {% endif %} +{% endblock %} + + {% block content %} {% include 'search_form.html' %}
diff --git a/netbox/templates/inc/custom_fields_panel.html b/netbox/templates/inc/custom_fields_panel.html index 84892f726..00e009611 100644 --- a/netbox/templates/inc/custom_fields_panel.html +++ b/netbox/templates/inc/custom_fields_panel.html @@ -7,7 +7,7 @@ {% for field, value in custom_fields.items %} - +
{{ field }}{{ field }} {% if field.type == 'boolean' and value == True %} diff --git a/netbox/templates/inc/nav_menu.html b/netbox/templates/inc/nav_menu.html index d2eb93ebd..e65d42623 100644 --- a/netbox/templates/inc/nav_menu.html +++ b/netbox/templates/inc/nav_menu.html @@ -462,6 +462,7 @@ {% if perms.secrets.add_secret %}
+
{% endif %} diff --git a/netbox/templates/secrets/secret_edit.html b/netbox/templates/secrets/secret_edit.html index 875e53c5c..699999a9d 100644 --- a/netbox/templates/secrets/secret_edit.html +++ b/netbox/templates/secrets/secret_edit.html @@ -21,12 +21,7 @@
Secret Attributes
-
- -
-

{{ secret.device }}

-
-
+ {% render_field form.device %} {% render_field form.role %} {% render_field form.name %} {% render_field form.userkeys %} diff --git a/netbox/templates/utilities/obj_edit.html b/netbox/templates/utilities/obj_edit.html index 0d7760f30..bbe162def 100644 --- a/netbox/templates/utilities/obj_edit.html +++ b/netbox/templates/utilities/obj_edit.html @@ -51,7 +51,7 @@
- {% if settings.DOCS_ROOT %} + {% if obj and settings.DOCS_ROOT %} {% include 'inc/modal.html' with name='docs' content=obj|get_docs %} {% endif %} {% endblock %} diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index 5b828b661..1e6fcaba5 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -44,10 +44,7 @@ class TenantForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() group = DynamicModelChoiceField( queryset=TenantGroup.objects.all(), - required=False, - widget=APISelect( - api_url="/api/tenancy/tenant-groups/" - ) + required=False ) comments = CommentField() tags = TagField( @@ -89,10 +86,7 @@ class TenantBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF ) group = DynamicModelChoiceField( queryset=TenantGroup.objects.all(), - required=False, - widget=APISelect( - api_url="/api/tenancy/tenant-groups/" - ) + required=False ) class Meta: @@ -112,7 +106,6 @@ class TenantFilterForm(BootstrapMixin, CustomFieldFilterForm): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/tenancy/tenant-groups/", value_field="slug", null_option=True, ) @@ -129,7 +122,6 @@ class TenancyForm(forms.Form): queryset=TenantGroup.objects.all(), required=False, widget=APISelect( - api_url="/api/tenancy/tenant-groups/", filter_for={ 'tenant': 'group_id', }, @@ -140,10 +132,7 @@ class TenancyForm(forms.Form): ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), - required=False, - widget=APISelect( - api_url='/api/tenancy/tenants/' - ) + required=False ) def __init__(self, *args, **kwargs): @@ -164,7 +153,6 @@ class TenancyFilterForm(forms.Form): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/tenancy/tenant-groups/", value_field="slug", null_option=True, filter_for={ @@ -177,7 +165,6 @@ class TenancyFilterForm(forms.Form): to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/tenancy/tenants/", value_field="slug", null_option=True, ) diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py index 9fa7f23ea..757728fbb 100644 --- a/netbox/tenancy/models.py +++ b/netbox/tenancy/models.py @@ -4,6 +4,7 @@ from django.urls import reverse from taggit.managers import TaggableManager from extras.models import CustomFieldModel, TaggedItem +from extras.utils import extras_features from utilities.models import ChangeLoggedModel @@ -43,6 +44,7 @@ class TenantGroup(ChangeLoggedModel): ) +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class Tenant(ChangeLoggedModel, CustomFieldModel): """ A Tenant represents an organization served by the NetBox owner. This is typically a customer or an internal diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 72a5735de..a34d7983a 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -234,6 +234,7 @@ class ValidatedModelSerializer(ModelSerializer): for k, v in attrs.items(): setattr(instance, k, v) instance.clean() + instance.validate_unique() return data diff --git a/netbox/utilities/background_tasks.py b/netbox/utilities/background_tasks.py new file mode 100644 index 000000000..1255846b7 --- /dev/null +++ b/netbox/utilities/background_tasks.py @@ -0,0 +1,52 @@ +import logging + +import requests +from cacheops.simple import cache, CacheMiss +from django.conf import settings +from django_rq import job +from packaging import version + +# Get an instance of a logger +logger = logging.getLogger('netbox.releases') + + +@job('check_releases') +def get_releases(pre_releases=False): + url = settings.RELEASE_CHECK_URL + headers = { + 'Accept': 'application/vnd.github.v3+json', + } + releases = [] + + # Check whether this URL has failed recently and shouldn't be retried yet + try: + if url == cache.get('latest_release_no_retry'): + logger.info("Skipping release check; URL failed recently: {}".format(url)) + return [] + except CacheMiss: + pass + + try: + logger.debug("Fetching new releases from {}".format(url)) + response = requests.get(url, headers=headers) + response.raise_for_status() + total_releases = len(response.json()) + + for release in response.json(): + if 'tag_name' not in release: + continue + if not pre_releases and (release.get('devrelease') or release.get('prerelease')): + continue + releases.append((version.parse(release['tag_name']), release.get('html_url'))) + logger.debug("Found {} releases; {} usable".format(total_releases, len(releases))) + + except requests.exceptions.RequestException: + # The request failed. Set a flag in the cache to disable future checks to this URL for 15 minutes. + logger.exception("Error while fetching {}. Disabling checks for 15 minutes.".format(url)) + cache.set('latest_release_no_retry', url, 900) + return [] + + # Cache the most recent release + cache.set('latest_release', max(releases), settings.RELEASE_CHECK_TIMEOUT) + + return releases diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 8825102d1..c17ff9299 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -10,6 +10,7 @@ from django.conf import settings from django.contrib.postgres.forms.jsonb import JSONField as _JSONField, InvalidJSONInput from django.db.models import Count from django.forms import BoundField +from django.urls import reverse from .choices import unpack_grouped_choices from .constants import * @@ -252,7 +253,7 @@ class APISelect(SelectWithDisabled): """ A select widget populated via an API call - :param api_url: API URL + :param api_url: API endpoint URL. Required if not set automatically by the parent field. :param display_field: (Optional) Field to display for child in selection list. Defaults to `name`. :param value_field: (Optional) Field to use for the option value in selection list. Defaults to `id`. :param disabled_indicator: (Optional) Mark option as disabled if this field equates true. @@ -269,7 +270,7 @@ class APISelect(SelectWithDisabled): """ def __init__( self, - api_url, + api_url=None, display_field=None, value_field=None, disabled_indicator=None, @@ -285,7 +286,8 @@ class APISelect(SelectWithDisabled): super().__init__(*args, **kwargs) self.attrs['class'] = 'netbox-select2-api' - self.attrs['data-url'] = '/{}{}'.format(settings.BASE_PATH, api_url.lstrip('/')) # Inject BASE_PATH + if api_url: + self.attrs['data-url'] = '/{}{}'.format(settings.BASE_PATH, api_url.lstrip('/')) # Inject BASE_PATH if full: self.attrs['data-full'] = full if display_field: @@ -566,6 +568,10 @@ class TagFilterField(forms.MultipleChoiceField): class DynamicModelChoiceMixin: filter = django_filters.ModelChoiceFilter + widget = APISelect + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) def get_bound_field(self, form, field_name): bound_field = BoundField(form, self, field_name) @@ -579,6 +585,14 @@ class DynamicModelChoiceMixin: else: self.queryset = self.queryset.none() + # Set the data URL on the APISelect widget (if not already set) + widget = bound_field.field.widget + if not widget.attrs.get('data-url'): + app_label = self.queryset.model._meta.app_label + model_name = self.queryset.model._meta.model_name + data_url = reverse('{}-api:{}-list'.format(app_label, model_name)) + widget.attrs['data-url'] = data_url + return bound_field @@ -595,6 +609,7 @@ class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultip A multiple-choice version of DynamicModelChoiceField. """ filter = django_filters.ModelMultipleChoiceFilter + widget = APISelectMultiple class LaxURLField(forms.URLField): diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 618641a07..7d05ce749 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -40,7 +40,7 @@ def render_markdown(value): value = strip_tags(value) # Render Markdown - html = markdown(value, extensions=['fenced_code']) + html = markdown(value, extensions=['fenced_code', 'tables']) return mark_safe(html) @@ -196,7 +196,7 @@ def get_docs(model): return "Unable to load documentation, error reading file: {}".format(path) # Render Markdown with the admonition extension - content = markdown(content, extensions=['admonition', 'fenced_code']) + content = markdown(content, extensions=['admonition', 'fenced_code', 'tables']) return mark_safe(content) diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 0dbe38324..490f3d63f 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -9,7 +9,7 @@ from dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, S from extras.forms import ( AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm, ) -from ipam.models import IPAddress, VLANGroup, VLAN +from ipam.models import IPAddress, VLAN from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant from utilities.forms import ( @@ -77,24 +77,15 @@ class ClusterGroupCSVForm(forms.ModelForm): class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): type = DynamicModelChoiceField( - queryset=ClusterType.objects.all(), - widget=APISelect( - api_url="/api/virtualization/cluster-types/" - ) + queryset=ClusterType.objects.all() ) group = DynamicModelChoiceField( queryset=ClusterGroup.objects.all(), - required=False, - widget=APISelect( - api_url="/api/virtualization/cluster-groups/" - ) + required=False ) site = DynamicModelChoiceField( queryset=Site.objects.all(), - required=False, - widget=APISelect( - api_url="/api/dcim/sites/" - ) + required=False ) comments = CommentField() tags = TagField( @@ -157,31 +148,19 @@ class ClusterBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit ) type = DynamicModelChoiceField( queryset=ClusterType.objects.all(), - required=False, - widget=APISelect( - api_url="/api/virtualization/cluster-types/" - ) + required=False ) group = DynamicModelChoiceField( queryset=ClusterGroup.objects.all(), - required=False, - widget=APISelect( - api_url="/api/virtualization/cluster-groups/" - ) + required=False ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), - required=False, - widget=APISelect( - api_url="/api/tenancy/tenants/" - ) + required=False ) site = DynamicModelChoiceField( queryset=Site.objects.all(), - required=False, - widget=APISelect( - api_url="/api/dcim/sites/" - ) + required=False ) comments = CommentField( widget=SmallTextarea, @@ -205,7 +184,6 @@ class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/virtualization/cluster-types/", value_field='slug', ) ) @@ -214,7 +192,6 @@ class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/regions/", value_field="slug", filter_for={ 'site': 'region' @@ -226,7 +203,6 @@ class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/dcim/sites/", value_field='slug', null_option=True, ) @@ -236,7 +212,6 @@ class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm to_field_name='slug', required=False, widget=APISelectMultiple( - api_url="/api/virtualization/cluster-groups/", value_field='slug', null_option=True, ) @@ -249,7 +224,6 @@ class ClusterAddDevicesForm(BootstrapMixin, forms.Form): queryset=Region.objects.all(), required=False, widget=APISelect( - api_url="/api/dcim/regions/", filter_for={ "site": "region_id", }, @@ -262,7 +236,6 @@ class ClusterAddDevicesForm(BootstrapMixin, forms.Form): queryset=Site.objects.all(), required=False, widget=APISelect( - api_url='/api/dcim/sites/', filter_for={ "rack": "site_id", "devices": "site_id", @@ -273,7 +246,6 @@ class ClusterAddDevicesForm(BootstrapMixin, forms.Form): queryset=Rack.objects.all(), required=False, widget=APISelect( - api_url='/api/dcim/racks/', filter_for={ "devices": "rack_id" }, @@ -285,7 +257,6 @@ class ClusterAddDevicesForm(BootstrapMixin, forms.Form): devices = DynamicModelMultipleChoiceField( queryset=Device.objects.filter(cluster__isnull=True), widget=APISelectMultiple( - api_url='/api/dcim/devices/', display_field='display_name', disabled_indicator='cluster' ) @@ -334,7 +305,6 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): queryset=ClusterGroup.objects.all(), required=False, widget=APISelect( - api_url='/api/virtualization/cluster-groups/', filter_for={ "cluster": "group_id", }, @@ -344,16 +314,12 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): ) ) cluster = DynamicModelChoiceField( - queryset=Cluster.objects.all(), - widget=APISelect( - api_url='/api/virtualization/clusters/' - ) + queryset=Cluster.objects.all() ) role = DynamicModelChoiceField( queryset=DeviceRole.objects.all(), required=False, widget=APISelect( - api_url="/api/dcim/device-roles/", additional_query_params={ "vm_role": "True" } @@ -361,10 +327,7 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): ) platform = DynamicModelChoiceField( queryset=Platform.objects.all(), - required=False, - widget=APISelect( - api_url='/api/dcim/platforms/' - ) + required=False ) tags = TagField( required=False @@ -499,10 +462,7 @@ class VirtualMachineBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldB ) cluster = DynamicModelChoiceField( queryset=Cluster.objects.all(), - required=False, - widget=APISelect( - api_url='/api/virtualization/clusters/' - ) + required=False ) role = DynamicModelChoiceField( queryset=DeviceRole.objects.filter( @@ -510,7 +470,6 @@ class VirtualMachineBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldB ), required=False, widget=APISelect( - api_url="/api/dcim/device-roles/", additional_query_params={ "vm_role": "True" } @@ -518,17 +477,11 @@ class VirtualMachineBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldB ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), - required=False, - widget=APISelect( - api_url='/api/tenancy/tenants/' - ) + required=False ) platform = DynamicModelChoiceField( queryset=Platform.objects.all(), - required=False, - widget=APISelect( - api_url='/api/dcim/platforms/' - ) + required=False ) vcpus = forms.IntegerField( required=False, @@ -568,7 +521,6 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil to_field_name='slug', required=False, widget=APISelectMultiple( - api_url='/api/virtualization/cluster-groups/', value_field="slug", null_option=True, ) @@ -578,7 +530,6 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil to_field_name='slug', required=False, widget=APISelectMultiple( - api_url='/api/virtualization/cluster-types/', value_field="slug", null_option=True, ) @@ -586,17 +537,13 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil cluster_id = DynamicModelMultipleChoiceField( queryset=Cluster.objects.all(), required=False, - label='Cluster', - widget=APISelectMultiple( - api_url='/api/virtualization/clusters/', - ) + label='Cluster' ) region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, widget=APISelectMultiple( - api_url='/api/dcim/regions/', value_field="slug", filter_for={ 'site': 'region' @@ -608,7 +555,6 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil to_field_name='slug', required=False, widget=APISelectMultiple( - api_url='/api/dcim/sites/', value_field="slug", null_option=True, ) @@ -618,7 +564,6 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil to_field_name='slug', required=False, widget=APISelectMultiple( - api_url='/api/dcim/device-roles/', value_field="slug", null_option=True, additional_query_params={ @@ -636,7 +581,6 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil to_field_name='slug', required=False, widget=APISelectMultiple( - api_url='/api/dcim/platforms/', value_field="slug", null_option=True, ) @@ -657,7 +601,6 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm): queryset=VLAN.objects.all(), required=False, widget=APISelect( - api_url="/api/ipam/vlans/", display_field='display_name', full=True, additional_query_params={ @@ -669,7 +612,6 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm): queryset=VLAN.objects.all(), required=False, widget=APISelectMultiple( - api_url="/api/ipam/vlans/", display_field='display_name', full=True, additional_query_params={ @@ -766,7 +708,6 @@ class InterfaceCreateForm(BootstrapMixin, forms.Form): queryset=VLAN.objects.all(), required=False, widget=APISelect( - api_url="/api/ipam/vlans/", display_field='display_name', full=True, additional_query_params={ @@ -778,7 +719,6 @@ class InterfaceCreateForm(BootstrapMixin, forms.Form): queryset=VLAN.objects.all(), required=False, widget=APISelectMultiple( - api_url="/api/ipam/vlans/", display_field='display_name', full=True, additional_query_params={ @@ -836,7 +776,6 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm): queryset=VLAN.objects.all(), required=False, widget=APISelect( - api_url="/api/ipam/vlans/", display_field='display_name', full=True, additional_query_params={ @@ -848,7 +787,6 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm): queryset=VLAN.objects.all(), required=False, widget=APISelectMultiple( - api_url="/api/ipam/vlans/", display_field='display_name', full=True, additional_query_params={ diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 13b181137..2bd391863 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -7,6 +7,7 @@ from taggit.managers import TaggableManager from dcim.models import Device from extras.models import ConfigContextModel, CustomFieldModel, TaggedItem +from extras.utils import extras_features from utilities.models import ChangeLoggedModel from .choices import * @@ -91,6 +92,7 @@ class ClusterGroup(ChangeLoggedModel): # Clusters # +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class Cluster(ChangeLoggedModel, CustomFieldModel): """ A cluster of VirtualMachines. Each Cluster may optionally be associated with one or more Devices. @@ -177,6 +179,7 @@ class Cluster(ChangeLoggedModel, CustomFieldModel): # Virtual machines # +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): """ A virtual machine which runs inside a Cluster. diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py index 719954c10..fa425c460 100644 --- a/netbox/virtualization/tests/test_api.py +++ b/netbox/virtualization/tests/test_api.py @@ -501,6 +501,18 @@ class VirtualMachineTest(APITestCase): self.assertFalse('config_context' in response.data['results'][0]) + def test_unique_name_per_cluster_constraint(self): + + data = { + 'name': 'Test Virtual Machine 1', + 'cluster': self.cluster1.pk, + } + + url = reverse('virtualization-api:virtualmachine-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + class InterfaceTest(APITestCase):