فهرست منبع

Merge pull request #2600 from digitalocean/develop

Release v2.4.8
Jeremy Stretch 7 سال پیش
والد
کامیت
8d4329197a
40فایلهای تغییر یافته به همراه469 افزوده شده و 114 حذف شده
  1. 18 0
      CHANGELOG.md
  2. 70 0
      docs/development/extending-models.md
  3. 0 7
      docs/development/index.md
  4. 41 0
      docs/development/style-guide.md
  5. 2 0
      mkdocs.yml
  6. 3 7
      netbox/circuits/filters.py
  7. 4 2
      netbox/dcim/api/views.py
  8. 8 22
      netbox/dcim/filters.py
  9. 2 2
      netbox/dcim/tables.py
  10. 4 2
      netbox/dcim/views.py
  11. 29 2
      netbox/extras/forms.py
  12. 29 1
      netbox/extras/tables.py
  13. 2 0
      netbox/extras/urls.py
  14. 59 6
      netbox/extras/views.py
  15. 7 19
      netbox/ipam/filters.py
  16. 1 1
      netbox/netbox/settings.py
  17. 1 1
      netbox/netbox/views.py
  18. 13 0
      netbox/project-static/css/base.css
  19. 2 4
      netbox/secrets/filters.py
  20. 10 6
      netbox/secrets/models.py
  21. 2 1
      netbox/secrets/tests/test_models.py
  22. 1 1
      netbox/templates/circuits/circuit.html
  23. 1 1
      netbox/templates/circuits/provider.html
  24. 2 1
      netbox/templates/dcim/device.html
  25. 1 1
      netbox/templates/dcim/devicetype.html
  26. 30 8
      netbox/templates/dcim/inc/interface.html
  27. 1 1
      netbox/templates/dcim/rack.html
  28. 1 1
      netbox/templates/dcim/site.html
  29. 1 1
      netbox/templates/extras/configcontext_list.html
  30. 69 0
      netbox/templates/extras/tag.html
  31. 4 1
      netbox/templates/extras/tag_list.html
  32. 1 1
      netbox/templates/tenancy/tenant.html
  33. 1 1
      netbox/templates/virtualization/cluster.html
  34. 1 1
      netbox/templates/virtualization/virtualmachine.html
  35. 2 4
      netbox/tenancy/filters.py
  36. 16 0
      netbox/utilities/filters.py
  37. 1 1
      netbox/virtualization/api/serializers.py
  38. 3 7
      netbox/virtualization/filters.py
  39. 12 0
      netbox/virtualization/tests/test_api.py
  40. 14 0
      scripts/git-hooks/pre-commit

+ 18 - 0
CHANGELOG.md

@@ -1,3 +1,21 @@
+v2.4.8 (2018-11-20)
+
+## Enhancements
+
+* [#2490](https://github.com/digitalocean/netbox/issues/2490) - Added bulk editing for config contexts
+* [#2557](https://github.com/digitalocean/netbox/issues/2557) - Added object view for tags
+
+## Bug Fixes
+
+* [#2473](https://github.com/digitalocean/netbox/issues/2473) - Fix encoding of long (>127 character) secrets
+* [#2558](https://github.com/digitalocean/netbox/issues/2558) - Filter on all tags when multiple are passed
+* [#2565](https://github.com/digitalocean/netbox/issues/2565) - Improved rendering of Markdown tables
+* [#2575](https://github.com/digitalocean/netbox/issues/2575) - Correct model specified for rack roles table
+* [#2588](https://github.com/digitalocean/netbox/issues/2588) - Catch all exceptions from failed NAPALM API Calls
+* [#2589](https://github.com/digitalocean/netbox/issues/2589) - Virtual machine API serializer should require cluster assignment
+
+---
+
 v2.4.7 (2018-11-06)
 v2.4.7 (2018-11-06)
 
 
 ## Enhancements
 ## Enhancements

+ 70 - 0
docs/development/extending-models.md

@@ -0,0 +1,70 @@
+# Extending Models
+
+Below is a list of items to consider when adding a new field to a model:
+
+### 1. Generate and run database migration
+
+Django migrations are used to express changes to the database schema. In most cases, Django can generate these automatically, however very complex changes may require manual intervention. Always remember to specify a short but descriptive name when generating a new migration.
+
+```
+./manage.py makemigrations <app> -n <name>
+./manage.py migrate
+```
+
+Where possible, try to merge related changes into a single migration. For example, if three new fields are being added to different models within an app, these can be expressed in the same migration. You can merge a new migration with an existing one by combining their `operations` lists.
+
+!!! note
+    Migrations can only be merged within a release. Once a new release has been published, its migrations cannot be altered.
+
+### 2. Add validation logic to `clean()`
+
+If the new field introduces additional validation requirements (beyond what's included with the field itself), implement them in the model's `clean()` method. Remember to call the model's original method using `super()` before or agter your custom validation as appropriate:
+
+```
+class Foo(models.Model):
+
+    def clean(self):
+
+        super(DeviceCSVForm, self).clean()
+
+        # Custom validation goes here
+        if self.bar is None:
+            raise ValidationError()
+```
+
+### 3. Add CSV helpers
+
+Add the name of the new field to `csv_headers` and included a CSV-friendly representation of its data in the model's `to_csv()` method. These will be used when exporting objects in CSV format.
+
+### 4. Update relevant querysets
+
+If you're adding a relational field (e.g. `ForeignKey`) and intend to include the data when retreiving a list of objects, be sure to include the field using `select_related()` or `prefetch_related()` as appropriate. This will optimize the view and avoid excessive database lookups.
+
+### 5. Update API serializer
+
+Extend the model's API serializer in `<app>.api.serializers` to include the new field. In most cases, it will not be necessary to also extend the nested serializer, which produces a minimal represenation of the model.
+
+### 6. Add field to forms
+
+Extend any forms to include the new field as appropriate. Common forms include:
+
+* **Credit/edit** - Manipulating a single object
+* **Bulk edit** - Performing a change on mnay objects at once
+* **CSV import** - The form used when bulk importing objects in CSV format
+* **Filter** - Displays the options available for filtering a list of objects (both UI and API)
+
+### 7. Extend object filter set
+
+If the new field should be filterable, add it to the `FilterSet` for the model. If the field should be searchable, remember to reference it in the FilterSet's `search()` method.
+
+### 8. Add column to object table
+
+If the new field will be included in the object list view, add a column to the model's table. For simple fields, adding the field name to `Meta.fields` will be sufficient. More complex fields may require explicitly declaring a new column.
+
+### 9. Update the UI templates
+
+Edit the object's view template to display the new field. There may also be a custom add/edit form template that needs to be updated.
+
+### 10. Adjust API and model tests
+
+Extend the model and/or API tests to verify that the new field and any accompanying validation logic perform as expected. This is especially important for relational fields.

+ 0 - 7
docs/development/index.md

@@ -28,10 +28,3 @@ NetBox components are arranged into functional subsections called _apps_ (a carr
 * `tenancy`: Tenants (such as customers) to which NetBox objects may be assigned
 * `tenancy`: Tenants (such as customers) to which NetBox objects may be assigned
 * `utilities`: Resources which are not user-facing (extendable classes, etc.)
 * `utilities`: Resources which are not user-facing (extendable classes, etc.)
 * `virtualization`: Virtual machines and clusters
 * `virtualization`: Virtual machines and clusters
-
-## Style Guide
-
-NetBox generally follows the [Django style guide](https://docs.djangoproject.com/en/dev/internals/contributing/writing-code/coding-style/), which is itself based on [PEP 8](https://www.python.org/dev/peps/pep-0008/). The following exceptions are noted:
-
-* [Pycodestyle](https://github.com/pycqa/pycodestyle) is used to validate code formatting, ignoring certain violations. See `scripts/cibuild.sh`.
-* Constants may be imported via wildcard (for example, `from .constants import *`).

+ 41 - 0
docs/development/style-guide.md

@@ -0,0 +1,41 @@
+# Style Guide
+
+NetBox generally follows the [Django style guide](https://docs.djangoproject.com/en/dev/internals/contributing/writing-code/coding-style/), which is itself based on [PEP 8](https://www.python.org/dev/peps/pep-0008/). [Pycodestyle](https://github.com/pycqa/pycodestyle) is used to validate code formatting, ignoring certain violations. See `scripts/cibuild.sh`.
+
+## PEP 8 Exceptions
+
+* Wildcard imports (for example, `from .constants import *`) are acceptable under any of the following conditions:
+    * The library being import contains only constant declarations (`constants.py`)
+    * The library being imported explicitly defines `__all__` (e.g. `<app>.api.nested_serializers`)
+
+* Maximum line length is 120 characters (E501)
+    * This does not apply to HTML templates or to automatically generated code (e.g. database migrations).
+
+* Line breaks are permitted following binary operators (W504)
+
+## Enforcing Code Style
+
+The `pycodestyle` utility (previously `pep8`) is used by the CI process to enforce code style. It is strongly recommended to include as part of your commit process. A git commit hook is provided in the source at `scripts/git-hooks/pre-commit`. Linking to this script from `.git/hooks/` will invoke `pycodestyle` prior to every commit attempt and abort if the validation fails.
+
+```
+$ cd .git/hooks/
+$ ln -s ../../scripts/git-hooks/pre-commit
+```
+
+To invoke `pycodestyle` manually, run:
+
+```
+pycodestyle --ignore=W504,E501 netbox/
+```
+
+## General Guidance
+
+* When in doubt, remain consistent: It is better to be consistently incorrect than inconsistently correct. If you notice in the course of unrelated work a pattern that should be corrected, continue to follow the pattern for now and open a bug so that the entire code base can be evaluated at a later point.
+
+* No easter eggs. While they can be fun, NetBox must be considered as a business-critical tool. The potential, however minor, for introducing a bug caused by unnecessary logic is best avoided entirely.
+
+* Constants (variables which generally do not change) should be declared in `constants.py` within each app. Wildcard imports from the file are acceptable.
+
+* Every model should have a docstring. Every custom method should include an expalantion of its function.
+
+* Nested API serializers generate minimal representations of an object. These are stored separately from the primary serializers to avoid circular dependencies. Always import nested serializers from other apps directly. For example, from within the DCIM app you would write `from ipam.api.nested_serializers import NestedIPAddressSerializer`.

+ 2 - 0
mkdocs.yml

@@ -45,7 +45,9 @@ pages:
         - Examples: 'api/examples.md'
         - Examples: 'api/examples.md'
     - Development:
     - Development:
         - Introduction: 'development/index.md'
         - Introduction: 'development/index.md'
+        - Style Guide: 'development/style-guide.md'
         - Utility Views: 'development/utility-views.md'
         - Utility Views: 'development/utility-views.md'
+        - Extending Models: 'development/extending-models.md'
         - Release Checklist: 'development/release-checklist.md'
         - Release Checklist: 'development/release-checklist.md'
 
 
 markdown_extensions:
 markdown_extensions:

+ 3 - 7
netbox/circuits/filters.py

@@ -6,7 +6,7 @@ from django.db.models import Q
 from dcim.models import Site
 from dcim.models import Site
 from extras.filters import CustomFieldFilterSet
 from extras.filters import CustomFieldFilterSet
 from tenancy.models import Tenant
 from tenancy.models import Tenant
-from utilities.filters import NumericInFilter
+from utilities.filters import NumericInFilter, TagFilter
 from .constants import CIRCUIT_STATUS_CHOICES
 from .constants import CIRCUIT_STATUS_CHOICES
 from .models import Provider, Circuit, CircuitTermination, CircuitType
 from .models import Provider, Circuit, CircuitTermination, CircuitType
 
 
@@ -28,9 +28,7 @@ class ProviderFilter(CustomFieldFilterSet, django_filters.FilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label='Site (slug)',
         label='Site (slug)',
     )
     )
-    tag = django_filters.CharFilter(
-        name='tags__slug',
-    )
+    tag = TagFilter()
 
 
     class Meta:
     class Meta:
         model = Provider
         model = Provider
@@ -106,9 +104,7 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label='Site (slug)',
         label='Site (slug)',
     )
     )
-    tag = django_filters.CharFilter(
-        name='tags__slug',
-    )
+    tag = TagFilter()
 
 
     class Meta:
     class Meta:
         model = Circuit
         model = Circuit

+ 4 - 2
netbox/dcim/api/views.py

@@ -263,9 +263,9 @@ class DeviceViewSet(CustomFieldModelViewSet):
         # Check that NAPALM is installed
         # Check that NAPALM is installed
         try:
         try:
             import napalm
             import napalm
+            from napalm.base.exceptions import ModuleImportError
         except ImportError:
         except ImportError:
             raise ServiceUnavailable("NAPALM is not installed. Please see the documentation for instructions.")
             raise ServiceUnavailable("NAPALM is not installed. Please see the documentation for instructions.")
-        from napalm.base.exceptions import ModuleImportError
 
 
         # Validate the configured driver
         # Validate the configured driver
         try:
         try:
@@ -309,7 +309,9 @@ class DeviceViewSet(CustomFieldModelViewSet):
             try:
             try:
                 response[method] = getattr(d, method)()
                 response[method] = getattr(d, method)()
             except NotImplementedError:
             except NotImplementedError:
-                response[method] = {'error': 'Method not implemented for NAPALM driver {}'.format(driver)}
+                response[method] = {'error': 'Method {} not implemented for NAPALM driver {}'.format(method, driver)}
+            except Exception as e:
+                response[method] = {'error': 'Method {} failed: {}'.format(method, e)}
         d.close()
         d.close()
 
 
         return Response(response)
         return Response(response)

+ 8 - 22
netbox/dcim/filters.py

@@ -9,7 +9,7 @@ from netaddr.core import AddrFormatError
 
 
 from extras.filters import CustomFieldFilterSet
 from extras.filters import CustomFieldFilterSet
 from tenancy.models import Tenant
 from tenancy.models import Tenant
-from utilities.filters import NullableCharFieldFilter, NumericInFilter
+from utilities.filters import NullableCharFieldFilter, NumericInFilter, TagFilter
 from virtualization.models import Cluster
 from virtualization.models import Cluster
 from .constants import (
 from .constants import (
     DEVICE_STATUS_CHOICES, IFACE_FF_LAG, NONCONNECTABLE_IFACE_TYPES, SITE_STATUS_CHOICES, VIRTUAL_IFACE_TYPES,
     DEVICE_STATUS_CHOICES, IFACE_FF_LAG, NONCONNECTABLE_IFACE_TYPES, SITE_STATUS_CHOICES, VIRTUAL_IFACE_TYPES,
@@ -83,9 +83,7 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label='Tenant (slug)',
         label='Tenant (slug)',
     )
     )
-    tag = django_filters.CharFilter(
-        name='tags__slug',
-    )
+    tag = TagFilter()
 
 
     class Meta:
     class Meta:
         model = Site
         model = Site
@@ -196,9 +194,7 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label='Role (slug)',
         label='Role (slug)',
     )
     )
-    tag = django_filters.CharFilter(
-        name='tags__slug',
-    )
+    tag = TagFilter()
 
 
     class Meta:
     class Meta:
         model = Rack
         model = Rack
@@ -306,9 +302,7 @@ class DeviceTypeFilter(CustomFieldFilterSet, django_filters.FilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label='Manufacturer (slug)',
         label='Manufacturer (slug)',
     )
     )
-    tag = django_filters.CharFilter(
-        name='tags__slug',
-    )
+    tag = TagFilter()
 
 
     class Meta:
     class Meta:
         model = DeviceType
         model = DeviceType
@@ -530,9 +524,7 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
         queryset=VirtualChassis.objects.all(),
         queryset=VirtualChassis.objects.all(),
         label='Virtual chassis (ID)',
         label='Virtual chassis (ID)',
     )
     )
-    tag = django_filters.CharFilter(
-        name='tags__slug',
-    )
+    tag = TagFilter()
 
 
     class Meta:
     class Meta:
         model = Device
         model = Device
@@ -592,9 +584,7 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
         to_field_name='name',
         to_field_name='name',
         label='Device (name)',
         label='Device (name)',
     )
     )
-    tag = django_filters.CharFilter(
-        name='tags__slug',
-    )
+    tag = TagFilter()
 
 
 
 
 class ConsolePortFilter(DeviceComponentFilterSet):
 class ConsolePortFilter(DeviceComponentFilterSet):
@@ -653,9 +643,7 @@ class InterfaceFilter(django_filters.FilterSet):
         method='_mac_address',
         method='_mac_address',
         label='MAC address',
         label='MAC address',
     )
     )
-    tag = django_filters.CharFilter(
-        name='tags__slug',
-    )
+    tag = TagFilter()
     vlan_id = django_filters.CharFilter(
     vlan_id = django_filters.CharFilter(
         method='filter_vlan_id',
         method='filter_vlan_id',
         label='Assigned VLAN'
         label='Assigned VLAN'
@@ -797,9 +785,7 @@ class VirtualChassisFilter(django_filters.FilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label='Tenant (slug)',
         label='Tenant (slug)',
     )
     )
-    tag = django_filters.CharFilter(
-        name='tags__slug',
-    )
+    tag = TagFilter()
 
 
     class Meta:
     class Meta:
         model = VirtualChassis
         model = VirtualChassis

+ 2 - 2
netbox/dcim/tables.py

@@ -9,7 +9,7 @@ from .models import (
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, InventoryItem,
     DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, InventoryItem,
     Manufacturer, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
     Manufacturer, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
-    RackReservation, Region, Site, VirtualChassis,
+    RackReservation, RackRole, Region, Site, VirtualChassis,
 )
 )
 
 
 REGION_LINK = """
 REGION_LINK = """
@@ -250,7 +250,7 @@ class RackRoleTable(BaseTable):
                                     verbose_name='')
                                     verbose_name='')
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
-        model = RackGroup
+        model = RackRole
         fields = ('pk', 'name', 'rack_count', 'color', 'slug', 'actions')
         fields = ('pk', 'name', 'rack_count', 'color', 'slug', 'actions')
 
 
 
 

+ 4 - 2
netbox/dcim/views.py

@@ -858,8 +858,10 @@ class DeviceView(View):
             device.device_type.interface_ordering
             device.device_type.interface_ordering
         ).select_related(
         ).select_related(
             'connected_as_a__interface_b__device', 'connected_as_b__interface_a__device',
             'connected_as_a__interface_b__device', 'connected_as_b__interface_a__device',
-            'circuit_termination__circuit'
-        ).prefetch_related('ip_addresses')
+            'circuit_termination__circuit__provider'
+        ).prefetch_related(
+            'tags', 'ip_addresses'
+        )
 
 
         # Device bays
         # Device bays
         device_bays = natsorted(
         device_bays = natsorted(

+ 29 - 2
netbox/extras/forms.py

@@ -13,8 +13,8 @@ from taggit.models import Tag
 from dcim.models import DeviceRole, Platform, Region, Site
 from dcim.models import DeviceRole, Platform, Region, Site
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
 from utilities.forms import (
 from utilities.forms import (
-    add_blank_choice, BootstrapMixin, BulkEditForm, FilterChoiceField, FilterTreeNodeMultipleChoiceField, LaxURLField,
-    JSONField, SlugField,
+    add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, FilterChoiceField,
+    FilterTreeNodeMultipleChoiceField, LaxURLField, JSONField, SlugField,
 )
 )
 from .constants import (
 from .constants import (
     CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL,
     CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL,
@@ -208,6 +208,11 @@ class AddRemoveTagsForm(forms.Form):
         self.fields['remove_tags'] = TagField(required=False)
         self.fields['remove_tags'] = TagField(required=False)
 
 
 
 
+class TagFilterForm(BootstrapMixin, forms.Form):
+    model = Tag
+    q = forms.CharField(required=False, label='Search')
+
+
 #
 #
 # Config contexts
 # Config contexts
 #
 #
@@ -227,6 +232,28 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm):
         ]
         ]
 
 
 
 
+class ConfigContextBulkEditForm(BootstrapMixin, BulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=ConfigContext.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    weight = forms.IntegerField(
+        required=False,
+        min_value=0
+    )
+    is_active = forms.NullBooleanField(
+        required=False,
+        widget=BulkEditNullBooleanSelect()
+    )
+    description = forms.CharField(
+        required=False,
+        max_length=100
+    )
+
+    class Meta:
+        nullable_fields = ['description']
+
+
 class ConfigContextFilterForm(BootstrapMixin, forms.Form):
 class ConfigContextFilterForm(BootstrapMixin, forms.Form):
     q = forms.CharField(
     q = forms.CharField(
         required=False,
         required=False,

+ 29 - 1
netbox/extras/tables.py

@@ -1,7 +1,8 @@
 from __future__ import unicode_literals
 from __future__ import unicode_literals
 
 
 import django_tables2 as tables
 import django_tables2 as tables
-from taggit.models import Tag
+from django_tables2.utils import Accessor
+from taggit.models import Tag, TaggedItem
 
 
 from utilities.tables import BaseTable, BooleanColumn, ToggleColumn
 from utilities.tables import BaseTable, BooleanColumn, ToggleColumn
 from .models import ConfigContext, ObjectChange
 from .models import ConfigContext, ObjectChange
@@ -15,6 +16,14 @@ TAG_ACTIONS = """
 {% endif %}
 {% endif %}
 """
 """
 
 
+TAGGED_ITEM = """
+{% if value.get_absolute_url %}
+    <a href="{{ value.get_absolute_url }}">{{ value }}</a>
+{% else %}
+    {{ value }}
+{% endif %}
+"""
+
 CONFIGCONTEXT_ACTIONS = """
 CONFIGCONTEXT_ACTIONS = """
 {% if perms.extras.change_configcontext %}
 {% if perms.extras.change_configcontext %}
     <a href="{% url 'extras:configcontext_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
     <a href="{% url 'extras:configcontext_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
@@ -55,6 +64,10 @@ OBJECTCHANGE_REQUEST_ID = """
 
 
 class TagTable(BaseTable):
 class TagTable(BaseTable):
     pk = ToggleColumn()
     pk = ToggleColumn()
+    name = tables.LinkColumn(
+        viewname='extras:tag',
+        args=[Accessor('slug')]
+    )
     actions = tables.TemplateColumn(
     actions = tables.TemplateColumn(
         template_code=TAG_ACTIONS,
         template_code=TAG_ACTIONS,
         attrs={'td': {'class': 'text-right'}},
         attrs={'td': {'class': 'text-right'}},
@@ -66,6 +79,21 @@ class TagTable(BaseTable):
         fields = ('pk', 'name', 'items', 'slug', 'actions')
         fields = ('pk', 'name', 'items', 'slug', 'actions')
 
 
 
 
+class TaggedItemTable(BaseTable):
+    content_object = tables.TemplateColumn(
+        template_code=TAGGED_ITEM,
+        orderable=False,
+        verbose_name='Object'
+    )
+    content_type = tables.Column(
+        verbose_name='Type'
+    )
+
+    class Meta(BaseTable.Meta):
+        model = TaggedItem
+        fields = ('content_object', 'content_type')
+
+
 class ConfigContextTable(BaseTable):
 class ConfigContextTable(BaseTable):
     pk = ToggleColumn()
     pk = ToggleColumn()
     name = tables.LinkColumn()
     name = tables.LinkColumn()

+ 2 - 0
netbox/extras/urls.py

@@ -9,6 +9,7 @@ urlpatterns = [
 
 
     # Tags
     # Tags
     url(r'^tags/$', views.TagListView.as_view(), name='tag_list'),
     url(r'^tags/$', views.TagListView.as_view(), name='tag_list'),
+    url(r'^tags/(?P<slug>[\w-]+)/$', views.TagView.as_view(), name='tag'),
     url(r'^tags/(?P<slug>[\w-]+)/edit/$', views.TagEditView.as_view(), name='tag_edit'),
     url(r'^tags/(?P<slug>[\w-]+)/edit/$', views.TagEditView.as_view(), name='tag_edit'),
     url(r'^tags/(?P<slug>[\w-]+)/delete/$', views.TagDeleteView.as_view(), name='tag_delete'),
     url(r'^tags/(?P<slug>[\w-]+)/delete/$', views.TagDeleteView.as_view(), name='tag_delete'),
     url(r'^tags/delete/$', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'),
     url(r'^tags/delete/$', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'),
@@ -16,6 +17,7 @@ urlpatterns = [
     # Config contexts
     # Config contexts
     url(r'^config-contexts/$', views.ConfigContextListView.as_view(), name='configcontext_list'),
     url(r'^config-contexts/$', views.ConfigContextListView.as_view(), name='configcontext_list'),
     url(r'^config-contexts/add/$', views.ConfigContextCreateView.as_view(), name='configcontext_add'),
     url(r'^config-contexts/add/$', views.ConfigContextCreateView.as_view(), name='configcontext_add'),
+    url(r'^config-contexts/edit/$', views.ConfigContextBulkEditView.as_view(), name='configcontext_bulk_edit'),
     url(r'^config-contexts/(?P<pk>\d+)/$', views.ConfigContextView.as_view(), name='configcontext'),
     url(r'^config-contexts/(?P<pk>\d+)/$', views.ConfigContextView.as_view(), name='configcontext'),
     url(r'^config-contexts/(?P<pk>\d+)/edit/$', views.ConfigContextEditView.as_view(), name='configcontext_edit'),
     url(r'^config-contexts/(?P<pk>\d+)/edit/$', views.ConfigContextEditView.as_view(), name='configcontext_edit'),
     url(r'^config-contexts/(?P<pk>\d+)/delete/$', views.ConfigContextDeleteView.as_view(), name='configcontext_delete'),
     url(r'^config-contexts/(?P<pk>\d+)/delete/$', views.ConfigContextDeleteView.as_view(), name='configcontext_delete'),

+ 59 - 6
netbox/extras/views.py

@@ -1,6 +1,7 @@
 from __future__ import unicode_literals
 from __future__ import unicode_literals
 
 
 from django import template
 from django import template
+from django.conf import settings
 from django.contrib import messages
 from django.contrib import messages
 from django.contrib.auth.mixins import PermissionRequiredMixin
 from django.contrib.auth.mixins import PermissionRequiredMixin
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
@@ -9,15 +10,20 @@ from django.http import Http404
 from django.shortcuts import get_object_or_404, redirect, render
 from django.shortcuts import get_object_or_404, redirect, render
 from django.utils.safestring import mark_safe
 from django.utils.safestring import mark_safe
 from django.views.generic import View
 from django.views.generic import View
-from taggit.models import Tag
+from django_tables2 import RequestConfig
+from taggit.models import Tag, TaggedItem
 
 
 from utilities.forms import ConfirmationForm
 from utilities.forms import ConfirmationForm
-from utilities.views import BulkDeleteView, ObjectDeleteView, ObjectEditView, ObjectListView
+from utilities.paginator import EnhancedPaginator
+from utilities.views import BulkDeleteView, BulkEditView, ObjectDeleteView, ObjectEditView, ObjectListView
 from . import filters
 from . import filters
-from .forms import ConfigContextForm, ConfigContextFilterForm, ImageAttachmentForm, ObjectChangeFilterForm, TagForm
+from .forms import (
+    ConfigContextForm, ConfigContextBulkEditForm, ConfigContextFilterForm, ImageAttachmentForm, ObjectChangeFilterForm,
+    TagFilterForm, TagForm,
+)
 from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult
 from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult
 from .reports import get_report, get_reports
 from .reports import get_report, get_reports
-from .tables import ConfigContextTable, ObjectChangeTable, TagTable
+from .tables import ConfigContextTable, ObjectChangeTable, TagTable, TaggedItemTable
 
 
 
 
 #
 #
@@ -25,11 +31,45 @@ from .tables import ConfigContextTable, ObjectChangeTable, TagTable
 #
 #
 
 
 class TagListView(ObjectListView):
 class TagListView(ObjectListView):
-    queryset = Tag.objects.annotate(items=Count('taggit_taggeditem_items')).order_by('name')
+    queryset = Tag.objects.annotate(
+        items=Count('taggit_taggeditem_items')
+    ).order_by(
+        'name'
+    )
+    filter = filters.TagFilter
+    filter_form = TagFilterForm
     table = TagTable
     table = TagTable
     template_name = 'extras/tag_list.html'
     template_name = 'extras/tag_list.html'
 
 
 
 
+class TagView(View):
+
+    def get(self, request, slug):
+
+        tag = get_object_or_404(Tag, slug=slug)
+        tagged_items = TaggedItem.objects.filter(
+            tag=tag
+        ).select_related(
+            'content_type'
+        ).prefetch_related(
+            'content_object'
+        )
+
+        # Generate a table of all items tagged with this Tag
+        items_table = TaggedItemTable(tagged_items)
+        paginate = {
+            'klass': EnhancedPaginator,
+            'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
+        }
+        RequestConfig(request, paginate).configure(items_table)
+
+        return render(request, 'extras/tag.html', {
+            'tag': tag,
+            'items_count': tagged_items.count(),
+            'items_table': items_table,
+        })
+
+
 class TagEditView(PermissionRequiredMixin, ObjectEditView):
 class TagEditView(PermissionRequiredMixin, ObjectEditView):
     permission_required = 'taggit.change_tag'
     permission_required = 'taggit.change_tag'
     model = Tag
     model = Tag
@@ -45,7 +85,11 @@ class TagDeleteView(PermissionRequiredMixin, ObjectDeleteView):
 
 
 class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'circuits.delete_circuittype'
     permission_required = 'circuits.delete_circuittype'
-    queryset = Tag.objects.annotate(items=Count('taggit_taggeditem_items')).order_by('name')
+    queryset = Tag.objects.annotate(
+        items=Count('taggit_taggeditem_items')
+    ).order_by(
+        'name'
+    )
     table = TagTable
     table = TagTable
     default_return_url = 'extras:tag_list'
     default_return_url = 'extras:tag_list'
 
 
@@ -85,6 +129,15 @@ class ConfigContextEditView(ConfigContextCreateView):
     permission_required = 'extras.change_configcontext'
     permission_required = 'extras.change_configcontext'
 
 
 
 
+class ConfigContextBulkEditView(PermissionRequiredMixin, BulkEditView):
+    permission_required = 'extras.change_configcontext'
+    queryset = ConfigContext.objects.all()
+    filter = filters.ConfigContextFilter
+    table = ConfigContextTable
+    form = ConfigContextBulkEditForm
+    default_return_url = 'extras:configcontext_list'
+
+
 class ConfigContextDeleteView(PermissionRequiredMixin, ObjectDeleteView):
 class ConfigContextDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     permission_required = 'extras.delete_configcontext'
     permission_required = 'extras.delete_configcontext'
     model = ConfigContext
     model = ConfigContext

+ 7 - 19
netbox/ipam/filters.py

@@ -9,7 +9,7 @@ from netaddr.core import AddrFormatError
 from dcim.models import Site, Device, Interface
 from dcim.models import Site, Device, Interface
 from extras.filters import CustomFieldFilterSet
 from extras.filters import CustomFieldFilterSet
 from tenancy.models import Tenant
 from tenancy.models import Tenant
-from utilities.filters import NumericInFilter
+from utilities.filters import NumericInFilter, TagFilter
 from virtualization.models import VirtualMachine
 from virtualization.models import VirtualMachine
 from .constants import IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, PREFIX_STATUS_CHOICES, VLAN_STATUS_CHOICES
 from .constants import IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, PREFIX_STATUS_CHOICES, VLAN_STATUS_CHOICES
 from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
 from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
@@ -31,9 +31,7 @@ class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label='Tenant (slug)',
         label='Tenant (slug)',
     )
     )
-    tag = django_filters.CharFilter(
-        name='tags__slug',
-    )
+    tag = TagFilter()
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -73,9 +71,7 @@ class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label='RIR (slug)',
         label='RIR (slug)',
     )
     )
-    tag = django_filters.CharFilter(
-        name='tags__slug',
-    )
+    tag = TagFilter()
 
 
     class Meta:
     class Meta:
         model = Aggregate
         model = Aggregate
@@ -174,9 +170,7 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
         choices=PREFIX_STATUS_CHOICES,
         choices=PREFIX_STATUS_CHOICES,
         null_value=None
         null_value=None
     )
     )
-    tag = django_filters.CharFilter(
-        name='tags__slug',
-    )
+    tag = TagFilter()
 
 
     class Meta:
     class Meta:
         model = Prefix
         model = Prefix
@@ -303,9 +297,7 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
     role = django_filters.MultipleChoiceFilter(
     role = django_filters.MultipleChoiceFilter(
         choices=IPADDRESS_ROLE_CHOICES
         choices=IPADDRESS_ROLE_CHOICES
     )
     )
-    tag = django_filters.CharFilter(
-        name='tags__slug',
-    )
+    tag = TagFilter()
 
 
     class Meta:
     class Meta:
         model = IPAddress
         model = IPAddress
@@ -422,9 +414,7 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet):
         choices=VLAN_STATUS_CHOICES,
         choices=VLAN_STATUS_CHOICES,
         null_value=None
         null_value=None
     )
     )
-    tag = django_filters.CharFilter(
-        name='tags__slug',
-    )
+    tag = TagFilter()
 
 
     class Meta:
     class Meta:
         model = VLAN
         model = VLAN
@@ -466,9 +456,7 @@ class ServiceFilter(django_filters.FilterSet):
         to_field_name='name',
         to_field_name='name',
         label='Virtual machine (name)',
         label='Virtual machine (name)',
     )
     )
-    tag = django_filters.CharFilter(
-        name='tags__slug',
-    )
+    tag = TagFilter()
 
 
     class Meta:
     class Meta:
         model = Service
         model = Service

+ 1 - 1
netbox/netbox/settings.py

@@ -22,7 +22,7 @@ if sys.version_info[0] < 3:
         DeprecationWarning
         DeprecationWarning
     )
     )
 
 
-VERSION = '2.4.7'
+VERSION = '2.4.8'
 
 
 BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
 BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
 
 

+ 1 - 1
netbox/netbox/views.py

@@ -197,7 +197,7 @@ class HomeView(View):
             'stats': stats,
             'stats': stats,
             'topology_maps': TopologyMap.objects.filter(site__isnull=True),
             'topology_maps': TopologyMap.objects.filter(site__isnull=True),
             'report_results': ReportResult.objects.order_by('-created')[:10],
             'report_results': ReportResult.objects.order_by('-created')[:10],
-            'changelog': ObjectChange.objects.select_related('user')[:50]
+            'changelog': ObjectChange.objects.select_related('user', 'changed_object_type')[:50]
         })
         })
 
 
 
 

+ 13 - 0
netbox/project-static/css/base.css

@@ -390,6 +390,19 @@ table.report th a {
     top: -51px;
     top: -51px;
 }
 }
 
 
+/* Rendered Markdown */
+.rendered-markdown table {
+    width: 100%;
+}
+.rendered-markdown th {
+    border-bottom: 2px solid #dddddd;
+    padding: 8px;
+}
+.rendered-markdown td {
+    border-top: 1px solid #dddddd;
+    padding: 8px;
+}
+
 /* AJAX loader */
 /* AJAX loader */
 .loading {
 .loading {
     position: fixed;
     position: fixed;

+ 2 - 4
netbox/secrets/filters.py

@@ -5,7 +5,7 @@ from django.db.models import Q
 
 
 from dcim.models import Device
 from dcim.models import Device
 from extras.filters import CustomFieldFilterSet
 from extras.filters import CustomFieldFilterSet
-from utilities.filters import NumericInFilter
+from utilities.filters import NumericInFilter, TagFilter
 from .models import Secret, SecretRole
 from .models import Secret, SecretRole
 
 
 
 
@@ -42,9 +42,7 @@ class SecretFilter(CustomFieldFilterSet, django_filters.FilterSet):
         to_field_name='name',
         to_field_name='name',
         label='Device (name)',
         label='Device (name)',
     )
     )
-    tag = django_filters.CharFilter(
-        name='tags__slug',
-    )
+    tag = TagFilter()
 
 
     class Meta:
     class Meta:
         model = Secret
         model = Secret

+ 10 - 6
netbox/secrets/models.py

@@ -1,6 +1,7 @@
 from __future__ import unicode_literals
 from __future__ import unicode_literals
 
 
 import os
 import os
+import sys
 
 
 from Crypto.Cipher import AES, PKCS1_OAEP
 from Crypto.Cipher import AES, PKCS1_OAEP
 from Crypto.PublicKey import RSA
 from Crypto.PublicKey import RSA
@@ -392,6 +393,7 @@ class Secret(ChangeLoggedModel, CustomFieldModel):
         s = s.encode('utf8')
         s = s.encode('utf8')
         if len(s) > 65535:
         if len(s) > 65535:
             raise ValueError("Maximum plaintext size is 65535 bytes.")
             raise ValueError("Maximum plaintext size is 65535 bytes.")
+
         # Minimum ciphertext size is 64 bytes to conceal the length of short secrets.
         # Minimum ciphertext size is 64 bytes to conceal the length of short secrets.
         if len(s) <= 62:
         if len(s) <= 62:
             pad_length = 62 - len(s)
             pad_length = 62 - len(s)
@@ -399,12 +401,14 @@ class Secret(ChangeLoggedModel, CustomFieldModel):
             pad_length = 16 - ((len(s) + 2) % 16)
             pad_length = 16 - ((len(s) + 2) % 16)
         else:
         else:
             pad_length = 0
             pad_length = 0
-        return (
-            chr(len(s) >> 8).encode() +
-            chr(len(s) % 256).encode() +
-            s +
-            os.urandom(pad_length)
-        )
+
+        # Python 2 compatibility
+        if sys.version_info[0] < 3:
+            header = chr(len(s) >> 8) + chr(len(s) % 256)
+        else:
+            header = bytes([len(s) >> 8]) + bytes([len(s) % 256])
+
+        return header + s + os.urandom(pad_length)
 
 
     def _unpad(self, s):
     def _unpad(self, s):
         """
         """

+ 2 - 1
netbox/secrets/tests/test_models.py

@@ -1,4 +1,5 @@
 from __future__ import unicode_literals
 from __future__ import unicode_literals
+import string
 
 
 from Crypto.PublicKey import RSA
 from Crypto.PublicKey import RSA
 from django.conf import settings
 from django.conf import settings
@@ -88,7 +89,7 @@ class SecretTestCase(TestCase):
         """
         """
         Test basic encryption and decryption functionality using a random master key.
         Test basic encryption and decryption functionality using a random master key.
         """
         """
-        plaintext = "FooBar123"
+        plaintext = string.printable * 2
         secret_key = generate_random_key()
         secret_key = generate_random_key()
         s = Secret(plaintext=plaintext)
         s = Secret(plaintext=plaintext)
         s.encrypt(secret_key)
         s.encrypt(secret_key)

+ 1 - 1
netbox/templates/circuits/circuit.html

@@ -131,7 +131,7 @@
             <div class="panel-heading">
             <div class="panel-heading">
                 <strong>Comments</strong>
                 <strong>Comments</strong>
             </div>
             </div>
-            <div class="panel-body">
+            <div class="panel-body rendered-markdown">
                 {% if circuit.comments %}
                 {% if circuit.comments %}
                     {{ circuit.comments|gfm }}
                     {{ circuit.comments|gfm }}
                 {% else %}
                 {% else %}

+ 1 - 1
netbox/templates/circuits/provider.html

@@ -129,7 +129,7 @@
             <div class="panel-heading">
             <div class="panel-heading">
                 <strong>Comments</strong>
                 <strong>Comments</strong>
             </div>
             </div>
-            <div class="panel-body">
+            <div class="panel-body rendered-markdown">
                 {% if provider.comments %}
                 {% if provider.comments %}
                     {{ provider.comments|gfm }}
                     {{ provider.comments|gfm }}
                 {% else %}
                 {% else %}

+ 2 - 1
netbox/templates/dcim/device.html

@@ -290,7 +290,7 @@
                 <div class="panel-heading">
                 <div class="panel-heading">
                     <strong>Comments</strong>
                     <strong>Comments</strong>
                 </div>
                 </div>
-                <div class="panel-body">
+                <div class="panel-body rendered-markdown">
                     {% if device.comments %}
                     {% if device.comments %}
                         {{ device.comments|gfm }}
                         {{ device.comments|gfm }}
                     {% else %}
                     {% else %}
@@ -525,6 +525,7 @@
                                 <th>Name</th>
                                 <th>Name</th>
                                 <th>LAG</th>
                                 <th>LAG</th>
                                 <th>Description</th>
                                 <th>Description</th>
+                                <th>MTU</th>
                                 <th>Mode</th>
                                 <th>Mode</th>
                                 <th colspan="2">Connection</th>
                                 <th colspan="2">Connection</th>
                                 <th></th>
                                 <th></th>

+ 1 - 1
netbox/templates/dcim/devicetype.html

@@ -164,7 +164,7 @@
             <div class="panel-heading">
             <div class="panel-heading">
                 <strong>Comments</strong>
                 <strong>Comments</strong>
             </div>
             </div>
-            <div class="panel-body">
+            <div class="panel-body rendered-markdown">
                 {% if devicetype.comments %}
                 {% if devicetype.comments %}
                     {{ devicetype.comments|gfm }}
                     {{ devicetype.comments|gfm }}
                 {% else %}
                 {% else %}

+ 30 - 8
netbox/templates/dcim/inc/interface.html

@@ -1,4 +1,5 @@
-<tr class="interface{% if not iface.enabled %} danger{% elif iface.connection and iface.connection.connection_status or iface.circuit_termination %} success{% elif iface.connection and not iface.connection.connection_status %} info{% elif iface.is_virtual %} warning{% endif %}" id="iface_{{ iface.name }}">
+{% load helpers %}
+<tr class="interface{% if not iface.enabled %} danger{% elif iface.connection and iface.connection.connection_status or iface.circuit_termination %} success{% elif iface.connection and not iface.connection.connection_status %} info{% elif iface.is_virtual %} warning{% endif %}" id="interface_{{ iface.name }}">
 
 
     {# Checkbox #}
     {# Checkbox #}
     {% if perms.dcim.change_interface or perms.dcim.delete_interface %}
     {% if perms.dcim.change_interface or perms.dcim.delete_interface %}
@@ -7,32 +8,53 @@
         </td>
         </td>
     {% endif %}
     {% endif %}
 
 
-    {# Icon and name #}
+    {# Icon/name/MAC #}
     <td>
     <td>
         <span title="{{ iface.get_form_factor_display }}">
         <span title="{{ iface.get_form_factor_display }}">
             <i class="fa fa-fw fa-{% if iface.mgmt_only %}wrench{% elif iface.is_lag %}align-justify{% elif iface.is_virtual %}circle{% elif iface.is_wireless %}wifi{% else %}exchange{% endif %}"></i>
             <i class="fa fa-fw fa-{% if iface.mgmt_only %}wrench{% elif iface.is_lag %}align-justify{% elif iface.is_virtual %}circle{% elif iface.is_wireless %}wifi{% else %}exchange{% endif %}"></i>
             <a href="{{ iface.get_absolute_url }}">{{ iface }}</a>
             <a href="{{ iface.get_absolute_url }}">{{ iface }}</a>
         </span>
         </span>
+        {% if iface.mac_address %}
+            <br/><small class="text-muted">{{ iface.mac_address }}</small>
+        {% endif %}
     </td>
     </td>
 
 
     {# LAG #}
     {# LAG #}
     <td>
     <td>
         {% if iface.lag %}
         {% if iface.lag %}
-            <a href="#iface_{{ iface.lag }}" class="label label-default">{{ iface.lag }}</a>
+            <a href="#interface_{{ iface.lag }}" class="label label-primary" title="{{ iface.lag.description }}">{{ iface.lag }}</a>
+        {% endif %}
+    </td>
+
+    {# Description/tags #}
+    <td>
+        {% if iface.description %}
+            {{ iface.description }}<br/>
         {% endif %}
         {% endif %}
+        {% for tag in iface.tags.all %}
+            {% tag tag %}
+        {% empty %}
+            {% if not iface.description %}&mdash;{% endif %}
+        {% endfor %}
     </td>
     </td>
 
 
-    {# Description #}
-    <td>{{ iface.description|default:"&mdash;" }}</td>
+    {# MTU #}
+    <td>{{ iface.mtu|default:"&mdash;" }}</td>
 
 
     {# 802.1Q mode #}
     {# 802.1Q mode #}
-    <td>{{ iface.get_mode_display }}</td>
+    <td>{{ iface.get_mode_display|default:"&mdash;" }}</td>
 
 
     {# Connection or type #}
     {# Connection or type #}
     {% if iface.is_lag %}
     {% if iface.is_lag %}
         <td colspan="2" class="text-muted">
         <td colspan="2" class="text-muted">
             LAG interface<br />
             LAG interface<br />
-            <small class="text-muted">{{ iface.member_interfaces.all|join:", "|default:"No members" }}</small>
+            <small class="text-muted">
+                {% for member in iface.member_interfaces.all %}
+                    <a href="#interface_{{ member.name }}">{{ member }}</a>{% if not forloop.last %}, {% endif %}
+                {% empty %}
+                    No members
+                {% endfor %}
+            </small>
         </td>
         </td>
     {% elif iface.is_virtual %}
     {% elif iface.is_virtual %}
         <td colspan="2" class="text-muted">Virtual interface</td>
         <td colspan="2" class="text-muted">Virtual interface</td>
@@ -138,7 +160,7 @@
             {% endif %}
             {% endif %}
 
 
             {# IP addresses table #}
             {# IP addresses table #}
-            <td colspan="7" style="padding: 0">
+            <td colspan="8" style="padding: 0">
                 <table class="table table-condensed interface-ips">
                 <table class="table table-condensed interface-ips">
                     <thead>
                     <thead>
                         <tr class="text-muted">
                         <tr class="text-muted">

+ 1 - 1
netbox/templates/dcim/rack.html

@@ -164,7 +164,7 @@
             <div class="panel-heading">
             <div class="panel-heading">
                 <strong>Comments</strong>
                 <strong>Comments</strong>
             </div>
             </div>
-            <div class="panel-body">
+            <div class="panel-body rendered-markdown">
                 {% if rack.comments %}
                 {% if rack.comments %}
                     {{ rack.comments|gfm }}
                     {{ rack.comments|gfm }}
                 {% else %}
                 {% else %}

+ 1 - 1
netbox/templates/dcim/site.html

@@ -230,7 +230,7 @@
             <div class="panel-heading">
             <div class="panel-heading">
                 <strong>Comments</strong>
                 <strong>Comments</strong>
             </div>
             </div>
-            <div class="panel-body">
+            <div class="panel-body rendered-markdown">
                 {% if site.comments %}
                 {% if site.comments %}
                     {{ site.comments|gfm }}
                     {{ site.comments|gfm }}
                 {% else %}
                 {% else %}

+ 1 - 1
netbox/templates/extras/configcontext_list.html

@@ -10,7 +10,7 @@
     <h1>{% block title %}Config Contexts{% endblock %}</h1>
     <h1>{% block title %}Config Contexts{% endblock %}</h1>
     <div class="row">
     <div class="row">
         <div class="col-md-9">
         <div class="col-md-9">
-            {% include 'utilities/obj_table.html' with bulk_delete_url='extras:configcontext_bulk_delete' %}
+            {% include 'utilities/obj_table.html' with bulk_edit_url='extras:configcontext_bulk_edit' bulk_delete_url='extras:configcontext_bulk_delete' %}
         </div>
         </div>
         <div class="col-md-3">
         <div class="col-md-3">
             {% include 'inc/search_panel.html' %}
             {% include 'inc/search_panel.html' %}

+ 69 - 0
netbox/templates/extras/tag.html

@@ -0,0 +1,69 @@
+{% extends '_base.html' %}
+{% load helpers %}
+
+{% block header %}
+    <div class="row">
+        <div class="col-sm-8 col-md-9">
+            <ol class="breadcrumb">
+                <li><a href="{% url 'extras:tag_list' %}">Tags</a></li>
+                <li>{{ tag }}</li>
+            </ol>
+        </div>
+        <div class="col-sm-4 col-md-3">
+            <form action="{% url 'extras:tag_list' %}" method="get">
+                <div class="input-group">
+                    <input type="text" name="q" class="form-control" />
+                    <span class="input-group-btn">
+                        <button type="submit" class="btn btn-primary">
+                            <span class="fa fa-search" aria-hidden="true"></span>
+                        </button>
+                    </span>
+                </div>
+            </form>
+        </div>
+    </div>
+    <div class="pull-right">
+        {% if perms.taggit.change_tag %}
+            <a href="{% url 'extras:tag_edit' slug=tag.slug %}?return_url={% url 'extras:tag' slug=tag.slug %}" class="btn btn-warning">
+                <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span>
+                Edit this tag
+            </a>
+        {% endif %}
+    </div>
+    <h1>{% block title %}Tag: {{ tag }}{% endblock %}</h1>
+{% endblock %}
+
+{% block content %}
+    <div class="row">
+        <div class="col-md-6">
+            <div class="panel panel-default">
+                <div class="panel-heading">
+                    <strong>Tag</strong>
+                </div>
+                <table class="table table-hover panel-body attr-table">
+                    <tr>
+                        <td>Name</td>
+                        <td>
+                            {{ tag.name }}
+                        </td>
+                    </tr>
+                    <tr>
+                        <td>Slug</td>
+                        <td>
+                            {{ tag.slug }}
+                        </td>
+                    </tr>
+                    <tr>
+                        <td>Tagged Items</td>
+                        <td>
+                            {{ items_count }}
+                        </td>
+                    </tr>
+                </table>
+            </div>
+        </div>
+        <div class="col-md-6">
+            {% include 'panel_table.html' with table=items_table heading='Tagged Objects' %}
+        </div>
+    </div>
+{% endblock %}

+ 4 - 1
netbox/templates/extras/tag_list.html

@@ -4,8 +4,11 @@
 {% block content %}
 {% block content %}
 <h1>{% block title %}Tags{% endblock %}</h1>
 <h1>{% block title %}Tags{% endblock %}</h1>
 <div class="row">
 <div class="row">
-	<div class="col-md-12">
+	<div class="col-md-9">
         {% include 'utilities/obj_table.html' with bulk_delete_url='extras:tag_bulk_delete' %}
         {% include 'utilities/obj_table.html' with bulk_delete_url='extras:tag_bulk_delete' %}
     </div>
     </div>
+    <div class="col-md-3">
+		{% include 'inc/search_panel.html' %}
+    </div>
 </div>
 </div>
 {% endblock %}
 {% endblock %}

+ 1 - 1
netbox/templates/tenancy/tenant.html

@@ -87,7 +87,7 @@
             <div class="panel-heading">
             <div class="panel-heading">
                 <strong>Comments</strong>
                 <strong>Comments</strong>
             </div>
             </div>
-            <div class="panel-body">
+            <div class="panel-body rendered-markdown">
                 {% if tenant.comments %}
                 {% if tenant.comments %}
                     {{ tenant.comments|gfm }}
                     {{ tenant.comments|gfm }}
                 {% else %}
                 {% else %}

+ 1 - 1
netbox/templates/virtualization/cluster.html

@@ -99,7 +99,7 @@
             <div class="panel-heading">
             <div class="panel-heading">
                 <strong>Comments</strong>
                 <strong>Comments</strong>
             </div>
             </div>
-            <div class="panel-body">
+            <div class="panel-body rendered-markdown">
                 {% if cluster.comments %}
                 {% if cluster.comments %}
                     {{ cluster.comments|gfm }}
                     {{ cluster.comments|gfm }}
                 {% else %}
                 {% else %}

+ 1 - 1
netbox/templates/virtualization/virtualmachine.html

@@ -143,7 +143,7 @@
             <div class="panel-heading">
             <div class="panel-heading">
                 <strong>Comments</strong>
                 <strong>Comments</strong>
             </div>
             </div>
-            <div class="panel-body">
+            <div class="panel-body rendered-markdown">
                 {% if virtualmachine.comments %}
                 {% if virtualmachine.comments %}
                     {{ virtualmachine.comments|gfm }}
                     {{ virtualmachine.comments|gfm }}
                 {% else %}
                 {% else %}

+ 2 - 4
netbox/tenancy/filters.py

@@ -4,7 +4,7 @@ import django_filters
 from django.db.models import Q
 from django.db.models import Q
 
 
 from extras.filters import CustomFieldFilterSet
 from extras.filters import CustomFieldFilterSet
-from utilities.filters import NumericInFilter
+from utilities.filters import NumericInFilter, TagFilter
 from .models import Tenant, TenantGroup
 from .models import Tenant, TenantGroup
 
 
 
 
@@ -31,9 +31,7 @@ class TenantFilter(CustomFieldFilterSet, django_filters.FilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label='Group (slug)',
         label='Group (slug)',
     )
     )
-    tag = django_filters.CharFilter(
-        name='tags__slug',
-    )
+    tag = TagFilter()
 
 
     class Meta:
     class Meta:
         model = Tenant
         model = Tenant

+ 16 - 0
netbox/utilities/filters.py

@@ -5,6 +5,7 @@ import itertools
 import django_filters
 import django_filters
 from django import forms
 from django import forms
 from django.utils.encoding import force_text
 from django.utils.encoding import force_text
+from taggit.models import Tag
 
 
 
 
 #
 #
@@ -68,3 +69,18 @@ class NullableModelMultipleChoiceField(forms.ModelMultipleChoiceField):
             stripped_value = value
             stripped_value = value
         super(NullableModelMultipleChoiceField, self).clean(stripped_value)
         super(NullableModelMultipleChoiceField, self).clean(stripped_value)
         return value
         return value
+
+
+class TagFilter(django_filters.ModelMultipleChoiceFilter):
+    """
+    Match on one or more assigned tags. If multiple tags are specified (e.g. ?tag=foo&tag=bar), the queryset is filtered
+    to objects matching all tags.
+    """
+    def __init__(self, *args, **kwargs):
+
+        kwargs.setdefault('name', 'tags__slug')
+        kwargs.setdefault('to_field_name', 'slug')
+        kwargs.setdefault('conjoined', True)
+        kwargs.setdefault('queryset', Tag.objects.all())
+
+        super(TagFilter, self).__init__(*args, **kwargs)

+ 1 - 1
netbox/virtualization/api/serializers.py

@@ -93,7 +93,7 @@ class VirtualMachineIPAddressSerializer(WritableNestedSerializer):
 class VirtualMachineSerializer(TaggitSerializer, CustomFieldModelSerializer):
 class VirtualMachineSerializer(TaggitSerializer, CustomFieldModelSerializer):
     status = ChoiceField(choices=VM_STATUS_CHOICES, required=False)
     status = ChoiceField(choices=VM_STATUS_CHOICES, required=False)
     site = NestedSiteSerializer(read_only=True)
     site = NestedSiteSerializer(read_only=True)
-    cluster = NestedClusterSerializer(required=False, allow_null=True)
+    cluster = NestedClusterSerializer()
     role = NestedDeviceRoleSerializer(required=False, allow_null=True)
     role = NestedDeviceRoleSerializer(required=False, allow_null=True)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     platform = NestedPlatformSerializer(required=False, allow_null=True)
     platform = NestedPlatformSerializer(required=False, allow_null=True)

+ 3 - 7
netbox/virtualization/filters.py

@@ -9,7 +9,7 @@ from netaddr.core import AddrFormatError
 from dcim.models import DeviceRole, Interface, Platform, Region, Site
 from dcim.models import DeviceRole, Interface, Platform, Region, Site
 from extras.filters import CustomFieldFilterSet
 from extras.filters import CustomFieldFilterSet
 from tenancy.models import Tenant
 from tenancy.models import Tenant
-from utilities.filters import NumericInFilter
+from utilities.filters import NumericInFilter, TagFilter
 from .constants import VM_STATUS_CHOICES
 from .constants import VM_STATUS_CHOICES
 from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
 from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
 
 
@@ -64,9 +64,7 @@ class ClusterFilter(CustomFieldFilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label='Site (slug)',
         label='Site (slug)',
     )
     )
-    tag = django_filters.CharFilter(
-        name='tags__slug',
-    )
+    tag = TagFilter()
 
 
     class Meta:
     class Meta:
         model = Cluster
         model = Cluster
@@ -168,9 +166,7 @@ class VirtualMachineFilter(CustomFieldFilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label='Platform (slug)',
         label='Platform (slug)',
     )
     )
-    tag = django_filters.CharFilter(
-        name='tags__slug',
-    )
+    tag = TagFilter()
 
 
     class Meta:
     class Meta:
         model = VirtualMachine
         model = VirtualMachine

+ 12 - 0
netbox/virtualization/tests/test_api.py

@@ -380,6 +380,18 @@ class VirtualMachineTest(APITestCase):
         self.assertEqual(virtualmachine4.name, data['name'])
         self.assertEqual(virtualmachine4.name, data['name'])
         self.assertEqual(virtualmachine4.cluster.pk, data['cluster'])
         self.assertEqual(virtualmachine4.cluster.pk, data['cluster'])
 
 
+    def test_create_virtualmachine_without_cluster(self):
+
+        data = {
+            'name': 'Test Virtual Machine 4',
+        }
+
+        url = reverse('virtualization-api:virtualmachine-list')
+        response = self.client.post(url, data, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+        self.assertEqual(VirtualMachine.objects.count(), 3)
+
     def test_create_virtualmachine_bulk(self):
     def test_create_virtualmachine_bulk(self):
 
 
         data = [
         data = [

+ 14 - 0
scripts/git-hooks/pre-commit

@@ -0,0 +1,14 @@
+#!/bin/sh
+# Create a link to this file at .git/hooks/pre-commit to
+# force PEP8 validation prior to committing
+#
+# Ignored violations:
+#
+#   W504: Line break after binary operator
+#   E501: Line too long
+
+exec 1>&2
+
+echo "Validating PEP8 compliance..."
+pycodestyle --ignore=W504,E501 netbox/
+