Ver Fonte

Merge branch 'develop' into develop-2.7

Jeremy Stretch há 6 anos atrás
pai
commit
1d63a30b7a
42 ficheiros alterados com 2296 adições e 182 exclusões
  1. 30 29
      CONTRIBUTING.md
  2. 1 1
      README.md
  3. 12 0
      docs/additional-features/custom-scripts.md
  4. 1 1
      docs/index.md
  5. 1 1
      docs/installation/2-netbox.md
  6. 4 0
      docs/installation/4-ldap.md
  7. 18 0
      docs/netbox_logo.svg
  8. 43 0
      docs/release-notes/version-2.6.md
  9. 2 3
      netbox/circuits/forms.py
  10. 27 18
      netbox/dcim/filters.py
  11. 78 29
      netbox/dcim/forms.py
  12. 20 2
      netbox/dcim/models.py
  13. 32 1
      netbox/extras/admin.py
  14. 27 5
      netbox/extras/api/customfields.py
  15. 9 11
      netbox/extras/forms.py
  16. 9 4
      netbox/extras/models.py
  17. 8 1
      netbox/extras/scripts.py
  18. 23 15
      netbox/extras/templatetags/custom_links.py
  19. 69 1
      netbox/extras/tests/test_api.py
  20. 34 0
      netbox/extras/tests/test_customfields.py
  21. 1 1
      netbox/extras/views.py
  22. 7 0
      netbox/ipam/filters.py
  23. 16 5
      netbox/ipam/forms.py
  24. 5 1
      netbox/ipam/tables.py
  25. 1 1
      netbox/netbox/settings.py
  26. 28 22
      netbox/netbox/views.py
  27. 1 0
      netbox/project-static/flatpickr-4.6.3/flatpickr.min.js
  28. 784 0
      netbox/project-static/flatpickr-4.6.3/themes/dark.css
  29. 798 0
      netbox/project-static/flatpickr-4.6.3/themes/light.css
  30. 20 0
      netbox/project-static/img/netbox_icon.svg
  31. 18 0
      netbox/project-static/img/netbox_logo.svg
  32. 49 4
      netbox/project-static/js/forms.js
  33. 2 0
      netbox/templates/_base.html
  34. 22 19
      netbox/templates/dcim/site.html
  35. 1 1
      netbox/templates/inc/nav_menu.html
  36. 9 1
      netbox/templates/users/api_tokens.html
  37. 3 3
      netbox/users/forms.py
  38. 1 1
      netbox/users/views.py
  39. 39 1
      netbox/utilities/forms.py
  40. 5 0
      netbox/utilities/templates/widgets/select_api.html
  41. 29 0
      netbox/utilities/tests/test_api.py
  42. 9 0
      netbox/utilities/utils.py

+ 30 - 29
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
 

+ 1 - 1
README.md

@@ -1,4 +1,4 @@
-![NetBox](docs/netbox_logo.png "NetBox logo")
+![NetBox](docs/netbox_logo.svg "NetBox logo")
 
 NetBox is an IP address management (IPAM) and data center infrastructure
 management (DCIM) tool. Initially conceived by the network engineering team at

+ 12 - 0
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:

+ 1 - 1
docs/index.md

@@ -1,4 +1,4 @@
-![NetBox](netbox_logo.png "NetBox logo")
+![NetBox](netbox_logo.svg "NetBox logo")
 
 # What is NetBox?
 

+ 1 - 1
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.

+ 4 - 0
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 do ficheiro suprimidas por serem muito extensas
+ 18 - 0
docs/netbox_logo.svg


+ 43 - 0
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

+ 2 - 3
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(),
         }
 
 

+ 27 - 18
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})
         )
 
 

+ 78 - 29
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
         )
     )

+ 20 - 2
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,

+ 32 - 1
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

+ 27 - 5
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():

+ 9 - 11
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),

+ 9 - 4
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

+ 8 - 1
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()

+ 23 - 15
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 += '<a class="btn btn-sm btn-default" disabled="disabled" title="{}">' \
+                                 '<i class="fa fa-warning"></i> {}</a>\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)
+                    '<li><a disabled="disabled" title="{}"><span class="text-muted">'
+                    '<i class="fa fa-warning"></i> {}</span></a></li>'.format(e, cl.name)
                 )
 
         if links_rendered:
@@ -71,7 +82,4 @@ def custom_links(obj):
                 links[0].button_class, group, ''.join(links_rendered)
             )
 
-    # Render template
-    rendered = Environment().from_string(source=template_code).render(**context)
-
-    return mark_safe(rendered)
+    return mark_safe(template_code)

+ 69 - 1
netbox/extras/tests/test_api.py

@@ -1,8 +1,11 @@
+import datetime
+
 from django.contrib.contenttypes.models import ContentType
 from django.urls import reverse
+from django.utils import timezone
 from rest_framework import status
 
-from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Platform, Region, Site
+from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Platform, Rack, RackGroup, RackRole, Region, Site
 from extras.api.views import ScriptViewSet
 from extras.models import ConfigContext, Graph, ExportTemplate, Tag
 from extras.scripts import BooleanVar, IntegerVar, Script, StringVar
@@ -598,3 +601,68 @@ class ScriptTest(APITestCase):
         self.assertEqual(response.data['log'][2]['status'], 'failure')
         self.assertEqual(response.data['log'][2]['message'], script_data['var3'])
         self.assertEqual(response.data['output'], 'Script complete')
+
+
+class CreatedUpdatedFilterTest(APITestCase):
+
+    def setUp(self):
+
+        super().setUp()
+
+        self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1')
+        self.rackgroup1 = RackGroup.objects.create(site=self.site1, name='Test Rack Group 1', slug='test-rack-group-1')
+        self.rackrole1 = RackRole.objects.create(name='Test Rack Role 1', slug='test-rack-role-1', color='ff0000')
+        self.rack1 = Rack.objects.create(
+            site=self.site1, group=self.rackgroup1, role=self.rackrole1, name='Test Rack 1', u_height=42,
+        )
+        self.rack2 = Rack.objects.create(
+            site=self.site1, group=self.rackgroup1, role=self.rackrole1, name='Test Rack 2', u_height=42,
+        )
+
+        # change the created and last_updated of one
+        Rack.objects.filter(pk=self.rack2.pk).update(
+            last_updated=datetime.datetime(2001, 2, 3, 1, 2, 3, 4, tzinfo=timezone.utc),
+            created=datetime.datetime(2001, 2, 3)
+        )
+
+    def test_get_rack_created(self):
+        url = reverse('dcim-api:rack-list')
+        response = self.client.get('{}?created=2001-02-03'.format(url), **self.header)
+
+        self.assertEqual(response.data['count'], 1)
+        self.assertEqual(response.data['results'][0]['id'], self.rack2.pk)
+
+    def test_get_rack_created_gte(self):
+        url = reverse('dcim-api:rack-list')
+        response = self.client.get('{}?created__gte=2001-02-04'.format(url), **self.header)
+
+        self.assertEqual(response.data['count'], 1)
+        self.assertEqual(response.data['results'][0]['id'], self.rack1.pk)
+
+    def test_get_rack_created_lte(self):
+        url = reverse('dcim-api:rack-list')
+        response = self.client.get('{}?created__lte=2001-02-04'.format(url), **self.header)
+
+        self.assertEqual(response.data['count'], 1)
+        self.assertEqual(response.data['results'][0]['id'], self.rack2.pk)
+
+    def test_get_rack_last_updated(self):
+        url = reverse('dcim-api:rack-list')
+        response = self.client.get('{}?last_updated=2001-02-03%2001:02:03.000004'.format(url), **self.header)
+
+        self.assertEqual(response.data['count'], 1)
+        self.assertEqual(response.data['results'][0]['id'], self.rack2.pk)
+
+    def test_get_rack_last_updated_gte(self):
+        url = reverse('dcim-api:rack-list')
+        response = self.client.get('{}?last_updated__gte=2001-02-04%2001:02:03.000004'.format(url), **self.header)
+
+        self.assertEqual(response.data['count'], 1)
+        self.assertEqual(response.data['results'][0]['id'], self.rack1.pk)
+
+    def test_get_rack_last_updated_lte(self):
+        url = reverse('dcim-api:rack-list')
+        response = self.client.get('{}?last_updated__lte=2001-02-04%2001:02:03.000004'.format(url), **self.header)
+
+        self.assertEqual(response.data['count'], 1)
+        self.assertEqual(response.data['results'][0]['id'], self.rack2.pk)

+ 34 - 0
netbox/extras/tests/test_customfields.py

@@ -301,6 +301,40 @@ class CustomFieldAPITest(APITestCase):
         cfv = self.site.custom_field_values.get(field=self.cf_select)
         self.assertEqual(cfv.value.pk, data['custom_fields']['magic_choice'])
 
+    def test_set_custom_field_defaults(self):
+        """
+        Create a new object with no custom field data. Custom field values should be created using the custom fields'
+        default values.
+        """
+        CUSTOM_FIELD_DEFAULTS = {
+            'magic_word': 'foobar',
+            'magic_number': '123',
+            'is_magic': 'true',
+            'magic_date': '2019-12-13',
+            'magic_url': 'http://example.com/',
+            'magic_choice': self.cf_select_choice1.value,
+        }
+
+        # Update CustomFields to set default values
+        for field_name, default_value in CUSTOM_FIELD_DEFAULTS.items():
+            CustomField.objects.filter(name=field_name).update(default=default_value)
+
+        data = {
+            'name': 'Test Site X',
+            'slug': 'test-site-x',
+        }
+
+        url = reverse('dcim-api:site-list')
+        response = self.client.post(url, data, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        self.assertEqual(response.data['custom_fields']['magic_word'], CUSTOM_FIELD_DEFAULTS['magic_word'])
+        self.assertEqual(response.data['custom_fields']['magic_number'], str(CUSTOM_FIELD_DEFAULTS['magic_number']))
+        self.assertEqual(response.data['custom_fields']['is_magic'], bool(CUSTOM_FIELD_DEFAULTS['is_magic']))
+        self.assertEqual(response.data['custom_fields']['magic_date'], CUSTOM_FIELD_DEFAULTS['magic_date'])
+        self.assertEqual(response.data['custom_fields']['magic_url'], CUSTOM_FIELD_DEFAULTS['magic_url'])
+        self.assertEqual(response.data['custom_fields']['magic_choice'], self.cf_select_choice1.pk)
+
 
 class CustomFieldChoiceAPITest(APITestCase):
     def setUp(self):

+ 1 - 1
netbox/extras/views.py

@@ -413,7 +413,7 @@ class ScriptView(PermissionRequiredMixin, View):
 
         if form.is_valid():
             commit = form.cleaned_data.pop('_commit')
-            output, execution_time = run_script(script, form.cleaned_data, request.FILES, commit)
+            output, execution_time = run_script(script, form.cleaned_data, request, commit)
 
         return render(request, 'extras/script.html', {
             'module': module,

+ 7 - 0
netbox/ipam/filters.py

@@ -309,6 +309,10 @@ class IPAddressFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilt
         queryset=Interface.objects.all(),
         label='Interface (ID)',
     )
+    assigned_to_interface = django_filters.BooleanFilter(
+        method='_assigned_to_interface',
+        label='Is assigned to an interface',
+    )
     status = django_filters.MultipleChoiceFilter(
         choices=IPAddressStatusChoices,
         null_value=None
@@ -366,6 +370,9 @@ class IPAddressFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilt
         except Device.DoesNotExist:
             return queryset.none()
 
+    def _assigned_to_interface(self, queryset, name, value):
+        return queryset.exclude(interface__isnull=value)
+
 
 class VLANGroupFilter(NameSlugSearchFilterSet):
     site_id = django_filters.ModelMultipleChoiceFilter(

+ 16 - 5
netbox/ipam/forms.py

@@ -9,8 +9,8 @@ from tenancy.forms import TenancyFilterForm, TenancyForm
 from tenancy.models import Tenant
 from utilities.forms import (
     add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditNullBooleanSelect, ChainedModelChoiceField,
-    CSVChoiceField, ExpandableIPAddressField, FilterChoiceField, FlexibleModelChoiceField, ReturnURLForm, SlugField,
-    StaticSelect2, StaticSelect2Multiple, BOOLEAN_WITH_BLANK_CHOICES
+    CSVChoiceField, DatePicker, ExpandableIPAddressField, FilterChoiceField, FlexibleModelChoiceField, ReturnURLForm,
+    SlugField, StaticSelect2, StaticSelect2Multiple, BOOLEAN_WITH_BLANK_CHOICES
 )
 from virtualization.models import VirtualMachine
 from .choices import *
@@ -156,12 +156,12 @@ class AggregateForm(BootstrapMixin, CustomFieldForm):
         help_texts = {
             'prefix': "IPv4 or IPv6 network",
             'rir': "Regional Internet Registry responsible for this prefix",
-            'date_added': "Format: YYYY-MM-DD",
         }
         widgets = {
             'rir': APISelect(
                 api_url="/api/ipam/rirs/"
-            )
+            ),
+            'date_added': DatePicker(),
         }
 
 
@@ -205,6 +205,9 @@ class AggregateBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd
         nullable_fields = [
             'date_added', 'description',
         ]
+        widgets = {
+            'date_added': DatePicker(),
+        }
 
 
 class AggregateFilterForm(BootstrapMixin, CustomFieldFilterForm):
@@ -935,7 +938,8 @@ class IPAddressAssignForm(BootstrapMixin, forms.Form):
 class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
     model = IPAddress
     field_order = [
-        'q', 'parent', 'family', 'mask_length', 'vrf_id', 'status', 'role', 'tenant_group', 'tenant',
+        'q', 'parent', 'family', 'mask_length', 'vrf_id', 'status', 'role', 'assigned_to_interface', 'tenant_group',
+        'tenant',
     ]
     q = forms.CharField(
         required=False,
@@ -981,6 +985,13 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterFo
         required=False,
         widget=StaticSelect2Multiple()
     )
+    assigned_to_interface = forms.NullBooleanField(
+        required=False,
+        label='Assigned to an interface',
+        widget=StaticSelect2(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
 
 
 #

+ 5 - 1
netbox/ipam/tables.py

@@ -85,7 +85,11 @@ IPADDRESS_LINK = """
 """
 
 IPADDRESS_ASSIGN_LINK = """
-<a href="{% url 'ipam:ipaddress_edit' pk=record.pk %}?interface={{ record.interface.pk }}&return_url={{ request.path }}">{{ record }}</a>
+{% if request.GET %}
+    <a href="{% url 'ipam:ipaddress_edit' pk=record.pk %}?interface={{ request.GET.interface }}&return_url={{ request.GET.return_url }}">{{ record }}</a>
+{% else %}
+    <a href="{% url 'ipam:ipaddress_edit' pk=record.pk %}?interface={{ record.interface.pk }}&return_url={{ request.path }}">{{ record }}</a>
+{% endif %}
 """
 
 IPADDRESS_PARENT = """

+ 1 - 1
netbox/netbox/settings.py

@@ -12,7 +12,7 @@ from django.core.exceptions import ImproperlyConfigured
 # Environment setup
 #
 
-VERSION = '2.7-beta1'
+VERSION = '2.7-beta2'
 
 # Hostname
 HOSTNAME = platform.node()

+ 28 - 22
netbox/netbox/views.py

@@ -1,6 +1,6 @@
 from collections import OrderedDict
 
-from django.db.models import Count, F
+from django.db.models import Count, F, OuterRef, Subquery
 from django.shortcuts import render
 from django.views.generic import View
 from rest_framework.response import Response
@@ -8,7 +8,7 @@ from rest_framework.reverse import reverse
 from rest_framework.views import APIView
 
 from circuits.filters import CircuitFilter, ProviderFilter
-from circuits.models import Circuit, Provider
+from circuits.models import Circuit, CircuitTermination, Provider
 from circuits.tables import CircuitTable, ProviderTable
 from dcim.filters import (
     CableFilter, DeviceFilter, DeviceTypeFilter, PowerFeedFilter, RackFilter, RackGroupFilter, SiteFilter,
@@ -49,9 +49,15 @@ SEARCH_TYPES = OrderedDict((
     ('circuit', {
         'permission': 'circuits.view_circuit',
         'queryset': Circuit.objects.prefetch_related(
-            'type', 'provider', 'tenant'
-        ).prefetch_related(
-            'terminations__site'
+            'type', 'provider', 'tenant', 'terminations__site'
+        ).annotate(
+            # Annotate A/Z terminations
+            a_side=Subquery(
+                CircuitTermination.objects.filter(circuit=OuterRef('pk')).filter(term_side='A').values('site__name')[:1]
+            ),
+            z_side=Subquery(
+                CircuitTermination.objects.filter(circuit=OuterRef('pk')).filter(term_side='Z').values('site__name')[:1]
+            ),
         ),
         'filter': CircuitFilter,
         'table': CircuitTable,
@@ -116,6 +122,23 @@ SEARCH_TYPES = OrderedDict((
         'table': PowerFeedTable,
         'url': 'dcim:powerfeed_list',
     }),
+    # Virtualization
+    ('cluster', {
+        'permission': 'virtualization.view_cluster',
+        'queryset': Cluster.objects.prefetch_related('type', 'group'),
+        'filter': ClusterFilter,
+        'table': ClusterTable,
+        'url': 'virtualization:cluster_list',
+    }),
+    ('virtualmachine', {
+        'permission': 'virtualization.view_virtualmachine',
+        'queryset': VirtualMachine.objects.prefetch_related(
+            'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6',
+        ),
+        'filter': VirtualMachineFilter,
+        'table': VirtualMachineDetailTable,
+        'url': 'virtualization:virtualmachine_list',
+    }),
     # IPAM
     ('vrf', {
         'permission': 'ipam.view_vrf',
@@ -168,23 +191,6 @@ SEARCH_TYPES = OrderedDict((
         'table': TenantTable,
         'url': 'tenancy:tenant_list',
     }),
-    # Virtualization
-    ('cluster', {
-        'permission': 'virtualization.view_cluster',
-        'queryset': Cluster.objects.prefetch_related('type', 'group'),
-        'filter': ClusterFilter,
-        'table': ClusterTable,
-        'url': 'virtualization:cluster_list',
-    }),
-    ('virtualmachine', {
-        'permission': 'virtualization.view_virtualmachine',
-        'queryset': VirtualMachine.objects.prefetch_related(
-            'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6',
-        ),
-        'filter': VirtualMachineFilter,
-        'table': VirtualMachineDetailTable,
-        'url': 'virtualization:virtualmachine_list',
-    }),
 ))
 
 

Diff do ficheiro suprimidas por serem muito extensas
+ 1 - 0
netbox/project-static/flatpickr-4.6.3/flatpickr.min.js


+ 784 - 0
netbox/project-static/flatpickr-4.6.3/themes/dark.css

@@ -0,0 +1,784 @@
+.flatpickr-calendar {
+  background: transparent;
+  opacity: 0;
+  display: none;
+  text-align: center;
+  visibility: hidden;
+  padding: 0;
+  -webkit-animation: none;
+          animation: none;
+  direction: ltr;
+  border: 0;
+  font-size: 14px;
+  line-height: 24px;
+  border-radius: 5px;
+  position: absolute;
+  width: 307.875px;
+  -webkit-box-sizing: border-box;
+          box-sizing: border-box;
+  -ms-touch-action: manipulation;
+      touch-action: manipulation;
+  background: #3f4458;
+  -webkit-box-shadow: 1px 0 0 #20222c, -1px 0 0 #20222c, 0 1px 0 #20222c, 0 -1px 0 #20222c, 0 3px 13px rgba(0,0,0,0.08);
+          box-shadow: 1px 0 0 #20222c, -1px 0 0 #20222c, 0 1px 0 #20222c, 0 -1px 0 #20222c, 0 3px 13px rgba(0,0,0,0.08);
+}
+.flatpickr-calendar.open,
+.flatpickr-calendar.inline {
+  opacity: 1;
+  max-height: 640px;
+  visibility: visible;
+}
+.flatpickr-calendar.open {
+  display: inline-block;
+  z-index: 99999;
+}
+.flatpickr-calendar.animate.open {
+  -webkit-animation: fpFadeInDown 300ms cubic-bezier(0.23, 1, 0.32, 1);
+          animation: fpFadeInDown 300ms cubic-bezier(0.23, 1, 0.32, 1);
+}
+.flatpickr-calendar.inline {
+  display: block;
+  position: relative;
+  top: 2px;
+}
+.flatpickr-calendar.static {
+  position: absolute;
+  top: calc(100% + 2px);
+}
+.flatpickr-calendar.static.open {
+  z-index: 999;
+  display: block;
+}
+.flatpickr-calendar.multiMonth .flatpickr-days .dayContainer:nth-child(n+1) .flatpickr-day.inRange:nth-child(7n+7) {
+  -webkit-box-shadow: none !important;
+          box-shadow: none !important;
+}
+.flatpickr-calendar.multiMonth .flatpickr-days .dayContainer:nth-child(n+2) .flatpickr-day.inRange:nth-child(7n+1) {
+  -webkit-box-shadow: -2px 0 0 #e6e6e6, 5px 0 0 #e6e6e6;
+          box-shadow: -2px 0 0 #e6e6e6, 5px 0 0 #e6e6e6;
+}
+.flatpickr-calendar .hasWeeks .dayContainer,
+.flatpickr-calendar .hasTime .dayContainer {
+  border-bottom: 0;
+  border-bottom-right-radius: 0;
+  border-bottom-left-radius: 0;
+}
+.flatpickr-calendar .hasWeeks .dayContainer {
+  border-left: 0;
+}
+.flatpickr-calendar.showTimeInput.hasTime .flatpickr-time {
+  height: 40px;
+  border-top: 1px solid #20222c;
+}
+.flatpickr-calendar.noCalendar.hasTime .flatpickr-time {
+  height: auto;
+}
+.flatpickr-calendar:before,
+.flatpickr-calendar:after {
+  position: absolute;
+  display: block;
+  pointer-events: none;
+  border: solid transparent;
+  content: '';
+  height: 0;
+  width: 0;
+  left: 22px;
+}
+.flatpickr-calendar.rightMost:before,
+.flatpickr-calendar.rightMost:after {
+  left: auto;
+  right: 22px;
+}
+.flatpickr-calendar:before {
+  border-width: 5px;
+  margin: 0 -5px;
+}
+.flatpickr-calendar:after {
+  border-width: 4px;
+  margin: 0 -4px;
+}
+.flatpickr-calendar.arrowTop:before,
+.flatpickr-calendar.arrowTop:after {
+  bottom: 100%;
+}
+.flatpickr-calendar.arrowTop:before {
+  border-bottom-color: #20222c;
+}
+.flatpickr-calendar.arrowTop:after {
+  border-bottom-color: #3f4458;
+}
+.flatpickr-calendar.arrowBottom:before,
+.flatpickr-calendar.arrowBottom:after {
+  top: 100%;
+}
+.flatpickr-calendar.arrowBottom:before {
+  border-top-color: #20222c;
+}
+.flatpickr-calendar.arrowBottom:after {
+  border-top-color: #3f4458;
+}
+.flatpickr-calendar:focus {
+  outline: 0;
+}
+.flatpickr-wrapper {
+  position: relative;
+  display: inline-block;
+}
+.flatpickr-months {
+  display: -webkit-box;
+  display: -webkit-flex;
+  display: -ms-flexbox;
+  display: flex;
+}
+.flatpickr-months .flatpickr-month {
+  background: #3f4458;
+  color: #fff;
+  fill: #fff;
+  height: 34px;
+  line-height: 1;
+  text-align: center;
+  position: relative;
+  -webkit-user-select: none;
+     -moz-user-select: none;
+      -ms-user-select: none;
+          user-select: none;
+  overflow: hidden;
+  -webkit-box-flex: 1;
+  -webkit-flex: 1;
+      -ms-flex: 1;
+          flex: 1;
+}
+.flatpickr-months .flatpickr-prev-month,
+.flatpickr-months .flatpickr-next-month {
+  text-decoration: none;
+  cursor: pointer;
+  position: absolute;
+  top: 0;
+  height: 34px;
+  padding: 10px;
+  z-index: 3;
+  color: #fff;
+  fill: #fff;
+}
+.flatpickr-months .flatpickr-prev-month.flatpickr-disabled,
+.flatpickr-months .flatpickr-next-month.flatpickr-disabled {
+  display: none;
+}
+.flatpickr-months .flatpickr-prev-month i,
+.flatpickr-months .flatpickr-next-month i {
+  position: relative;
+}
+.flatpickr-months .flatpickr-prev-month.flatpickr-prev-month,
+.flatpickr-months .flatpickr-next-month.flatpickr-prev-month {
+/*
+      /*rtl:begin:ignore*/
+/*
+      */
+  left: 0;
+/*
+      /*rtl:end:ignore*/
+/*
+      */
+}
+/*
+      /*rtl:begin:ignore*/
+/*
+      /*rtl:end:ignore*/
+.flatpickr-months .flatpickr-prev-month.flatpickr-next-month,
+.flatpickr-months .flatpickr-next-month.flatpickr-next-month {
+/*
+      /*rtl:begin:ignore*/
+/*
+      */
+  right: 0;
+/*
+      /*rtl:end:ignore*/
+/*
+      */
+}
+/*
+      /*rtl:begin:ignore*/
+/*
+      /*rtl:end:ignore*/
+.flatpickr-months .flatpickr-prev-month:hover,
+.flatpickr-months .flatpickr-next-month:hover {
+  color: #eee;
+}
+.flatpickr-months .flatpickr-prev-month:hover svg,
+.flatpickr-months .flatpickr-next-month:hover svg {
+  fill: #f64747;
+}
+.flatpickr-months .flatpickr-prev-month svg,
+.flatpickr-months .flatpickr-next-month svg {
+  width: 14px;
+  height: 14px;
+}
+.flatpickr-months .flatpickr-prev-month svg path,
+.flatpickr-months .flatpickr-next-month svg path {
+  -webkit-transition: fill 0.1s;
+  transition: fill 0.1s;
+  fill: inherit;
+}
+.numInputWrapper {
+  position: relative;
+  height: auto;
+}
+.numInputWrapper input,
+.numInputWrapper span {
+  display: inline-block;
+}
+.numInputWrapper input {
+  width: 100%;
+}
+.numInputWrapper input::-ms-clear {
+  display: none;
+}
+.numInputWrapper input::-webkit-outer-spin-button,
+.numInputWrapper input::-webkit-inner-spin-button {
+  margin: 0;
+  -webkit-appearance: none;
+}
+.numInputWrapper span {
+  position: absolute;
+  right: 0;
+  width: 14px;
+  padding: 0 4px 0 2px;
+  height: 50%;
+  line-height: 50%;
+  opacity: 0;
+  cursor: pointer;
+  border: 1px solid rgba(255,255,255,0.15);
+  -webkit-box-sizing: border-box;
+          box-sizing: border-box;
+}
+.numInputWrapper span:hover {
+  background: rgba(192,187,167,0.1);
+}
+.numInputWrapper span:active {
+  background: rgba(192,187,167,0.2);
+}
+.numInputWrapper span:after {
+  display: block;
+  content: "";
+  position: absolute;
+}
+.numInputWrapper span.arrowUp {
+  top: 0;
+  border-bottom: 0;
+}
+.numInputWrapper span.arrowUp:after {
+  border-left: 4px solid transparent;
+  border-right: 4px solid transparent;
+  border-bottom: 4px solid rgba(255,255,255,0.6);
+  top: 26%;
+}
+.numInputWrapper span.arrowDown {
+  top: 50%;
+}
+.numInputWrapper span.arrowDown:after {
+  border-left: 4px solid transparent;
+  border-right: 4px solid transparent;
+  border-top: 4px solid rgba(255,255,255,0.6);
+  top: 40%;
+}
+.numInputWrapper span svg {
+  width: inherit;
+  height: auto;
+}
+.numInputWrapper span svg path {
+  fill: rgba(255,255,255,0.5);
+}
+.numInputWrapper:hover {
+  background: rgba(192,187,167,0.05);
+}
+.numInputWrapper:hover span {
+  opacity: 1;
+}
+.flatpickr-current-month {
+  font-size: 135%;
+  line-height: inherit;
+  font-weight: 300;
+  color: inherit;
+  position: absolute;
+  width: 75%;
+  left: 12.5%;
+  padding: 7.48px 0 0 0;
+  line-height: 1;
+  height: 34px;
+  display: inline-block;
+  text-align: center;
+  -webkit-transform: translate3d(0px, 0px, 0px);
+          transform: translate3d(0px, 0px, 0px);
+}
+.flatpickr-current-month span.cur-month {
+  font-family: inherit;
+  font-weight: 700;
+  color: inherit;
+  display: inline-block;
+  margin-left: 0.5ch;
+  padding: 0;
+}
+.flatpickr-current-month span.cur-month:hover {
+  background: rgba(192,187,167,0.05);
+}
+.flatpickr-current-month .numInputWrapper {
+  width: 6ch;
+  width: 7ch\0;
+  display: inline-block;
+}
+.flatpickr-current-month .numInputWrapper span.arrowUp:after {
+  border-bottom-color: #fff;
+}
+.flatpickr-current-month .numInputWrapper span.arrowDown:after {
+  border-top-color: #fff;
+}
+.flatpickr-current-month input.cur-year {
+  background: transparent;
+  -webkit-box-sizing: border-box;
+          box-sizing: border-box;
+  color: inherit;
+  cursor: text;
+  padding: 0 0 0 0.5ch;
+  margin: 0;
+  display: inline-block;
+  font-size: inherit;
+  font-family: inherit;
+  font-weight: 300;
+  line-height: inherit;
+  height: auto;
+  border: 0;
+  border-radius: 0;
+  vertical-align: initial;
+  -webkit-appearance: textfield;
+  -moz-appearance: textfield;
+  appearance: textfield;
+}
+.flatpickr-current-month input.cur-year:focus {
+  outline: 0;
+}
+.flatpickr-current-month input.cur-year[disabled],
+.flatpickr-current-month input.cur-year[disabled]:hover {
+  font-size: 100%;
+  color: rgba(255,255,255,0.5);
+  background: transparent;
+  pointer-events: none;
+}
+.flatpickr-current-month .flatpickr-monthDropdown-months {
+  appearance: menulist;
+  background: #3f4458;
+  border: none;
+  border-radius: 0;
+  box-sizing: border-box;
+  color: inherit;
+  cursor: pointer;
+  font-size: inherit;
+  font-family: inherit;
+  font-weight: 300;
+  height: auto;
+  line-height: inherit;
+  margin: -1px 0 0 0;
+  outline: none;
+  padding: 0 0 0 0.5ch;
+  position: relative;
+  vertical-align: initial;
+  -webkit-box-sizing: border-box;
+  -webkit-appearance: menulist;
+  -moz-appearance: menulist;
+  width: auto;
+}
+.flatpickr-current-month .flatpickr-monthDropdown-months:focus,
+.flatpickr-current-month .flatpickr-monthDropdown-months:active {
+  outline: none;
+}
+.flatpickr-current-month .flatpickr-monthDropdown-months:hover {
+  background: rgba(192,187,167,0.05);
+}
+.flatpickr-current-month .flatpickr-monthDropdown-months .flatpickr-monthDropdown-month {
+  background-color: #3f4458;
+  outline: none;
+  padding: 0;
+}
+.flatpickr-weekdays {
+  background: transparent;
+  text-align: center;
+  overflow: hidden;
+  width: 100%;
+  display: -webkit-box;
+  display: -webkit-flex;
+  display: -ms-flexbox;
+  display: flex;
+  -webkit-box-align: center;
+  -webkit-align-items: center;
+      -ms-flex-align: center;
+          align-items: center;
+  height: 28px;
+}
+.flatpickr-weekdays .flatpickr-weekdaycontainer {
+  display: -webkit-box;
+  display: -webkit-flex;
+  display: -ms-flexbox;
+  display: flex;
+  -webkit-box-flex: 1;
+  -webkit-flex: 1;
+      -ms-flex: 1;
+          flex: 1;
+}
+span.flatpickr-weekday {
+  cursor: default;
+  font-size: 90%;
+  background: #3f4458;
+  color: #fff;
+  line-height: 1;
+  margin: 0;
+  text-align: center;
+  display: block;
+  -webkit-box-flex: 1;
+  -webkit-flex: 1;
+      -ms-flex: 1;
+          flex: 1;
+  font-weight: bolder;
+}
+.dayContainer,
+.flatpickr-weeks {
+  padding: 1px 0 0 0;
+}
+.flatpickr-days {
+  position: relative;
+  overflow: hidden;
+  display: -webkit-box;
+  display: -webkit-flex;
+  display: -ms-flexbox;
+  display: flex;
+  -webkit-box-align: start;
+  -webkit-align-items: flex-start;
+      -ms-flex-align: start;
+          align-items: flex-start;
+  width: 307.875px;
+}
+.flatpickr-days:focus {
+  outline: 0;
+}
+.dayContainer {
+  padding: 0;
+  outline: 0;
+  text-align: left;
+  width: 307.875px;
+  min-width: 307.875px;
+  max-width: 307.875px;
+  -webkit-box-sizing: border-box;
+          box-sizing: border-box;
+  display: inline-block;
+  display: -ms-flexbox;
+  display: -webkit-box;
+  display: -webkit-flex;
+  display: flex;
+  -webkit-flex-wrap: wrap;
+          flex-wrap: wrap;
+  -ms-flex-wrap: wrap;
+  -ms-flex-pack: justify;
+  -webkit-justify-content: space-around;
+          justify-content: space-around;
+  -webkit-transform: translate3d(0px, 0px, 0px);
+          transform: translate3d(0px, 0px, 0px);
+  opacity: 1;
+}
+.dayContainer + .dayContainer {
+  -webkit-box-shadow: -1px 0 0 #20222c;
+          box-shadow: -1px 0 0 #20222c;
+}
+.flatpickr-day {
+  background: none;
+  border: 1px solid transparent;
+  border-radius: 150px;
+  -webkit-box-sizing: border-box;
+          box-sizing: border-box;
+  color: rgba(255,255,255,0.95);
+  cursor: pointer;
+  font-weight: 400;
+  width: 14.2857143%;
+  -webkit-flex-basis: 14.2857143%;
+      -ms-flex-preferred-size: 14.2857143%;
+          flex-basis: 14.2857143%;
+  max-width: 39px;
+  height: 39px;
+  line-height: 39px;
+  margin: 0;
+  display: inline-block;
+  position: relative;
+  -webkit-box-pack: center;
+  -webkit-justify-content: center;
+      -ms-flex-pack: center;
+          justify-content: center;
+  text-align: center;
+}
+.flatpickr-day.inRange,
+.flatpickr-day.prevMonthDay.inRange,
+.flatpickr-day.nextMonthDay.inRange,
+.flatpickr-day.today.inRange,
+.flatpickr-day.prevMonthDay.today.inRange,
+.flatpickr-day.nextMonthDay.today.inRange,
+.flatpickr-day:hover,
+.flatpickr-day.prevMonthDay:hover,
+.flatpickr-day.nextMonthDay:hover,
+.flatpickr-day:focus,
+.flatpickr-day.prevMonthDay:focus,
+.flatpickr-day.nextMonthDay:focus {
+  cursor: pointer;
+  outline: 0;
+  background: #646c8c;
+  border-color: #646c8c;
+}
+.flatpickr-day.today {
+  border-color: #eee;
+}
+.flatpickr-day.today:hover,
+.flatpickr-day.today:focus {
+  border-color: #eee;
+  background: #eee;
+  color: #3f4458;
+}
+.flatpickr-day.selected,
+.flatpickr-day.startRange,
+.flatpickr-day.endRange,
+.flatpickr-day.selected.inRange,
+.flatpickr-day.startRange.inRange,
+.flatpickr-day.endRange.inRange,
+.flatpickr-day.selected:focus,
+.flatpickr-day.startRange:focus,
+.flatpickr-day.endRange:focus,
+.flatpickr-day.selected:hover,
+.flatpickr-day.startRange:hover,
+.flatpickr-day.endRange:hover,
+.flatpickr-day.selected.prevMonthDay,
+.flatpickr-day.startRange.prevMonthDay,
+.flatpickr-day.endRange.prevMonthDay,
+.flatpickr-day.selected.nextMonthDay,
+.flatpickr-day.startRange.nextMonthDay,
+.flatpickr-day.endRange.nextMonthDay {
+  background: #80cbc4;
+  -webkit-box-shadow: none;
+          box-shadow: none;
+  color: #fff;
+  border-color: #80cbc4;
+}
+.flatpickr-day.selected.startRange,
+.flatpickr-day.startRange.startRange,
+.flatpickr-day.endRange.startRange {
+  border-radius: 50px 0 0 50px;
+}
+.flatpickr-day.selected.endRange,
+.flatpickr-day.startRange.endRange,
+.flatpickr-day.endRange.endRange {
+  border-radius: 0 50px 50px 0;
+}
+.flatpickr-day.selected.startRange + .endRange:not(:nth-child(7n+1)),
+.flatpickr-day.startRange.startRange + .endRange:not(:nth-child(7n+1)),
+.flatpickr-day.endRange.startRange + .endRange:not(:nth-child(7n+1)) {
+  -webkit-box-shadow: -10px 0 0 #80cbc4;
+          box-shadow: -10px 0 0 #80cbc4;
+}
+.flatpickr-day.selected.startRange.endRange,
+.flatpickr-day.startRange.startRange.endRange,
+.flatpickr-day.endRange.startRange.endRange {
+  border-radius: 50px;
+}
+.flatpickr-day.inRange {
+  border-radius: 0;
+  -webkit-box-shadow: -5px 0 0 #646c8c, 5px 0 0 #646c8c;
+          box-shadow: -5px 0 0 #646c8c, 5px 0 0 #646c8c;
+}
+.flatpickr-day.flatpickr-disabled,
+.flatpickr-day.flatpickr-disabled:hover,
+.flatpickr-day.prevMonthDay,
+.flatpickr-day.nextMonthDay,
+.flatpickr-day.notAllowed,
+.flatpickr-day.notAllowed.prevMonthDay,
+.flatpickr-day.notAllowed.nextMonthDay {
+  color: rgba(255,255,255,0.3);
+  background: transparent;
+  border-color: transparent;
+  cursor: default;
+}
+.flatpickr-day.flatpickr-disabled,
+.flatpickr-day.flatpickr-disabled:hover {
+  cursor: not-allowed;
+  color: rgba(255,255,255,0.1);
+}
+.flatpickr-day.week.selected {
+  border-radius: 0;
+  -webkit-box-shadow: -5px 0 0 #80cbc4, 5px 0 0 #80cbc4;
+          box-shadow: -5px 0 0 #80cbc4, 5px 0 0 #80cbc4;
+}
+.flatpickr-day.hidden {
+  visibility: hidden;
+}
+.rangeMode .flatpickr-day {
+  margin-top: 1px;
+}
+.flatpickr-weekwrapper {
+  float: left;
+}
+.flatpickr-weekwrapper .flatpickr-weeks {
+  padding: 0 12px;
+  -webkit-box-shadow: 1px 0 0 #20222c;
+          box-shadow: 1px 0 0 #20222c;
+}
+.flatpickr-weekwrapper .flatpickr-weekday {
+  float: none;
+  width: 100%;
+  line-height: 28px;
+}
+.flatpickr-weekwrapper span.flatpickr-day,
+.flatpickr-weekwrapper span.flatpickr-day:hover {
+  display: block;
+  width: 100%;
+  max-width: none;
+  color: rgba(255,255,255,0.3);
+  background: transparent;
+  cursor: default;
+  border: none;
+}
+.flatpickr-innerContainer {
+  display: block;
+  display: -webkit-box;
+  display: -webkit-flex;
+  display: -ms-flexbox;
+  display: flex;
+  -webkit-box-sizing: border-box;
+          box-sizing: border-box;
+  overflow: hidden;
+}
+.flatpickr-rContainer {
+  display: inline-block;
+  padding: 0;
+  -webkit-box-sizing: border-box;
+          box-sizing: border-box;
+}
+.flatpickr-time {
+  text-align: center;
+  outline: 0;
+  display: block;
+  height: 0;
+  line-height: 40px;
+  max-height: 40px;
+  -webkit-box-sizing: border-box;
+          box-sizing: border-box;
+  overflow: hidden;
+  display: -webkit-box;
+  display: -webkit-flex;
+  display: -ms-flexbox;
+  display: flex;
+}
+.flatpickr-time:after {
+  content: "";
+  display: table;
+  clear: both;
+}
+.flatpickr-time .numInputWrapper {
+  -webkit-box-flex: 1;
+  -webkit-flex: 1;
+      -ms-flex: 1;
+          flex: 1;
+  width: 40%;
+  height: 40px;
+  float: left;
+}
+.flatpickr-time .numInputWrapper span.arrowUp:after {
+  border-bottom-color: rgba(255,255,255,0.95);
+}
+.flatpickr-time .numInputWrapper span.arrowDown:after {
+  border-top-color: rgba(255,255,255,0.95);
+}
+.flatpickr-time.hasSeconds .numInputWrapper {
+  width: 26%;
+}
+.flatpickr-time.time24hr .numInputWrapper {
+  width: 49%;
+}
+.flatpickr-time input {
+  background: transparent;
+  -webkit-box-shadow: none;
+          box-shadow: none;
+  border: 0;
+  border-radius: 0;
+  text-align: center;
+  margin: 0;
+  padding: 0;
+  height: inherit;
+  line-height: inherit;
+  color: rgba(255,255,255,0.95);
+  font-size: 14px;
+  position: relative;
+  -webkit-box-sizing: border-box;
+          box-sizing: border-box;
+  -webkit-appearance: textfield;
+  -moz-appearance: textfield;
+  appearance: textfield;
+}
+.flatpickr-time input.flatpickr-hour {
+  font-weight: bold;
+}
+.flatpickr-time input.flatpickr-minute,
+.flatpickr-time input.flatpickr-second {
+  font-weight: 400;
+}
+.flatpickr-time input:focus {
+  outline: 0;
+  border: 0;
+}
+.flatpickr-time .flatpickr-time-separator,
+.flatpickr-time .flatpickr-am-pm {
+  height: inherit;
+  float: left;
+  line-height: inherit;
+  color: rgba(255,255,255,0.95);
+  font-weight: bold;
+  width: 2%;
+  -webkit-user-select: none;
+     -moz-user-select: none;
+      -ms-user-select: none;
+          user-select: none;
+  -webkit-align-self: center;
+      -ms-flex-item-align: center;
+          align-self: center;
+}
+.flatpickr-time .flatpickr-am-pm {
+  outline: 0;
+  width: 18%;
+  cursor: pointer;
+  text-align: center;
+  font-weight: 400;
+}
+.flatpickr-time input:hover,
+.flatpickr-time .flatpickr-am-pm:hover,
+.flatpickr-time input:focus,
+.flatpickr-time .flatpickr-am-pm:focus {
+  background: #6a7395;
+}
+.flatpickr-input[readonly] {
+  cursor: pointer;
+}
+@-webkit-keyframes fpFadeInDown {
+  from {
+    opacity: 0;
+    -webkit-transform: translate3d(0, -20px, 0);
+            transform: translate3d(0, -20px, 0);
+  }
+  to {
+    opacity: 1;
+    -webkit-transform: translate3d(0, 0, 0);
+            transform: translate3d(0, 0, 0);
+  }
+}
+@keyframes fpFadeInDown {
+  from {
+    opacity: 0;
+    -webkit-transform: translate3d(0, -20px, 0);
+            transform: translate3d(0, -20px, 0);
+  }
+  to {
+    opacity: 1;
+    -webkit-transform: translate3d(0, 0, 0);
+            transform: translate3d(0, 0, 0);
+  }
+}

+ 798 - 0
netbox/project-static/flatpickr-4.6.3/themes/light.css

@@ -0,0 +1,798 @@
+.flatpickr-calendar {
+  background: transparent;
+  opacity: 0;
+  display: none;
+  text-align: center;
+  visibility: hidden;
+  padding: 0;
+  -webkit-animation: none;
+          animation: none;
+  direction: ltr;
+  border: 0;
+  font-size: 14px;
+  line-height: 24px;
+  border-radius: 5px;
+  position: absolute;
+  width: 307.875px;
+  -webkit-box-sizing: border-box;
+          box-sizing: border-box;
+  -ms-touch-action: manipulation;
+      touch-action: manipulation;
+  -webkit-box-shadow: 0 3px 13px rgba(0,0,0,0.08);
+          box-shadow: 0 3px 13px rgba(0,0,0,0.08);
+}
+.flatpickr-calendar.open,
+.flatpickr-calendar.inline {
+  opacity: 1;
+  max-height: 640px;
+  visibility: visible;
+}
+.flatpickr-calendar.open {
+  display: inline-block;
+  z-index: 99999;
+}
+.flatpickr-calendar.animate.open {
+  -webkit-animation: fpFadeInDown 300ms cubic-bezier(0.23, 1, 0.32, 1);
+          animation: fpFadeInDown 300ms cubic-bezier(0.23, 1, 0.32, 1);
+}
+.flatpickr-calendar.inline {
+  display: block;
+  position: relative;
+  top: 2px;
+}
+.flatpickr-calendar.static {
+  position: absolute;
+  top: calc(100% + 2px);
+}
+.flatpickr-calendar.static.open {
+  z-index: 999;
+  display: block;
+}
+.flatpickr-calendar.multiMonth .flatpickr-days .dayContainer:nth-child(n+1) .flatpickr-day.inRange:nth-child(7n+7) {
+  -webkit-box-shadow: none !important;
+          box-shadow: none !important;
+}
+.flatpickr-calendar.multiMonth .flatpickr-days .dayContainer:nth-child(n+2) .flatpickr-day.inRange:nth-child(7n+1) {
+  -webkit-box-shadow: -2px 0 0 #e6e6e6, 5px 0 0 #e6e6e6;
+          box-shadow: -2px 0 0 #e6e6e6, 5px 0 0 #e6e6e6;
+}
+.flatpickr-calendar .hasWeeks .dayContainer,
+.flatpickr-calendar .hasTime .dayContainer {
+  border-bottom: 0;
+  border-bottom-right-radius: 0;
+  border-bottom-left-radius: 0;
+}
+.flatpickr-calendar .hasWeeks .dayContainer {
+  border-left: 0;
+}
+.flatpickr-calendar.showTimeInput.hasTime .flatpickr-time {
+  height: 40px;
+  border-top: 1px solid #eceef1;
+}
+.flatpickr-calendar.showTimeInput.hasTime .flatpickr-innerContainer {
+  border-bottom: 0;
+}
+.flatpickr-calendar.showTimeInput.hasTime .flatpickr-time {
+  border: 1px solid #eceef1;
+}
+.flatpickr-calendar.noCalendar.hasTime .flatpickr-time {
+  height: auto;
+}
+.flatpickr-calendar:before,
+.flatpickr-calendar:after {
+  position: absolute;
+  display: block;
+  pointer-events: none;
+  border: solid transparent;
+  content: '';
+  height: 0;
+  width: 0;
+  left: 22px;
+}
+.flatpickr-calendar.rightMost:before,
+.flatpickr-calendar.rightMost:after {
+  left: auto;
+  right: 22px;
+}
+.flatpickr-calendar:before {
+  border-width: 5px;
+  margin: 0 -5px;
+}
+.flatpickr-calendar:after {
+  border-width: 4px;
+  margin: 0 -4px;
+}
+.flatpickr-calendar.arrowTop:before,
+.flatpickr-calendar.arrowTop:after {
+  bottom: 100%;
+}
+.flatpickr-calendar.arrowTop:before {
+  border-bottom-color: #eceef1;
+}
+.flatpickr-calendar.arrowTop:after {
+  border-bottom-color: #eceef1;
+}
+.flatpickr-calendar.arrowBottom:before,
+.flatpickr-calendar.arrowBottom:after {
+  top: 100%;
+}
+.flatpickr-calendar.arrowBottom:before {
+  border-top-color: #eceef1;
+}
+.flatpickr-calendar.arrowBottom:after {
+  border-top-color: #eceef1;
+}
+.flatpickr-calendar:focus {
+  outline: 0;
+}
+.flatpickr-wrapper {
+  position: relative;
+  display: inline-block;
+}
+.flatpickr-months {
+  display: -webkit-box;
+  display: -webkit-flex;
+  display: -ms-flexbox;
+  display: flex;
+}
+.flatpickr-months .flatpickr-month {
+  border-radius: 5px 5px 0 0;
+  background: #eceef1;
+  color: #5a6171;
+  fill: #5a6171;
+  height: 34px;
+  line-height: 1;
+  text-align: center;
+  position: relative;
+  -webkit-user-select: none;
+     -moz-user-select: none;
+      -ms-user-select: none;
+          user-select: none;
+  overflow: hidden;
+  -webkit-box-flex: 1;
+  -webkit-flex: 1;
+      -ms-flex: 1;
+          flex: 1;
+}
+.flatpickr-months .flatpickr-prev-month,
+.flatpickr-months .flatpickr-next-month {
+  text-decoration: none;
+  cursor: pointer;
+  position: absolute;
+  top: 0;
+  height: 34px;
+  padding: 10px;
+  z-index: 3;
+  color: #5a6171;
+  fill: #5a6171;
+}
+.flatpickr-months .flatpickr-prev-month.flatpickr-disabled,
+.flatpickr-months .flatpickr-next-month.flatpickr-disabled {
+  display: none;
+}
+.flatpickr-months .flatpickr-prev-month i,
+.flatpickr-months .flatpickr-next-month i {
+  position: relative;
+}
+.flatpickr-months .flatpickr-prev-month.flatpickr-prev-month,
+.flatpickr-months .flatpickr-next-month.flatpickr-prev-month {
+/*
+      /*rtl:begin:ignore*/
+/*
+      */
+  left: 0;
+/*
+      /*rtl:end:ignore*/
+/*
+      */
+}
+/*
+      /*rtl:begin:ignore*/
+/*
+      /*rtl:end:ignore*/
+.flatpickr-months .flatpickr-prev-month.flatpickr-next-month,
+.flatpickr-months .flatpickr-next-month.flatpickr-next-month {
+/*
+      /*rtl:begin:ignore*/
+/*
+      */
+  right: 0;
+/*
+      /*rtl:end:ignore*/
+/*
+      */
+}
+/*
+      /*rtl:begin:ignore*/
+/*
+      /*rtl:end:ignore*/
+.flatpickr-months .flatpickr-prev-month:hover,
+.flatpickr-months .flatpickr-next-month:hover {
+  color: #bbb;
+}
+.flatpickr-months .flatpickr-prev-month:hover svg,
+.flatpickr-months .flatpickr-next-month:hover svg {
+  fill: #f64747;
+}
+.flatpickr-months .flatpickr-prev-month svg,
+.flatpickr-months .flatpickr-next-month svg {
+  width: 14px;
+  height: 14px;
+}
+.flatpickr-months .flatpickr-prev-month svg path,
+.flatpickr-months .flatpickr-next-month svg path {
+  -webkit-transition: fill 0.1s;
+  transition: fill 0.1s;
+  fill: inherit;
+}
+.numInputWrapper {
+  position: relative;
+  height: auto;
+}
+.numInputWrapper input,
+.numInputWrapper span {
+  display: inline-block;
+}
+.numInputWrapper input {
+  width: 100%;
+}
+.numInputWrapper input::-ms-clear {
+  display: none;
+}
+.numInputWrapper input::-webkit-outer-spin-button,
+.numInputWrapper input::-webkit-inner-spin-button {
+  margin: 0;
+  -webkit-appearance: none;
+}
+.numInputWrapper span {
+  position: absolute;
+  right: 0;
+  width: 14px;
+  padding: 0 4px 0 2px;
+  height: 50%;
+  line-height: 50%;
+  opacity: 0;
+  cursor: pointer;
+  border: 1px solid rgba(72,72,72,0.15);
+  -webkit-box-sizing: border-box;
+          box-sizing: border-box;
+}
+.numInputWrapper span:hover {
+  background: rgba(0,0,0,0.1);
+}
+.numInputWrapper span:active {
+  background: rgba(0,0,0,0.2);
+}
+.numInputWrapper span:after {
+  display: block;
+  content: "";
+  position: absolute;
+}
+.numInputWrapper span.arrowUp {
+  top: 0;
+  border-bottom: 0;
+}
+.numInputWrapper span.arrowUp:after {
+  border-left: 4px solid transparent;
+  border-right: 4px solid transparent;
+  border-bottom: 4px solid rgba(72,72,72,0.6);
+  top: 26%;
+}
+.numInputWrapper span.arrowDown {
+  top: 50%;
+}
+.numInputWrapper span.arrowDown:after {
+  border-left: 4px solid transparent;
+  border-right: 4px solid transparent;
+  border-top: 4px solid rgba(72,72,72,0.6);
+  top: 40%;
+}
+.numInputWrapper span svg {
+  width: inherit;
+  height: auto;
+}
+.numInputWrapper span svg path {
+  fill: rgba(90,97,113,0.5);
+}
+.numInputWrapper:hover {
+  background: rgba(0,0,0,0.05);
+}
+.numInputWrapper:hover span {
+  opacity: 1;
+}
+.flatpickr-current-month {
+  font-size: 135%;
+  line-height: inherit;
+  font-weight: 300;
+  color: inherit;
+  position: absolute;
+  width: 75%;
+  left: 12.5%;
+  padding: 7.48px 0 0 0;
+  line-height: 1;
+  height: 34px;
+  display: inline-block;
+  text-align: center;
+  -webkit-transform: translate3d(0px, 0px, 0px);
+          transform: translate3d(0px, 0px, 0px);
+}
+.flatpickr-current-month span.cur-month {
+  font-family: inherit;
+  font-weight: 700;
+  color: inherit;
+  display: inline-block;
+  margin-left: 0.5ch;
+  padding: 0;
+}
+.flatpickr-current-month span.cur-month:hover {
+  background: rgba(0,0,0,0.05);
+}
+.flatpickr-current-month .numInputWrapper {
+  width: 6ch;
+  width: 7ch\0;
+  display: inline-block;
+}
+.flatpickr-current-month .numInputWrapper span.arrowUp:after {
+  border-bottom-color: #5a6171;
+}
+.flatpickr-current-month .numInputWrapper span.arrowDown:after {
+  border-top-color: #5a6171;
+}
+.flatpickr-current-month input.cur-year {
+  background: transparent;
+  -webkit-box-sizing: border-box;
+          box-sizing: border-box;
+  color: inherit;
+  cursor: text;
+  padding: 0 0 0 0.5ch;
+  margin: 0;
+  display: inline-block;
+  font-size: inherit;
+  font-family: inherit;
+  font-weight: 300;
+  line-height: inherit;
+  height: auto;
+  border: 0;
+  border-radius: 0;
+  vertical-align: initial;
+  -webkit-appearance: textfield;
+  -moz-appearance: textfield;
+  appearance: textfield;
+}
+.flatpickr-current-month input.cur-year:focus {
+  outline: 0;
+}
+.flatpickr-current-month input.cur-year[disabled],
+.flatpickr-current-month input.cur-year[disabled]:hover {
+  font-size: 100%;
+  color: rgba(90,97,113,0.5);
+  background: transparent;
+  pointer-events: none;
+}
+.flatpickr-current-month .flatpickr-monthDropdown-months {
+  appearance: menulist;
+  background: #eceef1;
+  border: none;
+  border-radius: 0;
+  box-sizing: border-box;
+  color: inherit;
+  cursor: pointer;
+  font-size: inherit;
+  font-family: inherit;
+  font-weight: 300;
+  height: auto;
+  line-height: inherit;
+  margin: -1px 0 0 0;
+  outline: none;
+  padding: 0 0 0 0.5ch;
+  position: relative;
+  vertical-align: initial;
+  -webkit-box-sizing: border-box;
+  -webkit-appearance: menulist;
+  -moz-appearance: menulist;
+  width: auto;
+}
+.flatpickr-current-month .flatpickr-monthDropdown-months:focus,
+.flatpickr-current-month .flatpickr-monthDropdown-months:active {
+  outline: none;
+}
+.flatpickr-current-month .flatpickr-monthDropdown-months:hover {
+  background: rgba(0,0,0,0.05);
+}
+.flatpickr-current-month .flatpickr-monthDropdown-months .flatpickr-monthDropdown-month {
+  background-color: #eceef1;
+  outline: none;
+  padding: 0;
+}
+.flatpickr-weekdays {
+  background: #eceef1;
+  text-align: center;
+  overflow: hidden;
+  width: 100%;
+  display: -webkit-box;
+  display: -webkit-flex;
+  display: -ms-flexbox;
+  display: flex;
+  -webkit-box-align: center;
+  -webkit-align-items: center;
+      -ms-flex-align: center;
+          align-items: center;
+  height: 28px;
+}
+.flatpickr-weekdays .flatpickr-weekdaycontainer {
+  display: -webkit-box;
+  display: -webkit-flex;
+  display: -ms-flexbox;
+  display: flex;
+  -webkit-box-flex: 1;
+  -webkit-flex: 1;
+      -ms-flex: 1;
+          flex: 1;
+}
+span.flatpickr-weekday {
+  cursor: default;
+  font-size: 90%;
+  background: #eceef1;
+  color: #5a6171;
+  line-height: 1;
+  margin: 0;
+  text-align: center;
+  display: block;
+  -webkit-box-flex: 1;
+  -webkit-flex: 1;
+      -ms-flex: 1;
+          flex: 1;
+  font-weight: bolder;
+}
+.dayContainer,
+.flatpickr-weeks {
+  padding: 1px 0 0 0;
+}
+.flatpickr-days {
+  position: relative;
+  overflow: hidden;
+  display: -webkit-box;
+  display: -webkit-flex;
+  display: -ms-flexbox;
+  display: flex;
+  -webkit-box-align: start;
+  -webkit-align-items: flex-start;
+      -ms-flex-align: start;
+          align-items: flex-start;
+  width: 307.875px;
+  border-left: 1px solid #eceef1;
+  border-right: 1px solid #eceef1;
+}
+.flatpickr-days:focus {
+  outline: 0;
+}
+.dayContainer {
+  padding: 0;
+  outline: 0;
+  text-align: left;
+  width: 307.875px;
+  min-width: 307.875px;
+  max-width: 307.875px;
+  -webkit-box-sizing: border-box;
+          box-sizing: border-box;
+  display: inline-block;
+  display: -ms-flexbox;
+  display: -webkit-box;
+  display: -webkit-flex;
+  display: flex;
+  -webkit-flex-wrap: wrap;
+          flex-wrap: wrap;
+  -ms-flex-wrap: wrap;
+  -ms-flex-pack: justify;
+  -webkit-justify-content: space-around;
+          justify-content: space-around;
+  -webkit-transform: translate3d(0px, 0px, 0px);
+          transform: translate3d(0px, 0px, 0px);
+  opacity: 1;
+}
+.dayContainer + .dayContainer {
+  -webkit-box-shadow: -1px 0 0 #eceef1;
+          box-shadow: -1px 0 0 #eceef1;
+}
+.flatpickr-day {
+  background: none;
+  border: 1px solid transparent;
+  border-radius: 150px;
+  -webkit-box-sizing: border-box;
+          box-sizing: border-box;
+  color: #484848;
+  cursor: pointer;
+  font-weight: 400;
+  width: 14.2857143%;
+  -webkit-flex-basis: 14.2857143%;
+      -ms-flex-preferred-size: 14.2857143%;
+          flex-basis: 14.2857143%;
+  max-width: 39px;
+  height: 39px;
+  line-height: 39px;
+  margin: 0;
+  display: inline-block;
+  position: relative;
+  -webkit-box-pack: center;
+  -webkit-justify-content: center;
+      -ms-flex-pack: center;
+          justify-content: center;
+  text-align: center;
+}
+.flatpickr-day.inRange,
+.flatpickr-day.prevMonthDay.inRange,
+.flatpickr-day.nextMonthDay.inRange,
+.flatpickr-day.today.inRange,
+.flatpickr-day.prevMonthDay.today.inRange,
+.flatpickr-day.nextMonthDay.today.inRange,
+.flatpickr-day:hover,
+.flatpickr-day.prevMonthDay:hover,
+.flatpickr-day.nextMonthDay:hover,
+.flatpickr-day:focus,
+.flatpickr-day.prevMonthDay:focus,
+.flatpickr-day.nextMonthDay:focus {
+  cursor: pointer;
+  outline: 0;
+  background: #e2e2e2;
+  border-color: #e2e2e2;
+}
+.flatpickr-day.today {
+  border-color: #bbb;
+}
+.flatpickr-day.today:hover,
+.flatpickr-day.today:focus {
+  border-color: #bbb;
+  background: #bbb;
+  color: #fff;
+}
+.flatpickr-day.selected,
+.flatpickr-day.startRange,
+.flatpickr-day.endRange,
+.flatpickr-day.selected.inRange,
+.flatpickr-day.startRange.inRange,
+.flatpickr-day.endRange.inRange,
+.flatpickr-day.selected:focus,
+.flatpickr-day.startRange:focus,
+.flatpickr-day.endRange:focus,
+.flatpickr-day.selected:hover,
+.flatpickr-day.startRange:hover,
+.flatpickr-day.endRange:hover,
+.flatpickr-day.selected.prevMonthDay,
+.flatpickr-day.startRange.prevMonthDay,
+.flatpickr-day.endRange.prevMonthDay,
+.flatpickr-day.selected.nextMonthDay,
+.flatpickr-day.startRange.nextMonthDay,
+.flatpickr-day.endRange.nextMonthDay {
+  background: #ff5a5f;
+  -webkit-box-shadow: none;
+          box-shadow: none;
+  color: #fff;
+  border-color: #ff5a5f;
+}
+.flatpickr-day.selected.startRange,
+.flatpickr-day.startRange.startRange,
+.flatpickr-day.endRange.startRange {
+  border-radius: 50px 0 0 50px;
+}
+.flatpickr-day.selected.endRange,
+.flatpickr-day.startRange.endRange,
+.flatpickr-day.endRange.endRange {
+  border-radius: 0 50px 50px 0;
+}
+.flatpickr-day.selected.startRange + .endRange:not(:nth-child(7n+1)),
+.flatpickr-day.startRange.startRange + .endRange:not(:nth-child(7n+1)),
+.flatpickr-day.endRange.startRange + .endRange:not(:nth-child(7n+1)) {
+  -webkit-box-shadow: -10px 0 0 #ff5a5f;
+          box-shadow: -10px 0 0 #ff5a5f;
+}
+.flatpickr-day.selected.startRange.endRange,
+.flatpickr-day.startRange.startRange.endRange,
+.flatpickr-day.endRange.startRange.endRange {
+  border-radius: 50px;
+}
+.flatpickr-day.inRange {
+  border-radius: 0;
+  -webkit-box-shadow: -5px 0 0 #e2e2e2, 5px 0 0 #e2e2e2;
+          box-shadow: -5px 0 0 #e2e2e2, 5px 0 0 #e2e2e2;
+}
+.flatpickr-day.flatpickr-disabled,
+.flatpickr-day.flatpickr-disabled:hover,
+.flatpickr-day.prevMonthDay,
+.flatpickr-day.nextMonthDay,
+.flatpickr-day.notAllowed,
+.flatpickr-day.notAllowed.prevMonthDay,
+.flatpickr-day.notAllowed.nextMonthDay {
+  color: rgba(72,72,72,0.3);
+  background: transparent;
+  border-color: transparent;
+  cursor: default;
+}
+.flatpickr-day.flatpickr-disabled,
+.flatpickr-day.flatpickr-disabled:hover {
+  cursor: not-allowed;
+  color: rgba(72,72,72,0.1);
+}
+.flatpickr-day.week.selected {
+  border-radius: 0;
+  -webkit-box-shadow: -5px 0 0 #ff5a5f, 5px 0 0 #ff5a5f;
+          box-shadow: -5px 0 0 #ff5a5f, 5px 0 0 #ff5a5f;
+}
+.flatpickr-day.hidden {
+  visibility: hidden;
+}
+.rangeMode .flatpickr-day {
+  margin-top: 1px;
+}
+.flatpickr-weekwrapper {
+  float: left;
+}
+.flatpickr-weekwrapper .flatpickr-weeks {
+  padding: 0 12px;
+  border-left: 1px solid #eceef1;
+}
+.flatpickr-weekwrapper .flatpickr-weekday {
+  float: none;
+  width: 100%;
+  line-height: 28px;
+}
+.flatpickr-weekwrapper span.flatpickr-day,
+.flatpickr-weekwrapper span.flatpickr-day:hover {
+  display: block;
+  width: 100%;
+  max-width: none;
+  color: rgba(72,72,72,0.3);
+  background: transparent;
+  cursor: default;
+  border: none;
+}
+.flatpickr-innerContainer {
+  display: block;
+  display: -webkit-box;
+  display: -webkit-flex;
+  display: -ms-flexbox;
+  display: flex;
+  -webkit-box-sizing: border-box;
+          box-sizing: border-box;
+  overflow: hidden;
+  background: #fff;
+  border-bottom: 1px solid #eceef1;
+}
+.flatpickr-rContainer {
+  display: inline-block;
+  padding: 0;
+  -webkit-box-sizing: border-box;
+          box-sizing: border-box;
+}
+.flatpickr-time {
+  text-align: center;
+  outline: 0;
+  display: block;
+  height: 0;
+  line-height: 40px;
+  max-height: 40px;
+  -webkit-box-sizing: border-box;
+          box-sizing: border-box;
+  overflow: hidden;
+  display: -webkit-box;
+  display: -webkit-flex;
+  display: -ms-flexbox;
+  display: flex;
+  background: #fff;
+  border-radius: 0 0 5px 5px;
+}
+.flatpickr-time:after {
+  content: "";
+  display: table;
+  clear: both;
+}
+.flatpickr-time .numInputWrapper {
+  -webkit-box-flex: 1;
+  -webkit-flex: 1;
+      -ms-flex: 1;
+          flex: 1;
+  width: 40%;
+  height: 40px;
+  float: left;
+}
+.flatpickr-time .numInputWrapper span.arrowUp:after {
+  border-bottom-color: #484848;
+}
+.flatpickr-time .numInputWrapper span.arrowDown:after {
+  border-top-color: #484848;
+}
+.flatpickr-time.hasSeconds .numInputWrapper {
+  width: 26%;
+}
+.flatpickr-time.time24hr .numInputWrapper {
+  width: 49%;
+}
+.flatpickr-time input {
+  background: transparent;
+  -webkit-box-shadow: none;
+          box-shadow: none;
+  border: 0;
+  border-radius: 0;
+  text-align: center;
+  margin: 0;
+  padding: 0;
+  height: inherit;
+  line-height: inherit;
+  color: #484848;
+  font-size: 14px;
+  position: relative;
+  -webkit-box-sizing: border-box;
+          box-sizing: border-box;
+  -webkit-appearance: textfield;
+  -moz-appearance: textfield;
+  appearance: textfield;
+}
+.flatpickr-time input.flatpickr-hour {
+  font-weight: bold;
+}
+.flatpickr-time input.flatpickr-minute,
+.flatpickr-time input.flatpickr-second {
+  font-weight: 400;
+}
+.flatpickr-time input:focus {
+  outline: 0;
+  border: 0;
+}
+.flatpickr-time .flatpickr-time-separator,
+.flatpickr-time .flatpickr-am-pm {
+  height: inherit;
+  float: left;
+  line-height: inherit;
+  color: #484848;
+  font-weight: bold;
+  width: 2%;
+  -webkit-user-select: none;
+     -moz-user-select: none;
+      -ms-user-select: none;
+          user-select: none;
+  -webkit-align-self: center;
+      -ms-flex-item-align: center;
+          align-self: center;
+}
+.flatpickr-time .flatpickr-am-pm {
+  outline: 0;
+  width: 18%;
+  cursor: pointer;
+  text-align: center;
+  font-weight: 400;
+}
+.flatpickr-time input:hover,
+.flatpickr-time .flatpickr-am-pm:hover,
+.flatpickr-time input:focus,
+.flatpickr-time .flatpickr-am-pm:focus {
+  background: #eaeaea;
+}
+.flatpickr-input[readonly] {
+  cursor: pointer;
+}
+@-webkit-keyframes fpFadeInDown {
+  from {
+    opacity: 0;
+    -webkit-transform: translate3d(0, -20px, 0);
+            transform: translate3d(0, -20px, 0);
+  }
+  to {
+    opacity: 1;
+    -webkit-transform: translate3d(0, 0, 0);
+            transform: translate3d(0, 0, 0);
+  }
+}
+@keyframes fpFadeInDown {
+  from {
+    opacity: 0;
+    -webkit-transform: translate3d(0, -20px, 0);
+            transform: translate3d(0, -20px, 0);
+  }
+  to {
+    opacity: 1;
+    -webkit-transform: translate3d(0, 0, 0);
+            transform: translate3d(0, 0, 0);
+  }
+}
+span.flatpickr-day.selected {
+  font-weight: bold;
+}

+ 20 - 0
netbox/project-static/img/netbox_icon.svg

@@ -0,0 +1,20 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 320">
+  <g fill="#9cc8f8" stroke="#9cc8f8">
+    <circle cx="37" cy="284" r="23"/>
+    <circle cx="101" cy="37" r="23"/>
+    <circle cx="101" cy="220" r="23"/>
+    <circle cx="284" cy="220" r="23"/>
+    <rect x="93" y="37" width="16" height="180"/>
+    <rect x="101" y="212" width="180" height="16"/>
+    <rect x="93" y="212" width="16" height="90" transform="rotate(45 101 220)"/>
+  </g>
+  <g fill="#1685fc" stroke="#1685fc">
+    <circle cx="284" cy="37" r="23"/>
+    <circle cx="37" cy="101" r="23"/>
+    <circle cx="220" cy="101" r="23"/>
+    <circle cx="220" cy="284" r="23"/>
+    <rect x="37" y="93" width="180" height="16"/>
+    <rect x="212" y="101" width="16" height="180"/>
+    <rect x="212" y="93" width="16" height="90" transform="rotate(225 220 101)"/>
+  </g>
+</svg>

Diff do ficheiro suprimidas por serem muito extensas
+ 18 - 0
netbox/project-static/img/netbox_logo.svg


+ 49 - 4
netbox/project-static/js/forms.js

@@ -103,14 +103,16 @@ $(document).ready(function() {
         placeholder: "---------",
         theme: "bootstrap",
         templateResult: colorPickerClassCopy,
-        templateSelection: colorPickerClassCopy
+        templateSelection: colorPickerClassCopy,
+        width: "off"
     });
 
     // Static choice selection
     $('.netbox-select2-static').select2({
         allowClear: true,
         placeholder: "---------",
-        theme: "bootstrap"
+        theme: "bootstrap",
+        width: "off"
     });
 
     // API backed selection
@@ -120,6 +122,7 @@ $(document).ready(function() {
         allowClear: true,
         placeholder: "---------",
         theme: "bootstrap",
+        width: "off",
         ajax: {
             delay: 500,
 
@@ -184,7 +187,15 @@ $(document).ready(function() {
                 $.each(element.attributes, function(index, attr){
                     if (attr.name.includes("data-additional-query-param-")){
                         var param_name = attr.name.split("data-additional-query-param-")[1];
-                        parameters[param_name] = attr.value;
+                        if (param_name in parameters) {
+                            if (Array.isArray(parameters[param_name])) {
+                                parameters[param_name].push(attr.value)
+                            } else {
+                                parameters[param_name] = [parameters[param_name], attr.value]
+                            }
+                        } else {
+                            parameters[param_name] = attr.value;
+                        }
                     }
                 });
 
@@ -251,6 +262,24 @@ $(document).ready(function() {
         }
     });
 
+    // Flatpickr selectors
+    $('.date-picker').flatpickr({
+        allowInput: true
+    });
+    $('.datetime-picker').flatpickr({
+        allowInput: true,
+        enableSeconds: true,
+        enableTime: true,
+        time_24hr: true
+    });
+    $('.time-picker').flatpickr({
+        allowInput: true,
+        enableSeconds: true,
+        enableTime: true,
+        noCalendar: true,
+        time_24hr: true
+    });
+
     // API backed tags
     var tags = $('#id_tags');
     if (tags.length > 0 && tags.val().length > 0){
@@ -273,7 +302,8 @@ $(document).ready(function() {
         multiple: true,
         allowClear: true,
         placeholder: "Tags",
-
+        theme: "bootstrap",
+        width: "off",
         ajax: {
             delay: 250,
             url: netbox_api_path + "extras/tags/",
@@ -353,4 +383,19 @@ $(document).ready(function() {
         });
         $('select#id_mode').trigger('change');
     }
+
+    // Scroll up an offset equal to the first nav element if a hash is present
+    // Cannot use '#navbar' because it is not always visible, like in small windows
+    function headerOffsetScroll() {
+        if (window.location.hash) {
+            // Short wait needed to allow the page to scroll to the element
+            setTimeout(function() {
+                window.scrollBy(0, -$('nav').height())
+            }, 10);
+        }
+    }
+
+    // Account for the header height when hash-scrolling
+    window.addEventListener('load', headerOffsetScroll);
+    window.addEventListener('hashchange', headerOffsetScroll);
 });

+ 2 - 0
netbox/templates/_base.html

@@ -9,6 +9,7 @@
     <link rel="stylesheet" href="{% static 'jquery-ui-1.12.1/jquery-ui.css' %}">
     <link rel="stylesheet" href="{% static 'select2-4.0.12/dist/css/select2.min.css' %}">
     <link rel="stylesheet" href="{% static 'select2-bootstrap-0.1.0-beta.10/select2-bootstrap.min.css' %}">
+    <link rel="stylesheet" href="{% static 'flatpickr-4.6.3/themes/light.css' %}">
     <link rel="stylesheet" href="{% static 'css/base.css' %}?v{{ settings.VERSION }}">
     <link rel="icon" type="image/png" href="{% static 'img/netbox.ico' %}" />
     <meta charset="UTF-8">
@@ -70,6 +71,7 @@
 <script src="{% static 'bootstrap-3.4.1-dist/js/bootstrap.min.js' %}"></script>
 <script src="{% static 'select2-4.0.12/dist/js/select2.min.js' %}"></script>
 <script src="{% static 'clipboard.js/clipboard-2.0.4.min.js' %}"></script>
+<script src="{% static 'flatpickr-4.6.3/flatpickr.min.js' %}"></script>
 <script src="{% static 'js/forms.js' %}?v{{ settings.VERSION }}"></script>
 <script type="text/javascript">
     var netbox_api_path = "/{{ settings.BASE_PATH }}api/";

+ 22 - 19
netbox/templates/dcim/site.html

@@ -249,25 +249,28 @@
             <div class="panel-heading">
                 <strong>Rack Groups</strong>
             </div>
-            {% if rack_groups %}
-                <table class="table table-hover panel-body">
-                    {% for rg in rack_groups %}
-                        <tr>
-                            <td><i class="fa fa-fw fa-folder-o"></i> <a href="{{ rg.get_absolute_url }}">{{ rg }}</a></td>
-                            <td>{{ rg.rack_count }}</td>
-                            <td class="text-right noprint">
-                                <a href="{% url 'dcim:rack_elevation_list' %}?group_id={{ rg.pk }}" class="btn btn-xs btn-primary" title="View elevations">
-                                    <i class="fa fa-eye"></i>
-                                </a>
-                            </td>
-                        </tr>
-                    {% endfor %}
-                </table>
-            {% else %}
-                <div class="panel-body text-muted">
-                    None
-                </div>
-            {% endif %}
+            <table class="table table-hover panel-body">
+                {% for rg in rack_groups %}
+                    <tr>
+                        <td><i class="fa fa-fw fa-folder-o"></i> <a href="{{ rg.get_absolute_url }}">{{ rg }}</a></td>
+                        <td>{{ rg.rack_count }}</td>
+                        <td class="text-right noprint">
+                            <a href="{% url 'dcim:rack_elevation_list' %}?group_id={{ rg.pk }}" class="btn btn-xs btn-primary" title="View elevations">
+                                <i class="fa fa-eye"></i>
+                            </a>
+                        </td>
+                    </tr>
+                {% endfor %}
+                <tr>
+                    <td><i class="fa fa-fw fa-folder-o"></i> All racks</td>
+                    <td>{{ stats.rack_count }}</td>
+                    <td class="text-right noprint">
+                        <a href="{% url 'dcim:rack_elevation_list' %}?site={{ site.slug }}" class="btn btn-xs btn-primary" title="View elevations">
+                            <i class="fa fa-eye"></i>
+                        </a>
+                    </td>
+                </tr>
+            </table>
         </div>
         <div class="panel panel-default">
             <div class="panel-heading">

+ 1 - 1
netbox/templates/inc/nav_menu.html

@@ -10,7 +10,7 @@
                 <span class="icon-bar"></span>
             </button>
             <a class="navbar-brand" href="{% url 'home' %}">
-                <img src="{% static 'img/netbox_logo.png' %}" />
+                <img src="{% static 'img/netbox_logo.svg' %}" height="30" />
             </a>
         </div>
         <div id="navbar" class="navbar-collapse collapse">

+ 9 - 1
netbox/templates/users/api_tokens.html

@@ -10,6 +10,7 @@
                 <div class="panel panel-{% if token.is_expired %}danger{% else %}default{% endif %}">
                     <div class="panel-heading">
                         <div class="pull-right noprint">
+                            <a class="btn btn-xs btn-success copy-token" data-clipboard-target="#token_{{ token.pk }}">Copy</a>
                             {% if perms.users.change_token %}
                                 <a href="{% url 'user:token_edit' pk=token.pk %}" class="btn btn-xs btn-warning">Edit</a>
                             {% endif %}
@@ -17,7 +18,8 @@
                                 <a href="{% url 'user:token_delete' pk=token.pk %}" class="btn btn-xs btn-danger">Delete</a>
                             {% endif %}
                         </div>
-                        <i class="fa fa-key"></i> {{ token.key }}
+                        <i class="fa fa-key"></i>
+                        <span id="token_{{ token.pk }}">{{ token.key }}</span>
                         {% if token.is_expired %}
                             <span class="label label-danger">Expired</span>
                         {% endif %}
@@ -66,3 +68,9 @@
         </div>
     </div>
 {% endblock %}
+
+{% block javascript %}
+<script type="text/javascript">
+new ClipboardJS('.copy-token');
+</script>
+{% endblock %}

+ 3 - 3
netbox/users/forms.py

@@ -1,7 +1,7 @@
 from django import forms
 from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm as DjangoPasswordChangeForm
 
-from utilities.forms import BootstrapMixin
+from utilities.forms import BootstrapMixin, DateTimePicker
 from .models import Token
 
 
@@ -29,6 +29,6 @@ class TokenForm(BootstrapMixin, forms.ModelForm):
         fields = [
             'key', 'write_enabled', 'expires', 'description',
         ]
-        help_texts = {
-            'expires': 'YYYY-MM-DD [HH:MM:SS]'
+        widgets = {
+            'expires': DateTimePicker(),
         }

+ 1 - 1
netbox/users/views.py

@@ -96,7 +96,7 @@ class ChangePasswordView(LoginRequiredMixin, View):
 
     def get(self, request):
         # LDAP users cannot change their password here
-        if getattr(request.user, 'ldap_username'):
+        if getattr(request.user, 'ldap_username', None):
             messages.warning(request, "LDAP-authenticated user credentials cannot be changed within NetBox.")
             return redirect('user:profile')
 

+ 39 - 1
netbox/utilities/forms.py

@@ -286,6 +286,8 @@ class APISelect(SelectWithDisabled):
         name of the query param and the value if the query param's value.
     :param null_option: If true, include the static null option in the selection list.
     """
+    # Only preload the selected option(s); new options are dynamically displayed and added via the API
+    template_name = 'widgets/select_api.html'
 
     def __init__(
         self,
@@ -363,6 +365,36 @@ class APISelectMultiple(APISelect, forms.SelectMultiple):
         self.attrs['data-multiple'] = 1
 
 
+class DatePicker(forms.TextInput):
+    """
+    Date picker using Flatpickr.
+    """
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.attrs['class'] = 'date-picker'
+        self.attrs['placeholder'] = 'YYYY-MM-DD'
+
+
+class DateTimePicker(forms.TextInput):
+    """
+    DateTime picker using Flatpickr.
+    """
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.attrs['class'] = 'datetime-picker'
+        self.attrs['placeholder'] = 'YYYY-MM-DD hh:mm:ss'
+
+
+class TimePicker(forms.TextInput):
+    """
+    Time picker using Flatpickr.
+    """
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.attrs['class'] = 'time-picker'
+        self.attrs['placeholder'] = 'hh:mm:ss'
+
+
 #
 # Form fields
 #
@@ -672,16 +704,22 @@ class ChainedFieldsMixin(forms.BaseForm):
                     else:
                         break
 
+                # Limit field queryset by chained field values
                 if filters_dict:
                     field.queryset = field.queryset.filter(**filters_dict)
+                # Editing an existing instance; limit field to its current value
                 elif not self.is_bound and getattr(self, 'instance', None) and hasattr(self.instance, field_name):
                     obj = getattr(self.instance, field_name)
                     if obj is not None:
                         field.queryset = field.queryset.filter(pk=obj.pk)
                     else:
                         field.queryset = field.queryset.none()
-                elif not self.is_bound:
+                # Creating a new instance with no bound data; nullify queryset
+                elif not self.data.get(field_name):
                     field.queryset = field.queryset.none()
+                # Creating a new instance with bound data; limit queryset to the specified value
+                else:
+                    field.queryset = field.queryset.filter(pk=self.data.get(field_name))
 
 
 class ReturnURLForm(forms.Form):

+ 5 - 0
netbox/utilities/templates/widgets/select_api.html

@@ -0,0 +1,5 @@
+<select name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %}>{% for group_name, group_choices, group_index in widget.optgroups %}{% if group_name %}
+  <optgroup label="{{ group_name }}">{% endif %}{% for widget in group_choices %}{% if widget.attrs.selected %}
+  {% include widget.template_name %}{% endif %}{% endfor %}{% if group_name %}
+  </optgroup>{% endif %}{% endfor %}
+</select>

+ 29 - 0
netbox/utilities/tests/test_api.py

@@ -1,7 +1,13 @@
+import urllib.parse
+
+from django.contrib.contenttypes.models import ContentType
+from django.test import Client, TestCase
 from django.urls import reverse
 from rest_framework import status
 
 from dcim.models import Region, Site
+from extras.choices import CustomFieldTypeChoices
+from extras.models import CustomField
 from ipam.models import VLAN
 from utilities.testing import APITestCase
 
@@ -117,3 +123,26 @@ class WritableNestedSerializerTest(APITestCase):
 
         self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
         self.assertEqual(VLAN.objects.count(), 0)
+
+
+class APIDocsTestCase(TestCase):
+
+    def setUp(self):
+        self.client = Client()
+
+        # Populate a CustomField to activate CustomFieldSerializer
+        content_type = ContentType.objects.get_for_model(Site)
+        self.cf_text = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='test')
+        self.cf_text.save()
+        self.cf_text.obj_type.set([content_type])
+        self.cf_text.save()
+
+    def test_api_docs(self):
+
+        url = reverse('api_docs')
+        params = {
+            "format": "openapi",
+        }
+
+        response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
+        self.assertEqual(response.status_code, 200)

+ 9 - 0
netbox/utilities/utils.py

@@ -4,6 +4,7 @@ from collections import OrderedDict
 
 from django.core.serializers import serialize
 from django.db.models import Count, OuterRef, Subquery
+from jinja2 import Environment
 
 from dcim.choices import CableLengthUnitChoices
 from extras.utils import is_taggable
@@ -181,6 +182,14 @@ def to_meters(length, unit):
         return length * 0.3048
     if unit == CableLengthUnitChoices.UNIT_INCH:
         return length * 0.3048 * 12
+    raise ValueError("Unknown unit {}. Must be 'm', 'cm', 'ft', or 'in'.".format(unit))
+
+
+def render_jinja2(template_code, context):
+    """
+    Render a Jinja2 template with the provided context. Return the rendered content.
+    """
+    return Environment().from_string(source=template_code).render(**context)
 
 
 def prepare_cloned_fields(instance):

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff